From b3c24a3d1c7d49df01e051c1aea21deb54c02b60 Mon Sep 17 00:00:00 2001 From: Ben Betz Date: Sun, 12 Jul 2026 20:53:51 -0700 Subject: [PATCH 1/3] feat: add terrain elevation service and tests - Implemented terrain elevation service with caching and LRU eviction. - Added decoding logic for terrain-rgb tiles and elevation calculations. - Created tests for terrain elevation functions including decoding, fetching, and caching behavior. feat: add track log service and tests - Introduced track log service to record and manage aircraft track points. - Implemented deduplication of points based on polling time and capacity management. - Added tests to verify track log functionality including recording, retrieval, and pruning. feat: add known airports service - Created a service to manage known US airport data with warming and proximity querying. - Implemented functions to fetch and normalize airport data from static sources. feat: add path store with Zustand - Implemented a Zustand store for managing path predictions, hold entries, alerts, and conflict pairs. - Added functionality for setting results and clearing state with revision tracking. test: add unit tests for path store - Created tests for the path store to verify state management and result handling. - Ensured correct behavior for setting results and clearing the store. --- CLAUDE.md | 54 +- src/components/layout/NotForNavigation.tsx | 2 +- src/components/map/AircraftOverlay.module.css | 88 ++ src/components/map/AircraftOverlay.tsx | 236 +++++- src/components/map/AppMap.tsx | 23 + src/components/map/DataBlock.module.css | 57 ++ src/components/map/DataBlock.tsx | 320 +++++--- src/components/map/HoldEntryLayer.tsx | 104 +++ src/components/map/PathControls.module.css | 110 +++ src/components/map/PathControls.tsx | 61 ++ src/components/map/PredictionLayer.tsx | 156 ++++ src/components/map/RangeRingsLayer.module.css | 12 + src/components/map/RangeRingsLayer.tsx | 121 +++ src/components/map/TrackLogLayer.tsx | 84 ++ .../map/__tests__/pickBlockQuadrant.test.ts | 47 ++ src/components/profile/ProfilePanel.tsx | 27 + src/components/profile/ProfileSvg.module.css | 12 + src/components/profile/ProfileSvg.tsx | 26 +- src/config/constants.ts | 138 ++++ src/geo/__tests__/conflicts.test.ts | 430 ++++++++++ src/geo/__tests__/holdEntry.test.ts | 760 ++++++++++++++++++ src/geo/__tests__/prediction.test.ts | 412 ++++++++++ src/geo/__tests__/profileTrail.test.ts | 110 +++ src/geo/__tests__/rangeRings.test.ts | 121 +++ src/geo/__tests__/tcasTables.test.ts | 139 ++++ src/geo/__tests__/terrainScan.test.ts | 258 ++++++ src/geo/conflicts.ts | 335 ++++++++ src/geo/holdEntry.ts | 500 ++++++++++++ src/geo/prediction.ts | 451 +++++++++++ src/geo/procedureShapes.ts | 8 +- src/geo/profileTrail.ts | 61 ++ src/geo/rangeRings.ts | 71 ++ src/geo/tcasTables.ts | 58 ++ src/geo/terrainScan.ts | 193 +++++ src/hooks/usePathEngine.ts | 346 ++++++++ src/services/__tests__/knownAirports.test.ts | 142 ++++ .../__tests__/terrainElevation.test.ts | 184 +++++ src/services/__tests__/trackLog.test.ts | 122 +++ src/services/knownAirports.ts | 94 +++ src/services/terrainElevation.ts | 199 +++++ src/services/trackLog.ts | 108 +++ src/store/__tests__/usePathStore.test.ts | 86 ++ src/store/usePathStore.ts | 47 ++ src/store/useSettingsStore.ts | 15 + src/types/path.ts | 79 ++ src/utils/__tests__/colorScheme.test.ts | 87 +- src/utils/colorScheme.ts | 62 +- src/workers/__tests__/cifpParse.test.ts | 102 +++ 48 files changed, 7088 insertions(+), 170 deletions(-) create mode 100644 src/components/map/HoldEntryLayer.tsx create mode 100644 src/components/map/PathControls.module.css create mode 100644 src/components/map/PathControls.tsx create mode 100644 src/components/map/PredictionLayer.tsx create mode 100644 src/components/map/RangeRingsLayer.module.css create mode 100644 src/components/map/RangeRingsLayer.tsx create mode 100644 src/components/map/TrackLogLayer.tsx create mode 100644 src/components/map/__tests__/pickBlockQuadrant.test.ts create mode 100644 src/geo/__tests__/conflicts.test.ts create mode 100644 src/geo/__tests__/holdEntry.test.ts create mode 100644 src/geo/__tests__/prediction.test.ts create mode 100644 src/geo/__tests__/profileTrail.test.ts create mode 100644 src/geo/__tests__/rangeRings.test.ts create mode 100644 src/geo/__tests__/tcasTables.test.ts create mode 100644 src/geo/__tests__/terrainScan.test.ts create mode 100644 src/geo/conflicts.ts create mode 100644 src/geo/holdEntry.ts create mode 100644 src/geo/prediction.ts create mode 100644 src/geo/profileTrail.ts create mode 100644 src/geo/rangeRings.ts create mode 100644 src/geo/tcasTables.ts create mode 100644 src/geo/terrainScan.ts create mode 100644 src/hooks/usePathEngine.ts create mode 100644 src/services/__tests__/knownAirports.test.ts create mode 100644 src/services/__tests__/terrainElevation.test.ts create mode 100644 src/services/__tests__/trackLog.test.ts create mode 100644 src/services/knownAirports.ts create mode 100644 src/services/terrainElevation.ts create mode 100644 src/services/trackLog.ts create mode 100644 src/store/__tests__/usePathStore.test.ts create mode 100644 src/store/usePathStore.ts create mode 100644 src/types/path.ts diff --git a/CLAUDE.md b/CLAUDE.md index 90f51e6..1fc323d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,7 +6,7 @@ A localhost web app that visualizes live ADS-B aircraft positions relative to pu - **React 18 + TypeScript + Vite** — no Next.js, plain Vite SPA - **react-map-gl v7 + mapbox-gl v2** — v7 to avoid Mapbox v3 license complexity -- **Zustand** for state (7 stores: aircraft, airport, map, pane, procedure, selection, settings); **TanStack Query v5** for the clustered ADS-B poll +- **Zustand** for state (8 stores: aircraft, airport, map, pane, path, procedure, selection, settings); **TanStack Query v5** for the clustered ADS-B poll - **@turf/turf v6** for geo math (cross-track distance, dead-reckoning, bearings) - **Fuse.js** for fuzzy airport search over `public/data/airport-index.json` (all US airports with published approaches), falling back to the bundled `public/data/airports.json` (89 airports) if the index hasn't been built yet - **fflate** for ZIP extraction (CIFP download is a ZIP, not gzip) @@ -59,11 +59,14 @@ src/ airport/ AirportSearch, AirportList, AirportSection — per-airport sidebar sections layout/ TopBar, Sidebar, SidebarHeader, ActiveProceduresOverlay, CifpStatusBanner config/ constants.ts — all tunable thresholds live here - geo/ Pure geo functions + __tests__ (detection, segments, centerline, shapes, interpolation, clusterAirports) - hooks/ React glue: polling, interpolation loop, detection, enrichment, dATIS, AIRAC lifecycle, airport search, pane mode - services/ cifpCache.ts (per-airport IndexedDB cache) + db.ts (injectable KVStore seam), mvaData.ts, airspaceData.ts - store/ Zustand stores (aircraft, airport, map, pane, procedure, selection, settings) - types/ Shared TS types (aircraft, airport, procedure) + geo/ Pure geo functions + __tests__ (detection, segments, centerline, shapes, interpolation, clusterAirports, + prediction, holdEntry, conflicts, terrainScan, tcasTables, rangeRings) + hooks/ React glue: polling, interpolation loop, detection, path prediction/alerting, enrichment, dATIS, + AIRAC lifecycle, airport search, pane mode + services/ cifpCache.ts (per-airport IndexedDB cache) + db.ts (injectable KVStore seam), mvaData.ts, airspaceData.ts, + trackLog.ts (non-reactive per-hex tracklog ring buffers), terrainElevation.ts (Mapbox terrain-rgb DEM cache) + store/ Zustand stores (aircraft, airport, map, pane, path, procedure, selection, settings) + types/ Shared TS types (aircraft, airport, procedure, path) utils/ Pure helpers + __tests__ (airac, arincCoords, altitude*, airlines, aircraftTypes, colorScheme, formatters, mapImages) workers/ cifpParser.worker.ts — parses ARINC 424 off the main thread; cifpGrouping.ts shares grouping/enumeration logic with scripts/buildAirportIndex.ts api/ Azure Functions proxy for production (mirrors the vite.config.ts dev proxies) @@ -86,7 +89,7 @@ layers and wires up every hook. **Aircraft at 60fps without React re-renders.** `src/hooks/useAircraftInterpolation.ts` runs a `requestAnimationFrame` loop, dead-reckons positions via `turf.destination()`, and calls `mapboxSource.setData()` directly — bypassing React entirely. React only re-renders the aircraft layer when the aircraft _set_ changes, which `useAircraftStore` signals via a `revision` counter bumped only on poll (not on interpolation). -**CIFP uses AIRAC-cycle-aware IndexedDB caching.** The FAA CIFP file (~9MB zip) follows the 28-day AIRAC cycle. `src/services/cifpCache.ts` downloads, parses (in `src/workers/cifpParser.worker.ts`), and stores in IndexedDB keyed by cycle effective date **and** `PARSER_VERSION` (currently 21 — bump it whenever parser logic changes so stale/buggy parses are discarded). `src/hooks/useAiracCycle.ts` drives the lifecycle: a `setTimeout` fires at the exact next-cycle boundary to refresh, and a `visibilitychange` listener handles tabs backgrounded across a boundary. Reference: `src/utils/airac.ts` for cycle math. Storage is per-airport, not one monolithic blob: the worker writes `airport:{key}` records plus meta keys (`effectiveDate`, `parserVersion`, `index`), and `useCifpStore` warms airports lazily via `ensureAirport(key)` on selection so cold-start memory stays bounded regardless of how many US airports are indexed. An injectable `dbGet`/`dbPut` seam (`src/services/db.ts`, `KVStore`) lets tests fake IndexedDB instead of hitting a real one. +**CIFP uses AIRAC-cycle-aware IndexedDB caching.** The FAA CIFP file (~9MB zip) follows the 28-day AIRAC cycle. `src/services/cifpCache.ts` downloads, parses (in `src/workers/cifpParser.worker.ts`), and stores in IndexedDB keyed by cycle effective date **and** `PARSER_VERSION` (currently 23 — bump it whenever parser logic changes so stale/buggy parses are discarded). `src/hooks/useAiracCycle.ts` drives the lifecycle: a `setTimeout` fires at the exact next-cycle boundary to refresh, and a `visibilitychange` listener handles tabs backgrounded across a boundary. Reference: `src/utils/airac.ts` for cycle math. Storage is per-airport, not one monolithic blob: the worker writes `airport:{key}` records plus meta keys (`effectiveDate`, `parserVersion`, `index`), and `useCifpStore` warms airports lazily via `ensureAirport(key)` on selection so cold-start memory stays bounded regardless of how many US airports are indexed. An injectable `dbGet`/`dbPut` seam (`src/services/db.ts`, `KVStore`) lets tests fake IndexedDB instead of hitting a real one. CIFP file facts (verified against live FAA data, June 2026): @@ -115,6 +118,12 @@ CIFP file facts (verified against live FAA data, June 2026): **Route enrichment.** `src/hooks/useRouteEnrichment.ts` resolves any real callsign (anything that isn't the hex fallback) to origin→destination through `src/api/routes.ts`, a pluggable `RouteProvider` layer: one batched POST per poll to adsb.lol `/routeset` (keyless; server-side position-plausibility flag) with adsbdb as per-callsign fallback for confirmed misses. Positives cache for the session, confirmed negatives for `ROUTE_NEGATIVE_TTL_MS`, transient failures retry with capped exponential backoff. A commented seam exists for a future FlightAware AeroAPI provider (filed flight plans, `RouteResult.filedRoute`). +**Path prediction, hold-entry, and conflict/terrain alerting run as one per-poll engine.** `src/hooks/usePathEngine.ts` mounts immediately after `useProcedureDetection()` in `AppMap.tsx` — a comment there warns not to reorder them, since React runs same-dependency-array (`[lastPollMs]`) effects in hook-call order, and the path engine needs _this_ poll's freshly-computed `aircraftAssignments` to know which aircraft are established on an approach. Per poll it: records one `TrackPoint` per aircraft into `src/services/trackLog.ts` (deliberately a plain module-level `Map` of fixed-capacity ring buffers, **not** a Zustand store — it's written once per poll and read imperatively by the prediction engine and `TrackLogLayer`, so there's no reactive consumer to justify subscription/notification machinery; `TRACKLOG_MAX_POINTS` (720, ~1h at 5s polls) preallocates each hex's array so memory is bounded and old points are overwritten in place rather than shifted); predicts every airborne aircraft's path 5 minutes out at 5s steps (`src/geo/prediction.ts`) — an aircraft assigned to and currently established on an approach (`isOnProcedureNow`, checked against whichever of the procedure's guidance paths — representative / DME-arc feeder / hold racetrack — it's laterally closest to) walks that path via `turf.along`, riding the descent profile's `descentProfilePoints`/`glideslopeAltAt` vertically and converging at `max(|baroRate|, PREDICT_MIN_DESCENT_FPM)`; otherwise it extrapolates the turn rate observed over the last ~3 poll tracks, held for `PREDICT_TURN_HOLD_S` (15s) then linearly decayed to straight flight by `PREDICT_TURN_DECAY_END_S` (45s); TIS-B tracks (hex `~`-prefixed) are always forced straight, too noisy to trust a turn rate from. Predictions feed hold-entry prediction (`src/geo/holdEntry.ts` — AIM 5-3-8 70°-line sector classification into direct/teardrop/parallel, entry geometry built from the same `procedureShapes` semicircle/`HOLD_TURN_R` the drawn racetrack uses so a predicted entry visually mates with it, a pure hysteresis reducer that clears an entry once the aircraft crosses the fix established inbound, diverges for `HOLD_ENTRY_CLEAR_POLLS` (3) consecutive non-qualifying polls, or gains a procedure assignment), traffic-conflict evaluation (`src/geo/tcasTables.ts`'s DO-185B sensitivity-level table + `src/geo/conflicts.ts` — sampled closest-point-of-approach on the shared 5s prediction grid, tau/DMOD/ZTHR gating for TA/RA with RA climb/descend sense chosen by simulating both `RA_ESCAPE_FPM` (±1500 fpm, after a `RA_RESPONSE_DELAY_S` pilot-response delay) escape senses to see which achieves `ALIM` separation at CPA, plus an independent ForeFlight-style radar tier — 2.0nm/±1200ft/45s alert, 1.3nm/±1200ft/25s warning — precedence `ra > warning > ta > alert`, and low-AGL near-airport suppression so parallel-runway/pattern traffic doesn't false-alarm), and MSAW-style terrain scanning (`src/geo/terrainScan.ts` — checks MVA sector floors first since they already bake in an obstacle buffer, falling back to Mapbox terrain-rgb DEM only where no sector covers a point; suppressed within `TERRAIN_ONAPPROACH_TOL_FT` (400ft) of an approach's own descent profile). Both alerting passes desensitize near **known** airports, not just active ones — `src/services/knownAirports.ts` warms a flat position list from `/data/airport-index.json` (falling back to the legacy `airports.json`) so a KSEA arrival still reads as a normal approach while only KPAE is active: traffic alerts are suppressed below `TRAFFIC_SUPPRESS_AGL_FT` (1000 ft, ForeFlight-style pattern-altitude relief) near a field, terrain scanning carves an MSAW-style exclusion volume (`TERRAIN_AIRPORT_EXCLUDE_NM`/`_FT` — 4 nm / 1500 ft above field elevation) around any known airport regardless of approach assignment, and the radar tier additionally requires `RADAR_MIN_CLOSURE_NM` of actual convergence so stable parallel-approach pairs holding constant separation never latch an alert. Two further inhibits cover field-verified false alerts: a TAWS-style landing-configuration inhibit (`TERRAIN_LANDING_GS_KT`/`_AGL_FT`) suppresses terrain scanning whenever an aircraft is slow AND low above the actual DEM-derived ground, covering strips absent from the airport index entirely; and formation/duplicate-track suppression (`FORMATION_SUPPRESS_NM`/`_DALT_FT`/`_TRK_DEG`/`_GS_KT` in `src/geo/conflicts.ts`) drops a traffic-conflict pair outright when both aircraft sustain near-identical position, altitude, track, and speed, since matched velocity means zero closure and the same gate also catches duplicate ADS-B/TIS-B tracks of one airframe — two more gates in the same loop catch what that 0.5nm radius misses: a wider-tolerance `TISB_SHADOW_NM`/`_DALT_FT`/`_TRK_DEG`/`_GS_KT` check for a co-moving TIS-B trackfile (up to ~60s stale, so it can trail 2+ nm), and an outright same-`registration`/`flight` dedupe regardless of geometry. All four results land in one `usePathStore.setResults()` call per poll, bumping a single `pathRevision` counter that every consumer (`RangeRingsLayer`, `TrackLogLayer`, `HoldEntryLayer`, `PredictionLayer`, `AircraftOverlay`, `DataBlock`) subscribes to instead of the Maps themselves. `AircraftOverlay` wraps an alerted aircraft's label in an amber border + filled chip (`TRAFFIC`/`TERRAIN`/`TA`) or a blinking red bar + chip (`TRAFFIC`/`TERRAIN`/`RA ↑`/`RA ↓`) keyed off `AircraftAlert.tier`; `DataBlock` (rendered via `SelectedAircraftDataBlock`) duplicates the same chip logic for the selected aircraft's popup — both re-render only on `pathRevision` (poll cadence), with the blink itself pure CSS. **Alerting is filter-aware:** `usePathEngine` computes a `hiddenHexes` set each poll mirroring `AircraftOverlay`'s hide rules (TIS-B `~`+`showTisb`, VFR squawk+`showVfr`, altitude-slider min/max) and drops radar-tier (`alert`/`warning`) conflict pairs, terrain scans, and hold-entry inputs for hidden aircraft — no warnings about a plane you can't see; the sole exception is TCAS TA/RA, which still evaluates across ALL aircraft, and when a TA/RA fires involving a hidden plane its hex is added to `usePathStore.forcedVisibleHexes` (consumed by the overlay) to force-un-hide it until the alert resolves; the same radar-tier post-filter also drops an `alert`/`warning` pair when both aircraft are established on an approach (assigned + `isOnProcedureNow`, computed once per aircraft and shared with the terrain pass's `onApproach` flag), mirroring real STARS Conflict Alert's approach-context inhibit for ATC-separated parallel-final/in-trail traffic — TCAS TA/RA is exempt and still evaluates them. + +**Terrain alerting needs MVA sectors loaded independent of the map's `showMva` display toggle.** A second effect in `usePathEngine` calls `ensureMvaLoaded(key)` for every active airport regardless of whether the user has MVA sectors turned on for display — the terrain scan reads the same sectors the map would draw, and hiding them visually must not silently disable the safety check. The same effect calls `prefetchAround` to warm the 2×2 nearest terrain-rgb tiles around each active airport so `elevationFtAt` (`src/services/terrainElevation.ts`) is more likely to already have a decoded tile the first time the scan needs one. That service fetches `.pngraw` tiles **directly from `api.mapbox.com`** with `VITE_MAPBOX_TOKEN` — unlike every other upstream in this app it is deliberately not proxied through `/api/*`, since the Mapbox token is already public/client-side by design (see Required environment variables above) — and decodes them into a memory-only LRU of `TERRAIN_TILE_CACHE_MAX` (48) tiles stored as `Int16Array` feet rather than `Float32Array` meters, bounding the resident footprint to ~6 MiB. Reads are synchronous and cache-only: a miss kicks off an async fetch+decode and returns `undefined` immediately, and the per-poll scan just skips that point and retries next poll once the tile lands — no prediction step ever blocks on network I/O. + +**`RangeRingsLayer` follows the selected aircraft with the same imperative `setData` pattern the aircraft-interpolation loop uses (see "Aircraft at 60fps" above), the one other per-frame map consumer in the app.** It drives its own `requestAnimationFrame` loop rather than re-rendering through React, since the three rings (radii bucketed by zoom via `src/geo/rangeRings.ts`'s `RING_ZOOM_BUCKETS`) must track the selected aircraft's interpolated position every frame, not just once per poll; a cheap epsilon check on lat/lon skips the `setData` call on frames where the position hasn't meaningfully moved. The "N NM" badge labels are DOM `Marker`s refreshed on a much cheaper 250ms `setInterval` instead, since sub-frame precision doesn't matter for text — with a 12→6 o'clock fallback when the 12 o'clock point projects off the top of the viewport. + **Map overlays** (in `src/components/map/`, render order matters — see the comment block at the top of `AppMap.tsx`): - `ProcedureLayer` — the procedure route lines, colored per `src/utils/colorScheme.ts`'s per-airport hue family (cyan/indigo/emerald SID/STAR/APPROACH for the first airport, a distinct trio per additional airport). Past `MAX_RENDERED_PROCEDURE_LINES` simultaneously-visible lines (~5 GL layers each), `AppMap` shows a dismissible hint to hide procedures or collapse airport sections — it never culls a line silently. **Approach feeder legs draw thin.** An approach transition that never reaches the MAP is a feeder (initial fix → the common IAF/IF where the final begins); the parser tags its inbound path feature `feeder: true` (in `buildProcedureFeatures`, only when some transition has a MAP, so no SID/STAR leg is ever mistaken for one), and `ProcedureLayer` draws feeders thin (a separate `proc-feeder-*` layer) regardless of detection so several feeders fanning into one approach (e.g. KPAE R34L: PAE + SEA → RARYO) don't clutter the map. The final segment keeps its detection-driven width; the feeder an aircraft is actually flying is thickened on top by the active-segment layers. **Transition holds (HILPT racetracks) draw at an intermediate weight** (`proc-hold-*` layer, between the thin feeders and the active final — the hold is part of the approach unless ATC clears skipping it); a holding aircraft thickens the **whole racetrack** to the active width (`findActiveSegments` emits the entire `kind:'hold'` feature, checked against every airborne aircraft including the selected one, using the same `HOLD_MATCH_*` tolerances as detection). Missed-approach holds stay dash-dot (`proc-missed-*`). @@ -122,7 +131,8 @@ CIFP file facts (verified against live FAA data, June 2026): - `AirportLabelsLayer` — ICAO/LID symbol labels at each active airport; hovering an `AirportSection` header in the sidebar highlights its map label. - `FlownSegmentLayer` / `AutoActiveSegmentsLayer` — highlight the specific leg(s) aircraft are actively flying (`src/geo/flownSegment.ts`, `activeSegments.ts`). - `ExtendedCenterlineLayer` — runway extended centerlines (`src/geo/extendedCenterline.ts`), toggle + length in settings. -- `RunwayLayer`, `AircraftOverlay`, `SelectedAircraftDataBlock` (TRACON-style data block for the selected target), `AltitudeFilter` (dual-handle slider, 20 positions SFC→Class A, see `src/utils/altitudeFilter.ts`). +- `RangeRingsLayer` / `TrackLogLayer` / `HoldEntryLayer` / `PredictionLayer` — the path-prediction overlay stack, mounted (in that bottom-to-top order) between `FlownSegmentLayer` and `WaypointMarkers`: range rings around the selected aircraft (zoom-bucketed radii, `src/geo/rangeRings.ts`), its flown trail colored by altitude (`src/services/trackLog.ts`), every in-flight aircraft's predicted FAA hold-entry lead-in (`src/geo/holdEntry.ts`), and the selected aircraft's predicted path — conflict pairs force-show **both** aircraft's paths tier-colored amber/red regardless of selection (`src/geo/prediction.ts`, `src/geo/conflicts.ts`). `PathControls` (bottom-right stack, above `TrafficFilter`) toggles predicted paths (PRED + 1'/2'/3'/5' horizon) and range rings. +- `RunwayLayer`, `AircraftOverlay`, `SelectedAircraftDataBlock` (TRACON-style data block for the selected target), `AltitudeFilter` (dual-handle slider, 20 positions SFC→Class A, see `src/utils/altitudeFilter.ts`). `AircraftOverlay` and the data block also render traffic/terrain alert chrome — see the path-prediction entry above. - `ActiveProceduresOverlay` (`src/components/layout/`) — the "IN USE" list; groups rows under a per-airport ident sub-header with an ATIS badge once 2+ airports are active, and scrolls past 3+. ## Data sources @@ -169,23 +179,35 @@ render budgets (`MAX_RENDERED_PROCEDURE_LINES`, `MAX_ONSCREEN_WAYPOINT_SYMBOLS`) ADS-B poll clustering (`POLL_CLUSTER_MAX_RADIUS_NM`), detection-machine gates and hysteresis (`DETECT_*`, including the bbox prefilter pad `DETECT_BBOX_PAD_NM`), glideslope math, route-cache TTLs/backoff (`ROUTE_*`), auto-hide delay, -extended-centerline length, map styles, and AIRAC/NASR cycle constants. +extended-centerline length, map styles, and AIRAC/NASR cycle constants. The +path-prediction engine adds its own groups: `TRACKLOG_*` (ring-buffer capacity, +gap-break), `PREDICT_*` (step/horizon, turn-rate clamps and hold/decay timing, +profile-capture tolerance), `HOLD_ENTRY_*` (trigger bearing/ETA/pass-distance +gates, alt tolerance, clear-poll hysteresis, dash pattern), `CONFLICT_*` / +`RADAR_*` (CPA horizon, prefilter, RA escape-maneuver model, ForeFlight-style +radar separation tiers — the TCAS TA/RA sensitivity-level table itself is +`TCAS_SL_TABLE` in `src/geo/tcasTables.ts`, not `constants.ts`), and +`TERRAIN_*` / `RING_ZOOM_BUCKETS` (DEM tile zoom/cache size, MVA/DEM clearance +thresholds, ring radii per zoom bucket). User-adjustable values (poll interval, radius, centerline toggle/length, altitude -filter) live in `useSettingsStore` and persist to localStorage. Active airports + -their per-airport ATIS also persist (`useAirportStore`); sidebar collapse state -persists via `usePaneStore`. +filter, predicted-path visibility/horizon, range rings) live in `useSettingsStore` +and persist to localStorage. Active airports + their per-airport ATIS also +persist (`useAirportStore`); sidebar collapse state persists via `usePaneStore`. ## Testing -Vitest with `jsdom` + globals (`vitest.config.ts`). ~513 tests, unit tests +Vitest with `jsdom` + globals (`vitest.config.ts`). ~696 tests, unit tests colocated in `__tests__/` folders under `geo/`, `utils/`, `api/`, `store/`, `services/`, `hooks/`, and `workers/`. There are no component/integration tests — the interpolation loop and map layers are untested by design (the `api/` suites test parsing/caching with a stubbed `fetch`). When changing ARINC parsing, altitude constraints, AIRAC math, detection geometry/state-machine rules, ATIS -parsing, route caching, airport-index build/validation logic, or multi-airport -store reducers (merge/remove, color assignment, clustering), add/adjust the -matching unit test. +parsing, route caching, airport-index build/validation logic, multi-airport +store reducers (merge/remove, color assignment, clustering), or path-prediction +logic (approach-following/turn-rate prediction, hold-entry classification and +its hysteresis reducer, traffic-conflict CPA/tau/sensitivity-level math, terrain +scan MVA/DEM gating, or the tracklog ring buffer), add/adjust the matching unit +test. ## TypeScript notes diff --git a/src/components/layout/NotForNavigation.tsx b/src/components/layout/NotForNavigation.tsx index 798d662..cfe924b 100644 --- a/src/components/layout/NotForNavigation.tsx +++ b/src/components/layout/NotForNavigation.tsx @@ -61,7 +61,7 @@ const KNOWN_DIFFERENCES: { heading: string; items: string[] }[] = [ items: [ 'Only aircraft broadcasting ADS-B appear — non-equipped, blocked, or non-transmitting traffic (including some military) is invisible.', 'Positions are polled every few seconds and dead-reckoned between polls, so a target on screen is an estimate that lags reality by seconds.', - 'No separation, TCAS, conflict, or wake information of any kind is shown or implied.', + 'Though traffic is depicted with advisory conflict indicators, separation, wake turbulence, and deconfliction is not guaranteed or implied.', 'Callsign-to-route (origin → destination) is best-effort crowd-sourced data and is frequently missing or wrong.', ], }, diff --git a/src/components/map/AircraftOverlay.module.css b/src/components/map/AircraftOverlay.module.css index 9914510..e3fbd16 100644 --- a/src/components/map/AircraftOverlay.module.css +++ b/src/components/map/AircraftOverlay.module.css @@ -29,6 +29,11 @@ /* ── Mini data label — three centered lines below the icon ───────────────── */ +/* transform is the base position; AircraftOverlay.tsx's rAF loop may set an + inline style.transform on this element (or .dataLabelWrap below) to push a + conflicting pair's labels apart, repeating this translate(-50%, 22px) since + an inline style fully replaces the rule below rather than composing with + it. Cleared back to '' once the pair-separation eases out to rest. */ .dataLabel { position: absolute; left: 0; @@ -168,3 +173,86 @@ .selected .altspd { color: #cbd5e1; } + +/* ── Traffic/terrain alert chrome (src/store/usePathStore.ts) ───────────── + Only rendered when the hex has an AircraftAlert — see AircraftOverlay.tsx. + .dataLabelWrap replaces the plain .dataLabel as the positioned element (same + translate(-50%, 22px) as the non-alerted .dataLabel, so the callsign line + doesn't shift when an alert appears/disappears) and stacks, top to bottom: + the unboxed callsign row, the .alertBox/.warnBox border around just the + data rows (altitude/speed/track + type/route/TIS-B), the chip, and — red + tier only — the blinking bar. The nested .dataLabel (now wrapping only the + data rows, inside the box) goes back to static/no-transform since + .dataLabelWrap carries the positioning. */ + +/* See the .dataLabel comment above — same inline pair-separation override + applies to this element when it's the alerted variant. */ +.dataLabelWrap { + position: absolute; + left: 0; + top: 0; + transform: translate(-50%, 22px); + display: flex; + flex-direction: column; + align-items: center; +} + +.dataLabelWrap .dataLabel { + position: static; + transform: none; +} + +/* Amber tier ('alert' | 'ta'): 1px amber outline around the data rows only + (callsign row sits above, unboxed), no fill. */ +.alertBox { + border: 1px solid #fbbf24; /* ALERT_AMBER */ + border-radius: 2px; + padding: 2px 5px; + background: transparent; +} + +/* Red tier ('warning' | 'ra'): pairs with the blinking bar below (.warnBar); + the outline itself stays amber like .alertBox. */ +.warnBox { +} + +.chip { + margin-top: 3px; + padding: 1px 5px; + border-radius: 2px; + font-family: 'Roboto Mono', 'DejaVu Sans Mono', monospace; + font-size: 10px; + font-weight: 700; + letter-spacing: 0.04em; + white-space: nowrap; +} + +.alertChip { + background: #fbbf24; /* ALERT_AMBER */ + color: #1a1406; +} + +.warnChip { + background: #ef4444; /* WARNING_RED */ + color: #ffffff; +} + +.warnBar { + align-self: stretch; + height: 5px; + margin-top: 3px; + border-radius: 1px; + background: #ef4444; /* WARNING_RED */ +} + +/* Blink is applied only to the red fill bar (not text elements), so labels + and chip text stay legible while the bar flashes. */ +.blink { + animation: blink 1s steps(2, jump-none) infinite; +} + +@keyframes blink { + 50% { + opacity: 0.15; + } +} diff --git a/src/components/map/AircraftOverlay.tsx b/src/components/map/AircraftOverlay.tsx index 0813837..8cf1cb7 100644 --- a/src/components/map/AircraftOverlay.tsx +++ b/src/components/map/AircraftOverlay.tsx @@ -3,6 +3,8 @@ import type { MapRef } from 'react-map-gl' import { useAircraftStore } from '../../store/useAircraftStore' import { useSelectionStore, selectedHexOf } from '../../store/useSelectionStore' import { useSettingsStore } from '../../store/useSettingsStore' +import { usePathStore } from '../../store/usePathStore' +import type { AircraftAlert } from '../../types/path' import { formatAltitude, formatSpeed, formatHeading } from '../../utils/formatters' import { altitudeColor } from '../../utils/colorScheme' import { positionToMinFt, positionToMaxFt } from '../../utils/altitudeFilter' @@ -13,6 +15,32 @@ interface Props { mapRef: React.RefObject } +/** Added to the altitude-derived z-index for any aircraft carrying an alert, + * so every alerted aircraft renders above all non-alerted traffic regardless + * of altitude, while still ranking by altitude among themselves. */ +const ALERT_Z_BASE = 100000 + +/** Screen-space threshold (px) below which a conflicting pair's labels are + * pushed apart so both stay readable. */ +const PAIR_LABEL_SEP_TRIGGER_PX = 140 + +/** How far (px) a label is displaced away from the other aircraft in a pair. */ +const PAIR_LABEL_SEP_PX = 46 + +/** Per-frame lerp factor easing the label displacement toward its target so + * it doesn't snap when an alert appears/clears. */ +const PAIR_LABEL_SEP_EASE = 0.25 + +/** Chip text + severity for one alert. Amber tiers ('alert', 'ta') render + * dark-on-amber; red tiers ('warning', 'ra') render white-on-red and add the + * blinking bar. */ +function alertChipInfo(alert: AircraftAlert): { text: string; isRed: boolean } { + const isRed = alert.tier === 'warning' || alert.tier === 'ra' + if (alert.tier === 'ra') return { text: alert.raSense === 'climb' ? 'RA ↑' : 'RA ↓', isRed } + if (alert.tier === 'ta') return { text: 'TA', isRed } + return { text: alert.kind === 'terrain' ? 'TERRAIN' : 'TRAFFIC', isRed } +} + function AircraftIcon() { return ( @@ -33,11 +61,24 @@ export function AircraftOverlay({ mapRef }: Props) { const selectedHex = useSelectionStore((s) => selectedHexOf(s.selected)) const toggleSelection = useSelectionStore((s) => s.toggle) const nodes = useRef>(new Map()) + // The label element (dataLabelWrap when alerted, plain dataLabel otherwise) + // for each aircraft, so the rAF loop can compose an extra pair-separation + // translate onto it without touching the icon/container node. + const labelNodes = useRef>(new Map()) + // Current eased label-separation offset per hex, in screen px. Only hexes + // that are (or were recently) displaced have an entry; settled hexes are + // removed so the steady-state per-frame cost is a `.has()` check. + const labelOffsets = useRef>(new Map()) + // Alerts are filled per-poll by a separate engine (usePathStore); subscribing + // to pathRevision (not the alerts Map itself) buys one cheap re-render per + // poll — same cadence as the aircraft-set revision below — without the rAF + // loop ever touching this store. + const pathRevision = usePathStore((s) => s.pathRevision) // Snapshot the airborne aircraft set; only changes on a poll. const aircraft = useMemo( () => useAircraftStore.getState().getAll().filter((a) => a.altBaro !== 'ground'), - [revision], + [revision, pathRevision], ) // Continuous reposition loop — also updates colour, z-index, and filter @@ -50,6 +91,7 @@ export function AircraftOverlay({ mapRef }: Props) { if (map) { const store = useAircraftStore.getState() const { altFilterMin, altFilterMax, showTisb, showVfr } = useSettingsStore.getState() + const { alerts, forcedVisibleHexes } = usePathStore.getState() const minFt = positionToMinFt(altFilterMin) const maxFt = positionToMaxFt(altFilterMax) @@ -59,12 +101,15 @@ export function AircraftOverlay({ mapRef }: Props) { const alt = ac.altBaro as number // Same imperative show/hide path as the altitude filter, so toggling - // these takes effect immediately without a React re-render. + // these takes effect immediately without a React re-render. A hex + // in forcedVisibleHexes (TA/RA participant) always renders through + // these filters. const hidden = - alt < minFt || - alt > maxFt || - (!showTisb && hex.startsWith('~')) || - (!showVfr && ac.squawk === VFR_SQUAWK) + !forcedVisibleHexes.has(hex) && + (alt < minFt || + alt > maxFt || + (!showTisb && hex.startsWith('~')) || + (!showVfr && ac.squawk === VFR_SQUAWK)) if (hidden) { node.style.display = 'none' @@ -75,8 +120,64 @@ export function AircraftOverlay({ mapRef }: Props) { const p = map.project([ac.interpLon, ac.interpLat]) node.style.transform = `translate(${p.x}px, ${p.y}px)` node.style.color = altitudeColor(ac.altBaro) - // Higher altitude = higher z-index = rendered on top. - node.style.zIndex = String(Math.max(1, Math.floor(alt / 100))) + + const alert = alerts.get(hex) + const baseZ = Math.max(1, Math.floor(alt / 100)) + // Alerted aircraft float above all non-alerted traffic, still + // ranked by altitude among themselves. + node.style.zIndex = String(alert ? ALERT_Z_BASE + baseZ : baseZ) + + // Pair-label separation: when this aircraft's alert names a + // conflicting other aircraft that's currently close on screen, + // displace the label away from it so both stay readable. Skipped + // entirely for hexes with no alert and no in-flight easing, so + // steady-state cost for the vast majority of (non-alerted) + // aircraft is a single Map.has() check. + const otherHex = alert?.otherHex + const hasPriorOffset = labelOffsets.current.has(hex) + const labelNode = labelNodes.current.get(hex) + if (labelNode && (otherHex || hasPriorOffset)) { + let targetX = 0 + let targetY = 0 + const other = otherHex ? store.aircraftMap.get(otherHex) : undefined + if (other) { + const op = map.project([other.interpLon, other.interpLat]) + let dx = p.x - op.x + let dy = p.y - op.y + const dist = Math.hypot(dx, dy) + if (dist < PAIR_LABEL_SEP_TRIGGER_PX) { + if (dist < 1e-6) { + // Coincident aircraft: split deterministically along +/-x + // rather than dividing by a near-zero distance. + dx = hex < otherHex! ? 1 : -1 + dy = 0 + } else { + dx /= dist + dy /= dist + } + targetX = dx * PAIR_LABEL_SEP_PX + targetY = dy * PAIR_LABEL_SEP_PX + } + } + + const prev = labelOffsets.current.get(hex) ?? { x: 0, y: 0 } + const nextX = prev.x + (targetX - prev.x) * PAIR_LABEL_SEP_EASE + const nextY = prev.y + (targetY - prev.y) * PAIR_LABEL_SEP_EASE + + if (targetX === 0 && targetY === 0 && Math.abs(nextX) < 0.05 && Math.abs(nextY) < 0.05) { + // Fully eased back to rest: drop the entry and the inline + // override so the label falls back to its plain CSS transform. + labelOffsets.current.delete(hex) + labelNode.style.transform = '' + } else { + labelOffsets.current.set(hex, { x: nextX, y: nextY }) + // Compose with the label's own base transform (translate(-50%, + // 22px) below the icon) rather than replacing it — an inline + // style always wins over the stylesheet rule, so the base + // offset must be repeated here. + labelNode.style.transform = `translate(-50%, 22px) translate(${nextX}px, ${nextY}px)` + } + } } } raf = requestAnimationFrame(frame) @@ -85,6 +186,10 @@ export function AircraftOverlay({ mapRef }: Props) { return () => cancelAnimationFrame(raf) }, [mapRef]) + // Read once per render (only re-runs on the poll cadence above, never in the + // per-frame rAF loop). + const alerts = usePathStore.getState().alerts + return (
{aircraft.map((ac) => { @@ -101,6 +206,52 @@ export function AircraftOverlay({ mapRef }: Props) { const dest = ac.destination || 'Unkwn' const isVfr = ac.squawk === VFR_SQUAWK const isTisb = ac.hex.startsWith('~') + const alert = alerts.get(ac.hex) + const chip = alert ? alertChipInfo(alert) : null + + // Line 1: callsign or tail number — kept separate from the data rows + // below so an alert's border box can wrap only the latter (see the + // alerted branch further down); rendered identically either way. + const callsignRow = ( +
+ {label} +
+ ) + + // Lines 2-3: ALT↑ SPD HDG°, then VFR/ORIG→DEST + TYPE + TIS-B. + const dataRows = ( + <> +
+ {altStr}{vsi} {formatSpeed(ac.groundspeed)} + {' '} + {formatHeading(ac.track)} +
+ {(isVfr || hasRoute || ac.typeCode || isTisb) && ( +
+ {isVfr ? ( + VFR + ) : ( + hasRoute && ( + {origin}→{dest} + ) + )} + {ac.typeCode && ( + {isVfr || hasRoute ? ' ' : ''}{ac.typeCode} + )} + {isTisb && ( + {isVfr || hasRoute || ac.typeCode ? ' ' : ''}TIS-B + )} +
+ )} + + ) + + const dataLabelContent = ( + <> + {callsignRow} + {dataRows} + + ) return (
{ e.stopPropagation() @@ -126,37 +283,40 @@ export function AircraftOverlay({ mapRef }: Props) {
-
- {/* Line 1: callsign or tail number */} -
- {label} + {alert && chip ? ( +
{ + if (el) labelNodes.current.set(ac.hex, el) + else { + labelNodes.current.delete(ac.hex) + labelOffsets.current.delete(ac.hex) + } + }} + > + {callsignRow} +
+
{dataRows}
+
+
+ {chip.text} +
+ {chip.isRed &&
}
- {/* Line 2: ALT↑ SPD HDG° */} -
- {altStr}{vsi} {formatSpeed(ac.groundspeed)} - {' '} - {formatHeading(ac.track)} + ) : ( +
{ + if (el) labelNodes.current.set(ac.hex, el) + else { + labelNodes.current.delete(ac.hex) + labelOffsets.current.delete(ac.hex) + } + }} + > + {dataLabelContent}
- {/* Line 3: VFR (squawk 1200) or ORIG→DEST, then TYPE, then a - TIS-B source tag for radar-rebroadcast (~hex) targets */} - {(isVfr || hasRoute || ac.typeCode || isTisb) && ( -
- {isVfr ? ( - VFR - ) : ( - hasRoute && ( - {origin}→{dest} - ) - )} - {ac.typeCode && ( - {isVfr || hasRoute ? ' ' : ''}{ac.typeCode} - )} - {isTisb && ( - {isVfr || hasRoute || ac.typeCode ? ' ' : ''}TIS-B - )} -
- )} -
+ )}
) })} diff --git a/src/components/map/AppMap.tsx b/src/components/map/AppMap.tsx index 1844543..fff38e8 100644 --- a/src/components/map/AppMap.tsx +++ b/src/components/map/AppMap.tsx @@ -19,6 +19,11 @@ import { MvaLayer } from './MvaLayer' import { AirspaceLayer } from './AirspaceLayer' import { LocFeatherLayer } from './LocFeatherLayer' import { WaypointMarkers } from './WaypointMarkers' +import { RangeRingsLayer } from './RangeRingsLayer' +import { TrackLogLayer } from './TrackLogLayer' +import { HoldEntryLayer } from './HoldEntryLayer' +import { PredictionLayer } from './PredictionLayer' +import { PathControls } from './PathControls' import { RenderBudgetHint } from './RenderBudgetHint' import { ActiveProceduresOverlay } from '../layout/ActiveProceduresOverlay' import { AltitudeFilter } from './AltitudeFilter' @@ -28,6 +33,7 @@ import { useAircraftInterpolation } from '../../hooks/useAircraftInterpolation' import { useAircraftPoll } from '../../hooks/useAircraftPoll' import { useProcedures } from '../../hooks/useProcedures' import { useProcedureDetection } from '../../hooks/useProcedureDetection' +import { usePathEngine } from '../../hooks/usePathEngine' import { useRouteEnrichment } from '../../hooks/useRouteEnrichment' import { useDatis } from '../../hooks/useDatis' import { useRunways } from '../../hooks/useRunways' @@ -40,6 +46,10 @@ import type { Procedure } from '../../types/procedure' // Render order: lowest value drawn first (bottom), highest last (top). // Approaches are ordered I > R > H > L so precision ILS sits on top. +// The path-prediction overlays mount between FlownSegmentLayer and +// WaypointMarkers, drawn bottom-to-top as RangeRingsLayer -> TrackLogLayer -> +// HoldEntryLayer -> PredictionLayer, so a predicted path sits above the +// historical track log and both sit above the range rings. const APPROACH_RENDER_PRIORITY: Record = { L: 2, H: 3, R: 4, I: 5 } function approachRenderOrder(p: Procedure): number { if (p.type === 'SID') return 0 @@ -65,6 +75,10 @@ export function AppMap() { useAircraftPoll() useProcedures() useProcedureDetection() + // Same-dependency effects run in hook call order, so the path engine sees + // this poll's detection assignments — must stay immediately after + // useProcedureDetection(). Do not reorder. + usePathEngine() useRouteEnrichment() useDatis() useRunways() @@ -173,6 +187,14 @@ export function AppMap() { + + + + + + + + @@ -198,6 +220,7 @@ export function AppMap() { pointerEvents: 'none', }} > +
diff --git a/src/components/map/DataBlock.module.css b/src/components/map/DataBlock.module.css index 3eb6002..4e35c19 100644 --- a/src/components/map/DataBlock.module.css +++ b/src/components/map/DataBlock.module.css @@ -158,3 +158,60 @@ .descending { color: #f87171; } + +/* ── Traffic/terrain alert chrome (src/store/usePathStore.ts) ───────────── + Only rendered when the selected aircraft's hex has an AircraftAlert — see + DataBlock.tsx. .alertWrap centers the chip/bar under .block. */ + +.alertWrap { + display: flex; + flex-direction: column; + align-items: center; +} + +/* Amber tier ('alert' | 'ta'): 1px amber outline on the existing block. */ +.alertBox { + border-color: #fbbf24; /* ALERT_AMBER */ +} + +.chip { + margin-top: 4px; + padding: 1px 6px; + border-radius: 2px; + font-family: 'Roboto Mono', 'Courier New', monospace; + font-size: 10px; + font-weight: 700; + letter-spacing: 0.05em; + white-space: nowrap; +} + +.alertChip { + background: #fbbf24; /* ALERT_AMBER */ + color: #1a1406; +} + +.warnChip { + background: #ef4444; /* WARNING_RED */ + color: #ffffff; +} + +/* Red tier ('warning' | 'ra'): blinking bar below the block. */ +.warnBar { + align-self: stretch; + height: 5px; + margin-top: 4px; + border-radius: 1px; + background: #ef4444; /* WARNING_RED */ +} + +/* Blink is applied only to the red fill bar (not text elements), so labels + and chip text stay legible while the bar flashes. */ +.blink { + animation: blink 1s steps(2, jump-none) infinite; +} + +@keyframes blink { + 50% { + opacity: 0.15; + } +} diff --git a/src/components/map/DataBlock.tsx b/src/components/map/DataBlock.tsx index a44b730..e9459d0 100644 --- a/src/components/map/DataBlock.tsx +++ b/src/components/map/DataBlock.tsx @@ -1,5 +1,6 @@ -import { useState } from 'react' +import { useRef, useState } from 'react' import { Popup } from 'react-map-gl' +import * as turf from '@turf/turf' import type { InterpolatedAircraft } from '../../types/aircraft' import { formatAltitude, @@ -12,7 +13,11 @@ import { import { decodeCallsign, airlineLogoUrl } from '../../utils/airlines' import { decodeAircraftType } from '../../utils/aircraftTypes' import { getAirportByIcao } from '../../hooks/useAirportSearch' +import { usePathStore } from '../../store/usePathStore' +import type { AircraftAlert } from '../../types/path' import { VFR_SQUAWK } from '../../config/constants' +import { bearingDelta } from '../../geo/lineMatching' +import { getRecent } from '../../services/trackLog' import styles from './DataBlock.module.css' interface Props { @@ -20,9 +25,100 @@ interface Props { onClose: () => void } +/** One of the four diagonal placements around the selected aircraft, named by + * its bearing from the plane. Diagonals only — never due N/S — so the block + * can never sit over the range-ring "N NM" badges (`RangeRingsLayer`/ + * `rangeRings.ts`, which anchor at bearing 0/180). */ +export type BlockQuadrant = 45 | 135 | 225 | 315 + +const QUADRANTS: readonly BlockQuadrant[] = [45, 135, 225, 315] + +// Hysteresis thresholds (see pickBlockQuadrant): the current quadrant is only +// abandoned when it's gotten genuinely bad (score below this) AND a candidate +// is clearly better (margin at or above this) — otherwise minor track jitter +// would flap the block between quadrants every poll. +const HYSTERESIS_MIN_SCORE_DEG = 30 +const HYSTERESIS_SWITCH_MARGIN_DEG = 20 + +/** + * Pick the diagonal quadrant (bearing from the aircraft) that best clears both + * the projected path ahead (`projectionDeg`, typically `aircraft.track`) and + * the recent flown trail behind (`trailDeg`). Each candidate is scored by its + * angular distance to the *nearer* of the two directions to avoid; the + * candidate farthest from its nearest obstacle wins. + * + * `currentQuadrant` (null when there isn't one yet, e.g. a fresh selection) + * enables hysteresis: the incumbent is kept unless its own score has dropped + * below `HYSTERESIS_MIN_SCORE_DEG` *and* the best candidate beats it by at + * least `HYSTERESIS_SWITCH_MARGIN_DEG`. Ties among candidates resolve to the + * first in `QUADRANTS` (45 → 135 → 225 → 315). + */ +export function pickBlockQuadrant( + projectionDeg: number, + trailDeg: number, + currentQuadrant: BlockQuadrant | null, +): BlockQuadrant { + let bestQuadrant: BlockQuadrant = QUADRANTS[0] + let bestScore = -Infinity + for (const q of QUADRANTS) { + const score = Math.min(bearingDelta(q, projectionDeg), bearingDelta(q, trailDeg)) + if (score > bestScore) { + bestScore = score + bestQuadrant = q + } + } + + if (currentQuadrant === null) return bestQuadrant + + const currentScore = Math.min( + bearingDelta(currentQuadrant, projectionDeg), + bearingDelta(currentQuadrant, trailDeg), + ) + const shouldSwitch = + currentScore < HYSTERESIS_MIN_SCORE_DEG && bestScore - currentScore >= HYSTERESIS_SWITCH_MARGIN_DEG + return shouldSwitch ? bestQuadrant : currentQuadrant +} + +/** react-map-gl Popup anchor + pixel offset for each quadrant — the anchor is + * the corner of the block nearest the plane, and the offset (signed per axis) + * pushes the block further into that quadrant. The horizontal push (44px) must + * clear the range-ring "N NM" badges and a due-N/S projection line, both of + * which occupy the vertical column through the aircraft (badges are ~48px wide, + * centered); the vertical push (22px) likewise clears a due-E/W projection + * line. Diagonal quadrant choice guarantees ≥~43° of angular clearance from + * the projection/trail, so these fixed pushes keep the block off both. */ +const QUADRANT_POPUP_PROPS: Record< + BlockQuadrant, + { anchor: 'bottom-left' | 'top-left' | 'top-right' | 'bottom-right'; offset: [number, number] } +> = { + 45: { anchor: 'bottom-left', offset: [44, -22] }, + 135: { anchor: 'top-left', offset: [44, 22] }, + 225: { anchor: 'top-right', offset: [-44, 22] }, + 315: { anchor: 'bottom-right', offset: [-44, -22] }, +} + +/** Chip text + severity for one alert. Amber tiers ('alert', 'ta') render + * dark-on-amber; red tiers ('warning', 'ra') render white-on-red and add the + * blinking bar. Duplicated from AircraftOverlay.tsx (small, per-component + * per project convention). */ +function alertChipInfo(alert: AircraftAlert): { text: string; isRed: boolean } { + const isRed = alert.tier === 'warning' || alert.tier === 'ra' + if (alert.tier === 'ra') return { text: alert.raSense === 'climb' ? 'RA ↑' : 'RA ↓', isRed } + if (alert.tier === 'ta') return { text: 'TA', isRed } + return { text: alert.kind === 'terrain' ? 'TERRAIN' : 'TRAFFIC', isRed } +} + export function DataBlock({ aircraft, onClose }: Props) { const [logoOk, setLogoOk] = useState(true) const decoded = decodeCallsign(aircraft.flight) + const quadrantRef = useRef<{ hex: string; quadrant: BlockQuadrant } | null>(null) + // Alerts are filled per-poll by a separate engine (usePathStore); subscribe + // to pathRevision (not the alerts Map itself) so this popup re-renders once + // per poll when an alert appears/clears/changes tier. + const pathRevision = usePathStore((s) => s.pathRevision) + void pathRevision + const alert = usePathStore.getState().alerts.get(aircraft.hex) + const chip = alert ? alertChipInfo(alert) : null const originAirport = aircraft.origin ? getAirportByIcao(aircraft.origin) : undefined const destAirport = aircraft.destination ? getAirportByIcao(aircraft.destination) : undefined @@ -30,112 +126,152 @@ export function DataBlock({ aircraft, onClose }: Props) { const isVfr = aircraft.squawk === VFR_SQUAWK const isTisb = aircraft.hex.startsWith('~') + // Direction of the recent flown trail, as seen looking back from the + // aircraft's current position (i.e. where the trail dots actually sit) — + // this is the reciprocal of travel between an older recent point and the + // newest one, which is why the <2-point fallback below is the reciprocal of + // `track` rather than `track` itself (keeps the two cases continuous). + const recent = getRecent(aircraft.hex, 3) + let trailDeg: number + if (recent.length >= 2) { + const older = recent[0] + const newest = recent[recent.length - 1] + trailDeg = + (turf.bearing(turf.point([newest.lon, newest.lat]), turf.point([older.lon, older.lat])) + 360) % 360 + } else { + trailDeg = (aircraft.track + 180) % 360 + } + + const prevChoice = quadrantRef.current + const currentQuadrant = prevChoice && prevChoice.hex === aircraft.hex ? prevChoice.quadrant : null + const quadrant = pickBlockQuadrant(aircraft.track, trailDeg, currentQuadrant) + quadrantRef.current = { hex: aircraft.hex, quadrant } + const { anchor, offset } = QUADRANT_POPUP_PROPS[quadrant] + return ( -
-
- {formatCallsign(aircraft.flight)} - {isTisb && TIS-B} -
-
- {formatAltitude(aircraft.altBaro)} - {formatSpeed(aircraft.groundspeed)} - {aircraft.typeCode || '???'} -
- - {isVfr ? ( -
- VFR -
- ) : ( - (aircraft.origin || aircraft.destination) && ( -
- {aircraft.origin && ( - <> - FROM - {aircraft.origin} - - )} - {aircraft.destination && ( - <> - TO - {aircraft.destination} - - )} + {(() => { + const blockBody = ( + <> +
+ {formatCallsign(aircraft.flight)} + {isTisb && TIS-B}
- ) - )} - -
- {decoded.airline && ( -
- {logoOk && ( - {decoded.airline.name} setLogoOk(false)} - /> - )} -
-
{decoded.airline.name}
- {decoded.flightNumber && ( -
Flight {decoded.flightNumber}
+
+ {formatAltitude(aircraft.altBaro)} + {formatSpeed(aircraft.groundspeed)} + {aircraft.typeCode || '???'} +
+ + {isVfr ? ( +
+ VFR +
+ ) : ( + (aircraft.origin || aircraft.destination) && ( +
+ {aircraft.origin && ( + <> + FROM + {aircraft.origin} + + )} + {aircraft.destination && ( + <> + TO + {aircraft.destination} + )}
-
+ ) )} -
- TYPE - {friendlyType || aircraft.typeCode || '---'} -
-
- REG - {aircraft.registration || '---'} -
-
- SQK - {formatSquawk(aircraft.squawk)} -
-
- HDG - {formatHeading(aircraft.track)} + +
+ {decoded.airline && ( +
+ {logoOk && ( + {decoded.airline.name} setLogoOk(false)} + /> + )} +
+
{decoded.airline.name}
+ {decoded.flightNumber && ( +
Flight {decoded.flightNumber}
+ )} +
+
+ )} +
+ TYPE + {friendlyType || aircraft.typeCode || '---'} +
+
+ REG + {aircraft.registration || '---'} +
+
+ SQK + {formatSquawk(aircraft.squawk)} +
+
+ HDG + {formatHeading(aircraft.track)} +
+
+ V/S + 200 ? styles.climbing : aircraft.baroRate < -200 ? styles.descending : ''}> + {formatVerticalRate(aircraft.baroRate)} + +
+ {aircraft.origin && ( +
+ FROM + + {aircraft.origin} + {originAirport ? ` · ${originAirport.name}` : ''} + +
+ )} + {aircraft.destination && ( +
+ TO + + {aircraft.destination} + {destAirport ? ` · ${destAirport.name}` : ''} + +
+ )}
-
- V/S - 200 ? styles.climbing : aircraft.baroRate < -200 ? styles.descending : ''}> - {formatVerticalRate(aircraft.baroRate)} - + + ) + + if (!alert || !chip) { + return
{blockBody}
+ } + + return ( +
+
{blockBody}
+
+ {chip.text}
- {aircraft.origin && ( -
- FROM - - {aircraft.origin} - {originAirport ? ` · ${originAirport.name}` : ''} - -
- )} - {aircraft.destination && ( -
- TO - - {aircraft.destination} - {destAirport ? ` · ${destAirport.name}` : ''} - -
- )} -
-
+ {chip.isRed &&
} +
+ ) + })()} ) } diff --git a/src/components/map/HoldEntryLayer.tsx b/src/components/map/HoldEntryLayer.tsx new file mode 100644 index 0000000..b3512a4 --- /dev/null +++ b/src/components/map/HoldEntryLayer.tsx @@ -0,0 +1,104 @@ +import { useMemo } from 'react' +import { Source, Layer } from 'react-map-gl' +import type { Feature, FeatureCollection, LineString } from 'geojson' +import { usePathStore } from '../../store/usePathStore' +import { useProcedureStore, computeVisibility } from '../../store/useProcedureStore' +import { HOLD_ENTRY_DASH } from '../../config/constants' + +// Neutral slate for a context line when its procedure carries no color. +const CONTEXT_FALLBACK_COLOR = '#94a3b8' + +/** procId is the part of the spec key before the `|fixId` suffix. */ +function procIdOf(specKey: string): string { + const i = specKey.indexOf('|') + return i === -1 ? specKey : specKey.slice(0, i) +} + +/** + * Draws every in-flight hold-entry prediction (AIM 5-3-8 direct/teardrop/ + * parallel) as a dotted lead-in path, for all aircraft (not just the + * selected one) — pilots and controllers alike want to see who's about to + * enter a hold and how. + * + * When an entry belongs to a procedure that ISN'T currently visible on the + * map, the white loop would otherwise float with no anchor. So we ALSO draw + * that procedure's own lines thin (context) UNDER the entry path — including + * its drawn hold racetrack, which lives in the same GeoJSON. Once the + * procedure becomes visible/detected normally (ProcedureLayer draws it thick), + * `computeVisibility` returns true and the extra context line drops out. + */ +export function HoldEntryLayer() { + const holdEntries = usePathStore((s) => s.holdEntries) + const procedures = useProcedureStore((s) => s.procedures) + const userToggles = useProcedureStore((s) => s.userToggles) + const autoVisible = useProcedureStore((s) => s.autoVisible) + + const fc = useMemo>(() => { + const features: Feature[] = Array.from(holdEntries.values()).map((entry) => ({ + type: 'Feature', + geometry: { type: 'LineString', coordinates: entry.path }, + properties: { hex: entry.hex }, + })) + return { type: 'FeatureCollection', features } + }, [holdEntries]) + + // Context lines: the LineString geometry of every not-currently-visible + // procedure that owns an active hold entry, stamped with its own color. + const contextFc = useMemo>(() => { + const procIds = new Set() + for (const entry of holdEntries.values()) { + const id = procIdOf(entry.specKey) + if (!computeVisibility(userToggles, autoVisible, id)) procIds.add(id) + } + if (procIds.size === 0) return { type: 'FeatureCollection', features: [] } + + const features: Feature[] = [] + for (const proc of procedures) { + if (!procIds.has(proc.id)) continue + const color = proc.color || CONTEXT_FALLBACK_COLOR + for (const f of proc.geojson.features) { + if (f.geometry.type !== 'LineString') continue + features.push({ + type: 'Feature', + geometry: f.geometry, + properties: { __ctxColor: color }, + }) + } + } + return { type: 'FeatureCollection', features } + }, [holdEntries, procedures, userToggles, autoVisible]) + + return ( + <> + {/* Context: the parent procedure drawn thin, only while it's hidden, so + the entry loop has something to anchor to. Rendered first → below the + entry path. */} + + + + + + + + + ) +} diff --git a/src/components/map/PathControls.module.css b/src/components/map/PathControls.module.css new file mode 100644 index 0000000..74e3cc3 --- /dev/null +++ b/src/components/map/PathControls.module.css @@ -0,0 +1,110 @@ +/* ── Path-prediction control cluster ─────────────────────────────────────── */ +/* Shares TrafficFilter's white NavigationControl-style box so the two sit */ +/* in the same bottom-right control stack (positioned by AppMap). */ + +.container { + background: #fff; + border-radius: 4px; + box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.1); + padding: 6px; + display: flex; + flex-direction: column; + gap: 4px; + pointer-events: all; + user-select: none; +} + +.toggle { + display: flex; + align-items: center; + gap: 5px; + width: 100%; + border: 1px solid #d1d5db; + border-radius: 3px; + background: #f3f4f6; + padding: 3px 6px; + font-family: 'Roboto Mono', monospace; + font-size: 10px; + font-weight: 600; + color: #1a1a1a; + cursor: pointer; + transition: background 0.1s, border-color 0.1s, opacity 0.1s; +} + +.toggle:hover { + border-color: #9ca3af; + background: #e5e7eb; +} + +/* Hidden state: dimmed + struck through, dot hollowed out. */ +.off { + opacity: 0.5; + text-decoration: line-through; +} + +.dot { + width: 8px; + height: 8px; + border-radius: 50%; + flex-shrink: 0; +} + +.dotPred { + background: #ffffff; + box-shadow: inset 0 0 0 1.5px #1a1a1a; +} + +.dotRings { + background: transparent; + box-shadow: inset 0 0 0 1.5px #1a1a1a; +} + +.off .dot { + background: transparent; + box-shadow: inset 0 0 0 1.5px currentColor; +} + +/* Segmented 1'/2'/3'/5' prediction-length group, between the two toggles. */ +.segmented { + display: flex; + gap: 2px; + transition: opacity 0.1s; +} + +.segmentedOff { + opacity: 0.5; +} + +.segment { + flex: 1; + border: 1px solid #d1d5db; + border-radius: 3px; + background: #f3f4f6; + padding: 3px 0; + font-family: 'Roboto Mono', monospace; + font-size: 10px; + font-weight: 600; + color: #1a1a1a; + cursor: pointer; + transition: background 0.1s, border-color 0.1s, color 0.1s; +} + +.segment:hover:not(:disabled) { + border-color: #9ca3af; + background: #e5e7eb; +} + +.segment:disabled { + cursor: default; +} + +.segmentActive { + background: #1e293b; + border-color: #1e293b; + color: #ffffff; +} + +.segmentActive:hover:not(:disabled) { + background: #1e293b; + border-color: #1e293b; +} diff --git a/src/components/map/PathControls.tsx b/src/components/map/PathControls.tsx new file mode 100644 index 0000000..d17c52e --- /dev/null +++ b/src/components/map/PathControls.tsx @@ -0,0 +1,61 @@ +import { useSettingsStore } from '../../store/useSettingsStore' +import { PREDICTION_MINUTES_OPTIONS } from '../../config/constants' +import styles from './PathControls.module.css' + +/** + * Bottom-right control cluster for the path-prediction engine, styled to + * match TrafficFilter so it slots into the same stack. PRED toggles + * predicted-path lines on/off; the 1'/2'/3'/5' segmented group picks how far + * ahead they extend (dimmed and disabled while PRED is off); RINGS toggles + * range rings around the selected aircraft. + */ +export function PathControls() { + const showPredictedPaths = useSettingsStore((s) => s.showPredictedPaths) + const predictionMinutes = useSettingsStore((s) => s.predictionMinutes) + const showRangeRings = useSettingsStore((s) => s.showRangeRings) + const togglePredictedPaths = useSettingsStore((s) => s.togglePredictedPaths) + const setPredictionMinutes = useSettingsStore((s) => s.setPredictionMinutes) + const toggleRangeRings = useSettingsStore((s) => s.toggleRangeRings) + + return ( +
+ + +
+ {PREDICTION_MINUTES_OPTIONS.map((m) => ( + + ))} +
+ + +
+ ) +} diff --git a/src/components/map/PredictionLayer.tsx b/src/components/map/PredictionLayer.tsx new file mode 100644 index 0000000..2edc50b --- /dev/null +++ b/src/components/map/PredictionLayer.tsx @@ -0,0 +1,156 @@ +import { useMemo } from 'react' +import { Source, Layer } from 'react-map-gl' +import type { Feature, FeatureCollection, LineString, Point } from 'geojson' +import type { AlertTier, PredPoint } from '../../types/path' +import { usePathStore } from '../../store/usePathStore' +import { useSelectionStore, selectedHexOf } from '../../store/useSelectionStore' +import { useSettingsStore } from '../../store/useSettingsStore' +import { + PREDICTION_LINE_COLOR, + ALERT_AMBER, + WARNING_RED, + PREDICT_STEP_S, + CONFLICT_HORIZON_S, +} from '../../config/constants' + +const EMPTY_LINES: FeatureCollection = { type: 'FeatureCollection', features: [] } +const EMPTY_POINTS: FeatureCollection = { type: 'FeatureCollection', features: [] } + +/** WARNING_RED for the two escalated tiers, ALERT_AMBER for the advisory ones. */ +function tierColor(tier: AlertTier): string { + return tier === 'warning' || tier === 'ra' ? WARNING_RED : ALERT_AMBER +} + +function lineFeature(points: PredPoint[], properties: Record = {}): Feature | null { + if (points.length < 2) return null + return { + type: 'Feature', + geometry: { type: 'LineString', coordinates: points.map((p) => [p.lon, p.lat]) }, + properties, + } +} + +function dotFeature(point: PredPoint, properties: Record = {}): Feature { + return { + type: 'Feature', + geometry: { type: 'Point', coordinates: [point.lon, point.lat] }, + properties, + } +} + +/** + * Two responsibilities layered into one component: + * 1. The selected aircraft's own predicted path, gated on the + * `showPredictedPaths` setting and sliced to `predictionMinutes`. + * 2. A force-shown predicted path (sliced to CONFLICT_HORIZON_S) for every + * aircraft in a conflict pair, colored by alert tier — this ignores the + * toggle and selection entirely, since a projected loss of separation is + * always worth seeing. A selected hex that's also conflicted renders only + * the conflict styling (no duplicate white line underneath). + */ +export function PredictionLayer() { + const predictions = usePathStore((s) => s.predictions) + const conflictPairs = usePathStore((s) => s.conflictPairs) + const selectedHex = useSelectionStore((s) => selectedHexOf(s.selected)) + const showPredictedPaths = useSettingsStore((s) => s.showPredictedPaths) + const predictionMinutes = useSettingsStore((s) => s.predictionMinutes) + + const conflictHexes = useMemo(() => { + const set = new Set() + for (const pair of conflictPairs) { + set.add(pair.hexA) + set.add(pair.hexB) + } + return set + }, [conflictPairs]) + + const selected = useMemo(() => { + if (!showPredictedPaths || !selectedHex || conflictHexes.has(selectedHex)) { + return { lines: EMPTY_LINES, dots: EMPTY_POINTS } + } + const pred = predictions.get(selectedHex) + if (!pred) return { lines: EMPTY_LINES, dots: EMPTY_POINTS } + + const count = Math.floor((predictionMinutes * 60) / PREDICT_STEP_S) + 1 + const sliced = pred.points.slice(0, count) + const line = lineFeature(sliced) + const last = sliced[sliced.length - 1] + return { + lines: { type: 'FeatureCollection', features: line ? [line] : [] } as FeatureCollection, + dots: { type: 'FeatureCollection', features: last ? [dotFeature(last)] : [] } as FeatureCollection, + } + }, [showPredictedPaths, selectedHex, conflictHexes, predictions, predictionMinutes]) + + const conflict = useMemo(() => { + const lines: Feature[] = [] + const dots: Feature[] = [] + for (const pair of conflictPairs) { + const color = tierColor(pair.tier) + for (const hex of [pair.hexA, pair.hexB]) { + const pred = predictions.get(hex) + if (!pred) continue + const sliced = pred.points.filter((p) => p.tSec <= CONFLICT_HORIZON_S) + const line = lineFeature(sliced, { tierColor: color }) + if (line) lines.push(line) + const last = sliced[sliced.length - 1] + if (last) dots.push(dotFeature(last, { tierColor: color })) + } + } + return { + lines: { type: 'FeatureCollection', features: lines } as FeatureCollection, + dots: { type: 'FeatureCollection', features: dots } as FeatureCollection, + } + }, [conflictPairs, predictions]) + + return ( + <> + + + + + + + + + + + + + + ) +} diff --git a/src/components/map/RangeRingsLayer.module.css b/src/components/map/RangeRingsLayer.module.css new file mode 100644 index 0000000..98c5778 --- /dev/null +++ b/src/components/map/RangeRingsLayer.module.css @@ -0,0 +1,12 @@ +/* Small "N NM" chip anchored to each range ring's 12/6 o'clock point. */ +.chip { + background: rgba(15, 23, 42, 0.85); + color: #ffffff; + border: 1px solid rgba(255, 255, 255, 0.4); + border-radius: 3px; + padding: 1px 4px; + font-family: 'Roboto Mono', 'DejaVu Sans Mono', monospace; + font-size: 9px; + white-space: nowrap; + pointer-events: none; +} diff --git a/src/components/map/RangeRingsLayer.tsx b/src/components/map/RangeRingsLayer.tsx new file mode 100644 index 0000000..cb04cc2 --- /dev/null +++ b/src/components/map/RangeRingsLayer.tsx @@ -0,0 +1,121 @@ +import { useEffect, useRef, useState } from 'react' +import { Source, Layer, Marker, useMap } from 'react-map-gl' +import type { FeatureCollection, LineString } from 'geojson' +import type { GeoJSONSource } from 'mapbox-gl' +import { useAircraftStore } from '../../store/useAircraftStore' +import { useSelectionStore, selectedHexOf } from '../../store/useSelectionStore' +import { useSettingsStore } from '../../store/useSettingsStore' +import { ringRadiiForZoom, ringFeatures, ringBadges, type RingBadge } from '../../geo/rangeRings' +import styles from './RangeRingsLayer.module.css' + +const EMPTY: FeatureCollection = { type: 'FeatureCollection', features: [] } +// Below this movement (~0.1m at the equator) a setData call would be a no-op +// visually, so the rAF loop skips it — the rings only need to actually move +// when the aircraft's interpolated position has meaningfully changed. +const MOVE_EPSILON_DEG = 1e-6 +const BADGE_INTERVAL_MS = 250 + +interface LastRings { + lat: number + lon: number + radii: readonly [number, number, number] +} + +/** + * Range rings (1/3/6 nm etc., bucketed by zoom) centered on the selected + * aircraft. The rings themselves follow every frame via an imperative + * `setData` (no React churn, mirroring AircraftOverlay's rAF pattern); + * `ringRadiiForZoom` returns the same array reference for a given zoom + * bucket, so a plain `!==` check is enough to detect a bucket change without + * re-deriving it. Badge labels are cheaper DOM Markers, refreshed on a slow + * timer since sub-frame precision doesn't matter for text. + */ +export function RangeRingsLayer() { + const showRangeRings = useSettingsStore((s) => s.showRangeRings) + const selectedHex = useSelectionStore((s) => selectedHexOf(s.selected)) + const { current: mapRef } = useMap() + const [badges, setBadges] = useState([]) + const lastRef = useRef(null) + + const active = showRangeRings && !!selectedHex + + useEffect(() => { + const map = mapRef?.getMap() + + if (!map || !active || !selectedHex) { + lastRef.current = null + const source = map?.getSource('range-rings') as GeoJSONSource | undefined + source?.setData(EMPTY) + return + } + + let raf = 0 + const frame = () => { + const ac = useAircraftStore.getState().aircraftMap.get(selectedHex) + if (ac) { + const radii = ringRadiiForZoom(map.getZoom()) + const last = lastRef.current + const moved = + !last || + Math.abs(last.lat - ac.interpLat) > MOVE_EPSILON_DEG || + Math.abs(last.lon - ac.interpLon) > MOVE_EPSILON_DEG + const bucketChanged = !last || last.radii !== radii + + if (moved || bucketChanged) { + const source = map.getSource('range-rings') as GeoJSONSource | undefined + source?.setData(ringFeatures(ac.interpLat, ac.interpLon, radii)) + lastRef.current = { lat: ac.interpLat, lon: ac.interpLon, radii } + } + } + raf = requestAnimationFrame(frame) + } + raf = requestAnimationFrame(frame) + return () => cancelAnimationFrame(raf) + }, [mapRef, active, selectedHex]) + + useEffect(() => { + const map = mapRef?.getMap() + if (!map || !active || !selectedHex) { + setBadges([]) + return + } + + const recompute = () => { + const ac = useAircraftStore.getState().aircraftMap.get(selectedHex) + if (!ac) { + setBadges([]) + return + } + const radii = ringRadiiForZoom(map.getZoom()) + const project = (lonLat: [number, number]) => map.project(lonLat) + setBadges(ringBadges(ac.interpLat, ac.interpLon, radii, project)) + } + + recompute() + const id = setInterval(recompute, BADGE_INTERVAL_MS) + return () => clearInterval(id) + }, [mapRef, active, selectedHex]) + + return ( + <> + + + + + {active && + badges.map((b) => ( + +
{b.radiusNm} NM
+
+ ))} + + ) +} diff --git a/src/components/map/TrackLogLayer.tsx b/src/components/map/TrackLogLayer.tsx new file mode 100644 index 0000000..4eae665 --- /dev/null +++ b/src/components/map/TrackLogLayer.tsx @@ -0,0 +1,84 @@ +import { useEffect, useState } from 'react' +import { Source, Layer } from 'react-map-gl' +import type { Feature, FeatureCollection, LineString } from 'geojson' +import { useAircraftStore } from '../../store/useAircraftStore' +import { useSelectionStore, selectedHexOf } from '../../store/useSelectionStore' +import { usePathStore } from '../../store/usePathStore' +import { getTrack } from '../../services/trackLog' +import { altitudeColor } from '../../utils/colorScheme' +import { TRACKLOG_GAP_BREAK_MS } from '../../config/constants' + +type TrackFeature = Feature + +const EMPTY: FeatureCollection = { type: 'FeatureCollection', features: [] } + +/** + * Draws the selected aircraft's flown trail: one 2-point segment per + * consecutive tracklog pair, colored by the older point's altitude (so the + * trail reads as a mini altitude heatmap), plus a live final segment from the + * newest logged point to the aircraft's current interpolated position so the + * tail keeps up between polls. Recomputed on a 1s interval like + * FlownSegmentLayer — the tracklog only grows once per poll, but the "final + * segment" endpoint moves every interpolation frame. + */ +export function TrackLogLayer() { + const selectedHex = useSelectionStore((s) => selectedHexOf(s.selected)) + const pathRevision = usePathStore((s) => s.pathRevision) + const [fc, setFc] = useState>(EMPTY) + + useEffect(() => { + if (!selectedHex) { + setFc(EMPTY) + return + } + + const recompute = () => { + const track = getTrack(selectedHex) + const features: TrackFeature[] = [] + + for (let i = 1; i < track.length; i++) { + const prev = track[i - 1] + const curr = track[i] + if (curr.tMs - prev.tMs > TRACKLOG_GAP_BREAK_MS) continue + features.push({ + type: 'Feature', + geometry: { type: 'LineString', coordinates: [[prev.lon, prev.lat], [curr.lon, curr.lat]] }, + properties: { color: altitudeColor(prev.altFt) }, + }) + } + + const last = track[track.length - 1] + if (last) { + const ac = useAircraftStore.getState().aircraftMap.get(selectedHex) + if (ac) { + features.push({ + type: 'Feature', + geometry: { type: 'LineString', coordinates: [[last.lon, last.lat], [ac.interpLon, ac.interpLat]] }, + properties: { color: altitudeColor(last.altFt) }, + }) + } + } + + setFc({ type: 'FeatureCollection', features }) + } + + recompute() + const id = setInterval(recompute, 1000) + return () => clearInterval(id) + }, [selectedHex, pathRevision]) + + return ( + + + + ) +} diff --git a/src/components/map/__tests__/pickBlockQuadrant.test.ts b/src/components/map/__tests__/pickBlockQuadrant.test.ts new file mode 100644 index 0000000..69d0e94 --- /dev/null +++ b/src/components/map/__tests__/pickBlockQuadrant.test.ts @@ -0,0 +1,47 @@ +import { describe, it, expect } from 'vitest' +import { pickBlockQuadrant } from '../DataBlock' + +describe('pickBlockQuadrant', () => { + it('picks the diagonal farthest from both the projected path ahead and the trail direction', () => { + // Heading due north (0) with a trail bearing due east (90, e.g. mid-turn) — + // the SW diagonal (225) is the only one far from both. + expect(pickBlockQuadrant(0, 90, null)).toBe(225) + }) + + it('picks a perpendicular diagonal for straight-line flight, never the fore/aft ones', () => { + // Track 015 with no incumbent: the trail direction for genuinely straight + // flight is the reciprocal of track (195, per the <2-track-point + // fallback), so the 45/225 diagonals (nearly astride the flight line) lose + // to the 135/315 diagonals (perpendicular to it). + const quadrant = pickBlockQuadrant(15, 195, null) + expect([135, 315]).toContain(quadrant) + }) + + it('resolves ties to the first quadrant in NE, SE, SW, NW order', () => { + // Due-north path / due-south trail scores all four diagonals equally (45° + // each) — the tie must resolve deterministically rather than flapping. + expect(pickBlockQuadrant(0, 180, null)).toBe(45) + }) + + it('keeps the incumbent quadrant while its score stays at/above the hysteresis floor', () => { + // Same tied geometry as above (every quadrant scores 45°, comfortably + // over the 30° floor) — a prior selection of 315 must not flap to the + // tie-break winner (45) just because 45 is what a fresh pick would choose. + expect(pickBlockQuadrant(0, 180, 315)).toBe(315) + }) + + it('keeps the incumbent exactly at the 30° floor (switch requires a strictly lower score)', () => { + // Track 015 / reciprocal trail scores the NE diagonal (45) at exactly 30° + // and the perpendicular diagonals at 60° — a 30-point margin that would + // clear the 20° hysteresis margin, but the switch condition requires the + // incumbent's own score to drop *below* 30, not just be beaten. + expect(pickBlockQuadrant(15, 195, 45)).toBe(45) + }) + + it('switches once the incumbent score drops below the 30° floor', () => { + // Nudging the same geometry so the incumbent's score is 29 (just under + // the floor) with a 32° margin to the best alternative (225) — both + // hysteresis conditions are now met, so it switches. + expect(pickBlockQuadrant(16, 164, 45)).toBe(225) + }) +}) diff --git a/src/components/profile/ProfilePanel.tsx b/src/components/profile/ProfilePanel.tsx index 293eef3..b8b4fb5 100644 --- a/src/components/profile/ProfilePanel.tsx +++ b/src/components/profile/ProfilePanel.tsx @@ -7,7 +7,10 @@ import { useAircraftStore } from '../../store/useAircraftStore' import { useCifpStore, getRunwayInfoForAirport } from '../../services/cifpCache' import { pickProfileTransition, buildProfileModel, alongTrackNm } from '../../geo/profileMath' import type { ProfileModel, LiveAircraft } from '../../geo/profileMath' +import { buildProfileTrail } from '../../geo/profileTrail' +import type { ProfileTrailPoint } from '../../geo/profileTrail' import { pickPanelAnchor, type Rect } from '../../geo/panelPlacement' +import { getTrack } from '../../services/trackLog' import { PROFILE_PANEL_MIN_W, PROFILE_PANEL_MIN_H, @@ -212,6 +215,29 @@ export function ProfilePanel({ mapRef }: Props) { return () => clearInterval(id) }, [procedure, transition, detectedHexesForProc, selectedHex]) + // ── Selected aircraft's flown-history trace (deviation from the published + // vertical path). Independent of the detection/assignment gate above — + // the trail exists whenever the selection has a tracklog, not only once + // it's confirmed flying this approach — but shares the same transition, + // projection, and refresh cadence as the live-aircraft dots above. No + // selection (or no kept points) means no trail. ── + const [selectedTrail, setSelectedTrail] = useState([]) + useEffect(() => { + if (!transition || !model || !selectedHex || model.fixes.length === 0) { + setSelectedTrail([]) + return + } + const maxDistNm = model.fixes[model.fixes.length - 1].distNm + + const tick = () => { + setSelectedTrail(buildProfileTrail(getTrack(selectedHex), transition, maxDistNm)) + } + + tick() + const id = setInterval(tick, PROFILE_AIRCRAFT_UPDATE_MS) + return () => clearInterval(id) + }, [transition, model, selectedHex]) + if (!procedure) return null return ( @@ -241,6 +267,7 @@ export function ProfilePanel({ mapRef }: Props) { diff --git a/src/components/profile/ProfileSvg.module.css b/src/components/profile/ProfileSvg.module.css index 760d877..0690ac9 100644 --- a/src/components/profile/ProfileSvg.module.css +++ b/src/components/profile/ProfileSvg.module.css @@ -9,6 +9,18 @@ font-size: 12px; } +/* ── selected aircraft's flown-history trace ────────────────────── + Deliberately subordinate: no fill, low opacity, no per-altitude coloring + (altitude IS the y axis already) — visible as a deviation trace but never + competing with the descent path or a live aircraft's own glyph. */ +.selectedTrail { + stroke: #ffffff; + stroke-width: 1.5; + stroke-opacity: 0.4; + stroke-linecap: round; + fill: none; +} + /* ── ground / terrain reference ─────────────────────────────────── */ .groundLine { stroke: #64748b; diff --git a/src/components/profile/ProfileSvg.tsx b/src/components/profile/ProfileSvg.tsx index dbefd27..b23d463 100644 --- a/src/components/profile/ProfileSvg.tsx +++ b/src/components/profile/ProfileSvg.tsx @@ -9,11 +9,16 @@ import { placeProfileLabels, } from '../../geo/profileMath' import type { ProfileFix, ProfileModel, LiveAircraft } from '../../geo/profileMath' +import type { ProfileTrailPoint } from '../../geo/profileTrail' import styles from './ProfileSvg.module.css' interface Props { model: ProfileModel liveAircraft?: LiveAircraft[] + /** Selected aircraft's flown-history trace (distNm/altFt), pre-segmented on + * time/distance gaps — see src/geo/profileTrail.ts. Empty/omitted when no + * aircraft is selected. Drawn as the lowest layer in the plot. */ + selectedTrail?: ProfileTrailPoint[][] width: number height: number } @@ -454,7 +459,7 @@ function HoldInLieuFigure({ // Memoized: the live-aircraft tick re-renders the parent every second, but // the static profile geometry only depends on model/width/height. -export const ProfileSvg = memo(function ProfileSvg({ model, liveAircraft = [], width, height }: Props) { +export const ProfileSvg = memo(function ProfileSvg({ model, liveAircraft = [], selectedTrail = [], width, height }: Props) { if (model.fixes.length < 2) { return ( @@ -628,9 +633,24 @@ export const ProfileSvg = memo(function ProfileSvg({ model, liveAircraft = [], w return ( + {/* selected aircraft's flown-history trace — rendered FIRST (no background + rect exists to sit above), so it's the lowest layer in the plot: the + descent path, fix symbols/labels, holds, glideslope, and every live + aircraft dot (including marker cones below) all draw on top of it. */} + {selectedTrail.map((seg, i) => + seg.length >= 2 ? ( + `${j === 0 ? 'M' : 'L'} ${xScale(pt.distNm)} ${yScale(pt.altFt)}`).join(' ')} + fill="none" + /> + ) : null, + )} + {/* marker-beacon cones — narrow dotted triangles rising from the ground at - each marker fix up to the top of the plot. Rendered first so they sit - behind every other layer. */} + each marker fix up to the top of the plot. Rendered early (just above + the selected-aircraft trail) so they sit behind every other layer. */} {model.fixes.map((f, i) => f.marker ? ( nm / (60 * Math.cos(LAT0 * D2R)) +/** Latitude degrees spanning `nm`. */ +const latNm = (nm: number) => nm / 60 + +const NO_AIRPORTS: ConflictContext = { airports: [] } + +interface AcSpec { + hex: string + lat: number + lon: number + track: number + gsKt: number + altFt: number + baroRateFpm?: number + altBaro?: number | 'ground' + squawk?: string + registration?: string + flight?: string +} + +/** Straight constant-rate predicted path on the 5 s grid (equirect motion). */ +function pred(spec: AcSpec): PredictedPath { + const rate = spec.baroRateFpm ?? 0 + const cosLat = Math.cos(spec.lat * D2R) + const points: PredPoint[] = [] + for (let t = 0; t <= HORIZON_S; t += STEP_S) { + const dNm = (spec.gsKt * t) / 3600 + points.push({ + lat: spec.lat + (dNm * Math.cos(spec.track * D2R)) / 60, + lon: spec.lon + (dNm * Math.sin(spec.track * D2R)) / (60 * cosLat), + tSec: t, + altFt: spec.altFt + (rate / 60) * t, + }) + } + return { hex: spec.hex, mode: 'straight', points } +} + +function makeAc(spec: AcSpec): InterpolatedAircraft { + return { + hex: spec.hex, + flight: spec.flight ?? spec.hex.toUpperCase(), + registration: spec.registration ?? `N${spec.hex.toUpperCase()}`, + typeCode: 'B738', + lat: spec.lat, + lon: spec.lon, + altBaro: spec.altBaro ?? spec.altFt, + altGeom: spec.altFt, + groundspeed: spec.gsKt, + track: spec.track, + baroRate: spec.baroRateFpm ?? 0, + squawk: spec.squawk ?? '3421', + lastPollMs: 0, + interpLat: spec.lat, + interpLon: spec.lon, + } +} + +function scenario(specs: AcSpec[]) { + const predictions = new Map() + const acByHex = new Map() + for (const s of specs) { + predictions.set(s.hex, pred(s)) + acByHex.set(s.hex, makeAc(s)) + } + return { predictions, acByHex } +} + +function run(specs: AcSpec[], ctx: ConflictContext = NO_AIRPORTS): ConflictPair[] { + const { predictions, acByHex } = scenario(specs) + return evaluateTrafficConflicts(predictions, acByHex, ctx) +} + +/** Head-on pair at SL5 altitude: A eastbound at LON0, B westbound `rangeNm` east. */ +function headOn(rangeNm: number, altA: number, altB: number, over: Partial[] = [{}, {}]): AcSpec[] { + return [ + { hex: 'aaa111', lat: LAT0, lon: LON0, track: 90, gsKt: 180, altFt: altA, ...over[0] }, + { hex: 'bbb222', lat: LAT0, lon: LON0 + lonNm(rangeNm), track: 270, gsKt: 180, altFt: altB, ...over[1] }, + ] +} + +describe('evaluateTrafficConflicts — TCAS tau tiers (head-on at 5000 MSL, SL5)', () => { + // 360 kt closure. 3.9 nm → tau 39 s: inside TA tau (40) but outside RA tau + // (25), and range stays >1.3 nm through the 25 s radar-warning window so the + // radar tier can't outrank the TA. + it('fires a TA when tau crosses the TA threshold but not RA', () => { + const pairs = run(headOn(3.9, 5000, 5000)) + expect(pairs).toHaveLength(1) + expect(pairs[0].tier).toBe('ta') + expect(pairs[0].raSenseA).toBeUndefined() + expect(pairs[0].raSenseB).toBeUndefined() + expect(pairs[0].cpaDAltFt).toBe(0) + }) + + // 2.0 nm → tau 20 s ≤ RA tau 25 s. + it('fires an RA as the geometry tightens (TA-before-RA ordering)', () => { + const ta = run(headOn(3.9, 5000, 5000))[0] + const ra = run(headOn(2.0, 5000, 5000))[0] + expect(ta.tier).toBe('ta') + expect(ra.tier).toBe('ra') + expect(ra.hexA).toBe('aaa111') + expect(ra.hexB).toBe('bbb222') + expect(ra.cpaTimeS).toBe(20) + expect(ra.cpaNm).toBeCloseTo(0, 3) + // Complementary senses always accompany an RA. + expect([ra.raSenseA, ra.raSenseB].sort()).toEqual(['climb', 'descend']) + }) +}) + +describe('evaluateTrafficConflicts — proximity (DMOD/ZTHR) triggering', () => { + // Parallel co-speed tracks: closure 0 → tau Infinity, so only the DMOD/ZTHR + // proximity box can trigger. 0.52 nm is inside SL5's RA DMOD (0.55) but just + // outside FORMATION_SUPPRESS_NM (0.5) — a genuinely tighter separation would + // be indistinguishable from the formation/duplicate-track case below, which + // is exactly the point of that gate. + it('zero closure inside the RA DMOD/ZTHR box → ra via proximity', () => { + const pairs = run([ + { hex: 'aaa111', lat: LAT0, lon: LON0, track: 90, gsKt: 180, altFt: 5000 }, + { hex: 'bbb222', lat: LAT0 + latNm(0.52), lon: LON0, track: 90, gsKt: 180, altFt: 5000 }, + ]) + expect(pairs).toHaveLength(1) + expect(pairs[0].tier).toBe('ra') + expect(pairs[0].cpaTimeS).toBe(0) + expect(pairs[0].cpaNm).toBeCloseTo(0.52, 3) + expect([pairs[0].raSenseA, pairs[0].raSenseB].sort()).toEqual(['climb', 'descend']) + }) + + // 0.6 nm sits between SL5's RA DMOD (0.55) and TA DMOD (0.75), so the TA + // proximity condition is satisfied. These two are on parallel co-speed tracks + // at CONSTANT 0.6 nm separation — nothing is converging — so the radar tier's + // convergence gate suppresses the radar warning it would otherwise inherit. + // TCAS DMOD/ZTHR proximity is unchanged, so it surfaces as the bare 'ta'. + it('zero closure between RA and TA DMOD, stable → ta (radar convergence gate suppresses the warning)', () => { + const pairs = run([ + { hex: 'aaa111', lat: LAT0, lon: LON0, track: 90, gsKt: 180, altFt: 5000 }, + { hex: 'bbb222', lat: LAT0 + latNm(0.6), lon: LON0, track: 90, gsKt: 180, altFt: 5000 }, + ]) + expect(pairs).toHaveLength(1) + expect(pairs[0].tier).toBe('ta') + expect(pairs[0].raSenseA).toBeUndefined() + }) +}) + +describe('evaluateTrafficConflicts — RA sense selection', () => { + it('gives the higher aircraft climb when both senses reach ALIM', () => { + // A level at 5200, B level at 5000, head-on RA (tau 20 s, CPA t=20). + // Both senses exceed ALIM 350 (950 vs 550 ft) → higher (A) climbs. + const pairs = run(headOn(2.0, 5200, 5000)) + expect(pairs).toHaveLength(1) + expect(pairs[0].tier).toBe('ra') + expect(pairs[0].raSenseA).toBe('climb') + expect(pairs[0].raSenseB).toBe('descend') + }) + + it('chooses the crossing sense when only higher-descends reaches ALIM', () => { + // A 4500 ft at −1000 fpm, B 4900 ft at −3000 fpm, CPA at t=15 (1.5 nm + // head-on, 360 kt closure). Projected at CPA: A 4250 > B 4150, so A is + // the "higher" aircraft — but A-climbs/B-descends only opens 267 ft + // (< ALIM 300 at SL4) because B's steep descent eats the escape, while + // the crossing sense (A descends, B climbs) opens 733 ft ≥ ALIM. + const pairs = run( + headOn(1.5, 4500, 4900, [{ baroRateFpm: -1000 }, { baroRateFpm: -3000 }]), + ) + expect(pairs).toHaveLength(1) + expect(pairs[0].tier).toBe('ra') + expect(pairs[0].cpaTimeS).toBe(15) + expect(pairs[0].raSenseA).toBe('descend') + expect(pairs[0].raSenseB).toBe('climb') + }) +}) + +describe('evaluateTrafficConflicts — radar tier', () => { + // Perpendicular-offset head-on crossing: A eastbound, B westbound offset + // `sepNm` north, positioned to pass abeam at `tAbeamS`. + function offsetCrossing(sepNm: number, dAltFt: number, tAbeamS: number, gsKt: number): AcSpec[] { + const alongNm = (gsKt * tAbeamS) / 3600 // each covers this before abeam + return [ + { hex: 'aaa111', lat: LAT0, lon: LON0 - lonNm(alongNm), track: 90, gsKt, altFt: 5000 }, + { hex: 'bbb222', lat: LAT0 + latNm(sepNm), lon: LON0 + lonNm(alongNm), track: 270, gsKt, altFt: 5000 + dAltFt }, + ] + } + + it('CPA 1.9 nm / 1100 ft at t=40 → alert', () => { + // 1100 ft > every TA/RA ZTHR and min sep 1.9 > warn's 1.3 nm, so only the + // radar alert box (2.0 nm / 1200 ft, t ≤ 45) catches it. + const pairs = run(offsetCrossing(1.9, 1100, 40, 180)) + expect(pairs).toHaveLength(1) + expect(pairs[0].tier).toBe('alert') + expect(pairs[0].cpaTimeS).toBe(40) + expect(pairs[0].cpaNm).toBeCloseTo(1.9, 2) + expect(pairs[0].cpaDAltFt).toBeCloseTo(1100, 6) + }) + + it('CPA 1.2 nm / 400 ft at t=20 → warning', () => { + const pairs = run(offsetCrossing(1.2, 400, 20, 180)) + expect(pairs).toHaveLength(1) + expect(pairs[0].tier).toBe('warning') + expect(pairs[0].cpaTimeS).toBe(20) + expect(pairs[0].cpaNm).toBeCloseTo(1.2, 2) + }) + + it('VFR-vs-VFR converging pair → radar tier inhibited, no pair', () => { + // Same geometry as the 1.9 nm / 1100 ft radar-alert case, but both aircraft + // squawk 1200. STARS Conflict Alert is inhibited for VFR-vs-VFR pairs + // (controllers don't separate VFRs), so the radar tier must not fire. The + // TCAS tier is unaffected but stays quiet here on its own: cpaDAlt 1100 ft + // exceeds every TA ZTHR (850) and range never enters a TA DMOD box, so tau + // never latches a TA → no pair at all. + const specs = offsetCrossing(1.9, 1100, 40, 180) + specs[0].squawk = '1200' + specs[1].squawk = '1200' + expect(run(specs)).toHaveLength(0) + }) + + it('the same converging geometry VFR-vs-IFR → radar alert as before', () => { + // One aircraft carries a discrete code, so the VFR-vs-VFR inhibit does not + // apply and the radar tier alerts exactly as the all-IFR case does. + const specs = offsetCrossing(1.9, 1100, 40, 180) + specs[0].squawk = '1200' + const pairs = run(specs) + expect(pairs).toHaveLength(1) + expect(pairs[0].tier).toBe('alert') + }) + + it('stable parallel tracks inside the radar window → no radar tier, no pair', () => { + // Two aircraft on long final, 0.6 nm abeam, identical track/speed/altitude: + // separation is constant, so nothing is converging into the radar window and + // the convergence gate must suppress the radar tier entirely. 0.6 nm sits + // inside the radar warn window (1.3 nm) — WITHOUT the gate this would surface + // as a 'warning'. Altitude 4000 MSL → SL4 (agl Infinity, no ctx airport), and + // 0.6 nm > SL4's TA DMOD (0.48) and RA DMOD (0.35) so no TCAS proximity fires + // either; tau is infinite (zero closure) so no tau-based TA/RA. Net: no pair. + const pairs = run([ + { hex: 'aaa111', lat: LAT0, lon: LON0, track: 90, gsKt: 180, altFt: 4000 }, + { hex: 'bbb222', lat: LAT0 + latNm(0.6), lon: LON0, track: 90, gsKt: 180, altFt: 4000 }, + ]) + expect(pairs).toHaveLength(0) + }) + + it('the same 1.2 nm / 400 ft geometry at t=60 → neither radar tier (no pair)', () => { + // Faster closure keeps them >2.0 nm through the whole 45 s alert window + // (2.33 nm at t=45) and tau at t=0 is ~61 s > every TA tau. + const pairs = run(offsetCrossing(1.2, 400, 60, 240)) + expect(pairs).toHaveLength(0) + }) +}) + +describe('evaluateTrafficConflicts — non-conflicts and prefilter', () => { + it('diverging aircraft produce no pair', () => { + const pairs = run([ + { hex: 'aaa111', lat: LAT0, lon: LON0, track: 90, gsKt: 180, altFt: 5000 }, + { hex: 'bbb222', lat: LAT0, lon: LON0 - lonNm(3), track: 270, gsKt: 180, altFt: 5000 }, + ]) + expect(pairs).toHaveLength(0) + }) + + it('prefilter: 40 nm apart → skipped', () => { + const pairs = run([ + { hex: 'aaa111', lat: LAT0, lon: LON0, track: 90, gsKt: 400, altFt: 10000 }, + { hex: 'bbb222', lat: LAT0, lon: LON0 + lonNm(40), track: 270, gsKt: 400, altFt: 10000 }, + ]) + expect(pairs).toHaveLength(0) + }) + + it('prefilter: 8000 ft apart with low vertical closure → skipped', () => { + const pairs = run([ + { hex: 'aaa111', lat: LAT0, lon: LON0, track: 90, gsKt: 180, altFt: 5000 }, + { hex: 'bbb222', lat: LAT0 + latNm(0.3), lon: LON0, track: 90, gsKt: 180, altFt: 13000 }, + ]) + expect(pairs).toHaveLength(0) + }) + + it('excludes aircraft reporting on-ground', () => { + const specs = headOn(2.0, 5000, 5000) + specs[1].altBaro = 'ground' + expect(run(specs)).toHaveLength(0) + }) +}) + +describe('evaluateTrafficConflicts — low-AGL near-airport suppression', () => { + // Head-on at 300 ft, 1.5 nm apart — normally a solid conflict. + const specs = headOn(1.5, 300, 300) + + it('suppresses when both are <400 AGL within 3 nm of a ctx airport', () => { + const ctx: ConflictContext = { + airports: [{ lat: LAT0, lon: LON0 + lonNm(0.75), elevationFt: 0 }], + } + expect(run(specs, ctx)).toHaveLength(0) + }) + + it('keeps the same geometry when no ctx airport is nearby', () => { + const ctx: ConflictContext = { + airports: [{ lat: LAT0 + latNm(100), lon: LON0, elevationFt: 0 }], + } + const pairs = run(specs, ctx) + expect(pairs).toHaveLength(1) + // 300 AGL is SL2 (TA-only): the TA fires but the radar warning outranks it. + expect(pairs[0].tier).toBe('warning') + expect(pairs[0].raSenseA).toBeUndefined() + }) +}) + +describe('evaluateTrafficConflicts — formation / duplicate-track suppression', () => { + // Two co-moving aircraft 0.3 nm abeam on the same track/speed/altitude — + // inside what would otherwise be RA DMOD/ZTHR proximity (see the "zero + // closure inside the RA DMOD/ZTHR box" case above at 0.5 nm). + function formationPair(over: Partial[] = [{}, {}]): AcSpec[] { + return [ + { hex: 'aaa111', lat: LAT0, lon: LON0, track: 90, gsKt: 180, altFt: 5000, ...over[0] }, + { hex: 'bbb222', lat: LAT0 + latNm(0.3), lon: LON0, track: 90, gsKt: 180, altFt: 5000, ...over[1] }, + ] + } + + it('co-moving pair (0.3 nm abeam, matched track/speed/alt) -> suppressed, no pair at all', () => { + const pairs = run(formationPair()) + expect(pairs).toHaveLength(0) + }) + + it('same positions but tracks 30° apart -> pair evaluated (not suppressed by this gate)', () => { + const pairs = run(formationPair([{}, { track: 120 }])) + expect(pairs.length).toBeGreaterThan(0) + }) + + it('same track but speeds 40 kt apart -> pair evaluated (not suppressed by this gate)', () => { + const pairs = run(formationPair([{}, { gsKt: 220 }])) + expect(pairs.length).toBeGreaterThan(0) + }) +}) + +describe('evaluateTrafficConflicts — TIS-B shadow suppression', () => { + /** Point `nm` from (lat, lon) at compass bearing `bearingDeg`. */ + function offsetBearing(lat: number, lon: number, bearingDeg: number, nm: number) { + const cosLat = Math.cos(lat * D2R) + return { + lat: lat + (nm * Math.cos(bearingDeg * D2R)) / 60, + lon: lon + (nm * Math.sin(bearingDeg * D2R)) / (60 * cosLat), + } + } + + // Field case: an MLAT-only target (a6d675) and a '~' TIS-B trackfile of the + // SAME airplane, ~35 s stale, trailing ~1.1 nm behind on a matched course + // (4500/4600 ft, 122/118 kt, track 340/342). 1.1 nm is well outside + // FORMATION_SUPPRESS_NM (0.5) — the formation gate misses it — but inside + // TISB_SHADOW_NM (2.5), and every other delta (100 ft, 2°, 4 kt) is inside + // its wider tolerances too. + function tisbShadowPair(over: Partial[] = [{}, {}]): AcSpec[] { + const behind = offsetBearing(LAT0, LON0, 340 + 180, 1.1) + return [ + { hex: 'a6d675', lat: LAT0, lon: LON0, track: 340, gsKt: 122, altFt: 4500, ...over[0] }, + { hex: '~2ba559', lat: behind.lat, lon: behind.lon, track: 342, gsKt: 118, altFt: 4600, ...over[1] }, + ] + } + + it('MLAT target + TIS-B twin 1.1 nm in-trail, 100 ft / 2° / 4 kt apart -> suppressed, no pair', () => { + const pairs = run(tisbShadowPair()) + expect(pairs).toHaveLength(0) + }) + + it('a converging pair where one side is TIS-B but tracks differ by 180° -> still alerts', () => { + // Reuses the known "fires a TA" head-on geometry (3.9 nm, 5000/5000 ft) + // but tags one hex '~'. bearingDelta(90, 270) = 180° >> TISB_SHADOW_TRK_DEG + // (15°), so the shadow gate must not apply, proving it is selective on + // co-moving track rather than blanket-suppressing any pair touching a + // TIS-B hex. + const specs = headOn(3.9, 5000, 5000) + specs[1].hex = '~bbb222' + const pairs = run(specs) + expect(pairs).toHaveLength(1) + expect(pairs[0].tier).toBe('ta') + }) +}) + +describe('evaluateTrafficConflicts — same-registration/callsign dedupe', () => { + it('same registration inside RA geometry -> suppressed, no pair', () => { + const specs = headOn(2.0, 5000, 5000) // otherwise fires an RA (see TCAS suite above) + specs[0].registration = 'N123AB' + specs[1].registration = 'N123AB' + expect(run(specs)).toHaveLength(0) + }) + + it('same flight/callsign inside RA geometry -> suppressed, no pair', () => { + const specs = headOn(2.0, 5000, 5000) + specs[0].flight = 'UAL123' + specs[1].flight = 'UAL123' + expect(run(specs)).toHaveLength(0) + }) + + it('distinct registration and callsign -> pair evaluated normally (ra)', () => { + const pairs = run(headOn(2.0, 5000, 5000)) + expect(pairs).toHaveLength(1) + expect(pairs[0].tier).toBe('ra') + }) +}) + +describe('alertsFromConflicts', () => { + const base = { cpaTimeS: 30, cpaNm: 1.0, cpaDAltFt: 200 } + + it('keeps the worst tier per hex and threads otherHex', () => { + const pairs: ConflictPair[] = [ + { hexA: 'aaa', hexB: 'bbb', tier: 'ta', ...base }, + { hexA: 'aaa', hexB: 'ccc', tier: 'warning', ...base }, + ] + const alerts = alertsFromConflicts(pairs) + expect(alerts.get('aaa')).toEqual({ kind: 'traffic', tier: 'warning', otherHex: 'ccc' }) + expect(alerts.get('bbb')).toEqual({ kind: 'traffic', tier: 'ta', otherHex: 'aaa' }) + expect(alerts.get('ccc')).toEqual({ kind: 'traffic', tier: 'warning', otherHex: 'aaa' }) + }) + + it('threads each side\'s RA sense through and ranks ra above all', () => { + const pairs: ConflictPair[] = [ + { hexA: 'aaa', hexB: 'bbb', tier: 'warning', ...base }, + { hexA: 'aaa', hexB: 'ddd', tier: 'ra', raSenseA: 'climb', raSenseB: 'descend', ...base }, + ] + const alerts = alertsFromConflicts(pairs) + expect(alerts.get('aaa')).toEqual({ kind: 'traffic', tier: 'ra', raSense: 'climb', otherHex: 'ddd' }) + expect(alerts.get('ddd')).toEqual({ kind: 'traffic', tier: 'ra', raSense: 'descend', otherHex: 'aaa' }) + expect(alerts.get('bbb')?.tier).toBe('warning') + }) +}) diff --git a/src/geo/__tests__/holdEntry.test.ts b/src/geo/__tests__/holdEntry.test.ts new file mode 100644 index 0000000..b0fb9e2 --- /dev/null +++ b/src/geo/__tests__/holdEntry.test.ts @@ -0,0 +1,760 @@ +import { describe, it, expect } from 'vitest' +import * as turf from '@turf/turf' +import { + collectHoldSpecs, + classifyHoldEntry, + holdEntryPath, + reduceHoldEntries, + emptyHoldEntryState, + type HoldEntryInput, + type HoldEntryState, +} from '../holdEntry' +import { dest, holdTrack } from '../procedureShapes' +import type { Procedure, AltConstraint } from '../../types/procedure' +import type { InterpolatedAircraft } from '../../types/aircraft' +import type { HoldSpec, PredictedPath } from '../../types/path' +import type { Feature } from 'geojson' + +const NM = { units: 'nauticalmiles' as const } +type Pt = [number, number] + +const FIX_LAT = 47.5 +const FIX_LON = -122.3 +const FIX: Pt = [FIX_LON, FIX_LAT] +const HEX = 'a1b2c3' + +const distNm = (a: Pt, b: Pt): number => turf.distance(turf.point(a), turf.point(b), NM) +const brg = (a: Pt, b: Pt): number => (turf.bearing(turf.point(a), turf.point(b)) + 360) % 360 +const brgDelta = (a: number, b: number): number => Math.abs(((a - b + 540) % 360) - 180) + +// ── Factories ─────────────────────────────────────────────────────────────── + +function makeSpec(over: Partial = {}): HoldSpec { + return { + key: 'KXYZ-R34|SAVOY', + procId: 'KXYZ-R34', + fixId: 'SAVOY', + fixLat: FIX_LAT, + fixLon: FIX_LON, + inboundCourseTrue: 360, + turnRight: true, + legNm: 4, + alt: null, + segment: 'transition', + ...over, + } +} + +function makeAc(over: Partial = {}): InterpolatedAircraft { + const pos = dest(FIX, 5, 210) // 5 nm out on the 210 radial → bearing to fix 030 + return { + hex: HEX, + flight: 'TEST1', + registration: 'N1', + typeCode: 'C172', + lat: pos[1], + lon: pos[0], + altBaro: 4000, + altGeom: 4000, + groundspeed: 150, + track: 30, + baroRate: 0, + squawk: '2345', + lastPollMs: 0, + interpLat: pos[1], + interpLon: pos[0], + ...over, + } +} + +/** Predicted path arriving at the fix along the given radial. */ +function makePred(altFt = 4000, radialDeg = 210): PredictedPath { + const p1 = dest(FIX, 2, radialDeg) + return { + hex: HEX, + mode: 'straight', + points: [ + { lon: p1[0], lat: p1[1], tSec: 60, altFt }, + { lon: FIX[0], lat: FIX[1], tSec: 120, altFt }, + ], + } +} + +function makeInput(over: Partial = {}): HoldEntryInput { + return { + nowMs: 1000, + aircraft: [makeAc()], + predictions: new Map([[HEX, makePred()]]), + specs: [makeSpec()], + assignments: {}, + ...over, + } +} + +function holdFeature(over: Record = {}, coords?: Pt[]): Feature { + return { + type: 'Feature', + properties: { + kind: 'hold', + segment: 'transition', + transitionId: 'SAVOY', + fixId: 'SAVOY', + inboundCourseMag: 345, + turnRight: true, + alt: { type: 'AT_OR_ABOVE', low: 2000 } satisfies AltConstraint, + ...over, + }, + geometry: { type: 'LineString', coordinates: coords ?? holdTrack(FIX_LAT, FIX_LON, 360, true, 4) }, + } +} + +function makeProc(over: Partial = {}): Procedure { + return { + id: 'KXYZ-R34', + icao: 'KXYZ', + name: 'R34', + type: 'APPROACH', + runways: ['34'], + waypoints: [ + { id: 'SAVOY', lat: FIX_LAT, lon: FIX_LON, navaidType: 'FIX', altConstraint: null, sequenceNumber: 10 }, + ], + symbols: [], + geojson: { type: 'FeatureCollection', features: [holdFeature()] }, + hasGeometry: true, + color: '#22d3ee', + magVarDeg: 15, + ...over, + } +} + +// ── classifyHoldEntry ─────────────────────────────────────────────────────── + +describe('classifyHoldEntry', () => { + const cases: Array<[number, 'direct' | 'teardrop' | 'parallel']> = [ + [0, 'direct'], + [69.9, 'direct'], + [70, 'direct'], // boundary: exactly 70 stays direct + [70.1, 'parallel'], + [120, 'parallel'], + [180, 'parallel'], // boundary: exactly 180 is parallel + [180.1, 'teardrop'], + [249.9, 'teardrop'], + [250, 'teardrop'], // boundary: exactly 250 is teardrop + [250.1, 'direct'], + [300, 'direct'], + ] + + it.each(cases)('right-turn hold, r=%s → %s', (r, expected) => { + // hold inbound 360; aircraft track = inbound + r + expect(classifyHoldEntry((360 + r) % 360, 360, true)).toBe(expected) + }) + + it('left-turn hold mirrors the sectors', () => { + // r_left = 360 − r_right: left r=290 behaves like right r=70 (direct) + expect(classifyHoldEntry(290, 360, false)).toBe('direct') + expect(classifyHoldEntry(289.9, 360, false)).toBe('parallel') // like right 70.1 + expect(classifyHoldEntry(180, 360, false)).toBe('parallel') // like right 180 + expect(classifyHoldEntry(110, 360, false)).toBe('teardrop') // like right 250 + expect(classifyHoldEntry(109.9, 360, false)).toBe('direct') // like right 250.1 + expect(classifyHoldEntry(0, 360, false)).toBe('direct') + }) +}) + +// ── collectHoldSpecs ──────────────────────────────────────────────────────── + +describe('collectHoldSpecs', () => { + it('dedupes holdInLieu vs hold feature at the same fix, the DRAWN feature winning', () => { + // holdInLieu disagrees with the drawn racetrack on turn direction (a + // HILPT/missed data mismatch). The drawn feature is what the user sees, so + // its values must win — otherwise the entry mirrors to the wrong side. + const proc = makeProc({ + holdInLieu: { + fixId: 'SAVOY', + transitionId: 'SAVOY', + inboundCourseMag: 345, + outboundCourseMag: 165, + turnRight: false, // ← opposite of the drawn feature (turnRight: true) + legNm: 5, + alt: { type: 'AT_OR_ABOVE', low: 2000 }, + }, + }) + const specs = collectHoldSpecs([proc]) + expect(specs).toHaveLength(1) + expect(specs[0].turnRight).toBe(true) // from the drawn feature, not holdInLieu + expect(specs[0].legNm).toBeCloseTo(4, 2) // measured off the drawn racetrack + expect(specs[0].key).toBe('KXYZ-R34|SAVOY') + expect(specs[0].segment).toBe('transition') + }) + + it('prefers a transition hold over a missed hold at the same fix', () => { + const proc = makeProc({ + geojson: { + type: 'FeatureCollection', + features: [ + holdFeature({ segment: 'missed', turnRight: false }), + holdFeature({ segment: 'transition', turnRight: true }), + ], + }, + }) + const specs = collectHoldSpecs([proc]) + expect(specs).toHaveLength(1) + expect(specs[0].segment).toBe('transition') + expect(specs[0].turnRight).toBe(true) + }) + + it('anchors the spec at the drawn racetrack fix, not a stray waypoint position', () => { + // Named waypoint deliberately far from where the racetrack is drawn: the + // spec (and thus the entry loop) must follow the drawn geometry, or the + // loop floats "in space" away from the visible hold. + const proc = makeProc({ + waypoints: [ + { id: 'SAVOY', lat: FIX_LAT + 3, lon: FIX_LON + 3, navaidType: 'FIX', altConstraint: null, sequenceNumber: 10 }, + ], + }) + const specs = collectHoldSpecs([proc]) + expect(specs).toHaveLength(1) + expect(specs[0].fixLat).toBeCloseTo(FIX_LAT, 4) + expect(specs[0].fixLon).toBeCloseTo(FIX_LON, 4) + expect(distNm(holdEntryPath(specs[0], 'direct')[0], FIX)).toBeLessThan(0.01) + }) + + it('applies magvar: inboundCourseTrue = mag + var(E)', () => { + const specs = collectHoldSpecs([makeProc()]) + expect(specs).toHaveLength(1) + expect(specs[0].inboundCourseTrue).toBeCloseTo(360 % 360, 5) // 345 + 15 + expect(specs[0].fixLat).toBeCloseTo(FIX_LAT, 6) + expect(specs[0].fixLon).toBeCloseTo(FIX_LON, 6) + }) + + it('defaults legNm to 4 for feature-only holds and keeps missed segment', () => { + const proc = makeProc({ + geojson: { type: 'FeatureCollection', features: [holdFeature({ segment: 'missed' })] }, + }) + const specs = collectHoldSpecs([proc]) + expect(specs).toHaveLength(1) + expect(specs[0].legNm).toBeCloseTo(4, 2) + expect(specs[0].segment).toBe('missed') + expect(specs[0].alt).toEqual({ type: 'AT_OR_ABOVE', low: 2000 }) + }) + + it('caches per Procedure object (identity-stable specs)', () => { + const proc = makeProc() + const a = collectHoldSpecs([proc]) + const b = collectHoldSpecs([proc]) + expect(b[0]).toBe(a[0]) + }) + + it('derives course and turn direction from the DRAWN geometry when props disagree (LOFAL)', () => { + // LOFAL-style: LEFT-turn hold, drawn true inbound ≈ 145° (130M + 15E). + // The feature's PROPS lie about both course and turn direction (drawn- + // racetrack course/magvar parser bugs are a separate investigation) — the + // spec must follow the drawn coordinates, which are what the user sees, so + // the entry can never mirror or rotate relative to the on-screen hold. + const proc = makeProc({ + geojson: { + type: 'FeatureCollection', + features: [ + holdFeature( + { inboundCourseMag: 310, turnRight: true }, // both wrong vs the drawn loop + holdTrack(FIX_LAT, FIX_LON, 145, false, 5), + ), + ], + }, + }) + const specs = collectHoldSpecs([proc]) + expect(specs).toHaveLength(1) + expect(brgDelta(specs[0].inboundCourseTrue, 145)).toBeLessThan(0.5) + expect(specs[0].turnRight).toBe(false) + expect(specs[0].legNm).toBeCloseTo(5, 1) + }) +}) + +// ── holdEntryPath ─────────────────────────────────────────────────────────── + +describe('holdEntryPath', () => { + const spec = makeSpec() + + it.each(['direct', 'teardrop', 'parallel'] as const)('%s path starts at the fix', (kind) => { + const path = holdEntryPath(spec, kind) + expect(distNm(path[0], FIX)).toBeLessThan(0.01) + }) + + it.each(['direct', 'teardrop', 'parallel'] as const)( + '%s last segment tracks the inbound course (±5°)', + (kind) => { + const path = holdEntryPath(spec, kind) + const last = brg(path[path.length - 2], path[path.length - 1]) + expect(brgDelta(last, 360)).toBeLessThan(5) + }, + ) + + it('direct entry is the racetrack itself (mates with holdTrack, right turns)', () => { + const path = holdEntryPath(spec, 'direct') + const track = holdTrack(FIX_LAT, FIX_LON, 360, true, 4) + for (const p of path) { + const nearest = Math.min(...track.map((t) => distNm(p, t))) + expect(nearest).toBeLessThan(0.02) + } + }) + + it('direct entry mates with holdTrack for a left-turn hold too', () => { + const left = makeSpec({ turnRight: false }) + const path = holdEntryPath(left, 'direct') + const track = holdTrack(FIX_LAT, FIX_LON, 360, false, 4) + for (const p of path) { + const nearest = Math.min(...track.map((t) => distNm(p, t))) + expect(nearest).toBeLessThan(0.02) + } + }) + + it('teardrop outbound is recip − 30° for a right hold (toward the holding side)', () => { + const path = holdEntryPath(spec, 'teardrop') + expect(brgDelta(brg(path[0], path[1]), 150)).toBeLessThan(1) + expect(path[1][0]).toBeGreaterThan(FIX_LON) // east = holding side of an inbound-360 right hold + }) + + it('teardrop outbound is recip + 30° for a left hold', () => { + const left = makeSpec({ turnRight: false }) + const path = holdEntryPath(left, 'teardrop') + expect(brgDelta(brg(path[0], path[1]), 210)).toBeLessThan(1) + expect(path[1][0]).toBeLessThan(FIX_LON) // west = holding side for left turns + }) + + it('parallel outbound lies on the NON-holding side', () => { + const rightPath = holdEntryPath(spec, 'parallel') + expect(rightPath[1][0]).toBeLessThan(FIX_LON) // west of an inbound-360 right hold + + const leftPath = holdEntryPath(makeSpec({ turnRight: false }), 'parallel') + expect(leftPath[1][0]).toBeGreaterThan(FIX_LON) + }) + + it('parallel rejoins the inbound course outside the fix', () => { + const path = holdEntryPath(spec, 'parallel') + const join = path[path.length - 2] + // Join point sits behind the fix (south, on the reciprocal side) on the course line. + expect(brgDelta(brg(FIX, join), 180)).toBeLessThan(1) + }) +}) + +// ── MGNUM-style geometry regressions (defects a & b) ───────────────────────── + +/** Signed cross-track of `p` from the inbound-course line through the fix: + * positive = right of the inbound direction (the holding side for right turns). */ +function sideOfCourse(p: Pt, fix: Pt, inb: number): number { + const d = distNm(fix, p) + const b = turf.bearing(turf.point(fix), turf.point(p)) + const theta = ((b - inb + 540) % 360) - 180 + return d * Math.sin((theta * Math.PI) / 180) +} +const centroidSide = (path: Pt[], fix: Pt, inb: number): number => + path.reduce((s, p) => s + sideOfCourse(p, fix, inb), 0) / path.length + +/** Largest interior direction reversal (deg), ignoring near-zero-length + * segments (coincident vertices produce a meaningless 180° artifact). */ +function maxKinkDeg(path: Pt[]): number { + let max = 0 + for (let i = 1; i < path.length - 1; i++) { + if (distNm(path[i], path[i + 1]) < 1e-4 || distNm(path[i - 1], path[i]) < 1e-4) continue + const b1 = brg(path[i - 1], path[i]) + const b2 = brg(path[i], path[i + 1]) + max = Math.max(max, brgDelta(b1, b2)) + } + return max +} + +describe('holdEntryPath MGNUM-style geometry', () => { + // MGNUM (KSEA I34L HILPT family) charts an inbound course near 161°. + const INB = 161 + + it.each([true, false])('direct & teardrop sit on the same side as holdTrack (right=%s)', (right) => { + const spec = makeSpec({ inboundCourseTrue: INB, turnRight: right }) + const trackSide = centroidSide(holdTrack(FIX_LAT, FIX_LON, INB, right, 4), FIX, INB) + for (const kind of ['direct', 'teardrop'] as const) { + const entrySide = centroidSide(holdEntryPath(spec, kind), FIX, INB) + // Same sign as the drawn racetrack — never mirrored to the far side. + expect(Math.sign(entrySide)).toBe(Math.sign(trackSide)) + } + // Parallel is deliberately drawn on the NON-holding side. + const parSide = centroidSide(holdEntryPath(spec, 'parallel'), FIX, INB) + expect(Math.sign(parSide)).toBe(-Math.sign(trackSide)) + }) + + it.each([true, false])('no entry path has a spurious mid-path reversal (right=%s)', (right) => { + for (const inb of [INB, 360, 90]) { + const spec = makeSpec({ inboundCourseTrue: inb, turnRight: right }) + for (const kind of ['direct', 'teardrop', 'parallel'] as const) { + // Turn arcs step ≤45°; the only intentional corner is the single 45° + // intercept. Anything ≥100° would be a jog/reversal like the old teardrop. + expect(maxKinkDeg(holdEntryPath(spec, kind))).toBeLessThan(100) + } + } + }) + + it('teardrop rolls out onto the inbound course with a single 45° intercept, no dog-leg', () => { + const spec = makeSpec({ inboundCourseTrue: INB, turnRight: true }) + const path = holdEntryPath(spec, 'teardrop') + // Final leg exactly on the inbound course. + expect(brgDelta(brg(path[path.length - 2], path[path.length - 1]), INB)).toBeLessThan(1) + // The single sharpest corner is ~45° (the intercept), not a ~135° reversal. + expect(maxKinkDeg(path)).toBeLessThan(55) + expect(maxKinkDeg(path)).toBeGreaterThan(35) + }) +}) + +// ── Direct-entry orientation invariant (LOFAL defect 1) ───────────────────── + +describe('direct entry superimposes on the drawn racetrack', () => { + // End-to-end through collectHoldSpecs with DELIBERATELY WRONG props (course + // and turn direction both lie): the spec derives from the drawn geometry, so + // the direct-entry loop must land on the drawn racetrack — extending from + // the fix toward the OUTBOUND side, never mirrored or flipped. + const cases: Array<[number, boolean]> = [ + [130, true], + [130, false], + [341, true], + [341, false], + ] + + it.each(cases)('inb=%s right=%s', (inb, right) => { + const track = holdTrack(FIX_LAT, FIX_LON, inb, right, 4) + const wrongMag = (inb + 180 - 15 + 360) % 360 // props claim the reciprocal + const proc = makeProc({ + geojson: { + type: 'FeatureCollection', + features: [holdFeature({ inboundCourseMag: wrongMag, turnRight: !right }, track)], + }, + }) + const specs = collectHoldSpecs([proc]) + expect(specs).toHaveLength(1) + expect(specs[0].turnRight).toBe(right) + expect(brgDelta(specs[0].inboundCourseTrue, inb)).toBeLessThan(0.5) + + const path = holdEntryPath(specs[0], 'direct') + const within = path.filter((p) => Math.min(...track.map((t) => distNm(p, t))) < 0.05) + expect(within.length / path.length).toBeGreaterThan(0.8) + }) +}) + +// ── Trigger gates ─────────────────────────────────────────────────────────── + +describe('reduceHoldEntries trigger gates', () => { + it('creates an entry when every gate passes', () => { + const s = reduceHoldEntries(emptyHoldEntryState(), makeInput()) + const rec = s.entries.get(HEX) + expect(rec).toBeDefined() + expect(rec!.specKey).toBe('KXYZ-R34|SAVOY') + expect(rec!.entry).toBe('direct') // arriving on 030 vs inbound 360 → r=30 + expect(rec!.path.length).toBeGreaterThan(2) + expect(rec!.divergedPolls).toBe(0) + expect(rec!.crossedFix).toBe(false) + }) + + it('classifies from the predicted arrival track (parallel case)', () => { + const pos = dest(FIX, 5, 300) + const input = makeInput({ + aircraft: [makeAc({ lat: pos[1], lon: pos[0], interpLat: pos[1], interpLon: pos[0], track: 120 })], + predictions: new Map([[HEX, makePred(4000, 300)]]), + }) + const s = reduceHoldEntries(emptyHoldEntryState(), input) + expect(s.entries.get(HEX)?.entry).toBe('parallel') // r = 120 + }) + + it('rejects a track 11° off the bearing to the fix', () => { + const s = reduceHoldEntries(emptyHoldEntryState(), makeInput({ aircraft: [makeAc({ track: 41 })] })) + expect(s.entries.size).toBe(0) + }) + + it('rejects an ETA over 180 s', () => { + // 5 nm at 90 kt → 200 s + const s = reduceHoldEntries( + emptyHoldEntryState(), + makeInput({ aircraft: [makeAc({ groundspeed: 90 })] }), + ) + expect(s.entries.size).toBe(0) + }) + + it('rejects a predicted path that never passes the fix', () => { + const p1 = dest(FIX, 5, 120) + const p2 = dest(FIX, 3, 120) + const miss: PredictedPath = { + hex: HEX, + mode: 'straight', + points: [ + { lon: p1[0], lat: p1[1], tSec: 60, altFt: 4000 }, + { lon: p2[0], lat: p2[1], tSec: 120, altFt: 4000 }, + ], + } + const s = reduceHoldEntries(emptyHoldEntryState(), makeInput({ predictions: new Map([[HEX, miss]]) })) + expect(s.entries.size).toBe(0) + }) + + it('rejects an aircraft already established inbound', () => { + const pos = dest(FIX, 3, 180) // on the inbound course line, south of the fix + const input = makeInput({ + aircraft: [makeAc({ lat: pos[1], lon: pos[0], interpLat: pos[1], interpLon: pos[0], track: 360 })], + predictions: new Map([[HEX, makePred(4000, 180)]]), + }) + const s = reduceHoldEntries(emptyHoldEntryState(), input) + expect(s.entries.size).toBe(0) + }) + + it('rejects a predicted altitude 2500 ft above an AT_OR_BELOW constraint', () => { + const spec = makeSpec({ alt: { type: 'AT_OR_BELOW', low: 4000 } }) + const bad = reduceHoldEntries( + emptyHoldEntryState(), + makeInput({ specs: [spec], predictions: new Map([[HEX, makePred(6500)]]) }), + ) + expect(bad.entries.size).toBe(0) + + const ok = reduceHoldEntries( + emptyHoldEntryState(), + makeInput({ specs: [spec], predictions: new Map([[HEX, makePred(4500)]]) }), + ) + expect(ok.entries.size).toBe(1) + }) + + it('rejects a hex with an approach assignment', () => { + const s = reduceHoldEntries( + emptyHoldEntryState(), + makeInput({ assignments: { [HEX]: 'KXYZ-I34' } }), + ) + expect(s.entries.size).toBe(0) + }) +}) + +// ── Reducer lifecycle ─────────────────────────────────────────────────────── + +describe('reduceHoldEntries lifecycle', () => { + function created(): HoldEntryState { + return reduceHoldEntries(emptyHoldEntryState(), makeInput()) + } + + it('keeps path identity stable across qualifying polls', () => { + const s1 = created() + const pos = dest(FIX, 4.5, 210) + const s2 = reduceHoldEntries( + s1, + makeInput({ + nowMs: 2000, + aircraft: [makeAc({ lat: pos[1], lon: pos[0], interpLat: pos[1], interpLon: pos[0] })], + }), + ) + const r1 = s1.entries.get(HEX)! + const r2 = s2.entries.get(HEX)! + expect(r2.path).toBe(r1.path) + expect(r2.entry).toBe(r1.entry) + expect(r2.lastQualifiedMs).toBe(2000) + expect(r2.divergedPolls).toBe(0) + }) + + it('clears when an assignment appears', () => { + const s2 = reduceHoldEntries(created(), makeInput({ assignments: { [HEX]: 'KXYZ-I34' } })) + expect(s2.entries.size).toBe(0) + }) + + it('clears once crossedFix and aligned with the inbound course', () => { + const s1 = created() + const pos = dest(FIX, 0.3, 210) + // Within 0.5 nm of the fix (crossedFix) and track 030 is within 75° of inbound 360. + const s2 = reduceHoldEntries( + s1, + makeInput({ + nowMs: 2000, + aircraft: [makeAc({ lat: pos[1], lon: pos[0], interpLat: pos[1], interpLon: pos[0] })], + }), + ) + expect(s2.entries.size).toBe(0) + }) + + it('persists after crossing the fix while still turning (track > 75° off inbound)', () => { + const s1 = created() + const pos = dest(FIX, 0.4, 210) + const s2 = reduceHoldEntries( + s1, + makeInput({ + nowMs: 2000, + aircraft: [makeAc({ lat: pos[1], lon: pos[0], interpLat: pos[1], interpLon: pos[0], track: 120 })], + }), + ) + const rec = s2.entries.get(HEX) + expect(rec).toBeDefined() + expect(rec!.crossedFix).toBe(true) + + // Then rolling out inbound clears it. + const s3 = reduceHoldEntries( + s2, + makeInput({ + nowMs: 3000, + aircraft: [makeAc({ lat: pos[1], lon: pos[0], interpLat: pos[1], interpLon: pos[0], track: 350 })], + }), + ) + expect(s3.entries.size).toBe(0) + }) + + function divergingInput(nowMs: number, distOut: number): HoldEntryInput { + const pos = dest(FIX, distOut, 210) + return makeInput({ + nowMs, + aircraft: [makeAc({ lat: pos[1], lon: pos[0], interpLat: pos[1], interpLon: pos[0], track: 210 })], + }) + } + + it('clears after 3 consecutive diverging polls', () => { + let s = created() + s = reduceHoldEntries(s, divergingInput(2000, 6)) + expect(s.entries.get(HEX)?.divergedPolls).toBe(1) + s = reduceHoldEntries(s, divergingInput(3000, 7)) + expect(s.entries.get(HEX)?.divergedPolls).toBe(2) + s = reduceHoldEntries(s, divergingInput(4000, 8)) + expect(s.entries.size).toBe(0) + }) + + it('clears a stalled entry after HOLD_ENTRY_STALE_MS even without divergence', () => { + // Aircraft loiters off the fix: non-qualifying (track points away) but at a + // constant distance, so divergedPolls never increments — the old code would + // strand the loop forever. The stale-out must clear it regardless. + const stalled = (nowMs: number): HoldEntryInput => { + const pos = dest(FIX, 5, 210) + return makeInput({ + nowMs, + aircraft: [makeAc({ lat: pos[1], lon: pos[0], interpLat: pos[1], interpLon: pos[0], track: 300 })], + }) + } + let s = created() // lastQualifiedMs = 1000 + s = reduceHoldEntries(s, stalled(20000)) + expect(s.entries.get(HEX)?.divergedPolls).toBe(0) // distance flat → never diverges + s = reduceHoldEntries(s, stalled(40000)) + expect(s.entries.has(HEX)).toBe(true) + s = reduceHoldEntries(s, stalled(61000)) // 61000 − 1000 ≥ 60000 → stale + expect(s.entries.size).toBe(0) + }) + + it('resets the diverging counter on a qualifying poll', () => { + let s = created() + s = reduceHoldEntries(s, divergingInput(2000, 6)) + s = reduceHoldEntries(s, divergingInput(3000, 7)) + expect(s.entries.get(HEX)?.divergedPolls).toBe(2) + // Turns back toward the fix and qualifies again. + const pos = dest(FIX, 5, 210) + s = reduceHoldEntries( + s, + makeInput({ + nowMs: 4000, + aircraft: [makeAc({ lat: pos[1], lon: pos[0], interpLat: pos[1], interpLon: pos[0] })], + }), + ) + const rec = s.entries.get(HEX) + expect(rec).toBeDefined() + expect(rec!.divergedPolls).toBe(0) + expect(rec!.lastQualifiedMs).toBe(4000) + }) + + it('clears when the hex vanishes from the aircraft list', () => { + const s2 = reduceHoldEntries(created(), makeInput({ aircraft: [] })) + expect(s2.entries.size).toBe(0) + expect(s2.lastDistNm.size).toBe(0) + }) +}) + +// ── LOFAL regression: freeze after first qualification (defect 2) ─────────── + +describe('reduceHoldEntries LOFAL freeze', () => { + // LEFT-turn hold at LOFAL: drawn true inbound ≈ 145° (130M + 15E), racetrack + // extends NW of the fix on the outbound side. Props deliberately disagree so + // only the geometry derivation can produce the correct spec. + const LOFAL_TRACK = holdTrack(FIX_LAT, FIX_LON, 145, false, 5) + const lofalProc = makeProc({ + geojson: { + type: 'FeatureCollection', + features: [holdFeature({ inboundCourseMag: 310, turnRight: true }, LOFAL_TRACK)], + }, + }) + const spec = collectHoldSpecs([lofalProc])[0] + + function inputAt( + nowMs: number, + radial: number, + distOut: number, + track: number, + pred: PredictedPath, + ): HoldEntryInput { + const pos = dest(FIX, distOut, radial) + return makeInput({ + nowMs, + aircraft: [makeAc({ lat: pos[1], lon: pos[0], interpLat: pos[1], interpLon: pos[0], track })], + predictions: new Map([[HEX, pred]]), + specs: [spec], + }) + } + + it('creates a DIRECT entry coincident with the drawn racetrack, then freezes it past the fix', () => { + // ASA1508-style: NW of the fix arriving ~135° (r ≈ 10 for the left hold → direct). + let s = reduceHoldEntries(emptyHoldEntryState(), inputAt(1000, 315, 5, 135, makePred(4000, 315))) + const rec1 = s.entries.get(HEX) + expect(rec1).toBeDefined() + expect(rec1!.entry).toBe('direct') + const onTrack = rec1!.path.filter((p) => Math.min(...LOFAL_TRACK.map((t) => distNm(p, t))) < 0.05) + expect(onTrack.length / rec1!.path.length).toBeGreaterThan(0.8) + + // Pre-crossing: the predicted arrival track drifts to garbage (25° — the + // old code re-classified to parallel and regenerated). Kind + path freeze. + s = reduceHoldEntries(s, inputAt(2000, 315, 4, 135, makePred(4000, 205))) + const rec2 = s.entries.get(HEX)! + expect(rec2.entry).toBe('direct') + expect(rec2.path).toBe(rec1!.path) + expect(rec2.lastQualifiedMs).toBe(2000) + + // AT/past the fix: predicted points beyond the fix yield a RECIPROCAL + // arrival track (325) — the old code re-classified and rebuilt the loop + // flipped SE along the course axis. The frozen path must be identity-equal. + s = reduceHoldEntries(s, inputAt(3000, 145, 0.4, 325, makePred(4000, 145))) + const rec3 = s.entries.get(HEX)! + expect(rec3.crossedFix).toBe(true) + expect(rec3.entry).toBe('direct') + expect(rec3.path).toBe(rec1!.path) + expect(rec3.specKey).toBe(spec.key) + }) + + it('locks the spec once the fix is crossed (no switch to another hold mid-entry)', () => { + let s = reduceHoldEntries(emptyHoldEntryState(), inputAt(1000, 315, 5, 135, makePred(4000, 315))) + s = reduceHoldEntries(s, inputAt(2000, 145, 0.4, 325, makePred(4000, 145))) + expect(s.entries.get(HEX)!.crossedFix).toBe(true) + const before = s.entries.get(HEX)! + + // A second hold 3 nm NE now qualifies (the LOFAL trigger fails: the track + // no longer points at LOFAL). The locked, in-progress entry must not jump. + const OTHER: Pt = dest(FIX, 3, 55) + const other = makeSpec({ + key: 'KXYZ-R34|OTHER', + fixId: 'OTHER', + fixLat: OTHER[1], + fixLon: OTHER[0], + inboundCourseTrue: 200, + turnRight: true, + }) + const pos = dest(FIX, 0.4, 145) + const track = brg(pos, OTHER) + const p1 = dest(OTHER, 2, brg(OTHER, pos)) + const predOther: PredictedPath = { + hex: HEX, + mode: 'straight', + points: [ + { lon: p1[0], lat: p1[1], tSec: 60, altFt: 4000 }, + { lon: OTHER[0], lat: OTHER[1], tSec: 120, altFt: 4000 }, + ], + } + s = reduceHoldEntries( + s, + makeInput({ + nowMs: 3000, + aircraft: [makeAc({ lat: pos[1], lon: pos[0], interpLat: pos[1], interpLon: pos[0], track })], + predictions: new Map([[HEX, predOther]]), + specs: [spec, other], + }), + ) + const after = s.entries.get(HEX)! + expect(after.specKey).toBe(spec.key) + expect(after.path).toBe(before.path) + expect(after.entry).toBe(before.entry) + }) +}) diff --git a/src/geo/__tests__/prediction.test.ts b/src/geo/__tests__/prediction.test.ts new file mode 100644 index 0000000..849437a --- /dev/null +++ b/src/geo/__tests__/prediction.test.ts @@ -0,0 +1,412 @@ +import { describe, it, expect } from 'vitest' +import * as turf from '@turf/turf' +import { + turnRateDps, + isOnProcedureNow, + prepareGuidance, + predictPath, +} from '../prediction' +import { holdTrack } from '../procedureShapes' +import { + PREDICT_STEP_S, + PREDICT_TURN_HOLD_S, + PREDICT_TURN_DECAY_END_S, +} from '../../config/constants' +import type { + Procedure, + ProcedureLeg, + AltConstraint, + WaypointRole, +} from '../../types/procedure' +import type { InterpolatedAircraft } from '../../types/aircraft' +import type { TrackPoint } from '../../types/path' + +// ── Fixtures (north-south corridor, mirrors procedureMatch.test.ts) ────────── + +const LON = -122.31 + +interface WptSpec { + id: string + lat: number + role?: WaypointRole + alt?: AltConstraint | null +} + +function leg(spec: WptSpec, over: Partial = {}): ProcedureLeg { + return { + seq: 10, + fixId: spec.id, + lat: spec.lat, + lon: LON, + navaidType: 'FIX', + altConstraint: spec.alt ?? null, + pathTerm: 'TF', + role: spec.role ?? 'normal', + flyover: false, + turnRight: true, + course: 180, + legNm: 0, + speedKt: 0, + dmeNm: null, + recNavId: '', + ...over, + } +} + +/** A southbound approach (fixes north → south) with a final/common transition. */ +function approachProc(wpts: WptSpec[]): Procedure { + return { + id: 'KSEA-APPROACH', + icao: 'KSEA', + name: 'I16C', + type: 'APPROACH', + runways: ['16C'], + waypoints: wpts.map((w, i) => ({ + id: w.id, + lat: w.lat, + lon: LON, + navaidType: 'FIX' as const, + altConstraint: w.alt ?? null, + sequenceNumber: (i + 1) * 10, + })), + symbols: wpts + .filter((w) => w.role === 'faf' || w.role === 'map') + .map((w) => ({ + id: w.id, + lat: w.lat, + lon: LON, + navaidType: 'FIX' as const, + role: w.role as WaypointRole, + alt: w.alt ?? null, + speedKt: null, + gsFaf: w.role === 'faf', + flyover: false, + })), + geojson: { type: 'FeatureCollection', features: [] }, + hasGeometry: true, + color: '#34d399', + transitions: [{ id: '(final)', legs: wpts.map((w) => leg(w)) }], + } +} + +function aircraft(over: Partial): InterpolatedAircraft { + const lat = over.interpLat ?? 47.45 + const lon = over.interpLon ?? LON + return { + hex: 'abc123', + flight: 'TEST1', + registration: 'N1', + typeCode: 'B738', + lat, + lon, + altBaro: 3000, + altGeom: 3000, + groundspeed: 180, + track: 180, + baroRate: 0, + squawk: '1200', + lastPollMs: 0, + interpLat: lat, + interpLon: lon, + ...over, + } +} + +/** Build a chronological tracklog from a series of tracks at fixed Δt. */ +function samples(tracks: number[], dtS: number): TrackPoint[] { + return tracks.map((track, i) => ({ + tMs: i * dtS * 1000, + lat: 47.45, + lon: LON, + altFt: 3000, + gs: 180, + track, + baroRate: 0, + })) +} + +// ── turnRateDps ────────────────────────────────────────────────────────────── + +describe('turnRateDps', () => { + it('is positive for a right (increasing-heading) turn', () => { + expect(turnRateDps(samples([100, 110, 120], 5))).toBeCloseTo(2, 5) + }) + + it('is negative for a left (decreasing-heading) turn', () => { + expect(turnRateDps(samples([120, 110, 100], 5))).toBeCloseTo(-2, 5) + }) + + it('handles the 360 wrap (355 -> 5 is +10 deg)', () => { + // Two samples, 5 s apart: +10 deg over 5 s = +2 deg/s. + expect(turnRateDps(samples([355, 5], 5))).toBeCloseTo(2, 5) + }) + + it('weights the most recent pair double', () => { + // Pair 1: +5/5 = 1 deg/s (weight 1). Pair 2: +15/5 = 3 deg/s (weight 2). + // Weighted mean = (1*1 + 3*2) / 3 = 7/3. + expect(turnRateDps(samples([100, 105, 120], 5))).toBeCloseTo(7 / 3, 5) + }) + + it('returns 0 for a single sample', () => { + expect(turnRateDps(samples([90], 5))).toBe(0) + }) + + it('skips pairs whose dt is outside [1, 20] s', () => { + // First pair spans 30 s (skipped); only the 5 s pair (+10/5 = 2) counts. + const pts: TrackPoint[] = [ + { tMs: 0, lat: 47.45, lon: LON, altFt: 3000, gs: 180, track: 90, baroRate: 0 }, + { tMs: 30_000, lat: 47.45, lon: LON, altFt: 3000, gs: 180, track: 100, baroRate: 0 }, + { tMs: 35_000, lat: 47.45, lon: LON, altFt: 3000, gs: 180, track: 110, baroRate: 0 }, + ] + expect(turnRateDps(pts)).toBeCloseTo(2, 5) + }) +}) + +// ── Extrapolation modes ────────────────────────────────────────────────────── + +function bearingBetween(a: { lon: number; lat: number }, b: { lon: number; lat: number }): number { + return turf.bearing(turf.point([a.lon, a.lat]), turf.point([b.lon, b.lat])) +} + +describe('predictPath — straight extrapolation', () => { + it('below the turn-rate floor flies a straight, constant-bearing line', () => { + // ~0.1 deg/s of jitter (< 0.5 floor) -> treated as straight. + const path = predictPath(aircraft({ track: 90 }), samples([89.7, 89.85, 90], 5), null, 0) + expect(path.mode).toBe('straight') + for (let i = 2; i < path.points.length; i++) { + const brg = bearingBetween(path.points[i - 1], path.points[i]) + expect(bearingDeltaAbs(brg, 90)).toBeLessThan(0.5) + } + }) + + it('spaces points by groundspeed * step along-track', () => { + const path = predictPath(aircraft({ groundspeed: 180 }), samples([180, 180, 180], 5), null, 0) + // 180 kt over 5 s = 0.25 nm. + const d = turf.distance( + turf.point([path.points[0].lon, path.points[0].lat]), + turf.point([path.points[1].lon, path.points[1].lat]), + { units: 'nauticalmiles' }, + ) + expect(d).toBeCloseTo(0.25, 2) + }) +}) + +function bearingDeltaAbs(a: number, b: number): number { + return Math.abs(((a - b + 540) % 360) - 180) +} + +describe('predictPath — turning extrapolation', () => { + it('stops accruing heading change after the decay window', () => { + const path = predictPath(aircraft({ track: 90 }), samples([80, 85, 90], 5), null, 0, 300) + // Compare consecutive-segment bearings; after PREDICT_TURN_DECAY_END_S they + // must be identical (no further turn). + const brgAt = (t: number) => { + const i = t / PREDICT_STEP_S + return bearingBetween(path.points[i], path.points[i + 1]) + } + const bAfter = brgAt(PREDICT_TURN_DECAY_END_S + PREDICT_STEP_S * 2) + const bLate = brgAt(280) + expect(bearingDeltaAbs(bAfter, bLate)).toBeLessThan(0.5) + }) + + it('total heading change approximates omega*(HOLD + (DECAY-HOLD)/2)', () => { + // +5 deg over 5 s per pair -> omega = +1 deg/s (right turn). + const path = predictPath(aircraft({ track: 90 }), samples([80, 85, 90], 5), null, 0, 300) + const first = bearingBetween(path.points[0], path.points[1]) + const settled = bearingBetween(path.points[58], path.points[59]) // ~t=290 + const totalTurn = ((settled - first + 540) % 360) - 180 + const omega = 1 + const analytic = omega * (PREDICT_TURN_HOLD_S + (PREDICT_TURN_DECAY_END_S - PREDICT_TURN_HOLD_S) / 2) + // Discrete Euler over-counts the ramp slightly; allow generous tolerance. + expect(totalTurn).toBeGreaterThan(analytic * 0.75) + expect(totalTurn).toBeLessThan(analytic * 1.25) + expect(totalTurn).toBeGreaterThan(0) + }) + + it('rolls out to straight once the samples show no turn', () => { + const path = predictPath(aircraft({ track: 90 }), samples([90, 90, 90], 5), null, 0) + expect(path.mode).toBe('straight') + }) + + it('forces straight for a noisy TIS-B (~) track despite a turning history', () => { + const path = predictPath( + aircraft({ hex: '~abc123', track: 90 }), + samples([80, 85, 90], 5), + null, + 0, + ) + expect(path.mode).toBe('straight') + }) +}) + +describe('predictPath — vertical extrapolation', () => { + it('descends at the baro rate and floors at field elevation', () => { + const path = predictPath( + aircraft({ altBaro: 3000, baroRate: -1000, track: 90 }), + samples([90, 90, 90], 5), + null, + 500, + 300, + ) + // Reaches 500 at t = (3000-500)/1000 * 60 = 150 s, then holds. + const altAt = (t: number) => path.points[t / PREDICT_STEP_S].altFt + expect(altAt(60)).toBeCloseTo(2000, 0) + expect(altAt(150)).toBeCloseTo(500, 0) + expect(altAt(200)).toBe(500) + expect(altAt(300)).toBe(500) + }) +}) + +// ── Approach following ─────────────────────────────────────────────────────── + +describe('predictPath — approach following', () => { + const proc = approachProc([ + { id: 'FAFXX', lat: 47.6, role: 'faf', alt: { type: 'AT', low: 3000 } }, + { id: 'MAPXX', lat: 47.4, role: 'map' }, + ]) + const FIELD = 500 + + it('stays on the corridor and rides the descent profile', () => { + const guidance = prepareGuidance(proc, null, FIELD) + // Place the aircraft on the corridor between the FAF and the MAP, at the + // profile altitude for its position so it captures immediately. + const acLat = 47.55 + const along = turf.distance( + turf.point([LON, 47.6]), + turf.point([LON, acLat]), + { units: 'nauticalmiles' }, + ) + const total = turf.distance( + turf.point([LON, 47.6]), + turf.point([LON, 47.4]), + { units: 'nauticalmiles' }, + ) + // Profile: 3000 at the FAF -> field+50 at the runway, linear. + const startAlt = 3000 + (FIELD + 50 - 3000) * (along / total) + const ac = aircraft({ + interpLat: acLat, + interpLon: LON, + track: 180, + groundspeed: 180, + altBaro: Math.round(startAlt), + baroRate: -600, + }) + const path = predictPath(ac, samples([180, 180, 180], 5), guidance, FIELD) + expect(path.mode).toBe('approach') + + // Lateral: every point sits on the LON corridor. + for (const p of path.points) { + expect(Math.abs(p.lon - LON)).toBeLessThan(0.001) + } + // Along-track spacing: 180 kt over 5 s = 0.25 nm. + const d = turf.distance( + turf.point([path.points[1].lon, path.points[1].lat]), + turf.point([path.points[2].lon, path.points[2].lat]), + { units: 'nauticalmiles' }, + ) + expect(d).toBeCloseTo(0.25, 2) + + // Vertical: each point rides the profile within ~50 ft (until it runs off + // the end of the descent, where it holds the runway-crossing altitude). + for (let i = 1; i < path.points.length; i++) { + const p = path.points[i] + const distFromFaf = turf.distance( + turf.point([LON, 47.6]), + turf.point([p.lon, p.lat]), + { units: 'nauticalmiles' }, + ) + const clamped = Math.min(distFromFaf, total) + const expected = 3000 + (FIELD + 50 - 3000) * (clamped / total) + expect(Math.abs(p.altFt - expected)).toBeLessThan(50) + expect(p.altFt).toBeGreaterThanOrEqual(FIELD - 0.01) + } + }) + + it('un-snaps to turn extrapolation when off course despite an assignment', () => { + const guidance = prepareGuidance(proc, null, FIELD) + // Well east of the corridor and turning -> not on any guidance path. + const ac = aircraft({ + interpLat: 47.5, + interpLon: LON + 0.2, + track: 90, + baroRate: 0, + }) + const path = predictPath(ac, samples([80, 85, 90], 5), guidance, FIELD) + expect(path.mode).toBe('turn') + }) +}) + +// ── isOnProcedureNow ───────────────────────────────────────────────────────── + +describe('isOnProcedureNow', () => { + const proc = approachProc([ + { id: 'NORTH', lat: 47.5 }, + { id: 'SOUTH', lat: 47.4 }, + ]) + + it('is true for an aircraft on course flying the procedure direction', () => { + expect(isOnProcedureNow(aircraft({ interpLat: 47.45, track: 180 }), proc)).toBe(true) + }) + + it('is false when the track is 90 deg off the segment', () => { + expect(isOnProcedureNow(aircraft({ interpLat: 47.45, track: 90 }), proc)).toBe(false) + }) + + it('is true within the roomier hold tolerance when closest to a hold path', () => { + const hp = holdProc() + const { lat, lon, track } = holdOutboundPoint() + // Track 70 deg off the hold segment: fails the 60 deg final gate but passes + // the 75 deg hold gate, and the aircraft is closest to the hold racetrack. + expect(isOnProcedureNow(aircraft({ interpLat: lat, interpLon: lon, track: (track + 70) % 360 }), hp)).toBe(true) + }) +}) + +const HOLD_FIX = { lat: 47.5, lon: -122.3 } + +function holdProc(): Procedure { + const track = holdTrack(HOLD_FIX.lat, HOLD_FIX.lon, 360, true, 4) + const wpt = (id: string, lat: number) => ({ + id, + lat, + lon: HOLD_FIX.lon, + navaidType: 'FIX' as const, + altConstraint: null, + sequenceNumber: 10, + }) + return { + id: 'KSEA-APPROACH-HOLD', + icao: 'KSEA', + name: 'R16C', + type: 'APPROACH', + runways: ['16C'], + waypoints: [wpt('HOLDF', HOLD_FIX.lat), wpt('RWY', HOLD_FIX.lat - 0.1)], + symbols: [], + geojson: { + type: 'FeatureCollection', + features: [ + { + type: 'Feature', + geometry: { type: 'LineString', coordinates: track }, + properties: { kind: 'hold', segment: 'transition', fixId: 'HOLDF', alt: null }, + }, + ], + }, + hasGeometry: true, + color: '#34d399', + } +} + +// Easternmost racetrack vertex (on the outbound leg), and its local tangent. +function holdOutboundPoint(): { lat: number; lon: number; track: number } { + const track = holdTrack(HOLD_FIX.lat, HOLD_FIX.lon, 360, true, 4) + let idx = 0 + track.forEach((p, i) => { + if (p[0] > track[idx][0]) idx = i + }) + const nxt = track[(idx + 1) % track.length] + return { + lat: track[idx][1], + lon: track[idx][0], + track: turf.bearing(turf.point(track[idx]), turf.point(nxt)), + } +} diff --git a/src/geo/__tests__/profileTrail.test.ts b/src/geo/__tests__/profileTrail.test.ts new file mode 100644 index 0000000..b9a144c --- /dev/null +++ b/src/geo/__tests__/profileTrail.test.ts @@ -0,0 +1,110 @@ +import { describe, it, expect } from 'vitest' +import { buildProfileTrail } from '../profileTrail' +import type { TrackPoint } from '../../types/path' +import type { ProcedureLeg, ProcedureTransition } from '../../types/procedure' +import { TRACKLOG_GAP_BREAK_MS } from '../../config/constants' + +function leg(overrides: Partial & Pick): ProcedureLeg { + return { + navaidType: 'FIX', + altConstraint: null, + pathTerm: 'CF', + role: 'normal', + flyover: false, + turnRight: false, + course: 180, + legNm: 5, + speedKt: 0, + dmeNm: null, + recNavId: '', + ...overrides, + } +} + +// A straight north-south transition (constant longitude), ~10nm long, so +// along-track distance and cross-track offset are easy to reason about. +const START = { lat: 47.5, lon: -122.0 } +const END = { lat: 47.3333, lon: -122.0 } // ~10nm south of START + +const transition: ProcedureTransition = { + id: 'T1', + legs: [leg({ seq: 10, fixId: 'A', ...START }), leg({ seq: 20, fixId: 'B', ...END })], +} + +function point(overrides: Partial): TrackPoint { + return { tMs: 0, lat: START.lat, lon: START.lon, altFt: 5000, gs: 120, track: 180, baroRate: 0, ...overrides } +} + +describe('buildProfileTrail', () => { + it('returns empty when the track is empty', () => { + expect(buildProfileTrail([], transition, 10)).toEqual([]) + }) + + it('drops points with a non-numeric (ground) altitude', () => { + const track = [point({ tMs: 0, altFt: 'ground' }), point({ tMs: 1000, lat: 47.4166, altFt: 3000 })] + const segs = buildProfileTrail(track, transition, 10) + // Only one numeric-altitude point survives — too few to form a segment. + expect(segs).toEqual([]) + }) + + it('drops points too far cross-track from the transition line', () => { + // ~6nm east of the line at the same latitude as START — well past the 3nm gate. + const farLon = START.lon + 6 / (60 * Math.cos((START.lat * Math.PI) / 180)) + const track = [ + point({ tMs: 0, lat: START.lat, lon: START.lon, altFt: 5000 }), + point({ tMs: 1000, lat: START.lat, lon: farLon, altFt: 4900 }), + // ~1nm south of START — close enough to the first kept point that this + // isn't also read as a distance-jump break. + point({ tMs: 2000, lat: START.lat - 1 / 60, lon: START.lon, altFt: 4800 }), + ] + const segs = buildProfileTrail(track, transition, 10) + expect(segs).toHaveLength(1) + expect(segs[0]).toHaveLength(2) + }) + + it('drops points outside the plotted distance range', () => { + const track = [ + point({ tMs: 0, lat: START.lat, altFt: 5000 }), // distNm ~0 + point({ tMs: 1000, lat: START.lat - 1 / 60, altFt: 4900 }), // distNm ~1, within range + point({ tMs: 2000, lat: END.lat, altFt: 3000 }), // distNm ~10, outside the clamp + ] + const segs = buildProfileTrail(track, transition, 6) // clamp range shorter than the full leg + // The last point (~10nm) falls outside [0, 6] and is dropped entirely + // (not merely segment-broken), leaving the first two points as one segment. + const allPts = segs.flat() + expect(allPts.every((p) => p.distNm <= 6)).toBe(true) + expect(allPts).toHaveLength(2) + }) + + it('breaks into segments when consecutive points are too far apart in time', () => { + const track = [ + point({ tMs: 0, lat: START.lat, altFt: 5000 }), + point({ tMs: 5_000, lat: 47.49, altFt: 4900 }), + // big time gap here + point({ tMs: 5_000 + TRACKLOG_GAP_BREAK_MS + 1, lat: 47.45, altFt: 4700 }), + point({ tMs: 5_000 + TRACKLOG_GAP_BREAK_MS + 6_000, lat: 47.44, altFt: 4600 }), + ] + const segs = buildProfileTrail(track, transition, 10) + expect(segs).toHaveLength(2) + expect(segs[0]).toHaveLength(2) + expect(segs[1]).toHaveLength(2) + }) + + it('breaks into segments when consecutive points jump too far in along-track distance', () => { + const track = [ + point({ tMs: 0, lat: START.lat, altFt: 5000 }), + point({ tMs: 1000, lat: 47.49, altFt: 4900 }), + // Jump ~5nm south in one poll (a projection jump), well over the 2nm break. + point({ tMs: 2000, lat: 47.41, altFt: 4400 }), + point({ tMs: 3000, lat: 47.4, altFt: 4300 }), + ] + const segs = buildProfileTrail(track, transition, 10) + expect(segs).toHaveLength(2) + }) + + it('returns empty when nothing survives the filters', () => { + const farLon = START.lon + 6 / (60 * Math.cos((START.lat * Math.PI) / 180)) + const track = [point({ tMs: 0, lat: START.lat, lon: farLon, altFt: 5000 })] + expect(buildProfileTrail(track, transition, 10)).toEqual([]) + }) +}) diff --git a/src/geo/__tests__/rangeRings.test.ts b/src/geo/__tests__/rangeRings.test.ts new file mode 100644 index 0000000..1ef8dac --- /dev/null +++ b/src/geo/__tests__/rangeRings.test.ts @@ -0,0 +1,121 @@ +import { describe, it, expect } from 'vitest' +import * as turf from '@turf/turf' +import { ringRadiiForZoom, ringFeatures, ringBadges } from '../rangeRings' + +const LAT = 47.4 +const LON = -122.3 + +describe('ringRadiiForZoom', () => { + it('zoom 11 -> [1, 3, 6]', () => { + expect(ringRadiiForZoom(11)).toEqual([1, 3, 6]) + }) + + it('zoom 10.99 -> [2, 5, 10]', () => { + expect(ringRadiiForZoom(10.99)).toEqual([2, 5, 10]) + }) + + it('zoom 9.5 -> [2, 5, 10]', () => { + expect(ringRadiiForZoom(9.5)).toEqual([2, 5, 10]) + }) + + it('zoom 9.49 -> [5, 10, 15]', () => { + expect(ringRadiiForZoom(9.49)).toEqual([5, 10, 15]) + }) + + it('zoom 8 -> [5, 10, 15]', () => { + expect(ringRadiiForZoom(8)).toEqual([5, 10, 15]) + }) + + it('zoom 7.99 -> [12, 25, 50]', () => { + expect(ringRadiiForZoom(7.99)).toEqual([12, 25, 50]) + }) + + it('very high zoom -> [1, 3, 6]', () => { + expect(ringRadiiForZoom(20)).toEqual([1, 3, 6]) + }) + + it('very low zoom -> [12, 25, 50]', () => { + expect(ringRadiiForZoom(-100)).toEqual([12, 25, 50]) + }) +}) + +describe('ringFeatures', () => { + const fc = ringFeatures(LAT, LON, [1, 3, 6]) + + it('produces one feature per radius', () => { + expect(fc.features.length).toBe(3) + }) + + it('each ring has ~65 positions and is closed (first ≈ last)', () => { + for (const f of fc.features) { + const coords = f.geometry.coordinates + expect(coords.length).toBeGreaterThanOrEqual(64) + expect(coords.length).toBeLessThanOrEqual(66) + const [firstLon, firstLat] = coords[0] + const [lastLon, lastLat] = coords[coords.length - 1] + expect(firstLon).toBeCloseTo(lastLon, 6) + expect(firstLat).toBeCloseTo(lastLat, 6) + } + }) + + it('sets the radiusNm property to match input order', () => { + expect(fc.features.map((f) => f.properties.radiusNm)).toEqual([1, 3, 6]) + }) + + it('ring points sit ~radiusNm from the center', () => { + const center = turf.point([LON, LAT]) + for (const f of fc.features) { + const radiusNm = f.properties.radiusNm + for (const pt of f.geometry.coordinates) { + const d = turf.distance(center, turf.point(pt), { units: 'nauticalmiles' }) + expect(d).toBeCloseTo(radiusNm, 1) + } + } + }) +}) + +describe('ringBadges', () => { + // Fake project: treat lat as a linear proxy for screen y (higher lat -> smaller y, + // i.e. further "up" the viewport), independent of lon. + const projectFromLat = + (originLat: number) => + ([, lat]: [number, number]): { x: number; y: number } | null => ({ + x: 0, + y: (originLat - lat) * 1000 + 500, + }) + + it('uses the 12 o’clock point (bearing 0) when it projects safely inside the viewport', () => { + const badges = ringBadges(LAT, LON, [1, 3, 6], projectFromLat(LAT), 8) + for (const b of badges) { + expect(b.position).toBe('12') + expect(b.lat).toBeGreaterThan(LAT) // bearing 0 = due north = higher latitude + } + }) + + it('flips to 6 o’clock when the 12 o’clock point projects above viewportTopPx', () => { + // Project so that ANY point north of center (12 o'clock) lands with y < viewportTopPx. + const project = ([, lat]: [number, number]): { x: number; y: number } | null => + lat > LAT ? { x: 0, y: -50 } : { x: 0, y: 500 } + const badges = ringBadges(LAT, LON, [1, 3, 6], project, 8) + for (const b of badges) { + expect(b.position).toBe('6') + expect(b.lat).toBeLessThan(LAT) // bearing 180 = due south = lower latitude + } + }) + + it('falls back to 6 o’clock when project returns null', () => { + const badges = ringBadges(LAT, LON, [1, 3, 6], () => null, 8) + for (const b of badges) { + expect(b.position).toBe('6') + } + }) + + it('badge lat/lon sit ~radiusNm from the center', () => { + const center = turf.point([LON, LAT]) + const badges = ringBadges(LAT, LON, [1, 3, 6], projectFromLat(LAT), 8) + for (const b of badges) { + const d = turf.distance(center, turf.point([b.lon, b.lat]), { units: 'nauticalmiles' }) + expect(d).toBeCloseTo(b.radiusNm, 1) + } + }) +}) diff --git a/src/geo/__tests__/tcasTables.test.ts b/src/geo/__tests__/tcasTables.test.ts new file mode 100644 index 0000000..e7aa946 --- /dev/null +++ b/src/geo/__tests__/tcasTables.test.ts @@ -0,0 +1,139 @@ +import { describe, it, expect } from 'vitest' +import { TCAS_SL_TABLE, sensitivityLevelFor } from '../tcasTables' + +describe('TCAS_SL_TABLE row values', () => { + it('SL2 (<1000 AGL): TA 20/0.30/850, no RA', () => { + const row = TCAS_SL_TABLE[0] + expect(row.sl).toBe(2) + expect(row.taTauS).toBe(20) + expect(row.taDmodNm).toBe(0.3) + expect(row.taZthrFt).toBe(850) + expect(row.raTauS).toBeNull() + expect(row.raDmodNm).toBeNull() + expect(row.raZthrFt).toBeNull() + expect(row.alimFt).toBeNull() + }) + + it('SL3 (1000-2350 AGL): TA 25/0.33/850, RA 15/0.20/600, ALIM 300', () => { + const row = TCAS_SL_TABLE[1] + expect(row.sl).toBe(3) + expect(row.taTauS).toBe(25) + expect(row.taDmodNm).toBe(0.33) + expect(row.taZthrFt).toBe(850) + expect(row.raTauS).toBe(15) + expect(row.raDmodNm).toBe(0.2) + expect(row.raZthrFt).toBe(600) + expect(row.alimFt).toBe(300) + }) + + it('SL4 (>2350 AGL, <5000 MSL): TA 30/0.48/850, RA 20/0.35/600, ALIM 300', () => { + const row = TCAS_SL_TABLE[2] + expect(row.sl).toBe(4) + expect(row.taTauS).toBe(30) + expect(row.taDmodNm).toBe(0.48) + expect(row.taZthrFt).toBe(850) + expect(row.raTauS).toBe(20) + expect(row.raDmodNm).toBe(0.35) + expect(row.raZthrFt).toBe(600) + expect(row.alimFt).toBe(300) + }) + + it('SL5 (5000-10000 MSL): TA 40/0.75/850, RA 25/0.55/600, ALIM 350', () => { + const row = TCAS_SL_TABLE[3] + expect(row.sl).toBe(5) + expect(row.taTauS).toBe(40) + expect(row.taDmodNm).toBe(0.75) + expect(row.taZthrFt).toBe(850) + expect(row.raTauS).toBe(25) + expect(row.raDmodNm).toBe(0.55) + expect(row.raZthrFt).toBe(600) + expect(row.alimFt).toBe(350) + }) + + it('SL6 (10000-20000 MSL): TA 45/1.00/850, RA 30/0.80/600, ALIM 400', () => { + const row = TCAS_SL_TABLE[4] + expect(row.sl).toBe(6) + expect(row.taTauS).toBe(45) + expect(row.taDmodNm).toBe(1.0) + expect(row.taZthrFt).toBe(850) + expect(row.raTauS).toBe(30) + expect(row.raDmodNm).toBe(0.8) + expect(row.raZthrFt).toBe(600) + expect(row.alimFt).toBe(400) + }) + + it('SL7 (20000-42000 MSL): TA 48/1.30/850, RA 35/1.10/700, ALIM 600', () => { + const row = TCAS_SL_TABLE[5] + expect(row.sl).toBe(7) + expect(row.taTauS).toBe(48) + expect(row.taDmodNm).toBe(1.3) + expect(row.taZthrFt).toBe(850) + expect(row.raTauS).toBe(35) + expect(row.raDmodNm).toBe(1.1) + expect(row.raZthrFt).toBe(700) + expect(row.alimFt).toBe(600) + }) + + it('SL8 (>42000 MSL): TA 48/1.30/1200, RA 35/1.10/800, ALIM 700', () => { + const row = TCAS_SL_TABLE[6] + expect(row.sl).toBe(8) + expect(row.taTauS).toBe(48) + expect(row.taDmodNm).toBe(1.3) + expect(row.taZthrFt).toBe(1200) + expect(row.raTauS).toBe(35) + expect(row.raDmodNm).toBe(1.1) + expect(row.raZthrFt).toBe(800) + expect(row.alimFt).toBe(700) + }) +}) + +describe('sensitivityLevelFor band boundaries', () => { + // AGL boundary: <1000 -> SL2, >=1000 -> SL3. MSL held constant and low so it + // never confounds the AGL-gated bands. + it('999 ft AGL -> SL2', () => { + expect(sensitivityLevelFor(3000, 999).sl).toBe(2) + }) + it('1000 ft AGL -> SL3', () => { + expect(sensitivityLevelFor(3000, 1000).sl).toBe(3) + }) + + // AGL boundary: <=2350 -> SL3, >2350 -> falls through to MSL bands. + it('2350 ft AGL -> SL3', () => { + expect(sensitivityLevelFor(3000, 2350).sl).toBe(3) + }) + it('2351 ft AGL (MSL 3000, <5000) -> SL4', () => { + expect(sensitivityLevelFor(3000, 2351).sl).toBe(4) + }) + + // MSL boundary: <5000 -> SL4, >=5000 -> SL5. AGL held high (>2350) so MSL decides. + it('4999 ft MSL -> SL4', () => { + expect(sensitivityLevelFor(4999, 5000).sl).toBe(4) + }) + it('5000 ft MSL -> SL5', () => { + expect(sensitivityLevelFor(5000, 5000).sl).toBe(5) + }) + + // MSL boundary: <10000 -> SL5, >=10000 -> SL6. + it('9999 ft MSL -> SL5', () => { + expect(sensitivityLevelFor(9999, 5000).sl).toBe(5) + }) + it('10000 ft MSL -> SL6', () => { + expect(sensitivityLevelFor(10000, 5000).sl).toBe(6) + }) + + // MSL boundary at 20000: <20000 -> SL6, >=20000 -> SL7. + it('20000 ft MSL -> SL7', () => { + expect(sensitivityLevelFor(20000, 5000).sl).toBe(7) + }) + it('19999 ft MSL -> SL6', () => { + expect(sensitivityLevelFor(19999, 5000).sl).toBe(6) + }) + + // MSL boundary: <=42000 -> SL7, >42000 -> SL8. + it('42000 ft MSL -> SL7', () => { + expect(sensitivityLevelFor(42000, 5000).sl).toBe(7) + }) + it('42001 ft MSL -> SL8', () => { + expect(sensitivityLevelFor(42001, 5000).sl).toBe(8) + }) +}) diff --git a/src/geo/__tests__/terrainScan.test.ts b/src/geo/__tests__/terrainScan.test.ts new file mode 100644 index 0000000..aeda8fe --- /dev/null +++ b/src/geo/__tests__/terrainScan.test.ts @@ -0,0 +1,258 @@ +import { describe, it, expect, vi } from 'vitest' +import { scanTerrain, type TerrainScanOpts } from '../terrainScan' +import type { MvaSector } from '../../utils/aixmMva' +import type { PredictedPath } from '../../types/path' + +// A 0.2deg square MVA sector, minAltFt 5000. +const EXTERIOR = [ + [-122.5, 47.0], + [-122.3, 47.0], + [-122.3, 47.2], + [-122.5, 47.2], + [-122.5, 47.0], +] +// A small hole carved out of the middle of the sector. +const HOLE = [ + [-122.42, 47.08], + [-122.38, 47.08], + [-122.38, 47.12], + [-122.42, 47.12], + [-122.42, 47.08], +] + +const SECTOR: MvaSector = { name: 'SECTOR 1', minAltFt: 5000, polygon: [EXTERIOR] } +const SECTOR_WITH_HOLE: MvaSector = { name: 'SECTOR 1', minAltFt: 5000, polygon: [EXTERIOR, HOLE] } + +// Deep inside the exterior ring but outside the hole. +const IN_SECTOR = { lat: 47.02, lon: -122.46 } +// Center of the hole — falls through the MVA sector to DEM. +const IN_HOLE = { lat: 47.1, lon: -122.4 } +// Well outside the sector's bbox entirely. +const OUTSIDE = { lat: 47.1, lon: -121.0 } + +const BASE_OPTS: TerrainScanOpts = { + onApproach: false, + profileDeviationFt: null, + airports: [], + gsKt: 180, + currentAglFt: 2000, +} + +function pathAt( + point: { lat: number; lon: number }, + altFt: number, + tSecs: number[] = [30], +): PredictedPath { + return { + hex: 'abc123', + mode: 'straight', + points: tSecs.map((tSec) => ({ lat: point.lat, lon: point.lon, altFt, tSec })), + } +} + +describe('scanTerrain — MVA sector', () => { + it('clears the sector minimum with margin -> null', () => { + const elevAt = vi.fn() + const result = scanTerrain(pathAt(IN_SECTOR, 5100), [SECTOR], elevAt, BASE_OPTS) + expect(result).toBeNull() + expect(elevAt).not.toHaveBeenCalled() // MVA covers this point — DEM never consulted + }) + + it('below the sector minimum -> alert', () => { + const result = scanTerrain(pathAt(IN_SECTOR, 4500), [SECTOR], vi.fn(), BASE_OPTS) + expect(result).toBe('alert') + }) + + it('more than TERRAIN_MVA_WARN_BELOW_FT under the sector minimum -> warning', () => { + // 5000 - 900 = 4100; 4000 is below that. + const result = scanTerrain(pathAt(IN_SECTOR, 4000), [SECTOR], vi.fn(), BASE_OPTS) + expect(result).toBe('warning') + }) + + it('a point outside every sector bbox falls back to DEM', () => { + const elevAt = vi.fn().mockReturnValue(0) + scanTerrain(pathAt(OUTSIDE, 5100), [SECTOR], elevAt, BASE_OPTS) + expect(elevAt).toHaveBeenCalledWith(OUTSIDE.lat, OUTSIDE.lon) + }) + + it('a point inside a sector hole falls through to DEM', () => { + const elevAt = vi.fn().mockReturnValue(0) + scanTerrain(pathAt(IN_HOLE, 5100), [SECTOR_WITH_HOLE], elevAt, BASE_OPTS) + expect(elevAt).toHaveBeenCalledWith(IN_HOLE.lat, IN_HOLE.lon) + }) +}) + +describe('scanTerrain — DEM fallback', () => { + it('1050 ft clearance -> null', () => { + const elevAt = vi.fn().mockReturnValue(0) + const result = scanTerrain(pathAt(OUTSIDE, 1050), [], elevAt, BASE_OPTS) + expect(result).toBeNull() + }) + + it('950 ft clearance -> alert', () => { + const elevAt = vi.fn().mockReturnValue(0) + const result = scanTerrain(pathAt(OUTSIDE, 950), [], elevAt, BASE_OPTS) + expect(result).toBe('alert') + }) + + it('90 ft clearance -> warning', () => { + const elevAt = vi.fn().mockReturnValue(0) + const result = scanTerrain(pathAt(OUTSIDE, 90), [], elevAt, BASE_OPTS) + expect(result).toBe('warning') + }) + + it('an unresolved (uncached) tile is skipped, not treated as a violation', () => { + const elevAt = vi.fn().mockReturnValue(undefined) + const result = scanTerrain(pathAt(OUTSIDE, -500), [], elevAt, BASE_OPTS) + expect(result).toBeNull() + }) +}) + +describe('scanTerrain — scan window / suppression', () => { + it('ignores points within the first TERRAIN_SCAN_SKIP_FIRST_S seconds', () => { + // Badly violating point, but at tSec 5 (<= the 10s skip window) and no + // other points in the path. + const elevAt = vi.fn().mockReturnValue(0) + const result = scanTerrain(pathAt(OUTSIDE, 1, [5]), [], elevAt, BASE_OPTS) + expect(result).toBeNull() + expect(elevAt).not.toHaveBeenCalled() + }) + + it('ignores violations beyond TERRAIN_SCAN_HORIZON_S (t=90 with a 60 s horizon)', () => { + // Badly violating point, but at tSec 90 — past the short terrain look-ahead. + // A descending aircraft will typically level off before then; extrapolating + // baro rate further just projects phantom MVA/terrain penetrations. + const elevAt = vi.fn().mockReturnValue(0) + const result = scanTerrain(pathAt(OUTSIDE, 1, [90]), [], elevAt, BASE_OPTS) + expect(result).toBeNull() + expect(elevAt).not.toHaveBeenCalled() + }) + + it('suppresses terrain alerts on approach within TERRAIN_ONAPPROACH_TOL_FT of profile, even when a point badly violates', () => { + const opts: TerrainScanOpts = { + onApproach: true, + profileDeviationFt: 100, + airports: [], + gsKt: 180, + currentAglFt: 2000, + } + const elevAt = vi.fn().mockReturnValue(0) + const result = scanTerrain(pathAt(OUTSIDE, -1000, [30]), [], elevAt, opts) + expect(result).toBeNull() + expect(elevAt).not.toHaveBeenCalled() // short-circuited before any point is scanned + }) + + it('still alerts on approach once the profile deviation exceeds the tolerance', () => { + const opts: TerrainScanOpts = { + onApproach: true, + profileDeviationFt: 500, + airports: [], + gsKt: 180, + currentAglFt: 2000, + } + const elevAt = vi.fn().mockReturnValue(0) + const result = scanTerrain(pathAt(OUTSIDE, 90, [30]), [], elevAt, opts) + expect(result).toBe('warning') + }) + + it('excludes a descent into a known airport even with onApproach: false', () => { + // OUTSIDE at 950 ft (elevAt 0) would DEM-alert, but a known airport sits at + // the same spot (within TERRAIN_AIRPORT_EXCLUDE_NM, below elev+1500) so the + // sample is an MSAW-style arrival/departure exclusion — no assignment needed. + const opts: TerrainScanOpts = { + onApproach: false, + profileDeviationFt: null, + airports: [{ lat: OUTSIDE.lat, lon: OUTSIDE.lon, elevationFt: 0 }], + gsKt: 180, + currentAglFt: 2000, + } + const elevAt = vi.fn().mockReturnValue(0) + const result = scanTerrain(pathAt(OUTSIDE, 950), [], elevAt, opts) + expect(result).toBeNull() + expect(elevAt).not.toHaveBeenCalled() // excluded before the DEM check + }) + + it('still alerts on the same descent 10 nm from any airport', () => { + // Airport ~10 nm north of OUTSIDE — outside the 4 nm exclusion volume. + const opts: TerrainScanOpts = { + onApproach: false, + profileDeviationFt: null, + airports: [{ lat: OUTSIDE.lat + 10 / 60, lon: OUTSIDE.lon, elevationFt: 0 }], + gsKt: 180, + currentAglFt: 2000, + } + const elevAt = vi.fn().mockReturnValue(0) + const result = scanTerrain(pathAt(OUTSIDE, 950), [], elevAt, opts) + expect(result).toBe('alert') + }) + + it('worst tier short-circuits: a warning point wins even if scanned before a later alert-only point', () => { + const elevAt = vi.fn().mockReturnValue(0) + const path: PredictedPath = { + hex: 'abc123', + mode: 'straight', + points: [ + { ...OUTSIDE, altFt: 90, tSec: 30 }, // warning + { ...OUTSIDE, altFt: 950, tSec: 60 }, // alert + ], + } + const result = scanTerrain(path, [], elevAt, BASE_OPTS) + expect(result).toBe('warning') + }) +}) + +describe('scanTerrain — TAWS-style landing-configuration inhibit', () => { + // Violating terrain (90 ft clearance would normally warn) placed OUTSIDE any + // MVA sector so it falls through to the DEM check. + it('67 kt at 100 ft AGL over violating terrain -> null (landing/departing at some strip)', () => { + const opts: TerrainScanOpts = { + onApproach: false, + profileDeviationFt: null, + airports: [], + gsKt: 67, + currentAglFt: 100, + } + const elevAt = vi.fn().mockReturnValue(0) + const result = scanTerrain(pathAt(OUTSIDE, 90), [], elevAt, opts) + expect(result).toBeNull() + }) + + it('same geometry at 140 kt -> still alerts (too fast to be landing config)', () => { + const opts: TerrainScanOpts = { + onApproach: false, + profileDeviationFt: null, + airports: [], + gsKt: 140, + currentAglFt: 100, + } + const elevAt = vi.fn().mockReturnValue(0) + const result = scanTerrain(pathAt(OUTSIDE, 90), [], elevAt, opts) + expect(result).toBe('warning') + }) + + it('67 kt but currentAglFt null -> still alerts (no false sense of safety on cold DEM tiles)', () => { + const opts: TerrainScanOpts = { + onApproach: false, + profileDeviationFt: null, + airports: [], + gsKt: 67, + currentAglFt: null, + } + const elevAt = vi.fn().mockReturnValue(0) + const result = scanTerrain(pathAt(OUTSIDE, 90), [], elevAt, opts) + expect(result).toBe('warning') + }) + + it('67 kt at 2000 ft AGL -> still alerts (too high to be landing config)', () => { + const opts: TerrainScanOpts = { + onApproach: false, + profileDeviationFt: null, + airports: [], + gsKt: 67, + currentAglFt: 2000, + } + const elevAt = vi.fn().mockReturnValue(0) + const result = scanTerrain(pathAt(OUTSIDE, 90), [], elevAt, opts) + expect(result).toBe('warning') + }) +}) diff --git a/src/geo/conflicts.ts b/src/geo/conflicts.ts new file mode 100644 index 0000000..9d292a2 --- /dev/null +++ b/src/geo/conflicts.ts @@ -0,0 +1,335 @@ +import { + CONFLICT_HORIZON_S, + CONFLICT_PREFILTER_NM, + CONFLICT_PREFILTER_DALT_FT, + FORMATION_SUPPRESS_DALT_FT, + FORMATION_SUPPRESS_GS_KT, + FORMATION_SUPPRESS_NM, + FORMATION_SUPPRESS_TRK_DEG, + TISB_SHADOW_DALT_FT, + TISB_SHADOW_GS_KT, + TISB_SHADOW_NM, + TISB_SHADOW_TRK_DEG, + RADAR_ALERT_SEP_NM, + RADAR_ALERT_DALT_FT, + RADAR_ALERT_HORIZON_S, + RADAR_WARN_SEP_NM, + RADAR_WARN_DALT_FT, + RADAR_WARN_HORIZON_S, + RADAR_MIN_CLOSURE_NM, + RA_ESCAPE_FPM, + RA_RESPONSE_DELAY_S, + TRAFFIC_SUPPRESS_AGL_FT, + TRAFFIC_SUPPRESS_AIRPORT_NM, + VFR_SQUAWK, +} from '../config/constants' +import { bearingDelta } from './lineMatching' +import { sensitivityLevelFor, type TcasSL } from './tcasTables' +import type { AircraftAlert, AlertTier, ConflictPair, PredictedPath, RaSense } from '../types/path' +import type { InterpolatedAircraft } from '../types/aircraft' + +const DEG2RAD = Math.PI / 180 +// Predicted-path sample spacing (matches the path predictor's 5 s grid). +const PRED_STEP_S = 5 +// Prefilter: a pair >CONFLICT_PREFILTER_DALT_FT apart vertically is only kept +// when the gap is closing at least this fast (aggressive climb/descent). +const PREFILTER_MIN_VCLOSE_FPM = 2000 + +export interface ConflictContext { + airports: { lat: number; lon: number; elevationFt: number }[] +} + +/** Equirectangular horizontal distance in nm (1° lat = 60 nm, cos-lat scaled lon). */ +function equirectNm(lat1: number, lon1: number, lat2: number, lon2: number, cosLat?: number): number { + const c = cosLat ?? Math.cos(((lat1 + lat2) / 2) * DEG2RAD) + const dxNm = (lon2 - lon1) * 60 * c + const dyNm = (lat2 - lat1) * 60 + return Math.sqrt(dxNm * dxNm + dyNm * dyNm) +} + +interface NearestAirport { + distNm: number + elevationFt: number +} + +function nearestAirport(lat: number, lon: number, ctx: ConflictContext): NearestAirport | null { + let best: NearestAirport | null = null + for (const ap of ctx.airports) { + const distNm = equirectNm(lat, lon, ap.lat, ap.lon) + if (!best || distNm < best.distNm) best = { distNm, elevationFt: ap.elevationFt } + } + return best +} + +/** + * Rate (ft/min, >= 0) at which the vertical gap between two aircraft is + * shrinking right now; 0 when the gap is flat, widening, or already zero. + */ +function verticalClosingRateFpm(altA0: number, altB0: number, rateA: number, rateB: number): number { + const gap = altA0 - altB0 + if (gap === 0) return 0 + const gapRateFpm = rateA - rateB // rate of change of (altA − altB) + const closing = gap > 0 ? -gapRateFpm : gapRateFpm + return closing > 0 ? closing : 0 +} + +/** + * One aircraft's altitude `atS` seconds from now under an RA escape maneuver: + * it keeps its current vertical rate through the pilot-response delay, then + * switches instantly to `escapeFpm` (signed — negative for a descend sense). + */ +function simulateAlt(alt0: number, currentRateFpm: number, escapeFpm: number, atS: number): number { + if (atS <= RA_RESPONSE_DELAY_S) return alt0 + (currentRateFpm / 60) * atS + const altAtDelay = alt0 + (currentRateFpm / 60) * RA_RESPONSE_DELAY_S + return altAtDelay + (escapeFpm / 60) * (atS - RA_RESPONSE_DELAY_S) +} + +/** + * Pairwise traffic-conflict evaluation over predicted paths, run once per + * ADS-B poll. Two tiers share one pass: a TCAS II model (TA/RA per the + * DO-185B sensitivity levels in src/geo/tcasTables.ts, with an RA + * climb/descend sense chosen by simulating both escape senses to CPA) and an + * ATC-radar-style separation model (RADAR_* thresholds). One ConflictPair is + * emitted per conflicting pair with the winning tier + * (ra > warning > ta > alert). + */ +export function evaluateTrafficConflicts( + predictions: ReadonlyMap, + acByHex: ReadonlyMap, + ctx: ConflictContext, +): ConflictPair[] { + // Airborne aircraft that have a predicted path, in prediction order. + const hexes: string[] = [] + for (const [hex, path] of predictions) { + const ac = acByHex.get(hex) + if (ac && ac.altBaro !== 'ground' && path.points.length > 0) hexes.push(hex) + } + + const horizonSteps = Math.floor(CONFLICT_HORIZON_S / PRED_STEP_S) + 1 // includes t=0 + const pairs: ConflictPair[] = [] + + for (let i = 0; i < hexes.length; i++) { + for (let j = i + 1; j < hexes.length; j++) { + const hexA = hexes[i] + const hexB = hexes[j] + const acA = acByHex.get(hexA)! + const acB = acByHex.get(hexB)! + const ptsA = predictions.get(hexA)!.points + const ptsB = predictions.get(hexB)!.points + + const a0 = ptsA[0] + const b0 = ptsB[0] + const altA0 = a0.altFt + const altB0 = b0.altFt + + // ── Cheap prefilter (no turf) ──────────────────────────────────────── + const dist0 = equirectNm(a0.lat, a0.lon, b0.lat, b0.lon) + if (dist0 > CONFLICT_PREFILTER_NM) continue + const dAlt0 = Math.abs(altA0 - altB0) + const vCloseFpm = verticalClosingRateFpm(altA0, altB0, acA.baroRate, acB.baroRate) + if (dAlt0 > CONFLICT_PREFILTER_DALT_FT && vCloseFpm < PREFILTER_MIN_VCLOSE_FPM) continue + + // ── Low-AGL near-airport suppression (pattern/parallel-runway noise) ─ + const nearA = nearestAirport(a0.lat, a0.lon, ctx) + const nearB = nearestAirport(b0.lat, b0.lon, ctx) + const aglA = nearA ? altA0 - nearA.elevationFt : Infinity + const aglB = nearB ? altB0 - nearB.elevationFt : Infinity + if ( + nearA !== null && + nearB !== null && + aglA < TRAFFIC_SUPPRESS_AGL_FT && + aglB < TRAFFIC_SUPPRESS_AGL_FT && + nearA.distNm <= TRAFFIC_SUPPRESS_AIRPORT_NM && + nearB.distNm <= TRAFFIC_SUPPRESS_AIRPORT_NM + ) { + continue + } + + // ── Formation / duplicate-track suppression ────────────────────────── + // A pair sustaining near-identical position, altitude, track, and speed + // is intentional formation flying, or two ADS-B/TIS-B tracks of one + // airframe (duplicate reception) — not a conflict. Matched velocity + // means zero closure, so skipping the pair loses nothing. Applies to + // BOTH the TCAS and radar tiers below — the whole pair is skipped. + if ( + dist0 < FORMATION_SUPPRESS_NM && + dAlt0 < FORMATION_SUPPRESS_DALT_FT && + bearingDelta(acA.track, acB.track) < FORMATION_SUPPRESS_TRK_DEG && + Math.abs(acA.groundspeed - acB.groundspeed) < FORMATION_SUPPRESS_GS_KT + ) { + continue + } + + // ── Same-airframe dedupe ────────────────────────────────────────────── + // Two tracks reporting the same registration or the same callsign are + // duplicate receptions of a single airplane — never a real conflict, + // regardless of the geometry between them. + if ( + (acA.registration && acB.registration && acA.registration === acB.registration) || + (acA.flight && acB.flight && acA.flight === acB.flight) + ) { + continue + } + + // ── TIS-B shadow suppression ────────────────────────────────────────── + // A TIS-B pseudo-track (hex starting '~') co-moving with the other + // member of the pair at wider-than-formation tolerances is a rebroadcast + // shadow of the same airframe's radar trackfile (or of a nearby + // aircraft's), not independent traffic — TIS-B can be tens of seconds + // stale, so the shadow trails well beyond FORMATION_SUPPRESS_NM on an + // otherwise matched course. Matched velocity again means zero closure, + // so nothing genuine is lost by skipping the pair. + if ( + (hexA.startsWith('~') || hexB.startsWith('~')) && + dist0 < TISB_SHADOW_NM && + dAlt0 < TISB_SHADOW_DALT_FT && + bearingDelta(acA.track, acB.track) < TISB_SHADOW_TRK_DEG && + Math.abs(acA.groundspeed - acB.groundspeed) < TISB_SHADOW_GS_KT + ) { + continue + } + + // ── Shared sample grid: t = 0, 5, …, CONFLICT_HORIZON_S ───────────── + const n = Math.min(ptsA.length, ptsB.length, horizonSteps) + const cosLat = Math.cos(((a0.lat + b0.lat) / 2) * DEG2RAD) + const rangeAt = (k: number) => equirectNm(ptsA[k].lat, ptsA[k].lon, ptsB[k].lat, ptsB[k].lon, cosLat) + const dAltAt = (k: number) => Math.abs(ptsA[k].altFt - ptsB[k].altFt) + + // CPA: min horizontal range over the grid, earliest sample on ties. + let cpaIdx = 0 + let cpaNm = rangeAt(0) + for (let k = 1; k < n; k++) { + const r = rangeAt(k) + if (r < cpaNm) { + cpaNm = r + cpaIdx = k + } + } + const cpaTimeS = ptsA[cpaIdx].tSec + const cpaDAltFt = dAltAt(cpaIdx) + + // ── Range tau from the first prediction step ───────────────────────── + const range0 = rangeAt(0) + const closureKt = n > 1 ? ((range0 - rangeAt(1)) / PRED_STEP_S) * 3600 : 0 + const tauS = closureKt > 0 ? (range0 / closureKt) * 3600 : Infinity + // Vertical tau analog: time to co-altitude at the current closing rate. + const verticalTauS = vCloseFpm > 0 ? dAlt0 / (vCloseFpm / 60) : Infinity + + // ── Sensitivity level: the more sensitive (higher-row) of the pair ── + const slA = sensitivityLevelFor(altA0, aglA) + const slB = sensitivityLevelFor(altB0, aglB) + const sl: TcasSL = slA.sl >= slB.sl ? slA : slB + + const taFires = + (tauS <= sl.taTauS && (cpaDAltFt <= sl.taZthrFt || verticalTauS <= sl.taTauS)) || + (range0 <= sl.taDmodNm && dAlt0 <= sl.taZthrFt) + + const raFires = + sl.raTauS !== null && + sl.raDmodNm !== null && + sl.raZthrFt !== null && + ((tauS <= sl.raTauS && (cpaDAltFt <= sl.raZthrFt || verticalTauS <= sl.raTauS)) || + (range0 <= sl.raDmodNm && dAlt0 <= sl.raZthrFt)) + + // ── Radar-style tier over the sample grid ──────────────────────────── + // The radar tier is a *convergence* alert ("path WILL intersect the + // threshold within the horizon"): a qualifying sample inside the window + // alerts only if the pair is actually closing into it — it either closes + // at least RADAR_MIN_CLOSURE_NM versus t=0, or enters the window from + // outside (t=0 separation already beyond the lateral threshold). Stable + // parallel-approach / in-trail / formation pairs sitting at constant + // separation already inside the window never latch a radar alert. + // + // VFR-vs-VFR inhibit: STARS Conflict Alert is inhibited for pairs where + // both aircraft squawk VFR (1200) — controllers don't separate VFRs, and + // flight-school pairs working in/near the pattern would otherwise paint + // the map with nuisance radar tiers. At least one aircraft must carry a + // discrete (non-VFR) code for the radar tier to apply. The TCAS TA/RA + // tier is deliberately untouched — real TCAS doesn't read squawks, and + // its tau gate keeps stable VFR pairs quiet on its own. + let radarAlert = false + let radarWarn = false + const radarEligible = acA.squawk !== VFR_SQUAWK || acB.squawk !== VFR_SQUAWK + for (let k = 0; radarEligible && k < n && ptsA[k].tSec <= RADAR_ALERT_HORIZON_S; k++) { + const sep = rangeAt(k) + const dAlt = dAltAt(k) + if (sep <= RADAR_ALERT_SEP_NM && dAlt <= RADAR_ALERT_DALT_FT) { + if (sep <= range0 - RADAR_MIN_CLOSURE_NM || range0 > RADAR_ALERT_SEP_NM) radarAlert = true + } + if (ptsA[k].tSec <= RADAR_WARN_HORIZON_S && sep <= RADAR_WARN_SEP_NM && dAlt <= RADAR_WARN_DALT_FT) { + if (sep <= range0 - RADAR_MIN_CLOSURE_NM || range0 > RADAR_WARN_SEP_NM) radarWarn = true + } + } + + // ── Tier precedence: ra > warning > ta > alert ─────────────────────── + let tier: AlertTier | null = null + if (raFires) tier = 'ra' + else if (radarWarn) tier = 'warning' + else if (taFires) tier = 'ta' + else if (radarAlert) tier = 'alert' + if (tier === null) continue + + // ── RA sense: simulate both escape senses to CPA ───────────────────── + let raSenseA: RaSense | undefined + let raSenseB: RaSense | undefined + if (tier === 'ra' && sl.alimFt !== null) { + const sepAClimb = Math.abs( + simulateAlt(altA0, acA.baroRate, RA_ESCAPE_FPM, cpaTimeS) - + simulateAlt(altB0, acB.baroRate, -RA_ESCAPE_FPM, cpaTimeS), + ) + const sepADescend = Math.abs( + simulateAlt(altA0, acA.baroRate, -RA_ESCAPE_FPM, cpaTimeS) - + simulateAlt(altB0, acB.baroRate, RA_ESCAPE_FPM, cpaTimeS), + ) + // Which aircraft is projected higher at CPA with no escape maneuver. + const aHigherAtCpa = + altA0 + (acA.baroRate / 60) * cpaTimeS >= altB0 + (acB.baroRate / 60) * cpaTimeS + const preferredSep = aHigherAtCpa ? sepAClimb : sepADescend + const otherSep = aHigherAtCpa ? sepADescend : sepAClimb + // Prefer "higher aircraft climbs" when it achieves ALIM; else the + // crossing sense if IT achieves ALIM; else whichever separates more. + let aClimbs: boolean + if (preferredSep >= sl.alimFt) aClimbs = aHigherAtCpa + else if (otherSep >= sl.alimFt) aClimbs = !aHigherAtCpa + else aClimbs = sepAClimb >= sepADescend + raSenseA = aClimbs ? 'climb' : 'descend' + raSenseB = aClimbs ? 'descend' : 'climb' + } + + pairs.push({ + hexA, + hexB, + tier, + ...(raSenseA ? { raSenseA } : {}), + ...(raSenseB ? { raSenseB } : {}), + cpaTimeS, + cpaNm, + cpaDAltFt, + }) + } + } + + return pairs +} + +const TIER_RANK: Record = { ra: 4, warning: 3, ta: 2, alert: 1 } + +/** Collapses conflict pairs to the single worst traffic alert per aircraft. */ +export function alertsFromConflicts(pairs: ConflictPair[]): Map { + const alerts = new Map() + const consider = (hex: string, otherHex: string, tier: AlertTier, raSense: RaSense | undefined) => { + const existing = alerts.get(hex) + if (existing && TIER_RANK[existing.tier] >= TIER_RANK[tier]) return + alerts.set(hex, { + kind: 'traffic', + tier, + otherHex, + ...(tier === 'ra' && raSense ? { raSense } : {}), + }) + } + for (const p of pairs) { + consider(p.hexA, p.hexB, p.tier, p.raSenseA) + consider(p.hexB, p.hexA, p.tier, p.raSenseB) + } + return alerts +} diff --git a/src/geo/holdEntry.ts b/src/geo/holdEntry.ts new file mode 100644 index 0000000..8676254 --- /dev/null +++ b/src/geo/holdEntry.ts @@ -0,0 +1,500 @@ +import * as turf from '@turf/turf' +import type { Feature } from 'geojson' +import { + HOLD_ENTRY_BRG_DEG, + HOLD_ENTRY_MAX_ETA_S, + HOLD_ENTRY_PASS_NM, + HOLD_ENTRY_ALT_TOL_FT, + HOLD_ENTRY_CLEAR_POLLS, + HOLD_ENTRY_TEARDROP_OFFSET_DEG, + HOLD_MATCH_DIR_DEG, +} from '../config/constants' +import { bearingDelta } from './lineMatching' +import { dest, semicircle, HOLD_TURN_R, clampHoldLeg } from './procedureShapes' +import { magneticToTrue } from '../utils/arincRecords' +import type { Procedure, AltConstraint } from '../types/procedure' +import type { InterpolatedAircraft } from '../types/aircraft' +import type { HoldSpec, HoldEntryKind, HoldEntryPrediction, PredictedPath } from '../types/path' + +type Pt = [number, number] + +const NM = { units: 'nauticalmiles' as const } +const DEG = Math.PI / 180 +const norm360 = (d: number): number => ((d % 360) + 360) % 360 + +// Crossing the fix / "established inbound" gates (module-internal conventions). +const FIX_CROSS_NM = 0.5 +const ESTABLISHED_TRACK_DEG = 20 +const ESTABLISHED_XT_NM = 0.5 +// Lateral offset of the drawn parallel-entry outbound leg on the non-holding side. +const PARALLEL_OFFSET_NM = 0.5 +// Default drawn leg length when the CIFP hold feature carries none. +const DEFAULT_HOLD_LEG_NM = 4 +// Clear an entry that has gone this long without a qualifying poll, regardless +// of the divergence heuristic. Guards a deadlock where an aircraft neither +// closes on the fix (so `divergedPolls` never increments), diverges, nor +// establishes inbound — e.g. orbiting just off the fix — which would otherwise +// strand the loop on screen indefinitely. ~a couple of polls past the last good one. +const HOLD_ENTRY_STALE_MS = 60_000 + +// ── Hold-spec collection ──────────────────────────────────────────────────── + +const specCache = new WeakMap() + +/** + * Extract every published hold from the given procedures as flat, true-course + * HoldSpecs. Sources: `kind:'hold'` GeoJSON features (HM/HF/HA racetracks the + * parser emits — geometry-authoritative, see `drawnHoldGeometry`) and + * `Procedure.holdInLieu` (fallback only), deduped by procId+fixId: the drawn + * feature wins, and a transition hold beats a missed hold at the same fix. + * Cached per Procedure object via WeakMap, so repeat calls are cheap and + * return identity-stable spec objects. + */ +export function collectHoldSpecs(procs: Procedure[]): HoldSpec[] { + const out: HoldSpec[] = [] + for (const proc of procs) { + let specs = specCache.get(proc) + if (!specs) { + specs = buildSpecs(proc) + specCache.set(proc, specs) + } + out.push(...specs) + } + return out +} + +function findFix(proc: Procedure, fixId: string): { lat: number; lon: number } | null { + const w = + proc.waypoints.find((x) => x.id === fixId) ?? proc.symbols.find((x) => x.id === fixId) + return w ? { lat: w.lat, lon: w.lon } : null +} + +interface DrawnHold { + fixLat: number + fixLon: number + inboundCourseTrue: number + turnRight: boolean + legNm: number +} + +/** + * Everything orientation-related a HoldSpec needs, derived purely from the + * drawn racetrack's own coordinates. `holdTrack` emits `[A, F, …loop…]`: A is + * the start of the inbound straight and F the fix, so + * - fix = coords[1] + * - inbound = geodetic bearing A → F (already TRUE — no magvar involved) + * - leg length = |A → F| + * - turn dir = which side of the inbound course line the loop's points + * fall on (net signed cross-track > 0 ⇒ right of course ⇒ + * right turns) + * Deriving from geometry instead of the feature's coded props means the entry + * can NEVER mirror, rotate, or tilt relative to the racetrack the user sees — + * even if the props (or the parser's course/magvar handling — under separate + * investigation) are wrong. Returns null for degenerate geometry, in which + * case the caller falls back to the coded props. + */ +function drawnHoldGeometry(f: Feature): DrawnHold | null { + if (f.geometry.type !== 'LineString') return null + const c = f.geometry.coordinates as Pt[] + if (c.length < 4) return null // need the loop, not just a straight + const A = c[0] + const F = c[1] + const legNm = turf.distance(turf.point(A), turf.point(F), NM) + if (legNm < 0.1) return null + const inb = norm360(turf.bearing(turf.point(A), turf.point(F))) + const fixPt = turf.point(F) + let side = 0 // Σ signed cross-track of the loop's points + for (const p of c) { + const d = turf.distance(fixPt, turf.point(p), NM) + const b = turf.bearing(fixPt, turf.point(p)) + const theta = ((b - inb + 540) % 360) - 180 + side += d * Math.sin(theta * DEG) + } + return { fixLat: F[1], fixLon: F[0], inboundCourseTrue: inb, turnRight: side > 0, legNm } +} + +function buildSpecs(proc: Procedure): HoldSpec[] { + const byKey = new Map() + const magVar = proc.magVarDeg ?? 0 + + // Drawn `kind:'hold'` racetracks FIRST — they are exactly what the user sees, + // so the entry's orientation (turn direction, inbound course) and anchor can + // never disagree with the drawn shape. This is the load-bearing fix for the + // "mirrored on the wrong side" defect: the old code let `holdInLieu` win, and + // a HILPT/missed pair at one fix could publish opposite turns, drawing the + // entry on the far side of the racetrack. On a same-fix collision (a + // transition HILPT and a missed hold sharing one fix with different + // courses/turns) the transition hold wins — it's the one an arriving aircraft + // is predicted to enter. + for (const f of proc.geojson.features) { + const p = f.properties as Record | null + if (!p || p.kind !== 'hold') continue + if (typeof p.fixId !== 'string' || typeof p.inboundCourseMag !== 'number') continue + const key = `${proc.id}|${p.fixId}` + const segment: HoldSpec['segment'] = p.segment === 'missed' ? 'missed' : 'transition' + const existing = byKey.get(key) + // Keep the existing spec unless we're upgrading a missed hold to the + // transition hold at the same fix. + if (existing && !(existing.segment === 'missed' && segment === 'transition')) continue + // The drawn coordinates are authoritative for EVERYTHING geometric — + // anchor, inbound course, holding side, leg length — so the entry can + // never mirror or rotate off the on-screen racetrack (LOFAL defect: the + // coded props' course/turn can disagree with the drawn loop). The coded + // props are only a fallback for degenerate geometry. + const g = drawnHoldGeometry(f) + if (g) { + byKey.set(key, { + key, + procId: proc.id, + fixId: p.fixId, + fixLat: g.fixLat, + fixLon: g.fixLon, + inboundCourseTrue: g.inboundCourseTrue, + turnRight: g.turnRight, + legNm: g.legNm, + alt: (p.alt as AltConstraint | null | undefined) ?? null, + segment, + }) + continue + } + const pos = findFix(proc, p.fixId) + if (!pos) continue + byKey.set(key, { + key, + procId: proc.id, + fixId: p.fixId, + fixLat: pos.lat, + fixLon: pos.lon, + inboundCourseTrue: magneticToTrue(p.inboundCourseMag, magVar), + turnRight: p.turnRight === true, + legNm: DEFAULT_HOLD_LEG_NM, + alt: (p.alt as AltConstraint | null | undefined) ?? null, + segment, + }) + } + + // Hold-in-lieu-of-PT: only when no drawn racetrack already covers the fix. In + // practice an HF leg always emits a drawn `kind:'hold'` feature too, so this + // is a safety fallback (it carries the published leg length). + const hil = proc.holdInLieu + if (hil) { + const key = `${proc.id}|${hil.fixId}` + if (!byKey.has(key)) { + const pos = findFix(proc, hil.fixId) + if (pos) { + byKey.set(key, { + key, + procId: proc.id, + fixId: hil.fixId, + fixLat: pos.lat, + fixLon: pos.lon, + inboundCourseTrue: magneticToTrue(hil.inboundCourseMag, magVar), + turnRight: hil.turnRight, + legNm: hil.legNm > 0 ? hil.legNm : DEFAULT_HOLD_LEG_NM, + alt: hil.alt ?? null, + segment: 'transition', + }) + } + } + } + + return [...byKey.values()] +} + +// ── Entry classification (AIM 5-3-8) ──────────────────────────────────────── + +/** + * Which entry the FAA recommends for an aircraft crossing the fix on + * `inboundTrackAtFixTrue` into a hold whose inbound course is + * `holdInboundTrue`. Sectors (right-turn hold, r = track − holdInbound, + * normalized 0–360): parallel r ∈ (70, 180], teardrop r ∈ (180, 250], direct + * otherwise. Left-turn holds mirror via r → 360 − r. Boundaries pinned: + * exactly 70 → direct, exactly 180 → parallel, exactly 250 → teardrop. + */ +export function classifyHoldEntry( + inboundTrackAtFixTrue: number, + holdInboundTrue: number, + turnRight: boolean, +): HoldEntryKind { + let r = norm360(inboundTrackAtFixTrue - holdInboundTrue) + if (!turnRight) r = norm360(360 - r) + if (r > 70 && r <= 180) return 'parallel' + if (r > 180 && r <= 250) return 'teardrop' + return 'direct' +} + +// ── Entry path geometry ───────────────────────────────────────────────────── + +/** Constant-radius turn from heading `hIn` to heading `hOut` in the given turn + * direction, starting at `from`. Returns the arc points including both ends. */ +function turnArc(from: Pt, hIn: number, hOut: number, right: boolean, r: number): Pt[] { + const center = dest(from, r, hIn + (right ? 90 : -90)) + const startBrg = hIn + (right ? -90 : 90) + const sweep = right ? norm360(hOut - hIn) : -norm360(hIn - hOut) + const steps = Math.max(4, Math.ceil(Math.abs(sweep) / 10)) + const out: Pt[] = [] + for (let i = 0; i <= steps; i++) out.push(dest(center, r, startBrg + (sweep * i) / steps)) + return out +} + +/** + * A 45°-style intercept from `from` back onto the hold's inbound course line, + * then the final run to the fix. Places the join point so the last segment + * lies exactly on the inbound course. Returns `[joinPoint, fix]`. + */ +function interceptToFix(from: Pt, fix: Pt, recip: number): Pt[] { + const d = turf.distance(turf.point(fix), turf.point(from), NM) + const brg = turf.bearing(turf.point(fix), turf.point(from)) + const theta = ((brg - recip + 540) % 360) - 180 + const along = d * Math.cos(theta * DEG) + const cross = Math.abs(d * Math.sin(theta * DEG)) + const backNm = Math.max(along - cross, 0.05) + return [dest(fix, backNm, recip), fix] +} + +/** + * The predicted entry path as a [lon, lat] polyline starting at the hold fix, + * using the same turn radius (HOLD_TURN_R) and clamped leg length the drawn + * racetrack uses, so entries visually mate with the published hold shape. + * Every variant's final segment lies exactly on the inbound course to the fix. + */ +export function holdEntryPath(spec: HoldSpec, entry: HoldEntryKind): [number, number][] { + const F: Pt = [spec.fixLon, spec.fixLat] + const inb = spec.inboundCourseTrue + const right = spec.turnRight + const recip = norm360(inb + 180) + const side = norm360(inb + (right ? 90 : -90)) + const L = clampHoldLeg(spec.legNm) + const r = HOLD_TURN_R + + if (entry === 'direct') { + // The racetrack itself, flown from the fix: near-end turn to outbound, + // outbound leg, far turn, inbound leg back to the fix. Identical anchor + // points and semicircles to holdTrack, just rotated to begin at F. + const A = dest(F, L, recip) + const C = dest(A, 2 * r, side) + // nearArc already ends at the abeam-fix point B, so don't re-emit an + // explicit B: the two are ~0.4 m apart (great-circle two-hop vs one-hop), + // and the duplicate created a zero-ish segment that read as a 180° reversal. + const nearArc = semicircle(dest(F, r, side), r, norm360(side + 180), right) // F → B + const farArc = semicircle(dest(A, r, side), r, side, right) // C → A + return [F, ...nearArc.slice(1), C, ...farArc.slice(1), F] + } + + if (entry === 'teardrop') { + // Outbound on the reciprocal offset 30° toward the holding side, one leg + // length, then a turn in the hold's direction that rolls out on a 45° + // intercept heading (NOT parallel to the course), so the straight run to + // the fix converges cleanly. Turning all the way to the inbound heading + // used to leave the aircraft parallel-but-offset, and interceptToFix — which + // assumes a 45° intercept — then inserted a backward dog-leg (the visible + // mid-path jog). The intercept comes from the holding side, so the turn is + // toward the course: inb − 45° for a right hold, inb + 45° for a left hold. + const out = norm360(recip + (right ? -1 : 1) * HOLD_ENTRY_TEARDROP_OFFSET_DEG) + const T = dest(F, L, out) + const intercept = norm360(inb + (right ? -45 : 45)) + const arc = turnArc(T, out, intercept, right, r) + return [F, T, ...arc.slice(1), ...interceptToFix(arc[arc.length - 1], F, recip)] + } + + // Parallel: outbound past the fix parallel to the reciprocal, offset onto the + // NON-holding side, one leg length, then a >180° turn in the hold's direction + // (through the outbound and inbound headings to a 45° intercept heading), + // rejoining the inbound course outside the fix. + const nonSide = norm360(inb + (right ? -90 : 90)) + const OE = dest(dest(F, L, recip), PARALLEL_OFFSET_NM, nonSide) + const arc = turnArc(OE, recip, norm360(inb + (right ? 45 : -45)), right, r) + return [F, OE, ...arc.slice(1), ...interceptToFix(arc[arc.length - 1], F, recip)] +} + +// ── Trigger evaluation ────────────────────────────────────────────────────── + +function altWithinTolerance(alt: AltConstraint, altFt: number): boolean { + const tol = HOLD_ENTRY_ALT_TOL_FT + switch (alt.type) { + case 'AT': + case 'AT_OR_ABOVE': + return altFt >= alt.low - tol + case 'AT_OR_BELOW': + return altFt <= (alt.high ?? alt.low) + tol + case 'BETWEEN': + return altFt >= alt.low - tol && altFt <= (alt.high ?? alt.low) + tol + } +} + +interface Qualification { + spec: HoldSpec + distNm: number + /** Predicted track arriving at the fix — classification input. */ + arrivalTrack: number +} + +function evaluateTrigger( + ac: InterpolatedAircraft, + spec: HoldSpec, + pred: PredictedPath | undefined, +): Qualification | null { + const acPt = turf.point([ac.lon, ac.lat]) + const fixPt = turf.point([spec.fixLon, spec.fixLat]) + const distNm = turf.distance(acPt, fixPt, NM) + + // Headed at the fix. + if (bearingDelta(ac.track, turf.bearing(acPt, fixPt)) > HOLD_ENTRY_BRG_DEG) return null + + // Arriving soon. + if (ac.groundspeed <= 0) return null + if ((distNm / ac.groundspeed) * 3600 > HOLD_ENTRY_MAX_ETA_S) return null + + // Predicted path actually passes the fix. + if (!pred || pred.points.length === 0) return null + let minD = Infinity + let minIdx = 0 + for (let i = 0; i < pred.points.length; i++) { + const p = pred.points[i] + const d = turf.distance(turf.point([p.lon, p.lat]), fixPt, NM) + if (d < minD) { + minD = d + minIdx = i + } + } + if (minD > HOLD_ENTRY_PASS_NM) return null + + // Not already established on the inbound course. + const recip = norm360(spec.inboundCourseTrue + 180) + const brgFromFix = turf.bearing(fixPt, acPt) + const theta = ((brgFromFix - recip + 540) % 360) - 180 + const xtNm = Math.abs(distNm * Math.sin(theta * DEG)) + const aligned = bearingDelta(ac.track, spec.inboundCourseTrue) <= ESTABLISHED_TRACK_DEG + if (aligned && xtNm <= ESTABLISHED_XT_NM) return null + + // Predicted altitude at the fix within tolerance of the hold's constraint. + if (spec.alt && !altWithinTolerance(spec.alt, pred.points[minIdx].altFt)) return null + + const prevPt = minIdx > 0 ? pred.points[minIdx - 1] : null + const arrivalTrack = prevPt + ? norm360( + turf.bearing( + turf.point([prevPt.lon, prevPt.lat]), + turf.point([pred.points[minIdx].lon, pred.points[minIdx].lat]), + ), + ) + : ac.track + return { spec, distNm, arrivalTrack } +} + +// ── Reducer ───────────────────────────────────────────────────────────────── + +export interface HoldEntryState { + entries: Map + /** Last poll's distance-to-fix per hex — divergence bookkeeping. */ + lastDistNm: Map +} + +export function emptyHoldEntryState(): HoldEntryState { + return { entries: new Map(), lastDistNm: new Map() } +} + +export interface HoldEntryInput { + nowMs: number + aircraft: readonly InterpolatedAircraft[] + predictions: ReadonlyMap + specs: readonly HoldSpec[] + assignments: Readonly> +} + +/** + * Per-poll hold-entry lifecycle. Pure: returns fresh Maps; record objects and + * their `path` arrays keep identity across polls. Entry kind and path FREEZE + * at first qualification — only a spec change (which is itself locked out + * once the fix is crossed) regenerates them. An entry clears when its aircraft + * gains an approach assignment, becomes established inbound after crossing the + * fix, diverges for HOLD_ENTRY_CLEAR_POLLS consecutive non-qualifying polls, + * goes HOLD_ENTRY_STALE_MS without qualifying, or vanishes. + */ +export function reduceHoldEntries(prev: HoldEntryState, input: HoldEntryInput): HoldEntryState { + const entries = new Map() + const lastDistNm = new Map() + const specByKey = new Map(input.specs.map((s) => [s.key, s])) + + for (const ac of input.aircraft) { + const hex = ac.hex + if (input.assignments[hex]) continue // assignment appeared (or exists) → no entry + + const pred = input.predictions.get(hex) + const prevRec = prev.entries.get(hex) + + // Closest qualifying spec this poll. + let best: Qualification | null = null + for (const spec of input.specs) { + const q = evaluateTrigger(ac, spec, pred) + if (q && (!best || q.distNm < best.distNm)) best = q + } + + if (!prevRec) { + if (!best) continue + const entry = classifyHoldEntry(best.arrivalTrack, best.spec.inboundCourseTrue, best.spec.turnRight) + entries.set(hex, { + hex, + specKey: best.spec.key, + entry, + path: holdEntryPath(best.spec, entry), + lastQualifiedMs: input.nowMs, + divergedPolls: 0, + crossedFix: best.distNm <= FIX_CROSS_NM, + }) + lastDistNm.set(hex, best.distNm) + continue + } + + // Once the fix is crossed the aircraft is EXECUTING the entry: lock the + // spec — switching to another hold mid-entry (and regenerating from it) + // flips the loop onto the reciprocal side of the fix. + const specChanged = !prevRec.crossedFix && best !== null && best.spec.key !== prevRec.specKey + const spec = specChanged && best ? best.spec : specByKey.get(prevRec.specKey) + if (!spec) continue // spec no longer published + + const sameSpecBest = best !== null && best.spec.key === spec.key ? best : null + const distNm = sameSpecBest + ? sameSpecBest.distNm + : turf.distance(turf.point([ac.lon, ac.lat]), turf.point([spec.fixLon, spec.fixLat]), NM) + const crossedFix = (specChanged ? false : prevRec.crossedFix) || distNm <= FIX_CROSS_NM + + // Established in the hold → prediction served its purpose. + if (crossedFix && bearingDelta(ac.track, spec.inboundCourseTrue) <= HOLD_MATCH_DIR_DEG) continue + + let rec: HoldEntryPrediction + if (sameSpecBest) { + // Entry kind and path FREEZE at first qualification: re-deriving the + // arrival track on later polls (especially from predicted points at or + // past the fix) yields reciprocal-course garbage that re-classified the + // entry and rebuilt the loop flipped along the course axis (LOFAL + // defect 2). Only a genuine spec change re-classifies and regenerates. + const entry = specChanged + ? classifyHoldEntry(sameSpecBest.arrivalTrack, spec.inboundCourseTrue, spec.turnRight) + : prevRec.entry + rec = { + ...prevRec, + specKey: spec.key, + entry, + path: specChanged ? holdEntryPath(spec, entry) : prevRec.path, + lastQualifiedMs: input.nowMs, + divergedPolls: 0, + crossedFix, + } + } else { + // Hard stale-out: clear regardless of geometry once too long has passed + // without a qualifying poll (breaks the "distance flat, trigger failing + // forever" deadlock that would otherwise never increment divergedPolls). + if (input.nowMs - prevRec.lastQualifiedMs >= HOLD_ENTRY_STALE_MS) continue + let diverged = prevRec.divergedPolls + const lastDist = prev.lastDistNm.get(hex) + if (lastDist !== undefined && distNm > lastDist) diverged += 1 + if (diverged >= HOLD_ENTRY_CLEAR_POLLS) continue + rec = { ...prevRec, divergedPolls: diverged, crossedFix } + } + entries.set(hex, rec) + lastDistNm.set(hex, distNm) + } + + return { entries, lastDistNm } +} diff --git a/src/geo/prediction.ts b/src/geo/prediction.ts new file mode 100644 index 0000000..b1bb22a --- /dev/null +++ b/src/geo/prediction.ts @@ -0,0 +1,451 @@ +import * as turf from '@turf/turf' +import type { InterpolatedAircraft } from '../types/aircraft' +import type { Procedure, ProcedureTransition } from '../types/procedure' +import type { CifpRunwayInfo } from '../types/cifp' +import type { TrackPoint, PredPoint, PredictedPath } from '../types/path' +import { + PREDICT_STEP_S, + PREDICT_MAX_S, + TURN_RATE_MIN_DPS, + TURN_RATE_MAX_DPS, + PREDICT_TURN_HOLD_S, + PREDICT_TURN_DECAY_END_S, + PREDICT_PROFILE_CAPTURE_FT, + PREDICT_MIN_DESCENT_FPM, + DETECT_CONFIRMED_XT_APPROACH_NM, + DETECT_CONFIRMED_DIR_DEG, + HOLD_MATCH_XT_NM, + HOLD_MATCH_DIR_DEG, +} from '../config/constants' +import { matchPointToLine, bearingDelta } from './lineMatching' +import { prepareProcedure } from './procedureMatch' +import { pickProfileTransition, buildProfileModel, descentProfilePoints, alongTrackNm } from './profileMath' + +const NM = { units: 'nauticalmiles' as const } + +// ── Guidance ──────────────────────────────────────────────────────────────── + +type PathKind = 'representative' | 'arc' | 'hold' + +/** One lateral guidance polyline the aircraft can be walked along. */ +interface GuidancePath { + kind: PathKind + coords: [number, number][] + /** Total length (nm) of the polyline. */ + lengthNm: number + /** Last vertex, for straight extrapolation past the end. */ + lastCoord: [number, number] + /** Bearing of the final segment, for straight extrapolation past the end. */ + lastBearing: number + /** + * Arc paths only: where the arc's end projects (along-track nm) onto the + * representative path, so a walk that runs off the arc continues on the final. + */ + junctionRepAlongNm?: number +} + +/** + * Cached lateral+vertical guidance for an aircraft assigned to a procedure. + * WeakMap-keyed by Procedure identity (safe: `procedures` is replaced wholesale + * on airport/AIRAC change). The lateral paths mirror detection's guidance set + * (representative waypoint polyline + each DME-arc feeder + each hold racetrack); + * the vertical model is the profile-panel recipe (final/common transition, + * runway TDZE) reduced to its descent vertices. + */ +export interface Guidance { + proc: Procedure + paths: GuidancePath[] + representative: GuidancePath + /** Descent vertices (distNm from the profile transition start, altFt MSL). */ + profilePoints: { distNm: number; altFt: number }[] + /** The profile transition, for projecting predicted points to profile distance. */ + transition: ProcedureTransition | null + /** Vertical floor (ft MSL): the greater of field elevation and runway TDZE. */ + floorFt: number +} + +const guidanceCache = new WeakMap() +// prepareGuidance is memoized per (proc) but its inputs (rwy, fieldElev) could +// in principle change; they don't within a session for a given procedure, so +// the cache key is the procedure identity alone (matching prepareProcedure). + +function makePath(kind: PathKind, coords: [number, number][]): GuidancePath | null { + if (coords.length < 2) return null + const lengthNm = turf.length(turf.lineString(coords), NM) + const last = coords[coords.length - 1] + const prev = coords[coords.length - 2] + const lastBearing = turf.bearing(turf.point(prev), turf.point(last)) + return { kind, coords, lengthNm, lastCoord: last, lastBearing } +} + +/** + * Build (and cache) the guidance bundle for an assigned approach. `rwy` is the + * runway end the profile model is built against (ProfilePanel recipe); when + * null, `fieldElevFt` is substituted for the TDZE so glideslope-anchored + * altitudes stay at field level instead of collapsing to sea level (which is + * what buildProfileModel/glideslopeAltAt do with a null TDZE). + */ +export function prepareGuidance( + proc: Procedure, + rwy: CifpRunwayInfo | null, + fieldElevFt: number, +): Guidance { + const cached = guidanceCache.get(proc) + if (cached) return cached + + const prepared = prepareProcedure(proc) + const paths: GuidancePath[] = [] + + const rep = prepared ? makePath('representative', prepared.coords) : null + const representative: GuidancePath = + rep ?? { + kind: 'representative', + coords: [], + lengthNm: 0, + lastCoord: [0, 0], + lastBearing: 0, + } + if (rep) paths.push(rep) + + if (prepared) { + for (const arc of prepared.arcPaths) { + const p = makePath('arc', arc.coords) + if (!p) continue + const end = arc.coords[arc.coords.length - 1] + if (rep) { + const m = matchPointToLine(rep.coords, end[1], end[0], 0, { + maxCrossTrackNm: Infinity, + directionToleranceDeg: 360, + }) + p.junctionRepAlongNm = m ? m.alongTrackNm : rep.lengthNm + } else { + p.junctionRepAlongNm = 0 + } + paths.push(p) + } + for (const hold of prepared.holdPaths) { + const p = makePath('hold', hold.coords) + if (p) paths.push(p) + } + } + + // ── Vertical: the profile-panel recipe (final/common transition + runway). ── + const transition = pickProfileTransition(proc) + // A null TDZE makes glideslopeAltAt() anchor at sea level; synthesize a runway + // end at field elevation so the anchor sits at the field instead. + const effRwy: CifpRunwayInfo | null = + rwy ?? { id: '', lat: 0, lon: 0, thresholdElevFt: fieldElevFt, lengthFt: null } + let profilePoints: { distNm: number; altFt: number }[] = [] + if (transition) { + const model = buildProfileModel(proc, transition, effRwy) + profilePoints = descentProfilePoints(model) + } + const tdze = rwy?.thresholdElevFt ?? fieldElevFt + const floorFt = Math.max(fieldElevFt, tdze) + + const guidance: Guidance = { proc, paths, representative, profilePoints, transition, floorFt } + guidanceCache.set(proc, guidance) + return guidance +} + +// ── Turn-rate estimation ──────────────────────────────────────────────────── + +/** Signed smallest delta from bearing `a` to `b` (deg, −180..180). */ +function signedTurn(a: number, b: number): number { + return ((b - a + 540) % 360) - 180 +} + +/** + * Estimate the aircraft's current turn rate (deg/s, right = positive) from up to + * the last three poll samples. Each consecutive pair contributes its signed + * heading delta over Δt; pairs with Δt outside [1, 20] s are skipped (a stale or + * duplicate sample), and the most recent pair is weighted double. Fewer than two + * usable pairs returns 0. + */ +export function turnRateDps(recent: readonly TrackPoint[]): number { + if (recent.length < 2) return 0 + const pts = recent.slice(-3) + let weighted = 0 + let weightSum = 0 + for (let i = 1; i < pts.length; i++) { + const dtS = (pts[i].tMs - pts[i - 1].tMs) / 1000 + if (dtS < 1 || dtS > 20) continue + const omega = signedTurn(pts[i - 1].track, pts[i].track) / dtS + const weight = i === pts.length - 1 ? 2 : 1 + weighted += omega * weight + weightSum += weight + } + if (weightSum === 0) return 0 + return weighted / weightSum +} + +/** Turn rate at time t, applying the hold-then-linear-decay envelope. */ +function turnRateAt(omega0: number, tSec: number): number { + if (tSec <= PREDICT_TURN_HOLD_S) return omega0 + if (tSec >= PREDICT_TURN_DECAY_END_S) return 0 + const frac = (PREDICT_TURN_DECAY_END_S - tSec) / (PREDICT_TURN_DECAY_END_S - PREDICT_TURN_HOLD_S) + return omega0 * frac +} + +// ── On-procedure test ─────────────────────────────────────────────────────── + +interface GateSpec { + coords: [number, number][] + xtLimitNm: number + dirLimitDeg: number +} + +/** + * Is the aircraft currently within confirmed lateral+direction tolerance of any + * guidance path (representative / arc / hold) of `proc`? Finds the laterally + * closest path first (no direction gate), then applies that path's own gates, + * so a holding aircraft (which turns continuously) is judged against the roomier + * hold tolerances rather than the tight final-approach ones. + */ +export function isOnProcedureNow(ac: InterpolatedAircraft, proc: Procedure): boolean { + if (ac.altBaro === 'ground') return false + const prepared = prepareProcedure(proc) + if (!prepared) return false + + const gates: GateSpec[] = [] + if (prepared.coords.length >= 2) { + gates.push({ + coords: prepared.coords, + xtLimitNm: DETECT_CONFIRMED_XT_APPROACH_NM, + dirLimitDeg: DETECT_CONFIRMED_DIR_DEG, + }) + } + for (const arc of prepared.arcPaths) { + if (arc.coords.length >= 2) { + gates.push({ + coords: arc.coords, + xtLimitNm: DETECT_CONFIRMED_XT_APPROACH_NM, + dirLimitDeg: DETECT_CONFIRMED_DIR_DEG, + }) + } + } + for (const hold of prepared.holdPaths) { + if (hold.coords.length >= 2) { + gates.push({ coords: hold.coords, xtLimitNm: HOLD_MATCH_XT_NM, dirLimitDeg: HOLD_MATCH_DIR_DEG }) + } + } + + let bestGate: GateSpec | null = null + let bestXt = Infinity + let bestBearing = 0 + for (const g of gates) { + const m = matchPointToLine(g.coords, ac.interpLat, ac.interpLon, ac.track, { + maxCrossTrackNm: Infinity, + directionToleranceDeg: 360, + }) + if (!m) continue + if (m.crossTrackNm < bestXt) { + bestXt = m.crossTrackNm + bestGate = g + bestBearing = m.segBearing + } + } + if (!bestGate) return false + const lateralOk = bestXt <= bestGate.xtLimitNm + const directionOk = bearingDelta(ac.track, bestBearing) <= bestGate.dirLimitDeg + return lateralOk && directionOk +} + +// ── Prediction ────────────────────────────────────────────────────────────── + +/** Interpolate the descent profile altitude at an along-track distance (nm). */ +function profileAltAt(points: { distNm: number; altFt: number }[], distNm: number): number { + const n = points.length + if (distNm <= points[0].distNm) return points[0].altFt + const last = points[n - 1] + if (distNm >= last.distNm) return last.altFt + for (let i = 1; i < n; i++) { + if (distNm <= points[i].distNm) { + const a = points[i - 1] + const b = points[i] + const span = b.distNm - a.distNm + const frac = span <= 0 ? 0 : (distNm - a.distNm) / span + return a.altFt + (b.altFt - a.altFt) * frac + } + } + return last.altFt +} + +/** Position along one guidance path at a given along-track distance (nm). */ +function positionAlong( + path: GuidancePath, + representative: GuidancePath, + distNm: number, +): { lat: number; lon: number } { + if (path.kind === 'hold' && path.lengthNm > 0) { + const wrapped = distNm % path.lengthNm + const c = turf.along(turf.lineString(path.coords), wrapped, NM).geometry.coordinates + return { lat: c[1], lon: c[0] } + } + if (path.kind === 'arc') { + if (distNm <= path.lengthNm) { + const c = turf.along(turf.lineString(path.coords), distNm, NM).geometry.coordinates + return { lat: c[1], lon: c[0] } + } + const repAlong = (path.junctionRepAlongNm ?? representative.lengthNm) + (distNm - path.lengthNm) + return positionAlong(representative, representative, repAlong) + } + // representative + if (distNm <= path.lengthNm && path.lengthNm > 0) { + const c = turf.along(turf.lineString(path.coords), distNm, NM).geometry.coordinates + return { lat: c[1], lon: c[0] } + } + // Past the end: extrapolate straight on the final segment bearing. + const overshoot = distNm - path.lengthNm + const dest = turf.destination(turf.point(path.lastCoord), Math.max(0, overshoot), path.lastBearing, NM) + return { lat: dest.geometry.coordinates[1], lon: dest.geometry.coordinates[0] } +} + +/** The guidance path the aircraft is laterally closest to (no direction gate). */ +function closestGuidancePath( + guidance: Guidance, + ac: InterpolatedAircraft, +): { path: GuidancePath; alongNowNm: number } { + let best = guidance.representative + let bestAlong = 0 + let bestXt = Infinity + for (const path of guidance.paths) { + const m = matchPointToLine(path.coords, ac.interpLat, ac.interpLon, ac.track, { + maxCrossTrackNm: Infinity, + directionToleranceDeg: 360, + }) + if (!m) continue + if (m.crossTrackNm < bestXt) { + bestXt = m.crossTrackNm + best = path + bestAlong = m.alongTrackNm + } + } + return { path: best, alongNowNm: bestAlong } +} + +function stepCount(horizonS: number): number { + return Math.max(0, Math.floor(horizonS / PREDICT_STEP_S)) +} + +/** Predict along the assigned approach's guidance, riding the descent profile. */ +function predictApproach( + ac: InterpolatedAircraft, + guidance: Guidance, + horizonS: number, +): PredictedPath { + const gs = ac.groundspeed + const onGround = ac.altBaro === 'ground' + const altNow = onGround ? guidance.floorFt : (ac.altBaro as number) + const baroRate = onGround ? 0 : ac.baroRate + const { path, alongNowNm } = closestGuidancePath(guidance, ac) + + const points: PredPoint[] = [ + { lon: ac.interpLon, lat: ac.interpLat, tSec: 0, altFt: altNow }, + ] + let altPred = altNow + const n = stepCount(horizonS) + for (let i = 1; i <= n; i++) { + const tSec = i * PREDICT_STEP_S + const distAlong = alongNowNm + (gs * tSec) / 3600 + const pos = positionAlong(path, guidance.representative, distAlong) + + let target: number + if (guidance.profilePoints.length >= 2) { + const along = guidance.transition + ? alongTrackNm(guidance.transition, pos.lat, pos.lon).distNm + : distAlong + target = profileAltAt(guidance.profilePoints, along) + } else { + target = altNow + (baroRate * tSec) / 60 + } + + const diff = target - altPred + if (Math.abs(diff) <= PREDICT_PROFILE_CAPTURE_FT) { + altPred = target + } else { + const rateFpm = Math.max(Math.abs(baroRate), PREDICT_MIN_DESCENT_FPM) + const maxDelta = (rateFpm * PREDICT_STEP_S) / 60 + altPred += Math.sign(diff) * Math.min(maxDelta, Math.abs(diff)) + } + // Never below the profile, never below the field/TDZE floor. + altPred = Math.max(altPred, target, guidance.floorFt) + + points.push({ lon: pos.lon, lat: pos.lat, tSec, altFt: altPred }) + } + + return { hex: ac.hex, mode: 'approach', points } +} + +/** Predict by turn-rate extrapolation (turning or straight dead-reckoning). */ +function predictExtrapolated( + ac: InterpolatedAircraft, + recent: readonly TrackPoint[], + fieldElevFt: number, + horizonS: number, + forceStraight: boolean, +): PredictedPath { + let omega = forceStraight ? 0 : turnRateDps(recent) + const turning = Math.abs(omega) >= TURN_RATE_MIN_DPS + if (!turning) omega = 0 + else omega = Math.max(-TURN_RATE_MAX_DPS, Math.min(TURN_RATE_MAX_DPS, omega)) + + const gs = ac.groundspeed + const onGround = ac.altBaro === 'ground' + const altNow = onGround ? fieldElevFt : (ac.altBaro as number) + const baroRate = onGround ? 0 : ac.baroRate + const floorFt = Math.max(0, fieldElevFt) + + let lat = ac.interpLat + let lon = ac.interpLon + let heading = ac.track + const points: PredPoint[] = [{ lon, lat, tSec: 0, altFt: altNow }] + + const n = stepCount(horizonS) + for (let i = 1; i <= n; i++) { + const tPrev = (i - 1) * PREDICT_STEP_S + heading = (heading + turnRateAt(omega, tPrev) * PREDICT_STEP_S + 360) % 360 + if (gs > 0) { + const dest = turf.destination( + turf.point([lon, lat]), + (gs * PREDICT_STEP_S) / 3600, + heading, + NM, + ) + lon = dest.geometry.coordinates[0] + lat = dest.geometry.coordinates[1] + } + const tSec = i * PREDICT_STEP_S + const altFt = Math.max(floorFt, altNow + (baroRate * tSec) / 60) + points.push({ lon, lat, tSec, altFt }) + } + + return { hex: ac.hex, mode: turning ? 'turn' : 'straight', points } +} + +/** + * Predict an aircraft's path up to `horizonS` seconds ahead, at PREDICT_STEP_S + * steps (point 0 = the current interpolated position). When the aircraft is + * assigned to an approach (guidance non-null) AND currently established on one + * of its guidance paths, the prediction follows the procedure laterally and the + * published descent profile vertically ('approach'). Otherwise it extrapolates + * the observed turn rate — decaying to straight flight — and the baro rate + * ('turn' or 'straight'). TIS-B tracks (hex starting '~') are too noisy to + * trust a turn rate from, so they are always extrapolated straight. + */ +export function predictPath( + ac: InterpolatedAircraft, + recent: readonly TrackPoint[], + guidance: Guidance | null, + fieldElevFt: number, + horizonS: number = PREDICT_MAX_S, +): PredictedPath { + const forceStraight = ac.hex.startsWith('~') + const onProcedure = + guidance !== null && !forceStraight && isOnProcedureNow(ac, guidance.proc) + + if (onProcedure && guidance) { + return predictApproach(ac, guidance, horizonS) + } + return predictExtrapolated(ac, recent, fieldElevFt, horizonS, forceStraight) +} diff --git a/src/geo/procedureShapes.ts b/src/geo/procedureShapes.ts index 39c1787..4e77f9f 100644 --- a/src/geo/procedureShapes.ts +++ b/src/geo/procedureShapes.ts @@ -4,7 +4,7 @@ type Pt = [number, number] const NM = { units: 'nauticalmiles' as const } -function dest(p: Pt, distNm: number, bearing: number): Pt { +export function dest(p: Pt, distNm: number, bearing: number): Pt { return turf.destination(turf.point(p), distNm, bearing, NM).geometry.coordinates as Pt } @@ -49,7 +49,7 @@ export function dmeArc( } /** Sweep an arc of `points` around `center`, from `startBrg` to `startBrg ± 180`. */ -function semicircle(center: Pt, radiusNm: number, startBrg: number, right: boolean, steps = 16): Pt[] { +export function semicircle(center: Pt, radiusNm: number, startBrg: number, right: boolean, steps = 16): Pt[] { const out: Pt[] = [] for (let i = 0; i <= steps; i++) { const f = i / steps @@ -67,8 +67,8 @@ function semicircle(center: Pt, radiusNm: number, startBrg: number, right: boole */ // Hold racetrack sizing (shared by holdTrack and holdOutboundLabelAnchor so the // label lands exactly on the drawn outbound leg). -const HOLD_TURN_R = 0.85 // nm — turn radius / half the track width -const clampHoldLeg = (legNm: number): number => Math.min(Math.max(legNm || 0, 1.5), 6) +export const HOLD_TURN_R = 0.85 // nm — turn radius / half the track width +export const clampHoldLeg = (legNm: number): number => Math.min(Math.max(legNm || 0, 1.5), 6) export function holdTrack( fixLat: number, diff --git a/src/geo/profileTrail.ts b/src/geo/profileTrail.ts new file mode 100644 index 0000000..af4a67f --- /dev/null +++ b/src/geo/profileTrail.ts @@ -0,0 +1,61 @@ +import type { TrackPoint } from '../types/path' +import type { ProcedureTransition } from '../types/procedure' +import { alongTrackNm } from './profileMath' +import { PROFILE_TRACK_XT_MAX_NM, TRACKLOG_GAP_BREAK_MS } from '../config/constants' + +export interface ProfileTrailPoint { + distNm: number + altFt: number +} + +// Consecutive kept points further apart than this in along-track distance are +// a projection jump (the aircraft is off doing something else, or briefly +// clipped a distant part of the transition line), not a continuous trace. +const DIST_JUMP_BREAK_NM = 2 + +/** + * Build the selected aircraft's flown-history trace for the vertical-profile + * panel: projects each TrackPoint of a hex's tracklog onto the profile's + * transition line via `alongTrackNm`, keeps only points that are laterally + * near the approach (`xtNm <= PROFILE_TRACK_XT_MAX_NM`, dropping unrelated + * wandering) and within the plotted distance range `[0, maxDistNm]`, then + * splits the kept points into segments wherever consecutive points are more + * than `TRACKLOG_GAP_BREAK_MS` apart in time (a coverage gap) or more than + * `DIST_JUMP_BREAK_NM` apart in along-track distance (a projection jump) — + * either way, not something that should be drawn as one continuous stroke. + * + * Pure function of its inputs; returns `[]` when there's no track, no + * altitude-bearing points, or nothing survives the xt/range gates. Segments + * of fewer than 2 points (nothing to draw a line between) are dropped. + */ +export function buildProfileTrail( + track: readonly TrackPoint[], + transition: ProcedureTransition, + maxDistNm: number, +): ProfileTrailPoint[][] { + const kept: { tMs: number; distNm: number; altFt: number }[] = [] + for (const p of track) { + if (typeof p.altFt !== 'number') continue + const { distNm, xtNm } = alongTrackNm(transition, p.lat, p.lon) + if (xtNm > PROFILE_TRACK_XT_MAX_NM) continue + if (distNm < 0 || distNm > maxDistNm) continue + kept.push({ tMs: p.tMs, distNm, altFt: p.altFt }) + } + if (kept.length === 0) return [] + + const segments: ProfileTrailPoint[][] = [] + let current: ProfileTrailPoint[] = [{ distNm: kept[0].distNm, altFt: kept[0].altFt }] + for (let i = 1; i < kept.length; i++) { + const prev = kept[i - 1] + const cur = kept[i] + const isBreak = cur.tMs - prev.tMs > TRACKLOG_GAP_BREAK_MS || Math.abs(cur.distNm - prev.distNm) > DIST_JUMP_BREAK_NM + if (isBreak) { + if (current.length >= 2) segments.push(current) + current = [] + } + current.push({ distNm: cur.distNm, altFt: cur.altFt }) + } + if (current.length >= 2) segments.push(current) + + return segments +} diff --git a/src/geo/rangeRings.ts b/src/geo/rangeRings.ts new file mode 100644 index 0000000..628dc82 --- /dev/null +++ b/src/geo/rangeRings.ts @@ -0,0 +1,71 @@ +import * as turf from '@turf/turf' +import type { Feature, FeatureCollection, LineString } from 'geojson' +import { RING_ZOOM_BUCKETS } from '../config/constants' +import { dest } from './procedureShapes' + +const RING_STEPS = 64 + +/** + * Ring radii (nm) for the given map zoom: the first `RING_ZOOM_BUCKETS` entry + * (walked in order) whose `minZoom` the zoom meets or exceeds. The last + * bucket's `minZoom` is `-Infinity`, so this always resolves. + */ +export function ringRadiiForZoom(zoom: number): [number, number, number] { + for (const bucket of RING_ZOOM_BUCKETS) { + if (zoom >= bucket.minZoom) return bucket.radiiNm + } + /* istanbul ignore next -- unreachable: the last bucket always matches */ + return RING_ZOOM_BUCKETS[RING_ZOOM_BUCKETS.length - 1].radiiNm +} + +/** + * One closed LineString ring per radius, centered on the aircraft. Rings are + * lines (not filled polygons) so `ProcedureLayer`-style consumers can style + * them as thin circles without a fill layer. + */ +export function ringFeatures( + lat: number, + lon: number, + radiiNm: [number, number, number], +): FeatureCollection { + const features: Feature[] = radiiNm.map((radiusNm) => { + const circle = turf.circle([lon, lat], radiusNm, { steps: RING_STEPS, units: 'nauticalmiles' }) + return { + type: 'Feature', + geometry: { type: 'LineString', coordinates: circle.geometry.coordinates[0] }, + properties: { radiusNm }, + } + }) + return { type: 'FeatureCollection', features } +} + +export interface RingBadge { + radiusNm: number + lat: number + lon: number + position: '12' | '6' +} + +/** + * Badge anchor for each ring: the 12 o'clock point (bearing 0 from center at + * `radiusNm`), unless the projected screen point is off the top of the + * viewport (null project, or `y < viewportTopPx`) — then the 6 o'clock point + * (bearing 180) is used instead so the "N NM" label always stays on-screen. + */ +export function ringBadges( + lat: number, + lon: number, + radiiNm: [number, number, number], + project: (lonLat: [number, number]) => { x: number; y: number } | null, + viewportTopPx = 8, +): RingBadge[] { + return radiiNm.map((radiusNm) => { + const twelve = dest([lon, lat], radiusNm, 0) + const projected = project(twelve) + if (projected !== null && projected.y >= viewportTopPx) { + return { radiusNm, lat: twelve[1], lon: twelve[0], position: '12' } + } + const six = dest([lon, lat], radiusNm, 180) + return { radiusNm, lat: six[1], lon: six[0], position: '6' } + }) +} diff --git a/src/geo/tcasTables.ts b/src/geo/tcasTables.ts new file mode 100644 index 0000000..142ed14 --- /dev/null +++ b/src/geo/tcasTables.ts @@ -0,0 +1,58 @@ +// RTCA DO-185B TCAS II sensitivity-level (SL) table. Each row's TA/RA +// thresholds (tau, DMOD, ZTHR) and RA-specific ALIM widen with altitude: closer +// to the ground, traffic is denser and alerts must stay tight (and below +// 1000 ft AGL there is no RA tier at all — an aircraft that low is assumed to +// be landing, and TCAS never issues resolution advisories that close to the +// runway). Bands 2–3 gate on the owner aircraft's own AGL; bands 4–8 gate on +// MSL once it's climbed above 2350 ft AGL. +// +// `sl` is DO-185B's own sensitivity-level number for rows 2–7. DO-185B's real +// table caps at SL7 (>42000 MSL still uses SL7's tau/DMOD, just a taller ZTHR/ +// ALIM). This table instead gives the >42000 ft band its own row so every band +// has an exact numeric identity; it is *not* an official DO-185B sensitivity +// level and is represented as `sl: 8` purely so `sensitivityLevelFor` and its +// tests can address it distinctly from SL7. +export interface TcasSL { + sl: number + taTauS: number + taDmodNm: number + taZthrFt: number + raTauS: number | null + raDmodNm: number | null + raZthrFt: number | null + alimFt: number | null +} + +export const TCAS_SL_TABLE: TcasSL[] = [ + // SL2: <1000 ft AGL — TA only, no RA (too close to the ground to maneuver). + { sl: 2, taTauS: 20, taDmodNm: 0.3, taZthrFt: 850, raTauS: null, raDmodNm: null, raZthrFt: null, alimFt: null }, + // SL3: 1000–2350 ft AGL. + { sl: 3, taTauS: 25, taDmodNm: 0.33, taZthrFt: 850, raTauS: 15, raDmodNm: 0.2, raZthrFt: 600, alimFt: 300 }, + // SL4: above 2350 ft AGL and <5000 ft MSL. + { sl: 4, taTauS: 30, taDmodNm: 0.48, taZthrFt: 850, raTauS: 20, raDmodNm: 0.35, raZthrFt: 600, alimFt: 300 }, + // SL5: 5000–10000 ft MSL. + { sl: 5, taTauS: 40, taDmodNm: 0.75, taZthrFt: 850, raTauS: 25, raDmodNm: 0.55, raZthrFt: 600, alimFt: 350 }, + // SL6: 10000–20000 ft MSL. + { sl: 6, taTauS: 45, taDmodNm: 1.0, taZthrFt: 850, raTauS: 30, raDmodNm: 0.8, raZthrFt: 600, alimFt: 400 }, + // SL7: 20000–42000 ft MSL. + { sl: 7, taTauS: 48, taDmodNm: 1.3, taZthrFt: 850, raTauS: 35, raDmodNm: 1.1, raZthrFt: 700, alimFt: 600 }, + // SL8 (not an official DO-185B level — see file header): >42000 ft MSL. + { sl: 8, taTauS: 48, taDmodNm: 1.3, taZthrFt: 1200, raTauS: 35, raDmodNm: 1.1, raZthrFt: 800, alimFt: 700 }, +] + +/** + * Selects the DO-185B sensitivity level for one aircraft. `ownAglFt` (height + * above the nearest relevant airport) decides the low-altitude bands (SL2/ + * SL3); once above 2350 ft AGL, `ownAltMslFt` decides the rest. Band edges are + * inclusive on their lower bound (matching how the bands were specified: e.g. + * exactly 1000 ft AGL is already SL3, exactly 20000 ft MSL is already SL7). + */ +export function sensitivityLevelFor(ownAltMslFt: number, ownAglFt: number): TcasSL { + if (ownAglFt < 1000) return TCAS_SL_TABLE[0] // SL2 + if (ownAglFt <= 2350) return TCAS_SL_TABLE[1] // SL3 + if (ownAltMslFt < 5000) return TCAS_SL_TABLE[2] // SL4 + if (ownAltMslFt < 10000) return TCAS_SL_TABLE[3] // SL5 + if (ownAltMslFt < 20000) return TCAS_SL_TABLE[4] // SL6 + if (ownAltMslFt <= 42000) return TCAS_SL_TABLE[5] // SL7 + return TCAS_SL_TABLE[6] // SL8 +} diff --git a/src/geo/terrainScan.ts b/src/geo/terrainScan.ts new file mode 100644 index 0000000..03b830d --- /dev/null +++ b/src/geo/terrainScan.ts @@ -0,0 +1,193 @@ +// Pure MSAW-style terrain scan over a predicted path: MVA sectors are +// checked first (they already bake in an obstacle buffer), and only where no +// sector covers a point does the scan fall back to DEM ground elevation +// (src/services/terrainElevation.ts). Thresholds follow ForeFlight Hazard +// Advisor conventions (amber "alert" / red "warning"). +import type { Position } from 'geojson' +import type { MvaSector } from '../utils/aixmMva' +import type { PredictedPath, PredPoint } from '../types/path' +import { + TERRAIN_AIRPORT_EXCLUDE_FT, + TERRAIN_AIRPORT_EXCLUDE_NM, + TERRAIN_ALERT_CLEARANCE_FT, + TERRAIN_LANDING_AGL_FT, + TERRAIN_LANDING_GS_KT, + TERRAIN_MVA_WARN_BELOW_FT, + TERRAIN_ONAPPROACH_TOL_FT, + TERRAIN_SCAN_HORIZON_S, + TERRAIN_SCAN_SKIP_FIRST_S, + TERRAIN_WARN_CLEARANCE_FT, +} from '../config/constants' + +const NM_PER_DEG_LAT = 60.04 // close enough for a few-nm proximity check + +export interface TerrainScanOpts { + /** True when the aircraft has a confirmed approach assignment this poll. */ + onApproach: boolean + /** |altNow - expected profile alt| when onApproach, else null. */ + profileDeviationFt: number | null + /** Known airports (active + nearby) — arrival/departure exclusion volumes. */ + airports: { lat: number; lon: number; elevationFt: number }[] + /** Current groundspeed (kt). */ + gsKt: number + /** AGL above the ACTUAL ground (DEM/nearest-airport-elev fallback) at the + * aircraft's CURRENT position; null when unresolvable (e.g. cold DEM tile + * and no fallback elevation). */ + currentAglFt: number | null +} + +interface SectorBbox { + sector: MvaSector + minLon: number + minLat: number + maxLon: number + maxLat: number +} + +// Per-poll scans are called with the same `sectors` array reference (loaded +// once per airport), so bboxes are computed once and reused for the array's +// lifetime rather than recomputed on every scan/point. +const bboxCache = new WeakMap() + +function bboxesFor(sectors: readonly MvaSector[]): SectorBbox[] { + const cached = bboxCache.get(sectors) + if (cached) return cached + + const bboxes = sectors.map((sector) => { + let minLon = Infinity + let minLat = Infinity + let maxLon = -Infinity + let maxLat = -Infinity + for (const [lon, lat] of sector.polygon[0]) { + if (lon < minLon) minLon = lon + if (lon > maxLon) maxLon = lon + if (lat < minLat) minLat = lat + if (lat > maxLat) maxLat = lat + } + return { sector, minLon, minLat, maxLon, maxLat } + }) + bboxCache.set(sectors, bboxes) + return bboxes +} + +function inBbox(b: SectorBbox, lat: number, lon: number): boolean { + return lon >= b.minLon && lon <= b.maxLon && lat >= b.minLat && lat <= b.maxLat +} + +/** Standard ray-casting point-in-ring test (even-odd rule). */ +function pointInRing(lat: number, lon: number, ring: Position[]): boolean { + let inside = false + for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) { + const [xi, yi] = ring[i] + const [xj, yj] = ring[j] + const crosses = yi > lat !== yj > lat && lon < ((xj - xi) * (lat - yi)) / (yj - yi) + xi + if (crosses) inside = !inside + } + return inside +} + +/** Inside the exterior ring and outside every hole. */ +function pointInSector(lat: number, lon: number, sector: MvaSector): boolean { + const [exterior, ...holes] = sector.polygon + if (!pointInRing(lat, lon, exterior)) return false + for (const hole of holes) { + if (pointInRing(lat, lon, hole)) return false + } + return true +} + +/** Cheap planar approximation — good enough for a 2 nm proximity gate. */ +function roughNmBetween(aLat: number, aLon: number, bLat: number, bLon: number): number { + const dLat = (aLat - bLat) * NM_PER_DEG_LAT + const dLon = (aLon - bLon) * NM_PER_DEG_LAT * Math.cos((aLat * Math.PI) / 180) + return Math.sqrt(dLat * dLat + dLon * dLon) +} + +/** + * MSAW-style approach/departure exclusion: a sample within + * TERRAIN_AIRPORT_EXCLUDE_NM of any known airport and below that airport's field + * elevation + TERRAIN_AIRPORT_EXCLUDE_FT is a normal arrival/departure, not a + * terrain conflict — excluded regardless of assignment. + */ +function inAirportExclusion( + point: PredPoint, + airports: TerrainScanOpts['airports'], +): boolean { + for (const airport of airports) { + if (roughNmBetween(point.lat, point.lon, airport.lat, airport.lon) <= TERRAIN_AIRPORT_EXCLUDE_NM) { + if (point.altFt < airport.elevationFt + TERRAIN_AIRPORT_EXCLUDE_FT) return true + } + } + return false +} + +/** + * Scans a predicted path for terrain conflicts. Returns the worst tier found + * ('warning' short-circuits immediately since nothing outranks it; otherwise + * 'alert' if any point violated the shallower threshold), or null if clear. + * + * Three suppressions apply: (1) a TAWS-style landing-configuration inhibit — + * slow AND low above the actual ground means landing/departing at SOME strip, + * charted or not, so it's checked first and short-circuits the whole scan; + * (2) an on-approach profile-deviation short-circuit — an aircraft tracking + * its descent profile within TERRAIN_ONAPPROACH_TOL_FT is descending toward + * terrain by design; and (3) an unconditional MSAW-style airport-exclusion + * volume (inAirportExclusion) that drops samples near ANY known airport below + * field-elev + TERRAIN_AIRPORT_EXCLUDE_FT, so normal arrivals/departures at + * non-active fields (e.g. a KSEA arrival while only KPAE is active) don't fire. + */ +export function scanTerrain( + pred: PredictedPath, + sectors: readonly MvaSector[], + elevAt: (lat: number, lon: number) => number | undefined, + opts: TerrainScanOpts, +): 'alert' | 'warning' | null { + // TAWS-style landing-config inhibit: covers strips absent from the airport + // index (no MSAW exclusion volume above) — a slow aircraft close above the + // real ground is landing or departing, not flying into terrain. Requires a + // resolved currentAglFt (null on a cold DEM tile with no fallback elevation + // falls through to the normal scan rather than assuming safety). + if (opts.gsKt < TERRAIN_LANDING_GS_KT && opts.currentAglFt !== null && opts.currentAglFt < TERRAIN_LANDING_AGL_FT) { + return null + } + + if ( + opts.onApproach && + opts.profileDeviationFt !== null && + opts.profileDeviationFt <= TERRAIN_ONAPPROACH_TOL_FT + ) { + return null + } + + const bboxes = bboxesFor(sectors) + let worst: 'alert' | 'warning' | null = null + + for (const point of pred.points) { + // Short look-ahead window: beyond TERRAIN_SCAN_HORIZON_S a descending + // aircraft will typically have leveled off, so extrapolating its baro rate + // further just projects phantom MVA penetrations (real MSAW look-ahead is + // shorter still). + if (point.tSec <= TERRAIN_SCAN_SKIP_FIRST_S || point.tSec > TERRAIN_SCAN_HORIZON_S) continue + if (inAirportExclusion(point, opts.airports)) continue + + const containing = bboxes.filter( + (b) => inBbox(b, point.lat, point.lon) && pointInSector(point.lat, point.lon, b.sector), + ) + + if (containing.length > 0) { + const minAltFt = Math.min(...containing.map((c) => c.sector.minAltFt)) + if (point.altFt < minAltFt - TERRAIN_MVA_WARN_BELOW_FT) return 'warning' + if (point.altFt < minAltFt) worst = 'alert' + continue // MVA covers this point — no DEM check. + } + + const groundFt = elevAt(point.lat, point.lon) + if (groundFt === undefined) continue // tile not cached yet — skip, retry next poll + + const clearanceFt = point.altFt - groundFt + if (clearanceFt < TERRAIN_WARN_CLEARANCE_FT) return 'warning' + if (clearanceFt < TERRAIN_ALERT_CLEARANCE_FT) worst = 'alert' + } + + return worst +} diff --git a/src/hooks/usePathEngine.ts b/src/hooks/usePathEngine.ts new file mode 100644 index 0000000..e2180fb --- /dev/null +++ b/src/hooks/usePathEngine.ts @@ -0,0 +1,346 @@ +import { useEffect, useRef } from 'react' +import { useAircraftStore } from '../store/useAircraftStore' +import { useAirportStore, airportKey } from '../store/useAirportStore' +import { useProcedureStore } from '../store/useProcedureStore' +import { usePathStore } from '../store/usePathStore' +import { useSettingsStore } from '../store/useSettingsStore' +import { useMvaStore, ensureMvaLoaded } from '../services/mvaData' +import { recordPoll, getRecent } from '../services/trackLog' +import { prepareGuidance, predictPath, isOnProcedureNow, type Guidance } from '../geo/prediction' +import { + collectHoldSpecs, + reduceHoldEntries, + emptyHoldEntryState, + type HoldEntryState, +} from '../geo/holdEntry' +import { evaluateTrafficConflicts, alertsFromConflicts } from '../geo/conflicts' +import { scanTerrain } from '../geo/terrainScan' +import { elevationFtAt, prefetchAround } from '../services/terrainElevation' +import { warmKnownAirports, airportsNear } from '../services/knownAirports' +import { getRunwayInfoForAirport } from '../services/cifpCache' +import { alongTrackNm } from '../geo/profileMath' +import { positionToMinFt, positionToMaxFt } from '../utils/altitudeFilter' +import { VFR_SQUAWK } from '../config/constants' +import type { InterpolatedAircraft } from '../types/aircraft' +import type { Procedure } from '../types/procedure' +import type { PredictedPath, AircraftAlert, AlertTier } from '../types/path' +import type { MvaSector } from '../utils/aixmMva' + +/** One active airport reduced to the fields every path-engine module consumes. */ +interface AirportCtx { + lat: number + lon: number + elevationFt: number +} + +// Terrain-vs-traffic precedence when both fire on one aircraft: ra > warning > +// ta > alert. Traffic wins ties (a traffic alert of equal rank is kept). +const TIER_RANK: Record = { ra: 4, warning: 3, ta: 2, alert: 1 } + +// Radius (nm) around each active airport within which known airports are folded +// into the suppression/alert context. Comfortably covers a TRACON's worth of +// satellite fields so a KSEA arrival gets near-airport relief while only KPAE is +// active. Larger than any per-airport ADS-B search radius (50 nm). +const KNOWN_AIRPORT_CONTEXT_RADIUS_NM = 80 + +/** + * The airport context for suppression/alerting: every active airport plus every + * known airport within KNOWN_AIRPORT_CONTEXT_RADIUS_NM of one, deduped by rounded + * position. This is what gives normal arrivals/departures at NON-active fields + * near-airport terrain + traffic desensitization. + */ +function expandAirportContext(active: AirportCtx[]): AirportCtx[] { + const byPos = new Map() + const add = (a: AirportCtx) => { + const key = `${a.lat.toFixed(3)},${a.lon.toFixed(3)}` + if (!byPos.has(key)) byPos.set(key, a) + } + for (const a of active) add(a) + for (const a of active) { + for (const near of airportsNear(a.lat, a.lon, KNOWN_AIRPORT_CONTEXT_RADIUS_NM)) { + add({ lat: near.lat, lon: near.lon, elevationFt: near.elevationFt }) + } + } + return Array.from(byPos.values()) +} + +/** Elevation (ft MSL) of the active airport nearest a point; 0 if none active. */ +function nearestElevFt(airports: AirportCtx[], lat: number, lon: number): number { + let best: AirportCtx | null = null + let bestSq = Infinity + for (const a of airports) { + const dLat = a.lat - lat + const dLon = a.lon - lon + const sq = dLat * dLat + dLon * dLon + if (sq < bestSq) { + bestSq = sq + best = a + } + } + return best ? best.elevationFt : 0 +} + +/** Linear interpolation of the descent profile altitude at an along-track distance. */ +function profileAltAtDist(points: { distNm: number; altFt: number }[], distNm: number): number { + const n = points.length + if (distNm <= points[0].distNm) return points[0].altFt + const last = points[n - 1] + if (distNm >= last.distNm) return last.altFt + for (let i = 1; i < n; i++) { + if (distNm <= points[i].distNm) { + const a = points[i - 1] + const b = points[i] + const span = b.distNm - a.distNm + const frac = span <= 0 ? 0 : (distNm - a.distNm) / span + return a.altFt + (b.altFt - a.altFt) * frac + } + } + return last.altFt +} + +/** + * |current altitude − expected profile altitude at the aircraft's current + * along-track distance| for an aircraft established on an approach, or null + * when there's no usable descent profile to compare against. + */ +function profileDeviationFt(ac: InterpolatedAircraft, guidance: Guidance): number | null { + if (!guidance.transition || guidance.profilePoints.length < 2) return null + if (ac.altBaro === 'ground') return null + const along = alongTrackNm(guidance.transition, ac.interpLat, ac.interpLon).distNm + const expected = profileAltAtDist(guidance.profilePoints, along) + return Math.abs(ac.altBaro - expected) +} + +/** + * Per-poll path-prediction orchestrator: pure glue over the tested path modules. + * Effect A runs once per ADS-B poll — records the tracklog, predicts every + * airborne aircraft's path (following an assigned approach's guidance when + * established, else turn/straight extrapolation), reduces hold-entry state, + * evaluates traffic conflicts, scans terrain, and pushes one result batch into + * usePathStore. All algorithms live in src/geo/* and src/services/*; this hook + * only snapshots stores and wires the outputs together. + */ +export function usePathEngine() { + const lastPollMs = useAircraftStore((s) => s.lastPollMs) + const activeAirports = useAirportStore((s) => s.activeAirports) + + // Persist hold-entry lifecycle state across polls (pure reducer input/output). + const holdStateRef = useRef(emptyHoldEntryState()) + + // ── Effect A: run the whole engine once per poll. ────────────────────────── + useEffect(() => { + if (lastPollMs === 0) return + + const aircraftMap = useAircraftStore.getState().aircraftMap + recordPoll(aircraftMap, lastPollMs) + + const airports = useAirportStore.getState().activeAirports + const airportCtx: AirportCtx[] = airports.map((a) => ({ + lat: a.lat, + lon: a.lon, + elevationFt: a.elevation, + })) + // Active airports ∪ nearby known airports — the near-airport relief context + // used for terrain exclusion, low-AGL traffic suppression, and nearest-field + // elevation lookups. Traffic near any known field (not just active ones) gets + // desensitized so non-active-airport arrivals stop firing nuisance alerts. + const suppressionAirports = expandAirportContext(airportCtx) + // Field elevation per airport key (=== uppercase proc.icao), for approach guidance. + const elevByKey: Record = {} + for (const a of airports) elevByKey[airportKey(a)] = a.elevation + + const airborne = Array.from(aircraftMap.values()).filter((ac) => ac.altBaro !== 'ground') + + // ── Hidden-aircraft set (mirrors AircraftOverlay's filter rules exactly). ─ + // The user's aircraft-overlay filters — TIS-B ('~' hex prefix) + showTisb, + // VFR squawk (1200) + showVfr, and the altitude-range slider — hide targets + // from the map. A hidden plane must not paint radar-tier conflict chrome or + // terrain alerts (no warnings about planes you can't see), and its predicted + // hold entry is just as invisible-context. These rules MUST stay in sync + // with the `hidden` computation in src/components/map/AircraftOverlay.tsx. + // TCAS TA/RA is the exception: it still evaluates across ALL aircraft below. + const { altFilterMin, altFilterMax, showTisb, showVfr } = useSettingsStore.getState() + const minFt = positionToMinFt(altFilterMin) + const maxFt = positionToMaxFt(altFilterMax) + const hiddenHexes = new Set() + for (const ac of airborne) { + const alt = ac.altBaro as number + if ( + alt < minFt || + alt > maxFt || + (!showTisb && ac.hex.startsWith('~')) || + (!showVfr && ac.squawk === VFR_SQUAWK) + ) { + hiddenHexes.add(ac.hex) + } + } + + const procedures = useProcedureStore.getState().procedures + const assignments = useProcedureStore.getState().aircraftAssignments + const procById = new Map(procedures.map((p) => [p.id, p])) + + // Guidance is WeakMap-cached per procedure, but the runway lookup that feeds + // it isn't — memoize per procedure id for this poll so N aircraft on one + // approach don't each redo the RW lookup. + const guidanceByProcId = new Map() + const guidanceFor = (proc: Procedure): Guidance => { + const cached = guidanceByProcId.get(proc.id) + if (cached) return cached + const rwyInfo = getRunwayInfoForAirport(proc.icao) + const ident = proc.runways[0] + const rwy = ident ? rwyInfo[`RW${ident}`] ?? null : null + const fieldElevFt = elevByKey[proc.icao.toUpperCase()] ?? 0 + const g = prepareGuidance(proc, rwy, fieldElevFt) + guidanceByProcId.set(proc.id, g) + return g + } + + // ── Predictions for every airborne aircraft. ───────────────────────────── + const predictions = new Map() + const acByHex = new Map() + // Per-aircraft approach guidance kept for the terrain pass (null = unassigned). + const guidanceByHex = new Map() + for (const ac of airborne) { + acByHex.set(ac.hex, ac) + const proc = assignments[ac.hex] ? procById.get(assignments[ac.hex]) : undefined + const recent = getRecent(ac.hex, 3) + if (proc) { + const guidance = guidanceFor(proc) + guidanceByHex.set(ac.hex, guidance) + // predictPath itself calls isOnProcedureNow(ac, proc) to decide whether + // to follow the guidance or fall back to turn-mode, so we just hand it + // the guidance and the field elevation. + const fieldElevFt = elevByKey[proc.icao.toUpperCase()] ?? nearestElevFt(suppressionAirports, ac.interpLat, ac.interpLon) + predictions.set(ac.hex, predictPath(ac, recent, guidance, fieldElevFt)) + } else { + const fieldElevFt = nearestElevFt(suppressionAirports, ac.interpLat, ac.interpLon) + predictions.set(ac.hex, predictPath(ac, recent, null, fieldElevFt)) + } + } + + // ── Hold entries (VFR, TIS-B, and filter-hidden traffic excluded). ─────── + // VFR squawk carries no filed hold; a TIS-B target's forced-straight + // prediction makes an entry classification meaningless; and a filter-hidden + // plane's predicted entry is invisible context like its alerts. + const specs = collectHoldSpecs(procedures) + const holdInput = { + nowMs: lastPollMs, + aircraft: airborne.filter( + (ac) => ac.squawk !== VFR_SQUAWK && !ac.hex.startsWith('~') && !hiddenHexes.has(ac.hex), + ), + predictions, + specs, + assignments, + } + const holdState = reduceHoldEntries(holdStateRef.current, holdInput) + holdStateRef.current = holdState + + // Per-aircraft "established on an approach" state — assigned to a procedure + // AND currently on-course per isOnProcedureNow (the same test predictPath + // uses to decide whether to follow guidance). Shared by the radar-tier + // traffic-conflict inhibit right below and the terrain onApproach flag + // further down, computed once per aircraft so isOnProcedureNow never runs + // twice for the same hex in one poll. + const onApproachHexes = new Set() + for (const ac of airborne) { + const proc = guidanceByHex.get(ac.hex)?.proc + if (proc !== undefined && isOnProcedureNow(ac, proc)) onApproachHexes.add(ac.hex) + } + + // ── Traffic conflicts → per-aircraft alerts. ───────────────────────────── + // TCAS TA/RA evaluates across ALL airborne aircraft (the stringent tier). + // Filter-aware post-pass: keep every TA/RA pair, but drop radar-tier + // ('alert'/'warning') pairs where EITHER member is filter-hidden — no + // radar conflict chrome about a plane the user can't see. Also mirrors + // real STARS Conflict Alert's approach-context inhibit: CA is suppressed + // between aircraft established on an approach, since parallel-final and + // in-trail spacing there is intentional, ATC-separated geometry, not a + // conflict — so drop a radar-tier pair when BOTH members are established. + // TCAS TA/RA is exempt from both inhibits: real TCAS runs through final + // approach too, and its own tau/DMOD/ZTHR gating plus low-AGL sensitivity + // levels already account for approach geometry. + const allPairs = evaluateTrafficConflicts(predictions, acByHex, { airports: suppressionAirports }) + const conflictPairs = allPairs.filter((p) => { + if (p.tier === 'ta' || p.tier === 'ra') return true + if (hiddenHexes.has(p.hexA) || hiddenHexes.has(p.hexB)) return false + if (onApproachHexes.has(p.hexA) && onApproachHexes.has(p.hexB)) return false + return true + }) + const alerts = alertsFromConflicts(conflictPairs) + + // Force-show any otherwise-hidden aircraft caught in a surviving TA/RA so + // the overlay reveals the target the TCAS alert is about (only planes that + // would actually be hidden need forcing; visible ones render already). + const forcedVisibleHexes = new Set() + for (const p of conflictPairs) { + if (p.tier !== 'ta' && p.tier !== 'ra') continue + if (hiddenHexes.has(p.hexA)) forcedVisibleHexes.add(p.hexA) + if (hiddenHexes.has(p.hexB)) forcedVisibleHexes.add(p.hexB) + } + + // ── Terrain scan per airborne aircraft. ────────────────────────────────── + // Terrain alerting always runs (independent of the showMva display toggle); + // MVA sectors are loaded on airport change in Effect B below. + const mvaByIcao = useMvaStore.getState().byIcao + const sectors: MvaSector[] = [] + for (const a of airports) { + const s = mvaByIcao[airportKey(a)] + if (s) sectors.push(...s) + } + for (const ac of airborne) { + // FAA MSAW processing is inhibited for VFR (1200) beacon codes — a + // floatplane or helicopter legitimately working at 600–1700 ft below an + // MVA floor is not a terrain conflict. TIS-B targets (ADS-B Exchange + // prefixes their hex with '~') carry coarse, often-stale positions that + // make the predicted path meaningless for terrain purposes — skip both. + // Also skip anything the user has filter-hidden: no terrain warning about + // a plane that isn't on the map. + if (ac.squawk === VFR_SQUAWK || ac.hex.startsWith('~') || hiddenHexes.has(ac.hex)) continue + const pred = predictions.get(ac.hex) + if (!pred) continue + const guidance = guidanceByHex.get(ac.hex) + const onApproach = onApproachHexes.has(ac.hex) + const deviationFt = onApproach && guidance ? profileDeviationFt(ac, guidance) : null + // Actual ground elevation under the aircraft's CURRENT position, for the + // TAWS-style landing-config inhibit — falls back to the nearest known + // airport's elevation when the DEM tile is cold (never leaves AGL + // unresolved due to a transient cache miss). + const groundElevFt = + elevationFtAt(ac.interpLat, ac.interpLon) ?? nearestElevFt(suppressionAirports, ac.interpLat, ac.interpLon) + const currentAglFt = ac.altBaro === 'ground' ? null : ac.altBaro - groundElevFt + const tier = scanTerrain(pred, sectors, elevationFtAt, { + onApproach, + profileDeviationFt: deviationFt, + airports: suppressionAirports, + gsKt: ac.groundspeed, + currentAglFt, + }) + if (!tier) continue + // Only surface a terrain alert if no traffic alert of equal-or-worse tier + // already owns this aircraft (traffic wins ties). + const existing = alerts.get(ac.hex) + if (existing && TIER_RANK[existing.tier] >= TIER_RANK[tier]) continue + const terrainAlert: AircraftAlert = { kind: 'terrain', tier } + alerts.set(ac.hex, terrainAlert) + } + + usePathStore.getState().setResults({ + predictions, + holdEntries: holdState.entries, + alerts, + conflictPairs, + forcedVisibleHexes, + }) + }, [lastPollMs]) + + // ── Effect B: load MVA + warm terrain tiles when active airports change. ─── + // Terrain alerting needs MVA sectors regardless of the showMva display toggle, + // so load them here off the display path. + useEffect(() => { + // Warm the known-airports list (idempotent) so near-airport relief can apply + // around non-active fields, not just the airports the user made active. + warmKnownAirports() + for (const a of activeAirports) void ensureMvaLoaded(airportKey(a)) + prefetchAround(activeAirports.map((a) => ({ lat: a.lat, lon: a.lon }))) + }, [activeAirports]) +} diff --git a/src/services/__tests__/knownAirports.test.ts b/src/services/__tests__/knownAirports.test.ts new file mode 100644 index 0000000..9a07ed9 --- /dev/null +++ b/src/services/__tests__/knownAirports.test.ts @@ -0,0 +1,142 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import { + warmKnownAirports, + getKnownAirports, + airportsNear, + _resetKnownAirports, +} from '../knownAirports' + +/** A fetch mock whose first ok/json is the index, optionally a second for legacy. */ +function mockFetchSequence(responses: Array<{ ok: boolean; json?: unknown; status?: number }>) { + let call = 0 + const fetchMock = vi.fn().mockImplementation(() => { + const r = responses[Math.min(call, responses.length - 1)] + call++ + return Promise.resolve({ + ok: r.ok, + status: r.status ?? (r.ok ? 200 : 404), + json: () => Promise.resolve(r.json), + }) + }) + vi.stubGlobal('fetch', fetchMock) + return fetchMock +} + +/** Wait for the async warm chain to settle (a macrotask flushes its microtasks). */ +async function flush() { + await new Promise((resolve) => setTimeout(resolve, 0)) +} + +beforeEach(() => { + _resetKnownAirports() +}) + +afterEach(() => { + vi.unstubAllGlobals() +}) + +describe('knownAirports — warming from the index', () => { + it('is empty before warming', () => { + expect(getKnownAirports()).toHaveLength(0) + }) + + it('loads the all-US index (elev field) into { lat, lon, elevationFt }', async () => { + mockFetchSequence([ + { + ok: true, + json: [ + { key: 'KPAE', lat: 47.9, lon: -122.28, elev: 606 }, + { key: 'KSEA', lat: 47.45, lon: -122.31, elev: 433 }, + ], + }, + ]) + warmKnownAirports() + await flush() + expect(getKnownAirports()).toEqual([ + { lat: 47.9, lon: -122.28, elevationFt: 606 }, + { lat: 47.45, lon: -122.31, elevationFt: 433 }, + ]) + }) + + it('is idempotent — a second warm does not refetch', async () => { + const fetchMock = mockFetchSequence([ + { ok: true, json: [{ key: 'KPAE', lat: 47.9, lon: -122.28, elev: 606 }] }, + ]) + warmKnownAirports() + await flush() + warmKnownAirports() + await flush() + expect(fetchMock).toHaveBeenCalledTimes(1) + }) +}) + +describe('knownAirports — legacy fallback', () => { + it('falls back to airports.json (elevation field) on a 404 index', async () => { + mockFetchSequence([ + { ok: false, status: 404 }, + { ok: true, json: [{ icao: 'KPAE', lat: 47.9, lon: -122.28, elevation: 606 }] }, + ]) + warmKnownAirports() + await flush() + expect(getKnownAirports()).toEqual([{ lat: 47.9, lon: -122.28, elevationFt: 606 }]) + }) +}) + +describe('knownAirports — malformed rows', () => { + it('skips rows without coordinates and defaults missing elevation to 0', async () => { + mockFetchSequence([ + { + ok: true, + json: [ + { key: 'GOOD', lat: 47.9, lon: -122.28 }, // no elevation → 0 + { key: 'NOLAT', lon: -122.28, elev: 100 }, // no lat → skipped + { key: 'NANLAT', lat: NaN, lon: -122.28, elev: 100 }, // NaN → skipped + null, // → skipped + 'nope', // → skipped + ], + }, + ]) + warmKnownAirports() + await flush() + expect(getKnownAirports()).toEqual([{ lat: 47.9, lon: -122.28, elevationFt: 0 }]) + }) + + it('leaves the list empty when the payload is not an array', async () => { + mockFetchSequence([{ ok: true, json: { not: 'an array' } }]) + warmKnownAirports() + await flush() + expect(getKnownAirports()).toHaveLength(0) + }) +}) + +describe('airportsNear', () => { + beforeEach(async () => { + mockFetchSequence([ + { + ok: true, + json: [ + { key: 'KPAE', lat: 47.906, lon: -122.281, elev: 606 }, // Everett + { key: 'KSEA', lat: 47.449, lon: -122.309, elev: 433 }, // ~27 nm south + { key: 'KJFK', lat: 40.64, lon: -73.78, elev: 13 }, // far away + ], + }, + ]) + warmKnownAirports() + await flush() + }) + + it('returns airports within the radius and excludes those outside', () => { + const near = airportsNear(47.906, -122.281, 40) + expect(near.map((a) => a.elevationFt).sort((x, y) => x - y)).toEqual([433, 606]) + }) + + it('excludes airports beyond the radius', () => { + const near = airportsNear(47.906, -122.281, 10) + expect(near).toEqual([{ lat: 47.906, lon: -122.281, elevationFt: 606 }]) + }) + + it('is empty before warming', () => { + _resetKnownAirports() + expect(airportsNear(47.906, -122.281, 100)).toHaveLength(0) + }) +}) diff --git a/src/services/__tests__/terrainElevation.test.ts b/src/services/__tests__/terrainElevation.test.ts new file mode 100644 index 0000000..6d5f16d --- /dev/null +++ b/src/services/__tests__/terrainElevation.test.ts @@ -0,0 +1,184 @@ +import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest' +import { + decodeTerrainTile, + elevationFtAt, + prefetchAround, + _setTileDecoder, + _resetTerrainCache, + type DecodedTileBlob, +} from '../terrainElevation' +import { FEET_PER_METER, TERRAIN_TILE_CACHE_MAX } from '../../config/constants' + +// A tiny synthetic "tile": 2x2 pixels, RGBA. Encodes elevation meters as +// R*65536 + G*256 + B, offset by -10000 per the terrain-rgb formula. +function rgbaForMeters(meters: number): [number, number, number] { + const encoded = Math.round((meters + 10000) / 0.1) + const r = Math.floor(encoded / 65536) % 256 + const g = Math.floor(encoded / 256) % 256 + const b = encoded % 256 + return [r, g, b] +} + +function makeFakeTile(size: number, metersPerPixel: number[]): DecodedTileBlob { + const rgba = new Uint8ClampedArray(size * size * 4) + for (let i = 0; i < size * size; i++) { + const [r, g, b] = rgbaForMeters(metersPerPixel[i] ?? metersPerPixel[0]) + rgba[i * 4] = r + rgba[i * 4 + 1] = g + rgba[i * 4 + 2] = b + rgba[i * 4 + 3] = 255 + } + return { rgba, size } +} + +beforeEach(() => { + _resetTerrainCache() + vi.stubGlobal('fetch', vi.fn()) + vi.stubEnv('VITE_MAPBOX_TOKEN', 'test-token') +}) + +afterEach(() => { + vi.unstubAllGlobals() + vi.unstubAllEnvs() +}) + +describe('decodeTerrainTile', () => { + it('decodes the terrain-rgb formula for 0 m, 1000 m, and negative elevations', () => { + const rgba = new Uint8ClampedArray(3 * 4) + const cases = [0, 1000, -500] + cases.forEach((meters, i) => { + const [r, g, b] = rgbaForMeters(meters) + rgba[i * 4] = r + rgba[i * 4 + 1] = g + rgba[i * 4 + 2] = b + rgba[i * 4 + 3] = 255 + }) + + // Treat as a 1-row-of-3 "tile" purely to exercise the loop; size*size + // must equal the pixel count, so call per-pixel via a 1x1 decode instead. + for (let i = 0; i < cases.length; i++) { + const single = new Uint8ClampedArray(4) + single.set(rgba.subarray(i * 4, i * 4 + 4)) + const out = decodeTerrainTile(single, 1) + expect(out[0]).toBeCloseTo(cases[i], 1) + } + }) +}) + +describe('elevationFtAt', () => { + it('returns undefined before the covering tile is decoded, then a value once it lands', async () => { + const blob = {} as Blob + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + statusText: 'OK', + blob: () => Promise.resolve(blob), + }) + vi.stubGlobal('fetch', fetchMock) + _setTileDecoder(() => Promise.resolve(makeFakeTile(2, [1000, 1000, 1000, 1000]))) + + const lat = 47.45 + const lon = -122.31 + + expect(elevationFtAt(lat, lon)).toBeUndefined() + expect(fetchMock).toHaveBeenCalledTimes(1) + + // Let the fetch+decode microtasks resolve. + await vi.waitFor(() => { + const v = elevationFtAt(lat, lon) + expect(v).not.toBeUndefined() + }) + + const feet = elevationFtAt(lat, lon) + expect(feet).toBeCloseTo(1000 * FEET_PER_METER, 0) + }) + + it('dedupes in-flight fetches for the same tile', async () => { + const fetchMock = vi.fn().mockImplementation( + () => + new Promise(() => { + // never resolves during this test — keeps the tile "pending" + }), + ) + vi.stubGlobal('fetch', fetchMock) + _setTileDecoder(() => Promise.resolve(makeFakeTile(2, [0, 0, 0, 0]))) + + const lat = 47.45 + const lon = -122.31 + expect(elevationFtAt(lat, lon)).toBeUndefined() + expect(elevationFtAt(lat, lon)).toBeUndefined() + expect(elevationFtAt(lat, lon)).toBeUndefined() + + expect(fetchMock).toHaveBeenCalledTimes(1) + }) + + it('a failed fetch does not refetch immediately', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: false, + status: 500, + statusText: 'boom', + blob: () => Promise.resolve({} as Blob), + }) + vi.stubGlobal('fetch', fetchMock) + _setTileDecoder(() => Promise.resolve(makeFakeTile(2, [0, 0, 0, 0]))) + + const lat = 47.45 + const lon = -122.31 + expect(elevationFtAt(lat, lon)).toBeUndefined() + + // Wait for the failure to land in the cache (as a "failed" marker). + await vi.waitFor(() => { + expect(fetchMock).toHaveBeenCalledTimes(1) + }) + // Give the rejection's .catch() a turn to run. + await Promise.resolve() + await Promise.resolve() + + expect(elevationFtAt(lat, lon)).toBeUndefined() + expect(fetchMock).toHaveBeenCalledTimes(1) // still within the retry-after backoff + }) +}) + +describe('LRU eviction', () => { + it('evicts the least-recently-used tile once the cache exceeds its cap', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + statusText: 'OK', + blob: () => Promise.resolve({} as Blob), + }) + vi.stubGlobal('fetch', fetchMock) + _setTileDecoder(() => Promise.resolve(makeFakeTile(2, [0, 0, 0, 0]))) + + // Distinct tile coordinates: spread points far enough apart in longitude + // that each maps to a different slippy tile at TERRAIN_TILE_ZOOM. Resolved + // strictly sequentially so cache insertion order is deterministic. + const points: { lat: number; lon: number }[] = [] + for (let i = 0; i < TERRAIN_TILE_CACHE_MAX + 1; i++) { + points.push({ lat: 0, lon: -170 + i * 2 }) + } + + for (const p of points) { + expect(elevationFtAt(p.lat, p.lon)).toBeUndefined() + await vi.waitFor(() => { + expect(elevationFtAt(p.lat, p.lon)).not.toBeUndefined() + }) + } + + // The first tile requested is the least-recently-used and should have + // been evicted once the (cap + 1)th tile landed; re-requesting it issues + // a new fetch call. + const callsBefore = fetchMock.mock.calls.length + expect(elevationFtAt(points[0].lat, points[0].lon)).toBeUndefined() + expect(fetchMock.mock.calls.length).toBeGreaterThan(callsBefore) + }) +}) + +describe('prefetchAround', () => { + it('warms tiles around each point without throwing', () => { + const fetchMock = vi.fn().mockImplementation(() => new Promise(() => {})) + vi.stubGlobal('fetch', fetchMock) + prefetchAround([{ lat: 47.45, lon: -122.31 }]) + expect(fetchMock.mock.calls.length).toBeGreaterThan(0) + }) +}) diff --git a/src/services/__tests__/trackLog.test.ts b/src/services/__tests__/trackLog.test.ts new file mode 100644 index 0000000..d15bc0b --- /dev/null +++ b/src/services/__tests__/trackLog.test.ts @@ -0,0 +1,122 @@ +import { describe, it, expect, beforeEach } from 'vitest' +import { recordPoll, getTrack, getRecent, _reset } from '../trackLog' +import type { InterpolatedAircraft } from '../../types/aircraft' +import { TRACKLOG_MAX_POINTS } from '../../config/constants' + +function aircraft(over: Partial = {}): InterpolatedAircraft { + const lat = over.lat ?? 47.45 + const lon = over.lon ?? -122.31 + return { + hex: 'abc123', + flight: 'TEST1', + registration: 'N1', + typeCode: 'B738', + lat, + lon, + altBaro: 3000, + altGeom: 3000, + groundspeed: 180, + track: 180, + baroRate: -500, + squawk: '1200', + lastPollMs: 0, + interpLat: lat, + interpLon: lon, + ...over, + } +} + +function mapOf(...acs: InterpolatedAircraft[]): Map { + const m = new Map() + for (const ac of acs) m.set(ac.hex, ac) + return m +} + +beforeEach(() => { + _reset() +}) + +describe('trackLog', () => { + it('records a point per poll and returns them chronologically', () => { + for (let i = 0; i < 5; i++) { + const ac = aircraft({ lastPollMs: i * 5000, lat: 47 + i * 0.01 }) + recordPoll(mapOf(ac), ac.lastPollMs) + } + const track = getTrack('abc123') + expect(track).toHaveLength(5) + expect(track.map((p) => p.tMs)).toEqual([0, 5000, 10000, 15000, 20000]) + expect(track[0].lat).toBeCloseTo(47) + expect(track[4].lat).toBeCloseTo(47.04) + }) + + it('wraps the ring at capacity, dropping the oldest and staying chronological', () => { + const total = TRACKLOG_MAX_POINTS + 5 + for (let i = 0; i < total; i++) { + const ac = aircraft({ lastPollMs: i * 5000 }) + recordPoll(mapOf(ac), ac.lastPollMs) + } + const track = getTrack('abc123') + expect(track).toHaveLength(TRACKLOG_MAX_POINTS) + // Oldest 5 points (tMs 0..20000) should have been dropped. + expect(track[0].tMs).toBe(5 * 5000) + expect(track[track.length - 1].tMs).toBe((total - 1) * 5000) + // Verify strictly increasing (chronological order preserved through wrap). + for (let i = 1; i < track.length; i++) { + expect(track[i].tMs).toBeGreaterThan(track[i - 1].tMs) + } + }) + + it('dedupes same lastPollMs seen twice into a single point', () => { + const ac1 = aircraft({ lastPollMs: 1000 }) + recordPoll(mapOf(ac1), 1000) + // Same poll round carried forward again with identical lastPollMs (stale carry-forward). + const ac2 = aircraft({ lastPollMs: 1000, lat: 48 }) + recordPoll(mapOf(ac2), 1000) + + const track = getTrack('abc123') + expect(track).toHaveLength(1) + expect(track[0].tMs).toBe(1000) + }) + + it('tracks multiple hexes independently', () => { + const a = aircraft({ hex: 'aaa111', lastPollMs: 1000 }) + const b = aircraft({ hex: 'bbb222', lastPollMs: 1000, lat: 40 }) + recordPoll(mapOf(a, b), 1000) + + const a2 = aircraft({ hex: 'aaa111', lastPollMs: 2000 }) + recordPoll(mapOf(a2, b), 2000) // b carries forward same lastPollMs, should not append + + expect(getTrack('aaa111')).toHaveLength(2) + expect(getTrack('bbb222')).toHaveLength(1) + }) + + it('prunes a ring when its hex vanishes from the aircraft map', () => { + const a = aircraft({ hex: 'aaa111', lastPollMs: 1000 }) + recordPoll(mapOf(a), 1000) + expect(getTrack('aaa111')).toHaveLength(1) + + // Next poll round: aaa111 is gone. + recordPoll(new Map(), 2000) + expect(getTrack('aaa111')).toEqual([]) + }) + + it('getRecent returns the last n points, chronological', () => { + for (let i = 0; i < 10; i++) { + const ac = aircraft({ lastPollMs: i * 1000 }) + recordPoll(mapOf(ac), ac.lastPollMs) + } + const recent = getRecent('abc123', 3) + expect(recent.map((p) => p.tMs)).toEqual([7000, 8000, 9000]) + }) + + it('getRecent on an unknown hex returns an empty array', () => { + expect(getRecent('nope', 5)).toEqual([]) + }) + + it('passes through a "ground" altFt', () => { + const ac = aircraft({ lastPollMs: 1000, altBaro: 'ground' }) + recordPoll(mapOf(ac), 1000) + const track = getTrack('abc123') + expect(track[0].altFt).toBe('ground') + }) +}) diff --git a/src/services/knownAirports.ts b/src/services/knownAirports.ts new file mode 100644 index 0000000..e714609 --- /dev/null +++ b/src/services/knownAirports.ts @@ -0,0 +1,94 @@ +// A flat list of every known US airport position, warmed once from the static +// airport data and queried synchronously thereafter. The path engine uses this +// to give near-airport desensitization (terrain + traffic alerting) around ANY +// airport, not just the airports the user has made active — a KSEA arrival while +// only KPAE is active is still a normal approach into a known field. +// +// Shapes handled (see scripts/lib/airportIndex.ts + public/data/airports.json): +// airport-index.json row: { lat, lon, elev, ... } (all US airports w/ approaches) +// airports.json row: { lat, lon, elevation, ... } (legacy 89-airport set) +// Both normalize to { lat, lon, elevationFt }; rows missing coordinates are +// skipped, missing elevation defaults to 0. + +export interface KnownAirport { + lat: number + lon: number + elevationFt: number +} + +const NM_PER_DEG_LAT = 60.04 + +let airports: KnownAirport[] = [] +let warmed = false + +/** Cheap planar distance in nm — good enough for a coarse proximity filter. */ +function roughNmBetween(aLat: number, aLon: number, bLat: number, bLon: number): number { + const dLat = (aLat - bLat) * NM_PER_DEG_LAT + const dLon = (aLon - bLon) * NM_PER_DEG_LAT * Math.cos((aLat * Math.PI) / 180) + return Math.sqrt(dLat * dLat + dLon * dLon) +} + +/** Normalize one raw row (either shape) to a KnownAirport, or null if unusable. */ +function normalizeRow(row: unknown): KnownAirport | null { + if (!row || typeof row !== 'object') return null + const r = row as { lat?: unknown; lon?: unknown; elev?: unknown; elevation?: unknown } + if (typeof r.lat !== 'number' || typeof r.lon !== 'number') return null + if (Number.isNaN(r.lat) || Number.isNaN(r.lon)) return null + const elevRaw = typeof r.elev === 'number' ? r.elev : typeof r.elevation === 'number' ? r.elevation : 0 + const elevationFt = Number.isNaN(elevRaw) ? 0 : elevRaw + return { lat: r.lat, lon: r.lon, elevationFt } +} + +function ingest(json: unknown): void { + if (!Array.isArray(json)) return + const out: KnownAirport[] = [] + for (const row of json) { + const norm = normalizeRow(row) + if (norm) out.push(norm) + } + airports = out +} + +/** + * Warm the known-airports list from static data. Idempotent — after the first + * call (or while one is in flight) subsequent calls are no-ops. Fetches the + * all-US index first, falling back to the legacy 89-airport set on failure/404. + * Returns immediately; the list populates asynchronously. + */ +export function warmKnownAirports(): void { + if (warmed) return + warmed = true + void fetch('/data/airport-index.json') + .then((res) => { + if (!res.ok) throw new Error(`airport-index.json: HTTP ${res.status}`) + return res.json() + }) + .catch(() => fetch('/data/airports.json').then((res) => res.json())) + .then((json) => ingest(json)) + .catch((err) => { + // Both sources failed — leave the list empty (near-airport relief simply + // won't apply). Reset so a later call can retry. + warmed = false + console.warn('knownAirports: failed to warm from static data:', err) + }) +} + +/** The warmed known-airport list; empty until warmKnownAirports() resolves. */ +export function getKnownAirports(): readonly KnownAirport[] { + return airports +} + +/** Known airports within `radiusNm` of a point (cheap equirectangular filter). */ +export function airportsNear(lat: number, lon: number, radiusNm: number): KnownAirport[] { + const out: KnownAirport[] = [] + for (const ap of airports) { + if (roughNmBetween(lat, lon, ap.lat, ap.lon) <= radiusNm) out.push(ap) + } + return out +} + +/** Test seam: clear the warmed state and cached list. */ +export function _resetKnownAirports(): void { + airports = [] + warmed = false +} diff --git a/src/services/terrainElevation.ts b/src/services/terrainElevation.ts new file mode 100644 index 0000000..7c0290f --- /dev/null +++ b/src/services/terrainElevation.ts @@ -0,0 +1,199 @@ +// Memory-only LRU of decoded Mapbox terrain-rgb tiles, used by the terrain +// scan (src/geo/terrainScan.ts) as the ground-elevation fallback wherever no +// MVA sector covers a predicted point. +// +// Reads are synchronous and cache-only: `elevationFtAt` never awaits a +// network round-trip. If the covering tile isn't decoded yet it kicks off an +// async fetch+decode (deduped per tile key) and returns undefined; the caller +// (a per-poll scan) just skips that point and picks it up again next poll +// once the tile has landed. +// +// Tile format: Mapbox v4 terrain-rgb pngraw tiles are 256x256 (verified by +// reading the decoded bitmap's own dimensions rather than assuming — see +// `decodeTerrainTile`/`fetchAndDecode`). Decoded tiles are stored as +// Int16Array feet (not Float32Array meters) to bound memory: 256*256*2 bytes +// = 128 KiB/tile, so TERRAIN_TILE_CACHE_MAX (48) tiles cost at most +// 48 * 128 KiB ~= 6 MiB resident. Feet fit comfortably in an Int16 (max +// terrain-rgb range is roughly -11000..+9000 m, i.e. about -36000..+30000 ft). +import { FEET_PER_METER, TERRAIN_TILE_CACHE_MAX, TERRAIN_TILE_ZOOM } from '../config/constants' + +/** Raw RGBA pixels decoded from a terrain-rgb tile image, plus its (square) side length. */ +export interface DecodedTileBlob { + rgba: Uint8ClampedArray + size: number +} + +/** Injectable blob -> pixel decode step, so tests never need canvas/jsdom support. */ +export type TileBlobDecoder = (blob: Blob) => Promise + +interface ReadyTile { + status: 'ready' + size: number + feet: Int16Array +} + +interface PendingTile { + status: 'pending' +} + +interface FailedTile { + status: 'failed' + retryAtMs: number +} + +type TileEntry = ReadyTile | PendingTile | FailedTile + +// Backoff before retrying a tile whose fetch/decode failed (missing token, +// network error, non-2xx, decode error) — avoids hot-looping a bad tile. +const FAILED_RETRY_MS = 60_000 + +const tileCache = new Map() + +function tileKey(x: number, y: number): string { + return `${TERRAIN_TILE_ZOOM}/${x}/${y}` +} + +/** Web Mercator slippy-tile math: which tile a lon/lat falls in, plus its fractional position within that tile (0..1, top-left origin). */ +function lonLatToTile( + lon: number, + lat: number, + zoom: number, +): { x: number; y: number; fx: number; fy: number } { + const latRad = (lat * Math.PI) / 180 + const n = 2 ** zoom + const xFrac = ((lon + 180) / 360) * n + const yFrac = ((1 - Math.log(Math.tan(latRad) + 1 / Math.cos(latRad)) / Math.PI) / 2) * n + const x = Math.floor(xFrac) + const y = Math.floor(yFrac) + return { x, y, fx: xFrac - x, fy: yFrac - y } +} + +/** + * Decodes terrain-rgb pixels into elevation meters, one value per pixel + * (row-major). Pure and canvas-free so it's directly unit-testable. + * Formula: elevation = -10000 + (R*65536 + G*256 + B) * 0.1 + */ +export function decodeTerrainTile(rgba: Uint8ClampedArray, size: number): Float32Array { + const out = new Float32Array(size * size) + for (let i = 0; i < size * size; i++) { + const r = rgba[i * 4] + const g = rgba[i * 4 + 1] + const b = rgba[i * 4 + 2] + out[i] = -10000 + (r * 65536 + g * 256 + b) * 0.1 + } + return out +} + +/** Default blob decoder: browser-only (createImageBitmap + OffscreenCanvas). Swappable via `_setTileDecoder` in tests. */ +async function defaultTileBlobDecoder(blob: Blob): Promise { + const bitmap = await createImageBitmap(blob) + const size = bitmap.width + const canvas = new OffscreenCanvas(size, size) + const ctx = canvas.getContext('2d') + if (!ctx) throw new Error('OffscreenCanvas 2d context unavailable') + ctx.drawImage(bitmap, 0, 0) + const { data } = ctx.getImageData(0, 0, size, size) + return { rgba: data, size } +} + +let tileBlobDecoder: TileBlobDecoder = defaultTileBlobDecoder + +/** Test seam: swap the blob->pixels step so vitest/jsdom never needs canvas. */ +export function _setTileDecoder(decoder: TileBlobDecoder): void { + tileBlobDecoder = decoder +} + +/** Moves an existing cache entry to the end of the Map (most-recently-used) without changing its value. */ +function touch(key: string): void { + const entry = tileCache.get(key) + if (entry === undefined) return + tileCache.delete(key) + tileCache.set(key, entry) +} + +/** Evicts least-recently-used entries (Map iteration order) until at/under the cap. */ +function evictIfNeeded(): void { + while (tileCache.size > TERRAIN_TILE_CACHE_MAX) { + const oldestKey = tileCache.keys().next().value + if (oldestKey === undefined) break + tileCache.delete(oldestKey) + } +} + +async function fetchAndDecode(x: number, y: number): Promise<{ size: number; feet: Int16Array }> { + const token = import.meta.env.VITE_MAPBOX_TOKEN + if (!token) throw new Error('VITE_MAPBOX_TOKEN is not configured') + + const url = `https://api.mapbox.com/v4/mapbox.terrain-rgb/${TERRAIN_TILE_ZOOM}/${x}/${y}.pngraw?access_token=${token}` + const resp = await fetch(url) + if (!resp.ok) throw new Error(`terrain-rgb tile fetch failed: ${resp.status} ${resp.statusText}`) + + const blob = await resp.blob() + const { rgba, size } = await tileBlobDecoder(blob) + const meters = decodeTerrainTile(rgba, size) + + const feet = new Int16Array(meters.length) + for (let i = 0; i < meters.length; i++) feet[i] = Math.round(meters[i] * FEET_PER_METER) + + return { size, feet } +} + +/** Kicks off (or skips, if already pending/ready/backed-off) a fetch+decode for one tile. Fire-and-forget; result lands in `tileCache`. */ +function ensureFetch(x: number, y: number): void { + const key = tileKey(x, y) + const entry = tileCache.get(key) + if (entry?.status === 'ready' || entry?.status === 'pending') return + if (entry?.status === 'failed' && Date.now() < entry.retryAtMs) return + + tileCache.set(key, { status: 'pending' }) + + fetchAndDecode(x, y) + .then((tile) => { + tileCache.set(key, { status: 'ready', size: tile.size, feet: tile.feet }) + touch(key) + evictIfNeeded() + }) + .catch(() => { + tileCache.set(key, { status: 'failed', retryAtMs: Date.now() + FAILED_RETRY_MS }) + }) +} + +/** + * Synchronous elevation lookup in feet MSL. Returns undefined (and enqueues + * an async fetch, deduped per tile) when the covering tile isn't decoded yet + * — callers should skip the point and retry on the next poll. + */ +export function elevationFtAt(lat: number, lon: number): number | undefined { + const { x, y, fx, fy } = lonLatToTile(lon, lat, TERRAIN_TILE_ZOOM) + const key = tileKey(x, y) + const entry = tileCache.get(key) + + if (entry?.status === 'ready') { + touch(key) + const px = Math.min(entry.size - 1, Math.floor(fx * entry.size)) + const py = Math.min(entry.size - 1, Math.floor(fy * entry.size)) + return entry.feet[py * entry.size + px] + } + + ensureFetch(x, y) + return undefined +} + +/** Warms the 2x2 tiles nearest each point (the containing tile plus whichever neighbor the point sits closer to on each axis), so a subsequent `elevationFtAt` is more likely to hit. */ +export function prefetchAround(points: { lat: number; lon: number }[]): void { + for (const p of points) { + const { x, y, fx, fy } = lonLatToTile(p.lon, p.lat, TERRAIN_TILE_ZOOM) + const dx = fx < 0.5 ? -1 : 1 + const dy = fy < 0.5 ? -1 : 1 + for (const xx of [x, x + dx]) { + for (const yy of [y, y + dy]) { + ensureFetch(xx, yy) + } + } + } +} + +/** Test-only: clears the entire tile cache. */ +export function _resetTerrainCache(): void { + tileCache.clear() +} diff --git a/src/services/trackLog.ts b/src/services/trackLog.ts new file mode 100644 index 0000000..9e26eb3 --- /dev/null +++ b/src/services/trackLog.ts @@ -0,0 +1,108 @@ +import type { InterpolatedAircraft } from '../types/aircraft' +import type { TrackPoint } from '../types/path' +import { TRACKLOG_MAX_POINTS } from '../config/constants' + +// Non-reactive, module-level flown-path store. Deliberately NOT a zustand +// store: it's appended to once per ADS-B poll and read imperatively (not via +// React re-renders) by the path-prediction engine and the tracklog map layer, +// so there's no need to pay for subscription/notification machinery here. +// +// Memory bound: one ring buffer per tracked hex, each a fixed-length +// TrackPoint[] of capacity TRACKLOG_MAX_POINTS (720). The array is +// preallocated on first write, so per-hex memory is bounded and doesn't grow +// with session length — old points are overwritten in place, not shifted. +// Total footprint is O(activeHexCount × TRACKLOG_MAX_POINTS), and hexes gone +// from the latest poll are pruned in the same recordPoll call. + +interface Ring { + points: (TrackPoint | undefined)[] + head: number // index the NEXT write goes to + size: number // number of valid entries (<= capacity) +} + +const rings = new Map() + +function newRing(): Ring { + return { points: new Array(TRACKLOG_MAX_POINTS), head: 0, size: 0 } +} + +function lastPoint(ring: Ring): TrackPoint | undefined { + if (ring.size === 0) return undefined + const idx = (ring.head - 1 + TRACKLOG_MAX_POINTS) % TRACKLOG_MAX_POINTS + return ring.points[idx] +} + +function push(ring: Ring, point: TrackPoint): void { + ring.points[ring.head] = point + ring.head = (ring.head + 1) % TRACKLOG_MAX_POINTS + ring.size = Math.min(ring.size + 1, TRACKLOG_MAX_POINTS) +} + +/** Chronological (oldest -> newest) snapshot of a ring's contents. */ +function toChronological(ring: Ring): TrackPoint[] { + if (ring.size === 0) return [] + const out: TrackPoint[] = new Array(ring.size) + // Oldest entry is at `head` when the ring is full; when not yet full it's + // simply index 0 (head hasn't wrapped). + const start = ring.size < TRACKLOG_MAX_POINTS ? 0 : ring.head + for (let i = 0; i < ring.size; i++) { + const idx = (start + i) % TRACKLOG_MAX_POINTS + out[i] = ring.points[idx] as TrackPoint + } + return out +} + +/** + * Append one TrackPoint per aircraft (deduped on lastPollMs) and prune ring + * buffers for hexes no longer present in the aircraft map. Call once per + * poll round. + */ +export function recordPoll(aircraftMap: Map, _pollMs: number): void { + for (const [hex, ac] of aircraftMap) { + if (!Number.isFinite(ac.lat) || !Number.isFinite(ac.lon)) continue + + let ring = rings.get(hex) + const prevLast = ring ? lastPoint(ring) : undefined + if (prevLast && ac.lastPollMs <= prevLast.tMs) continue // dedupe stale-carried poll + + if (!ring) { + ring = newRing() + rings.set(hex, ring) + } + + push(ring, { + tMs: ac.lastPollMs, + lat: ac.lat, + lon: ac.lon, + altFt: ac.altBaro, + gs: ac.groundspeed, + track: ac.track, + baroRate: ac.baroRate, + }) + } + + // Drop ring buffers for hexes no longer tracked. + for (const hex of rings.keys()) { + if (!aircraftMap.has(hex)) rings.delete(hex) + } +} + +/** Full chronological (oldest -> newest) track for a hex; empty if unknown. */ +export function getTrack(hex: string): readonly TrackPoint[] { + const ring = rings.get(hex) + if (!ring) return [] + return toChronological(ring) +} + +/** Last `n` points for a hex, chronological (oldest -> newest). */ +export function getRecent(hex: string, n: number): TrackPoint[] { + const ring = rings.get(hex) + if (!ring || n <= 0) return [] + const full = toChronological(ring) + return full.slice(Math.max(0, full.length - n)) +} + +/** Test helper: clear all tracked rings. */ +export function _reset(): void { + rings.clear() +} diff --git a/src/store/__tests__/usePathStore.test.ts b/src/store/__tests__/usePathStore.test.ts new file mode 100644 index 0000000..7612736 --- /dev/null +++ b/src/store/__tests__/usePathStore.test.ts @@ -0,0 +1,86 @@ +import { describe, it, expect, beforeEach } from 'vitest' +import { usePathStore } from '../usePathStore' +import type { PredictedPath, HoldEntryPrediction, AircraftAlert, ConflictPair } from '../../types/path' + +function makePrediction(hex: string): PredictedPath { + return { hex, mode: 'straight', points: [{ lon: -122.3, lat: 47.9, tSec: 5, altFt: 3000 }] } +} + +function makeHoldEntry(hex: string): HoldEntryPrediction { + return { + hex, + specKey: 'KPAE-V-A|PAE', + entry: 'direct', + path: [[-122.3, 47.9]], + lastQualifiedMs: 1000, + divergedPolls: 0, + crossedFix: false, + } +} + +function makeAlert(otherHex: string): AircraftAlert { + return { kind: 'traffic', tier: 'alert', otherHex } +} + +function makeConflict(hexA: string, hexB: string): ConflictPair { + return { hexA, hexB, tier: 'alert', cpaTimeS: 40, cpaNm: 1.5, cpaDAltFt: 600 } +} + +function resultsFor(hex: string) { + return { + predictions: new Map([[hex, makePrediction(hex)]]), + holdEntries: new Map([[hex, makeHoldEntry(hex)]]), + alerts: new Map([[hex, makeAlert('zzzzzz')]]), + conflictPairs: [makeConflict(hex, 'zzzzzz')], + forcedVisibleHexes: new Set(), + } +} + +describe('usePathStore', () => { + beforeEach(() => { + usePathStore.setState({ + predictions: new Map(), + holdEntries: new Map(), + alerts: new Map(), + conflictPairs: [], + pathRevision: 0, + }) + }) + + it('setResults bumps pathRevision exactly once per call', () => { + expect(usePathStore.getState().pathRevision).toBe(0) + usePathStore.getState().setResults(resultsFor('a1b2c3')) + expect(usePathStore.getState().pathRevision).toBe(1) + usePathStore.getState().setResults(resultsFor('a1b2c3')) + expect(usePathStore.getState().pathRevision).toBe(2) + }) + + it('setResults replaces all collections wholesale, dropping stale hexes', () => { + usePathStore.getState().setResults(resultsFor('a1b2c3')) + usePathStore.getState().setResults(resultsFor('d4e5f6')) + + const s = usePathStore.getState() + expect(s.predictions.has('a1b2c3')).toBe(false) + expect(s.predictions.has('d4e5f6')).toBe(true) + expect(s.holdEntries.has('a1b2c3')).toBe(false) + expect(s.holdEntries.has('d4e5f6')).toBe(true) + expect(s.alerts.has('a1b2c3')).toBe(false) + expect(s.alerts.has('d4e5f6')).toBe(true) + expect(s.conflictPairs).toHaveLength(1) + expect(s.conflictPairs[0].hexA).toBe('d4e5f6') + }) + + it('clear empties every collection and bumps pathRevision', () => { + usePathStore.getState().setResults(resultsFor('a1b2c3')) + expect(usePathStore.getState().pathRevision).toBe(1) + + usePathStore.getState().clear() + + const s = usePathStore.getState() + expect(s.predictions.size).toBe(0) + expect(s.holdEntries.size).toBe(0) + expect(s.alerts.size).toBe(0) + expect(s.conflictPairs).toEqual([]) + expect(s.pathRevision).toBe(2) + }) +}) diff --git a/src/store/usePathStore.ts b/src/store/usePathStore.ts new file mode 100644 index 0000000..20e0f41 --- /dev/null +++ b/src/store/usePathStore.ts @@ -0,0 +1,47 @@ +import { create } from 'zustand' +import type { + PredictedPath, + HoldEntryPrediction, + AircraftAlert, + ConflictPair, +} from '../types/path' + +interface PathResults { + /** Predicted path per aircraft hex. */ + predictions: Map + /** Hold-entry prediction per aircraft hex. */ + holdEntries: Map + /** Highest-priority alert per aircraft hex. */ + alerts: Map + conflictPairs: ConflictPair[] + /** Hexes that must render even when the user's TIS-B/VFR/altitude filters + * would hide them — aircraft involved in an active TA/RA. Cleared by the + * same wholesale replacement as everything else once the alert resolves. */ + forcedVisibleHexes: Set +} + +interface PathState extends PathResults { + /** Bumped once per setResults/clear — subscribe to this, not the Maps. */ + pathRevision: number + setResults: (r: PathResults) => void + clear: () => void +} + +const emptyResults = (): PathResults => ({ + predictions: new Map(), + holdEntries: new Map(), + alerts: new Map(), + conflictPairs: [], + forcedVisibleHexes: new Set(), +}) + +export const usePathStore = create((set) => ({ + ...emptyResults(), + pathRevision: 0, + + // Wholesale replacement: the caller passes fresh collections each cycle, so + // stale hexes disappear by construction rather than needing pruning here. + setResults: (r) => set((s) => ({ ...r, pathRevision: s.pathRevision + 1 })), + + clear: () => set((s) => ({ ...emptyResults(), pathRevision: s.pathRevision + 1 })), +})) diff --git a/src/store/useSettingsStore.ts b/src/store/useSettingsStore.ts index 4875c1f..58f28a3 100644 --- a/src/store/useSettingsStore.ts +++ b/src/store/useSettingsStore.ts @@ -19,6 +19,12 @@ interface SettingsStore { showTisb: boolean /** Show VFR traffic (squawking 1200). */ showVfr: boolean + /** Draw predicted-path lines ahead of moving aircraft. */ + showPredictedPaths: boolean + /** How far ahead (minutes) the predicted path extends. */ + predictionMinutes: 1 | 2 | 3 | 5 + /** Draw range rings around the selected aircraft. */ + showRangeRings: boolean /** Slider position (0–19) for the lower altitude filter handle. */ altFilterMin: number /** Slider position (0–19) for the upper altitude filter handle. */ @@ -34,6 +40,9 @@ interface SettingsStore { toggleAirspace: () => void toggleTisb: () => void toggleVfr: () => void + togglePredictedPaths: () => void + setPredictionMinutes: (m: 1 | 2 | 3 | 5) => void + toggleRangeRings: () => void setAltFilterMin: (pos: number) => void setAltFilterMax: (pos: number) => void } @@ -51,6 +60,9 @@ export const useSettingsStore = create()( showAirspace: false, showTisb: true, showVfr: true, + showPredictedPaths: true, + predictionMinutes: 3, + showRangeRings: false, altFilterMin: 0, altFilterMax: 19, @@ -66,6 +78,9 @@ export const useSettingsStore = create()( toggleAirspace: () => set((s) => ({ showAirspace: !s.showAirspace })), toggleTisb: () => set((s) => ({ showTisb: !s.showTisb })), toggleVfr: () => set((s) => ({ showVfr: !s.showVfr })), + togglePredictedPaths: () => set((s) => ({ showPredictedPaths: !s.showPredictedPaths })), + setPredictionMinutes: (m) => set({ predictionMinutes: m }), + toggleRangeRings: () => set((s) => ({ showRangeRings: !s.showRangeRings })), setAltFilterMin: (pos) => set({ altFilterMin: Math.max(0, Math.min(19, pos)) }), setAltFilterMax: (pos) => set({ altFilterMax: Math.max(0, Math.min(19, pos)) }), }), diff --git a/src/types/path.ts b/src/types/path.ts new file mode 100644 index 0000000..03fa880 --- /dev/null +++ b/src/types/path.ts @@ -0,0 +1,79 @@ +import type { AltConstraint } from './procedure' + +/** One retained ADS-B sample in an aircraft's tracklog. */ +export interface TrackPoint { + tMs: number + lat: number + lon: number + altFt: number | 'ground' + gs: number + track: number + baroRate: number +} + +/** One point along a predicted path, tSec seconds ahead of "now". */ +export interface PredPoint { + lon: number + lat: number + tSec: number + altFt: number +} + +export type PredictionMode = 'approach' | 'turn' | 'straight' + +export interface PredictedPath { + hex: string + mode: PredictionMode + points: PredPoint[] +} + +/** A published hold extracted from a procedure, in true-course terms. */ +export interface HoldSpec { + key: string // `${procId}|${fixId}` + procId: string + fixId: string + fixLat: number + fixLon: number + inboundCourseTrue: number + turnRight: boolean + legNm: number + alt: AltConstraint | null + segment: 'transition' | 'missed' +} + +/** AIM 5-3-8 hold entry sectors. */ +export type HoldEntryKind = 'direct' | 'teardrop' | 'parallel' + +export interface HoldEntryPrediction { + hex: string + specKey: string + entry: HoldEntryKind + path: [number, number][] // [lon, lat] + lastQualifiedMs: number + divergedPolls: number + crossedFix: boolean +} + +export type AlertTier = 'alert' | 'warning' | 'ta' | 'ra' + +export type RaSense = 'climb' | 'descend' + +/** The single highest-priority alert attached to one aircraft. */ +export interface AircraftAlert { + kind: 'traffic' | 'terrain' + tier: AlertTier + raSense?: RaSense + otherHex?: string +} + +/** A projected loss-of-separation between two aircraft at closest approach. */ +export interface ConflictPair { + hexA: string + hexB: string + tier: AlertTier + raSenseA?: RaSense + raSenseB?: RaSense + cpaTimeS: number + cpaNm: number + cpaDAltFt: number +} diff --git a/src/utils/__tests__/colorScheme.test.ts b/src/utils/__tests__/colorScheme.test.ts index 49df15c..a066f96 100644 --- a/src/utils/__tests__/colorScheme.test.ts +++ b/src/utils/__tests__/colorScheme.test.ts @@ -1,7 +1,18 @@ import { describe, it, expect } from 'vitest' -import { assignProcedureColors, PROCEDURE_COLOR_FAMILIES } from '../colorScheme' +import { assignProcedureColors, PROCEDURE_COLOR_FAMILIES, altitudeColor } from '../colorScheme' import type { Procedure } from '../../types/procedure' +function hexToRgb(hex: string): [number, number, number] { + const n = parseInt(hex.slice(1), 16) + return [(n >> 16) & 0xff, (n >> 8) & 0xff, n & 0xff] +} + +function rgbDistance(a: string, b: string): number { + const [r1, g1, b1] = hexToRgb(a) + const [r2, g2, b2] = hexToRgb(b) + return Math.sqrt((r1 - r2) ** 2 + (g1 - g2) ** 2 + (b1 - b2) ** 2) +} + function proc(icao: string, name: string, type: Procedure['type']): Procedure { return { id: `${icao}-${name}`, @@ -98,3 +109,77 @@ describe('assignProcedureColors', () => { } }) }) + +describe('altitudeColor', () => { + // Perceptibility floors: adjacent 200 ft bands below 3000 ft shift modestly + // (the ramp actually produces ~13-30 RGB units per step), while 600 ft of + // separation must read as a clearly different color (~55+ actual). + const ADJACENT_200FT_FLOOR = 12 + const APART_600FT_FLOOR = 35 + + it('returns the reserved ground amber for "ground", unchanged', () => { + expect(altitudeColor('ground')).toBe('#f59e0b') + }) + + it('is sensitive below 3000 ft: 700 vs 1200 vs 1700 ft are all clearly distinct', () => { + const c700 = altitudeColor(700) + const c1200 = altitudeColor(1200) + const c1700 = altitudeColor(1700) + expect(rgbDistance(c700, c1200)).toBeGreaterThan(APART_600FT_FLOOR) + expect(rgbDistance(c1200, c1700)).toBeGreaterThan(APART_600FT_FLOOR) + expect(rgbDistance(c700, c1700)).toBeGreaterThan(APART_600FT_FLOOR) + }) + + it('adjacent 200 ft bands below 3000 ft each shift perceptibly', () => { + for (let ft = 200; ft <= 3000; ft += 200) { + expect(rgbDistance(altitudeColor(ft - 200), altitudeColor(ft))).toBeGreaterThan( + ADJACENT_200FT_FLOOR, + ) + } + }) + + it('bands 600 ft apart below 3000 ft are clearly different', () => { + for (let ft = 600; ft <= 3000; ft += 200) { + expect(rgbDistance(altitudeColor(ft - 600), altitudeColor(ft))).toBeGreaterThan( + APART_600FT_FLOOR, + ) + } + }) + + it('pins the named stop colors across the full 0-18000 ft walk', () => { + expect(altitudeColor(0)).toBe('#8a340f') + expect(altitudeColor(400)).toBe('#ae4e10') + expect(altitudeColor(800)).toBe('#cd7311') + expect(altitudeColor(1200)).toBe('#dca51a') + expect(altitudeColor(1600)).toBe('#d3d629') + expect(altitudeColor(2000)).toBe('#9bda2e') + expect(altitudeColor(2400)).toBe('#61d33a') + expect(altitudeColor(2800)).toBe('#3fc550') + expect(altitudeColor(3000)).toBe('#38bf5a') + expect(altitudeColor(6000)).toBe('#2bb388') + expect(altitudeColor(9000)).toBe('#2ba8ac') + expect(altitudeColor(13000)).toBe('#30a8d9') + }) + + it('never emits an exact reserved UI color for airborne altitudes', () => { + const reserved = new Set(['#f59e0b', '#facc15', '#ff2bd6', '#fbbf24', '#ef4444']) + for (let ft = 0; ft <= 20000; ft += 250) { + expect(reserved.has(altitudeColor(ft))).toBe(false) + } + }) + + it('Class A (>=18000 ft) behavior is unchanged: dark navy at the floor, brightening toward sky-400', () => { + expect(altitudeColor(18000)).toBe('#0c4a6e') + const c30000 = altitudeColor(30000) + const c60000 = altitudeColor(60000) + expect(altitudeColor(60000)).toBe('#38bdf8') + // Monotonically brightening with altitude within Class A. + expect(rgbDistance('#0c4a6e', c30000)).toBeGreaterThan(0) + expect(rgbDistance(c30000, c60000)).toBeGreaterThan(0) + const [, g18000] = hexToRgb(altitudeColor(18000)) + const [, g30000] = hexToRgb(c30000) + const [, g60000] = hexToRgb(c60000) + expect(g30000).toBeGreaterThan(g18000) + expect(g60000).toBeGreaterThan(g30000) + }) +}) diff --git a/src/utils/colorScheme.ts b/src/utils/colorScheme.ts index e65c735..351ab51 100644 --- a/src/utils/colorScheme.ts +++ b/src/utils/colorScheme.ts @@ -12,7 +12,9 @@ import type { Procedure, ProcedureType } from '../types/procedure' * procedure types in different hues so type stays readable within an airport, * while the family shift keeps airports distinguishable from one another. * All ramps avoid the reserved aircraft (#f59e0b), active-segment (#ff2bd6), - * highlight (#facc15), centerline (#6b7280) and runway (#64748b) colors. + * highlight (#facc15), centerline (#6b7280) and runway (#64748b) colors, as + * well as the traffic/terrain alert chrome — ALERT_AMBER (#fbbf24) and + * WARNING_RED (#ef4444), which live in src/config/constants.ts. */ export const PROCEDURE_COLOR_FAMILIES: ReadonlyArray> = [ { @@ -105,28 +107,58 @@ function lerp3(a: RGB, b: RGB, t: number): RGB { } /** - * Heatmap stops below Class A: orange at SFC → green near 18 000 ft. + * Heatmap stops below Class A: brick red-brown at SFC → light sky near + * 18 000 ft, walking through vermillion/orange/amber/gold/yellow/chartreuse/ + * lime/green/emerald/teal/cyan. * - * Stops are spaced at 3 000 ft in the approach/terminal range (0–9 000 ft) - * so a 500 ft altitude change covers ~17% of a segment and is clearly - * perceptible. Above 9 000 ft the spacing widens because en-route - * altitude discrimination matters less. + * Below 3 000 ft (takeoff, landing, traffic-pattern, and maneuvering + * altitudes) stops sit every 200 ft, spending the entire warm arc there so a + * 200 ft change reads as a small-but-visible shift and a 600 ft change (e.g. + * 800 ft pattern work vs 1 400 ft transit) is unmistakable. From + * 3 000–18 000 ft the walk continues every 3 000–4 000 ft, since en-route + * altitude discrimination matters less. The final stop lands on a light sky + * blue so the ramp hands off smoothly in hue (though not lightness — see + * altitudeColor below) into the ≥18 000 ft Class-A lerp, which starts at a + * dark navy and brightens with altitude. + * + * None of these exactly reuse the reserved UI colors documented above this + * ramp's callers: AIRCRAFT_COLOR/'ground' (#f59e0b), ACTIVE_SEGMENT_COLOR + * (#ff2bd6), ACTIVE_PROCEDURE_HIGHLIGHT (#facc15), or the traffic/terrain + * alert chrome ALERT_AMBER (#fbbf24) / WARNING_RED (#ef4444) in + * src/config/constants.ts — every stop keeps ≥30 RGB-space distance from + * all five (the 0 ft brick is ~90 from WARNING_RED and much darker). */ const HEATMAP: Array<[number, RGB]> = [ - [0, hexToRgb('#fb923c')], // orange-400 - [3000, hexToRgb('#fbbf24')], // amber-400 - [6000, hexToRgb('#facc15')], // yellow-400 - [9000, hexToRgb('#bef264')], // lime-300 - [13000, hexToRgb('#4ade80')], // green-400 - [18000, hexToRgb('#4ade80')], // green-400 — hold at green before Class A + [0, hexToRgb('#8a340f')], // brick / burnt red-brown + [200, hexToRgb('#9c400f')], + [400, hexToRgb('#ae4e10')], // vermillion-brown + [600, hexToRgb('#c05e10')], + [800, hexToRgb('#cd7311')], // orange + [1000, hexToRgb('#d68b15')], // amber-orange + [1200, hexToRgb('#dca51a')], // gold + [1400, hexToRgb('#ddc122')], // yellow + [1600, hexToRgb('#d3d629')], // yellow-chartreuse + [1800, hexToRgb('#b8d92c')], // chartreuse + [2000, hexToRgb('#9bda2e')], + [2200, hexToRgb('#7dd832')], // lime + [2400, hexToRgb('#61d33a')], + [2600, hexToRgb('#4ccb45')], // lime-green + [2800, hexToRgb('#3fc550')], + [3000, hexToRgb('#38bf5a')], // green + [6000, hexToRgb('#2bb388')], // emerald + [9000, hexToRgb('#2ba8ac')], // teal + [13000, hexToRgb('#30a8d9')], // cyan + [18000, hexToRgb('#4fc3f7')], // sky — hands off to Class A blue lerp ] /** * Returns the display colour for an aircraft at the given barometric altitude. * - * Below 18 000 ft — heatmap gradient (blue → cyan → green → yellow → red). - * 18 000 ft and above (Class A) — goldenrod intensity: dark amber at the floor, - * brightening to full goldenrod (#f59e0b) at high altitudes. + * Below 18 000 ft — HEATMAP gradient, brick red-brown at SFC walking + * through orange/gold/yellow/lime/green/emerald/teal/cyan to a light sky at + * 18 000 ft, densely sampled every 200 ft below 3 000 ft. + * 18 000 ft and above (Class A) — dark sky-blue at the floor, brightening to + * a bright sky-blue (#38bdf8) at high altitudes. */ export function altitudeColor(alt: number | 'ground'): string { if (alt === 'ground') return '#f59e0b' diff --git a/src/workers/__tests__/cifpParse.test.ts b/src/workers/__tests__/cifpParse.test.ts index 1f1e34a..0c5382a 100644 --- a/src/workers/__tests__/cifpParse.test.ts +++ b/src/workers/__tests__/cifpParse.test.ts @@ -1,5 +1,7 @@ import { describe, it, expect, vi } from 'vitest' +import * as turf from '@turf/turf' import { parseCifp } from '../cifpParse' +import { magneticToTrue } from '../../utils/arincRecords' // Regression test for the cifpParser.worker.ts -> cifpParse.ts extraction seam // (see CLAUDE.md: "Pure, node-runnable CIFP parse"). Builds a full synthetic @@ -185,3 +187,103 @@ describe('parseCifp (KAWO FL34 end-to-end fixture)', () => { expect(a).toEqual(b) }) }) + +// Hold-racetrack orientation regression. The `kind:'hold'` racetrack geometry is +// built in TRUE bearings (holdTrack expects true) from the leg's MAGNETIC inbound +// course, converted with the airport magvar (see cifpParse.ts). A past defect +// passed the raw magnetic course straight into holdTrack (no magneticToTrue), so +// the drawn loop's long axis was rotated by the full magnetic variation off the +// straight legs through the same fix — visibly tilted ~magvar° while both were +// still labeled the same magnetic course (KSEA MGNUM at ~16°E was the report +// case). This locks the invariant for BOTH hold classes: a transition hold +// (HILPT / single-leg HF, e.g. KAWO R34 SAVOY) and a missed-approach hold (HM). +describe('parseCifp hold racetrack orientation (magvar-converted true bearing)', () => { + // holdTrack emits [A, F, …] — A is the start of the inbound straight, F the + // fix. The inbound leg's geodetic bearing is bearing(A → F). + const drawnHoldInboundBearing = (coords: [number, number][]): number => { + const b = turf.bearing(turf.point(coords[0]), turf.point(coords[1])) + return ((b % 360) + 360) % 360 + } + + // KAWO airport reference (PA) record — verbatim, magvar +17.0E. A large, + // nonzero variation makes an omitted conversion fail loudly (17° of tilt). + const PA = PA_KAWO + // Terminal-waypoint (P/C) + runway (P/G) records for the referenced fixes. + const PC_SAVOY_ = mkLine({ 4: 'P', 12: 'C', 13: 'SAVOY', 32: 'N48000000', 41: 'W122100000' }) + const PC_WATON_ = mkLine({ 4: 'P', 12: 'C', 13: 'WATON', 32: 'N48050000', 41: 'W122150000' }) + const PC_AW_ = mkLine({ 4: 'P', 12: 'C', 13: 'AW', 32: 'N47500000', 41: 'W122200000' }) + const PG_RW34_ = mkLine({ 4: 'P', 12: 'G', 6: 'KAWO', 13: 'RW34', 32: 'N48070000', 41: 'W122160000' }) + + // Transition hold: a single-leg HF (hold-in-lieu-of-PT) at SAVOY, its own + // transition (route type 'A' at col 20, transition id 'SAVOY'), left turns, + // inbound course 342.1° magnetic. + const HF_SAVOY = mkLine({ + 4: 'P', 6: 'KAWO', 12: 'F', 13: 'R34', 19: 'A', 20: 'SAVOY', + 26: '010', 29: 'SAVOY', 38: '0', 42: 'E', 43: 'L', 47: 'HF', + 70: '3421', 74: '0040', 82: '+', 84: '02000', + }) + // Final (blank) transition: IF SAVOY → FAF WATON → MAP RW34 → missed HM at AW. + // The HM (inbound 161.0° magnetic, left turns) sits after the MAP so it is + // tagged segment 'missed' — a different fix/course from the HILPT above so the + // missed-vs-transition dedup keeps both. + const IF_SAVOY = mkLine({ + 4: 'P', 6: 'KAWO', 12: 'F', 13: 'R34', 26: '010', 29: 'SAVOY', 38: '0', 42: 'I', 47: 'IF', 82: '+', 84: '02000', + }) + const FAF_WATON = mkLine({ + 4: 'P', 6: 'KAWO', 12: 'F', 13: 'R34', 26: '020', 29: 'WATON', 38: '0', 42: 'F', 47: 'CF', + 70: '3441', 74: '0060', 82: '+', 84: '01700', + }) + const MAP_RW34 = mkLine({ + 4: 'P', 6: 'KAWO', 12: 'F', 13: 'R34', 26: '030', 29: 'RW34', 38: '0', 42: 'M', 47: 'CF', 70: '3441', 74: '0047', + }) + const HM_AW = mkLine({ + 4: 'P', 6: 'KAWO', 12: 'F', 13: 'R34', 26: '040', 29: 'AW', 38: '0', 42: 'E', 43: 'L', 47: 'HM', + 70: '1610', 74: 'T010', 82: '+', 84: '05000', + }) + + const HOLD_TEXT = [PA, PC_SAVOY_, PC_WATON_, PC_AW_, PG_RW34_, HF_SAVOY, IF_SAVOY, FAF_WATON, MAP_RW34, HM_AW].join('\n') + + const holdFeatures = () => { + const proc = parseCifp(HOLD_TEXT).KAWO.procedures[0] + return proc.geojson.features.filter((f) => (f.properties as { kind: string }).kind === 'hold') + } + + it('resolves the +17.0E magvar the conversion depends on', () => { + expect(parseCifp(HOLD_TEXT).KAWO.magVarDeg).toBeCloseTo(17.0, 5) + }) + + it('draws the transition HILPT (HF) racetrack inbound leg at magneticToTrue(course, magvar)', () => { + const f = holdFeatures().find( + (f) => (f.properties as { fixId: string; segment: string }).fixId === 'SAVOY', + )! + const props = f.properties as { segment: string; inboundCourseMag: number } + expect(props.segment).toBe('transition') + expect(props.inboundCourseMag).toBeCloseTo(342.1, 5) + const drawn = drawnHoldInboundBearing((f.geometry as unknown as { coordinates: [number, number][] }).coordinates) + // Correct true bearing (342.1 + 17.0 = 359.1), NOT the raw magnetic 342.1. + expect(drawn).toBeCloseTo(magneticToTrue(342.1, 17), 0) // within ~1° + expect(Math.abs(((drawn - 342.1 + 540) % 360) - 180)).toBeGreaterThan(10) // clearly magvar-rotated + }) + + it('draws the missed-approach (HM) racetrack inbound leg at magneticToTrue(course, magvar)', () => { + const f = holdFeatures().find( + (f) => (f.properties as { fixId: string; segment: string }).fixId === 'AW', + )! + const props = f.properties as { segment: string; inboundCourseMag: number } + expect(props.segment).toBe('missed') + expect(props.inboundCourseMag).toBeCloseTo(161.0, 5) + const drawn = drawnHoldInboundBearing((f.geometry as unknown as { coordinates: [number, number][] }).coordinates) + // Correct true bearing (161.0 + 17.0 = 178.0), NOT the raw magnetic 161.0. + expect(drawn).toBeCloseTo(magneticToTrue(161.0, 17), 0) // within ~1° + expect(Math.abs(((drawn - 161.0 + 540) % 360) - 180)).toBeGreaterThan(10) // clearly magvar-rotated + }) + + it('keeps every hold feature (both classes) parallel to its own true course', () => { + for (const f of holdFeatures()) { + const props = f.properties as { inboundCourseMag: number } + const drawn = drawnHoldInboundBearing((f.geometry as unknown as { coordinates: [number, number][] }).coordinates) + const expected = magneticToTrue(props.inboundCourseMag, 17) + expect(Math.abs(((drawn - expected + 540) % 360) - 180)).toBeLessThan(1) + } + }) +}) From e49e94eb11c4d231ba02e3ea61802ebe49488030 Mon Sep 17 00:00:00 2001 From: Ben Betz Date: Sun, 12 Jul 2026 21:58:10 -0700 Subject: [PATCH 2/3] feat: add hold entry display toggle and related functionality - Implemented a toggle for displaying predicted hold-entry paths in the HoldEntryLayer component, defaulting to off. - Enhanced PathControls to include a button for toggling hold entries visibility. - Updated the settings store to manage the new hold entry toggle state. - Introduced constants for hold entry confirmation and abandonment criteria to improve entry prediction accuracy. - Modified the hold entry logic to require multiple consecutive qualifying polls before drawing an entry, preventing transient qualifications from causing visual artifacts. - Updated terrain scanning logic to ensure proper handling of MVA sectors and DEM ground clearance, improving safety alerts. - Added tests to validate the new hold entry confirmation logic and ensure correct behavior under various conditions. --- src/components/map/AircraftOverlay.tsx | 33 ++- src/components/map/DataBlock.tsx | 15 +- src/components/map/HoldEntryLayer.tsx | 6 + src/components/map/PathControls.module.css | 13 ++ src/components/map/PathControls.tsx | 39 ++++ src/components/map/PredictionLayer.tsx | 13 +- src/components/profile/ProfileSvg.module.css | 2 +- src/config/constants.ts | 15 ++ src/geo/__tests__/holdEntry.test.ts | 224 +++++++++++++++++-- src/geo/__tests__/terrainScan.test.ts | 39 +++- src/geo/holdEntry.ts | 111 +++++++-- src/geo/terrainScan.ts | 37 ++- src/store/useSettingsStore.ts | 15 ++ 13 files changed, 494 insertions(+), 68 deletions(-) diff --git a/src/components/map/AircraftOverlay.tsx b/src/components/map/AircraftOverlay.tsx index 8cf1cb7..9e12838 100644 --- a/src/components/map/AircraftOverlay.tsx +++ b/src/components/map/AircraftOverlay.tsx @@ -41,6 +41,17 @@ function alertChipInfo(alert: AircraftAlert): { text: string; isRed: boolean } { return { text: alert.kind === 'terrain' ? 'TERRAIN' : 'TRAFFIC', isRed } } +/** An alert only counts when its category is toggled on (PathControls TERR/TFC). */ +function visibleAlert( + alert: AircraftAlert | undefined, + showTerrainAlerts: boolean, + showTrafficAlerts: boolean, +): AircraftAlert | undefined { + if (!alert) return undefined + if (alert.kind === 'terrain') return showTerrainAlerts ? alert : undefined + return showTrafficAlerts ? alert : undefined +} + function AircraftIcon() { return ( @@ -74,6 +85,11 @@ export function AircraftOverlay({ mapRef }: Props) { // poll — same cadence as the aircraft-set revision below — without the rAF // loop ever touching this store. const pathRevision = usePathStore((s) => s.pathRevision) + // Alert-category display toggles (PathControls). Subscribed (not getState) so + // toggling re-renders the chrome immediately; the rAF loop below reads them + // from getState per frame for the z-order/visibility side. + const showTerrainAlerts = useSettingsStore((s) => s.showTerrainAlerts) + const showTrafficAlerts = useSettingsStore((s) => s.showTrafficAlerts) // Snapshot the airborne aircraft set; only changes on a poll. const aircraft = useMemo( @@ -90,7 +106,14 @@ export function AircraftOverlay({ mapRef }: Props) { const map = mapRef.current?.getMap() if (map) { const store = useAircraftStore.getState() - const { altFilterMin, altFilterMax, showTisb, showVfr } = useSettingsStore.getState() + const { + altFilterMin, + altFilterMax, + showTisb, + showVfr, + showTerrainAlerts: terrOn, + showTrafficAlerts: tfcOn, + } = useSettingsStore.getState() const { alerts, forcedVisibleHexes } = usePathStore.getState() const minFt = positionToMinFt(altFilterMin) const maxFt = positionToMaxFt(altFilterMax) @@ -104,8 +127,10 @@ export function AircraftOverlay({ mapRef }: Props) { // these takes effect immediately without a React re-render. A hex // in forcedVisibleHexes (TA/RA participant) always renders through // these filters. + // forcedVisibleHexes reveals TA/RA participants through the filters — + // but only while traffic alerts are actually being shown. const hidden = - !forcedVisibleHexes.has(hex) && + !(tfcOn && forcedVisibleHexes.has(hex)) && (alt < minFt || alt > maxFt || (!showTisb && hex.startsWith('~')) || @@ -121,7 +146,7 @@ export function AircraftOverlay({ mapRef }: Props) { node.style.transform = `translate(${p.x}px, ${p.y}px)` node.style.color = altitudeColor(ac.altBaro) - const alert = alerts.get(hex) + const alert = visibleAlert(alerts.get(hex), terrOn, tfcOn) const baseZ = Math.max(1, Math.floor(alt / 100)) // Alerted aircraft float above all non-alerted traffic, still // ranked by altitude among themselves. @@ -206,7 +231,7 @@ export function AircraftOverlay({ mapRef }: Props) { const dest = ac.destination || 'Unkwn' const isVfr = ac.squawk === VFR_SQUAWK const isTisb = ac.hex.startsWith('~') - const alert = alerts.get(ac.hex) + const alert = visibleAlert(alerts.get(ac.hex), showTerrainAlerts, showTrafficAlerts) const chip = alert ? alertChipInfo(alert) : null // Line 1: callsign or tail number — kept separate from the data rows diff --git a/src/components/map/DataBlock.tsx b/src/components/map/DataBlock.tsx index e9459d0..78a7aab 100644 --- a/src/components/map/DataBlock.tsx +++ b/src/components/map/DataBlock.tsx @@ -14,6 +14,7 @@ import { decodeCallsign, airlineLogoUrl } from '../../utils/airlines' import { decodeAircraftType } from '../../utils/aircraftTypes' import { getAirportByIcao } from '../../hooks/useAirportSearch' import { usePathStore } from '../../store/usePathStore' +import { useSettingsStore } from '../../store/useSettingsStore' import type { AircraftAlert } from '../../types/path' import { VFR_SQUAWK } from '../../config/constants' import { bearingDelta } from '../../geo/lineMatching' @@ -117,7 +118,19 @@ export function DataBlock({ aircraft, onClose }: Props) { // per poll when an alert appears/clears/changes tier. const pathRevision = usePathStore((s) => s.pathRevision) void pathRevision - const alert = usePathStore.getState().alerts.get(aircraft.hex) + const showTerrainAlerts = useSettingsStore((s) => s.showTerrainAlerts) + const showTrafficAlerts = useSettingsStore((s) => s.showTrafficAlerts) + const rawAlert = usePathStore.getState().alerts.get(aircraft.hex) + // Only surface an alert whose category toggle (PathControls TERR/TFC) is on. + const alert = rawAlert + ? rawAlert.kind === 'terrain' + ? showTerrainAlerts + ? rawAlert + : undefined + : showTrafficAlerts + ? rawAlert + : undefined + : undefined const chip = alert ? alertChipInfo(alert) : null const originAirport = aircraft.origin ? getAirportByIcao(aircraft.origin) : undefined diff --git a/src/components/map/HoldEntryLayer.tsx b/src/components/map/HoldEntryLayer.tsx index b3512a4..a43c258 100644 --- a/src/components/map/HoldEntryLayer.tsx +++ b/src/components/map/HoldEntryLayer.tsx @@ -3,6 +3,7 @@ import { Source, Layer } from 'react-map-gl' import type { Feature, FeatureCollection, LineString } from 'geojson' import { usePathStore } from '../../store/usePathStore' import { useProcedureStore, computeVisibility } from '../../store/useProcedureStore' +import { useSettingsStore } from '../../store/useSettingsStore' import { HOLD_ENTRY_DASH } from '../../config/constants' // Neutral slate for a context line when its procedure carries no color. @@ -28,6 +29,7 @@ function procIdOf(specKey: string): string { * `computeVisibility` returns true and the extra context line drops out. */ export function HoldEntryLayer() { + const showHoldEntries = useSettingsStore((s) => s.showHoldEntries) const holdEntries = usePathStore((s) => s.holdEntries) const procedures = useProcedureStore((s) => s.procedures) const userToggles = useProcedureStore((s) => s.userToggles) @@ -68,6 +70,10 @@ export function HoldEntryLayer() { return { type: 'FeatureCollection', features } }, [holdEntries, procedures, userToggles, autoVisible]) + // Off by default — a display toggle (PathControls "HOLD"). The engine keeps + // computing entries regardless, so toggling on shows them immediately. + if (!showHoldEntries) return null + return ( <> {/* Context: the parent procedure drawn thin, only while it's hidden, so diff --git a/src/components/map/PathControls.module.css b/src/components/map/PathControls.module.css index 74e3cc3..1205cf4 100644 --- a/src/components/map/PathControls.module.css +++ b/src/components/map/PathControls.module.css @@ -59,6 +59,19 @@ box-shadow: inset 0 0 0 1.5px #1a1a1a; } +.dotHold { + background: #ffffff; + box-shadow: inset 0 0 0 1.5px #1a1a1a; +} + +.dotTerrain { + background: #fbbf24; /* ALERT_AMBER */ +} + +.dotTraffic { + background: #ef4444; /* WARNING_RED */ +} + .off .dot { background: transparent; box-shadow: inset 0 0 0 1.5px currentColor; diff --git a/src/components/map/PathControls.tsx b/src/components/map/PathControls.tsx index d17c52e..7123d34 100644 --- a/src/components/map/PathControls.tsx +++ b/src/components/map/PathControls.tsx @@ -13,9 +13,15 @@ export function PathControls() { const showPredictedPaths = useSettingsStore((s) => s.showPredictedPaths) const predictionMinutes = useSettingsStore((s) => s.predictionMinutes) const showRangeRings = useSettingsStore((s) => s.showRangeRings) + const showHoldEntries = useSettingsStore((s) => s.showHoldEntries) + const showTerrainAlerts = useSettingsStore((s) => s.showTerrainAlerts) + const showTrafficAlerts = useSettingsStore((s) => s.showTrafficAlerts) const togglePredictedPaths = useSettingsStore((s) => s.togglePredictedPaths) const setPredictionMinutes = useSettingsStore((s) => s.setPredictionMinutes) const toggleRangeRings = useSettingsStore((s) => s.toggleRangeRings) + const toggleHoldEntries = useSettingsStore((s) => s.toggleHoldEntries) + const toggleTerrainAlerts = useSettingsStore((s) => s.toggleTerrainAlerts) + const toggleTrafficAlerts = useSettingsStore((s) => s.toggleTrafficAlerts) return (
@@ -56,6 +62,39 @@ export function PathControls() { RINGS + + + + + +
) } diff --git a/src/components/map/PredictionLayer.tsx b/src/components/map/PredictionLayer.tsx index 2edc50b..0f7889d 100644 --- a/src/components/map/PredictionLayer.tsx +++ b/src/components/map/PredictionLayer.tsx @@ -54,15 +54,18 @@ export function PredictionLayer() { const selectedHex = useSelectionStore((s) => selectedHexOf(s.selected)) const showPredictedPaths = useSettingsStore((s) => s.showPredictedPaths) const predictionMinutes = useSettingsStore((s) => s.predictionMinutes) + const showTrafficAlerts = useSettingsStore((s) => s.showTrafficAlerts) + // Force-shown conflict paths are a traffic-alert feature — gated with it. const conflictHexes = useMemo(() => { const set = new Set() + if (!showTrafficAlerts) return set for (const pair of conflictPairs) { set.add(pair.hexA) set.add(pair.hexB) } return set - }, [conflictPairs]) + }, [conflictPairs, showTrafficAlerts]) const selected = useMemo(() => { if (!showPredictedPaths || !selectedHex || conflictHexes.has(selectedHex)) { @@ -84,6 +87,12 @@ export function PredictionLayer() { const conflict = useMemo(() => { const lines: Feature[] = [] const dots: Feature[] = [] + if (!showTrafficAlerts) { + return { + lines: { type: 'FeatureCollection', features: lines } as FeatureCollection, + dots: { type: 'FeatureCollection', features: dots } as FeatureCollection, + } + } for (const pair of conflictPairs) { const color = tierColor(pair.tier) for (const hex of [pair.hexA, pair.hexB]) { @@ -100,7 +109,7 @@ export function PredictionLayer() { lines: { type: 'FeatureCollection', features: lines } as FeatureCollection, dots: { type: 'FeatureCollection', features: dots } as FeatureCollection, } - }, [conflictPairs, predictions]) + }, [conflictPairs, predictions, showTrafficAlerts]) return ( <> diff --git a/src/components/profile/ProfileSvg.module.css b/src/components/profile/ProfileSvg.module.css index 0690ac9..e91cfca 100644 --- a/src/components/profile/ProfileSvg.module.css +++ b/src/components/profile/ProfileSvg.module.css @@ -14,7 +14,7 @@ (altitude IS the y axis already) — visible as a deviation trace but never competing with the descent path or a live aircraft's own glyph. */ .selectedTrail { - stroke: #ffffff; + stroke: #38bdf8; /* same sky blue as the selected aircraft glyph (.aircraft), dimmed */ stroke-width: 1.5; stroke-opacity: 0.4; stroke-linecap: round; diff --git a/src/config/constants.ts b/src/config/constants.ts index e86b02e..bd0df5e 100644 --- a/src/config/constants.ts +++ b/src/config/constants.ts @@ -244,6 +244,21 @@ export const HOLD_ENTRY_MAX_ETA_S = 180 export const HOLD_ENTRY_PASS_NM = 1.0 export const HOLD_ENTRY_ALT_TOL_FT = 2000 export const HOLD_ENTRY_CLEAR_POLLS = 3 +// A predicted entry must qualify on this many CONSECUTIVE polls before it's +// drawn — a low maneuvering aircraft can transiently satisfy the bearing/ETA +// gates for one poll while turning, which used to strand an entry loop on the +// map. Sustained intent (a plane actually tracking to the fix) survives. +export const HOLD_ENTRY_CONFIRM_POLLS = 2 +// Before reaching the fix, if the aircraft's track diverges from the bearing to +// the fix by more than this, it isn't entering — clear immediately rather than +// waiting out HOLD_ENTRY_CLEAR_POLLS. (After crossing the fix the aircraft turns +// away by design to fly the entry, so this only applies pre-crossing.) +export const HOLD_ENTRY_ABANDON_BRG_DEG = 45 +// An aircraft climbing faster than this is departing or overflying, not entering +// a hold (holds are flown level or descending) — don't predict an entry for it. +// Guards against a climbing overflight crossing a fix and getting a spurious +// (and, for a parallel entry, far-side-looking) entry drawn. +export const HOLD_ENTRY_MAX_CLIMB_FPM = 500 // AIM 5-3-8 teardrop entry: outbound offset ~30° from the reciprocal of the // inbound course, on the holding side. export const HOLD_ENTRY_TEARDROP_OFFSET_DEG = 30 diff --git a/src/geo/__tests__/holdEntry.test.ts b/src/geo/__tests__/holdEntry.test.ts index b0fb9e2..c8e7696 100644 --- a/src/geo/__tests__/holdEntry.test.ts +++ b/src/geo/__tests__/holdEntry.test.ts @@ -10,6 +10,7 @@ import { type HoldEntryState, } from '../holdEntry' import { dest, holdTrack } from '../procedureShapes' +import { HOLD_ENTRY_CONFIRM_POLLS } from '../../config/constants' import type { Procedure, AltConstraint } from '../../types/procedure' import type { InterpolatedAircraft } from '../../types/aircraft' import type { HoldSpec, PredictedPath } from '../../types/path' @@ -108,6 +109,25 @@ function holdFeature(over: Record = {}, coords?: Pt[]): Feature } } +/** + * Feed HOLD_ENTRY_CONFIRM_POLLS consecutive qualifying polls (1000ms apart, + * starting at nowMs 0) so a fresh hex clears the confirm gate and an entry + * appears on the final poll — `reduceHoldEntries` no longer creates an entry + * on the first qualifying poll (the FFL640 fix). `makeInputAt(nowMs)` must + * return an input that qualifies for the SAME spec every time it's called, or + * the pending count resets instead of accumulating. + */ +function confirmEntry( + makeInputAt: (nowMs: number) => HoldEntryInput, + start: HoldEntryState = emptyHoldEntryState(), +): HoldEntryState { + let s = start + for (let i = 0; i < HOLD_ENTRY_CONFIRM_POLLS; i++) { + s = reduceHoldEntries(s, makeInputAt(i * 1000)) + } + return s +} + function makeProc(over: Partial = {}): Procedure { return { id: 'KXYZ-R34', @@ -320,12 +340,21 @@ describe('holdEntryPath', () => { expect(path[1][0]).toBeLessThan(FIX_LON) // west = holding side for left turns }) - it('parallel outbound lies on the NON-holding side', () => { + it('parallel outbound overlies the inbound course; entry on the holding side', () => { + // The outbound leg flies the reciprocal of the inbound course FROM the fix, + // overlying the hold's own inbound leg — on the course line (longitude ≈ fix + // for an inbound-360 hold), not offset to either side. const rightPath = holdEntryPath(spec, 'parallel') - expect(rightPath[1][0]).toBeLessThan(FIX_LON) // west of an inbound-360 right hold + expect(Math.abs(rightPath[1][0] - FIX_LON)).toBeLessThan(0.02) + // The reversal turns INTO the protected side, so the whole entry sits on the + // holding side — same as the drawn racetrack, never swung to the far side. + const rightTrack = centroidSide(holdTrack(FIX_LAT, FIX_LON, 360, true, 4), FIX, 360) + expect(Math.sign(centroidSide(rightPath, FIX, 360))).toBe(Math.sign(rightTrack)) const leftPath = holdEntryPath(makeSpec({ turnRight: false }), 'parallel') - expect(leftPath[1][0]).toBeGreaterThan(FIX_LON) + expect(Math.abs(leftPath[1][0] - FIX_LON)).toBeLessThan(0.02) + const leftTrack = centroidSide(holdTrack(FIX_LAT, FIX_LON, 360, false, 4), FIX, 360) + expect(Math.sign(centroidSide(leftPath, FIX, 360))).toBe(Math.sign(leftTrack)) }) it('parallel rejoins the inbound course outside the fix', () => { @@ -374,9 +403,10 @@ describe('holdEntryPath MGNUM-style geometry', () => { // Same sign as the drawn racetrack — never mirrored to the far side. expect(Math.sign(entrySide)).toBe(Math.sign(trackSide)) } - // Parallel is deliberately drawn on the NON-holding side. + // Parallel too: it parallels outbound on the course, then reverses INTO the + // protected side, so it also sits on the holding side (same as the racetrack). const parSide = centroidSide(holdEntryPath(spec, 'parallel'), FIX, INB) - expect(Math.sign(parSide)).toBe(-Math.sign(trackSide)) + expect(Math.sign(parSide)).toBe(Math.sign(trackSide)) }) it.each([true, false])('no entry path has a spurious mid-path reversal (right=%s)', (right) => { @@ -439,7 +469,7 @@ describe('direct entry superimposes on the drawn racetrack', () => { describe('reduceHoldEntries trigger gates', () => { it('creates an entry when every gate passes', () => { - const s = reduceHoldEntries(emptyHoldEntryState(), makeInput()) + const s = confirmEntry((nowMs) => makeInput({ nowMs })) const rec = s.entries.get(HEX) expect(rec).toBeDefined() expect(rec!.specKey).toBe('KXYZ-R34|SAVOY') @@ -451,11 +481,13 @@ describe('reduceHoldEntries trigger gates', () => { it('classifies from the predicted arrival track (parallel case)', () => { const pos = dest(FIX, 5, 300) - const input = makeInput({ - aircraft: [makeAc({ lat: pos[1], lon: pos[0], interpLat: pos[1], interpLon: pos[0], track: 120 })], - predictions: new Map([[HEX, makePred(4000, 300)]]), - }) - const s = reduceHoldEntries(emptyHoldEntryState(), input) + const makeAt = (nowMs: number): HoldEntryInput => + makeInput({ + nowMs, + aircraft: [makeAc({ lat: pos[1], lon: pos[0], interpLat: pos[1], interpLon: pos[0], track: 120 })], + predictions: new Map([[HEX, makePred(4000, 300)]]), + }) + const s = confirmEntry(makeAt) expect(s.entries.get(HEX)?.entry).toBe('parallel') // r = 120 }) @@ -464,6 +496,21 @@ describe('reduceHoldEntries trigger gates', () => { expect(s.entries.size).toBe(0) }) + it('rejects a climbing aircraft (departing/overflying, not holding)', () => { + // Every other gate passes, but baroRate exceeds HOLD_ENTRY_MAX_CLIMB_FPM. + const s = confirmEntry((nowMs) => + makeInput({ nowMs, aircraft: [makeAc({ baroRate: 1500 })] }), + ) + expect(s.entries.size).toBe(0) + }) + + it('allows a level or descending aircraft', () => { + const s = confirmEntry((nowMs) => + makeInput({ nowMs, aircraft: [makeAc({ baroRate: -600 })] }), + ) + expect(s.entries.get(HEX)).toBeDefined() + }) + it('rejects an ETA over 180 s', () => { // 5 nm at 90 kt → 200 s const s = reduceHoldEntries( @@ -500,15 +547,13 @@ describe('reduceHoldEntries trigger gates', () => { it('rejects a predicted altitude 2500 ft above an AT_OR_BELOW constraint', () => { const spec = makeSpec({ alt: { type: 'AT_OR_BELOW', low: 4000 } }) - const bad = reduceHoldEntries( - emptyHoldEntryState(), - makeInput({ specs: [spec], predictions: new Map([[HEX, makePred(6500)]]) }), + const bad = confirmEntry((nowMs) => + makeInput({ nowMs, specs: [spec], predictions: new Map([[HEX, makePred(6500)]]) }), ) expect(bad.entries.size).toBe(0) - const ok = reduceHoldEntries( - emptyHoldEntryState(), - makeInput({ specs: [spec], predictions: new Map([[HEX, makePred(4500)]]) }), + const ok = confirmEntry((nowMs) => + makeInput({ nowMs, specs: [spec], predictions: new Map([[HEX, makePred(4500)]]) }), ) expect(ok.entries.size).toBe(1) }) @@ -526,7 +571,7 @@ describe('reduceHoldEntries trigger gates', () => { describe('reduceHoldEntries lifecycle', () => { function created(): HoldEntryState { - return reduceHoldEntries(emptyHoldEntryState(), makeInput()) + return confirmEntry((nowMs) => makeInput({ nowMs })) } it('keeps path identity stable across qualifying polls', () => { @@ -593,9 +638,15 @@ describe('reduceHoldEntries lifecycle', () => { function divergingInput(nowMs: number, distOut: number): HoldEntryInput { const pos = dest(FIX, distOut, 210) + // Track is 20° off the bearing to the fix (30°): outside the qualify gate + // (HOLD_ENTRY_BRG_DEG=10, so it never re-qualifies) but inside the + // pre-crossing abandon gate (HOLD_ENTRY_ABANDON_BRG_DEG=45), so this + // exercises the gradual diverge counter rather than the immediate abandon + // clear (a directly-away track like the old 210 now triggers that instead + // — covered separately below). return makeInput({ nowMs, - aircraft: [makeAc({ lat: pos[1], lon: pos[0], interpLat: pos[1], interpLon: pos[0], track: 210 })], + aircraft: [makeAc({ lat: pos[1], lon: pos[0], interpLat: pos[1], interpLon: pos[0], track: 50 })], }) } @@ -615,9 +666,12 @@ describe('reduceHoldEntries lifecycle', () => { // strand the loop forever. The stale-out must clear it regardless. const stalled = (nowMs: number): HoldEntryInput => { const pos = dest(FIX, 5, 210) + // Track 30° off the bearing to the fix: fails to qualify but stays + // inside the pre-crossing abandon gate, so only the stale-timeout path + // (not the immediate abandon clear) can retire this entry. return makeInput({ nowMs, - aircraft: [makeAc({ lat: pos[1], lon: pos[0], interpLat: pos[1], interpLon: pos[0], track: 300 })], + aircraft: [makeAc({ lat: pos[1], lon: pos[0], interpLat: pos[1], interpLon: pos[0], track: 60 })], }) } let s = created() // lastQualifiedMs = 1000 @@ -689,7 +743,7 @@ describe('reduceHoldEntries LOFAL freeze', () => { it('creates a DIRECT entry coincident with the drawn racetrack, then freezes it past the fix', () => { // ASA1508-style: NW of the fix arriving ~135° (r ≈ 10 for the left hold → direct). - let s = reduceHoldEntries(emptyHoldEntryState(), inputAt(1000, 315, 5, 135, makePred(4000, 315))) + let s = confirmEntry((nowMs) => inputAt(nowMs, 315, 5, 135, makePred(4000, 315))) const rec1 = s.entries.get(HEX) expect(rec1).toBeDefined() expect(rec1!.entry).toBe('direct') @@ -758,3 +812,131 @@ describe('reduceHoldEntries LOFAL freeze', () => { expect(after.entry).toBe(before.entry) }) }) + +// ── FFL640 regression: transient qualification must not latch ────────────── + +describe('reduceHoldEntries confirm-polls latch (FFL640 regression)', () => { + it('does NOT create an entry from a single qualifying poll that is not repeated', () => { + // First poll qualifies — pending only, no entry yet. + const s1 = reduceHoldEntries(emptyHoldEntryState(), makeInput({ nowMs: 0 })) + expect(s1.entries.size).toBe(0) + expect(s1.pending.get(HEX)).toEqual({ specKey: 'KXYZ-R34|SAVOY', count: 1 }) + + // Next poll: track swings away (e.g. the aircraft was maneuvering) — no + // longer heading at the fix, so it fails to qualify. This must NEVER + // latch an entry, even transiently, or a loop strands on screen behind a + // maneuvering aircraft (the field failure this guards against). + const s2 = reduceHoldEntries(s1, makeInput({ nowMs: 1000, aircraft: [makeAc({ track: 90 })] })) + expect(s2.entries.size).toBe(0) + expect(s2.pending.has(HEX)).toBe(false) + }) + + it('DOES create an entry after HOLD_ENTRY_CONFIRM_POLLS consecutive qualifying polls', () => { + const s = confirmEntry((nowMs) => makeInput({ nowMs })) + const rec = s.entries.get(HEX) + expect(rec).toBeDefined() + expect(rec!.specKey).toBe('KXYZ-R34|SAVOY') + }) +}) + +// ── Pre-crossing abandon regression ───────────────────────────────────────── + +describe('reduceHoldEntries pre-crossing abandon', () => { + function farEntry(): HoldEntryState { + return confirmEntry((nowMs) => makeInput({ nowMs })) + } + + it('clears immediately when track swings >45° off the bearing to the fix before crossing', () => { + const s1 = farEntry() + expect(s1.entries.get(HEX)?.crossedFix).toBe(false) + + // Track 60° off the bearing to the fix (30°) — over HOLD_ENTRY_ABANDON_BRG_DEG + // (45°) and short of the fix (still ~5 nm out, crossedFix false): clears + // THIS poll instead of waiting out HOLD_ENTRY_CLEAR_POLLS. + const s2 = reduceHoldEntries(s1, makeInput({ nowMs: 2000, aircraft: [makeAc({ track: 90 })] })) + expect(s2.entries.has(HEX)).toBe(false) + }) + + it('does NOT immediately clear on a <45° deviation (falls through to the diverge counter instead)', () => { + const s1 = farEntry() + // Track 20° off the bearing to the fix — fails to qualify (over the ≤10° + // qualify gate) but stays within the 45° abandon gate, so this poll must + // NOT clear the entry immediately. + const s2 = reduceHoldEntries(s1, makeInput({ nowMs: 2000, aircraft: [makeAc({ track: 50 })] })) + expect(s2.entries.has(HEX)).toBe(true) + // Distance unchanged from the last qualifying poll → the diverge counter + // it fell through to hasn't incremented either. + expect(s2.entries.get(HEX)!.divergedPolls).toBe(0) + }) + + it('does not abandon on track once past the fix (executing the entry)', () => { + let s = farEntry() + const pos = dest(FIX, 0.4, 210) // within FIX_CROSS_NM + s = reduceHoldEntries( + s, + makeInput({ + nowMs: 2000, + aircraft: [makeAc({ lat: pos[1], lon: pos[0], interpLat: pos[1], interpLon: pos[0], track: 120 })], + }), + ) + expect(s.entries.get(HEX)?.crossedFix).toBe(true) + + // Track 90°+ off the bearing to the fix is normal once executing an + // entry (the aircraft is turning away to fly the racetrack) — the + // pre-crossing abandon must NOT fire now that crossedFix is true. + s = reduceHoldEntries( + s, + makeInput({ + nowMs: 3000, + aircraft: [makeAc({ lat: pos[1], lon: pos[0], interpLat: pos[1], interpLon: pos[0], track: 200 })], + }), + ) + expect(s.entries.has(HEX)).toBe(true) + }) +}) + +// ── Teardrop/parallel join geometry regression (no mid-path jog) ──────────── + +describe('holdEntryPath classification-driven join geometry', () => { + // Given a target AIM sector value `r` (see classifyHoldEntry's docblock), + // returns the arrival track that produces it for either turn direction — + // classifyHoldEntry mirrors right-turn sectors for left-turn holds via + // r → 360 − r, so the raw (pre-mirror) track offset must be the complement. + const arrivalTrackFor = (inbound: number, turnRight: boolean, r: number): number => + (((inbound + (turnRight ? r : 360 - r)) % 360) + 360) % 360 + + it.each([true, false])('teardrop entry (turnRight=%s): no mid-path jog, rolls out inbound, joins from the outbound side', (right) => { + const spec = makeSpec({ turnRight: right, inboundCourseTrue: 360 }) + const arrivalTrack = arrivalTrackFor(spec.inboundCourseTrue, right, 200) // (180,250] → teardrop + const kind = classifyHoldEntry(arrivalTrack, spec.inboundCourseTrue, right) + expect(kind).toBe('teardrop') + + const path = holdEntryPath(spec, kind) + // The only intentional corner is the ~45° roll-out intercept; anything + // ≥100° would be the old jog where the arc met the intercept. + expect(maxKinkDeg(path)).toBeLessThan(100) + // Final segment lies exactly on the inbound course. + const last = brg(path[path.length - 2], path[path.length - 1]) + expect(brgDelta(last, spec.inboundCourseTrue % 360)).toBeLessThan(5) + // The join point sits on the outbound (reciprocal) side of the fix, so + // the final segment approaches the fix FROM the outbound direction. + const join = path[path.length - 2] + const recip = (spec.inboundCourseTrue + 180) % 360 + expect(brgDelta(brg(FIX, join), recip)).toBeLessThan(5) + }) + + it.each([true, false])('parallel entry (turnRight=%s): no mid-path jog, rolls out inbound, joins from the outbound side', (right) => { + const spec = makeSpec({ turnRight: right, inboundCourseTrue: 360 }) + const arrivalTrack = arrivalTrackFor(spec.inboundCourseTrue, right, 120) // (70,180] → parallel + const kind = classifyHoldEntry(arrivalTrack, spec.inboundCourseTrue, right) + expect(kind).toBe('parallel') + + const path = holdEntryPath(spec, kind) + expect(maxKinkDeg(path)).toBeLessThan(100) + const last = brg(path[path.length - 2], path[path.length - 1]) + expect(brgDelta(last, spec.inboundCourseTrue % 360)).toBeLessThan(5) + const join = path[path.length - 2] + const recip = (spec.inboundCourseTrue + 180) % 360 + expect(brgDelta(brg(FIX, join), recip)).toBeLessThan(5) + }) +}) diff --git a/src/geo/__tests__/terrainScan.test.ts b/src/geo/__tests__/terrainScan.test.ts index aeda8fe..69b7867 100644 --- a/src/geo/__tests__/terrainScan.test.ts +++ b/src/geo/__tests__/terrainScan.test.ts @@ -51,21 +51,46 @@ function pathAt( } describe('scanTerrain — MVA sector', () => { - it('clears the sector minimum with margin -> null', () => { + it('at or above the sector minimum -> null, DEM never consulted', () => { const elevAt = vi.fn() const result = scanTerrain(pathAt(IN_SECTOR, 5100), [SECTOR], elevAt, BASE_OPTS) expect(result).toBeNull() - expect(elevAt).not.toHaveBeenCalled() // MVA covers this point — DEM never consulted + expect(elevAt).not.toHaveBeenCalled() // above the vectoring floor — clear }) - it('below the sector minimum -> alert', () => { - const result = scanTerrain(pathAt(IN_SECTOR, 4500), [SECTOR], vi.fn(), BASE_OPTS) + it('below the floor but comfortable DEM ground clearance -> null (VFR under a Bravo shelf)', () => { + // 1375 ft over ground at 0 ft = 1375 ft clearance — well above the 1000 ft + // alert threshold — even though it's far below the 5000 ft MVA floor. This + // is the FFL640 case: below the vectoring minimum is not a terrain conflict. + const elevAt = vi.fn().mockReturnValue(0) + const result = scanTerrain(pathAt(IN_SECTOR, 1375), [SECTOR], elevAt, BASE_OPTS) + expect(result).toBeNull() + expect(elevAt).toHaveBeenCalledWith(IN_SECTOR.lat, IN_SECTOR.lon) + }) + + it('below the floor and marginal DEM clearance -> DEM tier wins (alert)', () => { + // 950 ft over ground at 0 ft = 950 ft clearance -> alert, regardless of MVA. + const elevAt = vi.fn().mockReturnValue(0) + const result = scanTerrain(pathAt(IN_SECTOR, 950), [SECTOR], elevAt, BASE_OPTS) + expect(result).toBe('alert') + }) + + it('below the floor and very low DEM clearance -> warning', () => { + const elevAt = vi.fn().mockReturnValue(0) + const result = scanTerrain(pathAt(IN_SECTOR, 90), [SECTOR], elevAt, BASE_OPTS) + expect(result).toBe('warning') + }) + + it('below the floor with a COLD DEM tile -> conservative MVA-floor fallback (alert)', () => { + const elevAt = vi.fn().mockReturnValue(undefined) + const result = scanTerrain(pathAt(IN_SECTOR, 4500), [SECTOR], elevAt, BASE_OPTS) expect(result).toBe('alert') }) - it('more than TERRAIN_MVA_WARN_BELOW_FT under the sector minimum -> warning', () => { - // 5000 - 900 = 4100; 4000 is below that. - const result = scanTerrain(pathAt(IN_SECTOR, 4000), [SECTOR], vi.fn(), BASE_OPTS) + it('cold DEM tile, more than TERRAIN_MVA_WARN_BELOW_FT under the floor -> warning', () => { + // 5000 - 900 = 4100; 4000 is below that, DEM cold -> MVA-floor fallback. + const elevAt = vi.fn().mockReturnValue(undefined) + const result = scanTerrain(pathAt(IN_SECTOR, 4000), [SECTOR], elevAt, BASE_OPTS) expect(result).toBe('warning') }) diff --git a/src/geo/holdEntry.ts b/src/geo/holdEntry.ts index 8676254..6b01106 100644 --- a/src/geo/holdEntry.ts +++ b/src/geo/holdEntry.ts @@ -6,6 +6,9 @@ import { HOLD_ENTRY_PASS_NM, HOLD_ENTRY_ALT_TOL_FT, HOLD_ENTRY_CLEAR_POLLS, + HOLD_ENTRY_CONFIRM_POLLS, + HOLD_ENTRY_ABANDON_BRG_DEG, + HOLD_ENTRY_MAX_CLIMB_FPM, HOLD_ENTRY_TEARDROP_OFFSET_DEG, HOLD_MATCH_DIR_DEG, } from '../config/constants' @@ -26,8 +29,6 @@ const norm360 = (d: number): number => ((d % 360) + 360) % 360 const FIX_CROSS_NM = 0.5 const ESTABLISHED_TRACK_DEG = 20 const ESTABLISHED_XT_NM = 0.5 -// Lateral offset of the drawn parallel-entry outbound leg on the non-holding side. -const PARALLEL_OFFSET_NM = 0.5 // Default drawn leg length when the CIFP hold feature carries none. const DEFAULT_HOLD_LEG_NM = 4 // Clear an entry that has gone this long without a qualifying poll, regardless @@ -237,19 +238,41 @@ function turnArc(from: Pt, hIn: number, hOut: number, right: boolean, r: number) return out } +const NM_PER_DEG = 60 + /** - * A 45°-style intercept from `from` back onto the hold's inbound course line, - * then the final run to the fix. Places the join point so the last segment - * lies exactly on the inbound course. Returns `[joinPoint, fix]`. + * Join point where the ray leaving `from` on heading `hdg` crosses the hold's + * inbound-course line (the line through `fix` at bearing `lineBrg`). Because the + * join lies ON that ray, the segment `from → join` is collinear with the + * roll-out heading (no dog-leg), and because it lies ON the course line, the + * following `join → fix` segment runs along the inbound course. This replaced a + * project-onto-the-course version whose join point was NOT on the roll-out ray, + * producing a visible mid-path jog where the arc met the intercept. Planar + * (equirectangular about the fix) — exact at hold scale. Falls back to the + * foot of the perpendicular when the ray points away from the line. */ -function interceptToFix(from: Pt, fix: Pt, recip: number): Pt[] { - const d = turf.distance(turf.point(fix), turf.point(from), NM) - const brg = turf.bearing(turf.point(fix), turf.point(from)) - const theta = ((brg - recip + 540) % 360) - 180 - const along = d * Math.cos(theta * DEG) - const cross = Math.abs(d * Math.sin(theta * DEG)) - const backNm = Math.max(along - cross, 0.05) - return [dest(fix, backNm, recip), fix] +function courseIntercept(from: Pt, hdg: number, fix: Pt, lineBrg: number): Pt { + const cosLat = Math.cos(fix[1] * DEG) + const fx = (from[0] - fix[0]) * NM_PER_DEG * cosLat + const fy = (from[1] - fix[1]) * NM_PER_DEG + const rx = Math.sin(hdg * DEG) + const ry = Math.cos(hdg * DEG) + const lx = Math.sin(lineBrg * DEG) + const ly = Math.cos(lineBrg * DEG) + const det = lx * ry - rx * ly + let jx: number + let jy: number + const t = Math.abs(det) < 1e-9 ? -1 : (fx * ly - lx * fy) / det + if (t >= 0) { + jx = fx + t * rx + jy = fy + t * ry + } else { + // Ray points away from the line — fall back to the perpendicular foot. + const s = fx * lx + fy * ly + jx = s * lx + jy = s * ly + } + return [fix[0] + jx / (NM_PER_DEG * cosLat), fix[1] + jy / NM_PER_DEG] } /** @@ -294,17 +317,24 @@ export function holdEntryPath(spec: HoldSpec, entry: HoldEntryKind): [number, nu const T = dest(F, L, out) const intercept = norm360(inb + (right ? -45 : 45)) const arc = turnArc(T, out, intercept, right, r) - return [F, T, ...arc.slice(1), ...interceptToFix(arc[arc.length - 1], F, recip)] + const join = courseIntercept(arc[arc.length - 1], intercept, F, recip) + return [F, T, ...arc.slice(1), join, F] } - // Parallel: outbound past the fix parallel to the reciprocal, offset onto the - // NON-holding side, one leg length, then a >180° turn in the hold's direction - // (through the outbound and inbound headings to a 45° intercept heading), - // rejoining the inbound course outside the fix. - const nonSide = norm360(inb + (right ? -90 : 90)) - const OE = dest(dest(F, L, recip), PARALLEL_OFFSET_NM, nonSide) - const arc = turnArc(OE, recip, norm360(inb + (right ? 45 : -45)), right, r) - return [F, OE, ...arc.slice(1), ...interceptToFix(arc[arc.length - 1], F, recip)] + // Parallel (AIM 5-3-8): from the fix, fly the RECIPROCAL of the inbound course + // outbound — overlying the hold's own inbound leg, right on the course line — + // for one leg length, then a course-reversal turn in the OPPOSITE direction to + // the hold that curves INTO the holding (protected) side and rolls out + // intercepting the inbound course back to the fix. The turn is opposite the + // hold's direction on purpose: the aircraft parallels outbound on the + // non-holding side, so it must reverse toward the protected side. Turning the + // hold's OWN direction (the old bug) swung the entry out onto the far, + // non-holding side — the wrong side entirely. + const OE = dest(F, L, recip) + const rollout = norm360(inb + (right ? -45 : 45)) + const arc = turnArc(OE, recip, rollout, !right, r) + const join = courseIntercept(arc[arc.length - 1], rollout, F, recip) + return [F, OE, ...arc.slice(1), join, F] } // ── Trigger evaluation ────────────────────────────────────────────────────── @@ -334,6 +364,10 @@ function evaluateTrigger( spec: HoldSpec, pred: PredictedPath | undefined, ): Qualification | null { + // Climbing → departing or overflying, not entering a hold (holds are level or + // descending). Catches the climbing-overflight-crossing-a-fix false trigger. + if (ac.baroRate > HOLD_ENTRY_MAX_CLIMB_FPM) return null + const acPt = turf.point([ac.lon, ac.lat]) const fixPt = turf.point([spec.fixLon, spec.fixLat]) const distNm = turf.distance(acPt, fixPt, NM) @@ -388,10 +422,14 @@ export interface HoldEntryState { entries: Map /** Last poll's distance-to-fix per hex — divergence bookkeeping. */ lastDistNm: Map + /** Consecutive qualifying polls per hex that has not yet been drawn — an + * entry only appears once this reaches HOLD_ENTRY_CONFIRM_POLLS, so a + * transient one-poll qualification (a maneuvering aircraft) never latches. */ + pending: Map } export function emptyHoldEntryState(): HoldEntryState { - return { entries: new Map(), lastDistNm: new Map() } + return { entries: new Map(), lastDistNm: new Map(), pending: new Map() } } export interface HoldEntryInput { @@ -414,6 +452,7 @@ export interface HoldEntryInput { export function reduceHoldEntries(prev: HoldEntryState, input: HoldEntryInput): HoldEntryState { const entries = new Map() const lastDistNm = new Map() + const pending = new Map() const specByKey = new Map(input.specs.map((s) => [s.key, s])) for (const ac of input.aircraft) { @@ -431,7 +470,18 @@ export function reduceHoldEntries(prev: HoldEntryState, input: HoldEntryInput): } if (!prevRec) { + // Not yet drawn — require sustained qualification (HOLD_ENTRY_CONFIRM_POLLS + // consecutive polls on the same spec) before an entry appears, so a + // maneuvering aircraft that momentarily points at a fix doesn't strand a + // loop on the map (the FFL640 case). if (!best) continue + const prevPending = prev.pending.get(hex) + const count = + prevPending && prevPending.specKey === best.spec.key ? prevPending.count + 1 : 1 + if (count < HOLD_ENTRY_CONFIRM_POLLS) { + pending.set(hex, { specKey: best.spec.key, count }) + continue + } const entry = classifyHoldEntry(best.arrivalTrack, best.spec.inboundCourseTrue, best.spec.turnRight) entries.set(hex, { hex, @@ -482,6 +532,19 @@ export function reduceHoldEntries(prev: HoldEntryState, input: HoldEntryInput): crossedFix, } } else { + // Pre-crossing abandon: before the aircraft reaches the fix, if its track + // has swung well off the bearing to the fix it isn't going to enter — + // clear at once instead of waiting out HOLD_ENTRY_CLEAR_POLLS (a stale + // loop lingering behind a maneuvering aircraft is exactly what looked + // wrong). Gated on !crossedFix because a hold entry legitimately turns the + // aircraft away from the fix once it's executing. + if (!crossedFix) { + const brgToFix = turf.bearing( + turf.point([ac.lon, ac.lat]), + turf.point([spec.fixLon, spec.fixLat]), + ) + if (bearingDelta(ac.track, brgToFix) > HOLD_ENTRY_ABANDON_BRG_DEG) continue + } // Hard stale-out: clear regardless of geometry once too long has passed // without a qualifying poll (breaks the "distance flat, trigger failing // forever" deadlock that would otherwise never increment divergedPolls). @@ -496,5 +559,5 @@ export function reduceHoldEntries(prev: HoldEntryState, input: HoldEntryInput): lastDistNm.set(hex, distNm) } - return { entries, lastDistNm } + return { entries, lastDistNm, pending } } diff --git a/src/geo/terrainScan.ts b/src/geo/terrainScan.ts index 03b830d..51566ba 100644 --- a/src/geo/terrainScan.ts +++ b/src/geo/terrainScan.ts @@ -1,8 +1,12 @@ -// Pure MSAW-style terrain scan over a predicted path: MVA sectors are -// checked first (they already bake in an obstacle buffer), and only where no -// sector covers a point does the scan fall back to DEM ground elevation -// (src/services/terrainElevation.ts). Thresholds follow ForeFlight Hazard -// Advisor conventions (amber "alert" / red "warning"). +// Pure MSAW-style terrain scan over a predicted path. Terrain proximity is +// judged by actual DEM ground clearance (src/services/terrainElevation.ts); +// MVA sectors gate WHERE that matters — below an MVA floor the DEM clearance +// decides the tier, and the MVA floor itself is only used (conservatively) as +// a fallback where the DEM tile isn't cached yet. Above the MVA floor, or with +// comfortable DEM clearance below it, there's no conflict — an aircraft well +// above the ground but under the vectoring minimum (VFR beneath a Bravo shelf) +// is fine. Thresholds follow ForeFlight Hazard Advisor conventions (amber +// "alert" / red "warning"). import type { Position } from 'geojson' import type { MvaSector } from '../utils/aixmMva' import type { PredictedPath, PredPoint } from '../types/path' @@ -176,9 +180,26 @@ export function scanTerrain( if (containing.length > 0) { const minAltFt = Math.min(...containing.map((c) => c.sector.minAltFt)) - if (point.altFt < minAltFt - TERRAIN_MVA_WARN_BELOW_FT) return 'warning' - if (point.altFt < minAltFt) worst = 'alert' - continue // MVA covers this point — no DEM check. + if (point.altFt >= minAltFt) continue // above the vectoring floor — clear. + // Below the MVA floor. The floor is a minimum VECTORING altitude + // (highest obstacle + ~1000 ft + airspace buffers), NOT ground + // proximity — an aircraft can be far below it yet comfortably above the + // actual terrain (e.g. VFR under a Bravo shelf over flat ground, the + // FFL640 case). Corroborate with the real DEM ground clearance and let + // it win: only when DEM confirms marginal clearance — or DEM is cold — + // does the MVA penetration stand. + const groundFt = elevAt(point.lat, point.lon) + if (groundFt !== undefined) { + const clearanceFt = point.altFt - groundFt + if (clearanceFt < TERRAIN_WARN_CLEARANCE_FT) return 'warning' + if (clearanceFt < TERRAIN_ALERT_CLEARANCE_FT) worst = 'alert' + // else: good ground clearance → below-MVA is not a terrain conflict. + } else { + // DEM tile not cached — fall back to the conservative MVA-floor logic. + if (point.altFt < minAltFt - TERRAIN_MVA_WARN_BELOW_FT) return 'warning' + worst = 'alert' + } + continue } const groundFt = elevAt(point.lat, point.lon) diff --git a/src/store/useSettingsStore.ts b/src/store/useSettingsStore.ts index 58f28a3..0aaadf4 100644 --- a/src/store/useSettingsStore.ts +++ b/src/store/useSettingsStore.ts @@ -25,6 +25,12 @@ interface SettingsStore { predictionMinutes: 1 | 2 | 3 | 5 /** Draw range rings around the selected aircraft. */ showRangeRings: boolean + /** Draw predicted hold-entry paths (direct/teardrop/parallel). Off by default. */ + showHoldEntries: boolean + /** Show terrain (MSAW-style) alert/warning chrome. */ + showTerrainAlerts: boolean + /** Show traffic alert/warning + TCAS TA/RA chrome and force-shown conflict paths. */ + showTrafficAlerts: boolean /** Slider position (0–19) for the lower altitude filter handle. */ altFilterMin: number /** Slider position (0–19) for the upper altitude filter handle. */ @@ -43,6 +49,9 @@ interface SettingsStore { togglePredictedPaths: () => void setPredictionMinutes: (m: 1 | 2 | 3 | 5) => void toggleRangeRings: () => void + toggleHoldEntries: () => void + toggleTerrainAlerts: () => void + toggleTrafficAlerts: () => void setAltFilterMin: (pos: number) => void setAltFilterMax: (pos: number) => void } @@ -63,6 +72,9 @@ export const useSettingsStore = create()( showPredictedPaths: true, predictionMinutes: 3, showRangeRings: false, + showHoldEntries: false, + showTerrainAlerts: true, + showTrafficAlerts: true, altFilterMin: 0, altFilterMax: 19, @@ -81,6 +93,9 @@ export const useSettingsStore = create()( togglePredictedPaths: () => set((s) => ({ showPredictedPaths: !s.showPredictedPaths })), setPredictionMinutes: (m) => set({ predictionMinutes: m }), toggleRangeRings: () => set((s) => ({ showRangeRings: !s.showRangeRings })), + toggleHoldEntries: () => set((s) => ({ showHoldEntries: !s.showHoldEntries })), + toggleTerrainAlerts: () => set((s) => ({ showTerrainAlerts: !s.showTerrainAlerts })), + toggleTrafficAlerts: () => set((s) => ({ showTrafficAlerts: !s.showTrafficAlerts })), setAltFilterMin: (pos) => set({ altFilterMin: Math.max(0, Math.min(19, pos)) }), setAltFilterMax: (pos) => set({ altFilterMax: Math.max(0, Math.min(19, pos)) }), }), From 80d7cfe9c71437e21eac3c9435585b5166590d91 Mon Sep 17 00:00:00 2001 From: Ben Betz Date: Sun, 12 Jul 2026 22:33:27 -0700 Subject: [PATCH 3/3] feat: add context line labeling for hold entries to improve visibility --- src/components/map/HoldEntryLayer.tsx | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/src/components/map/HoldEntryLayer.tsx b/src/components/map/HoldEntryLayer.tsx index a43c258..0b7249e 100644 --- a/src/components/map/HoldEntryLayer.tsx +++ b/src/components/map/HoldEntryLayer.tsx @@ -63,7 +63,9 @@ export function HoldEntryLayer() { features.push({ type: 'Feature', geometry: f.geometry, - properties: { __ctxColor: color }, + // __ctxLabel identifies the otherwise-fix-less thin line as its parent + // approach (the sidebar ident, e.g. "I34C") along the line. + properties: { __ctxColor: color, __ctxLabel: proc.name }, }) } } @@ -90,6 +92,27 @@ export function HoldEntryLayer() { }} layout={{ 'line-join': 'round', 'line-cap': 'round' }} /> + {/* Identify the fix-less context line with its parent approach's ident, + placed along the line so the thin lines aren't a mystery. */} +