* Refactor aircraft photo and hero banner components to reset loading state on photo change - Updated Lightbox component to reset image loading state when navigating between photos. - Modified HeroBanner component to reset loading state when the photo changes. Clean up control panel search logic - Removed unnecessary hasResults variable in SearchContent component. Implement flight API client with fallback mechanism - Added flight-api-client to handle fetching flight data from multiple sources (airplanes.live, adsb.lol, OpenSky). - Introduced flight-api-parsing module to convert raw API responses into standardized FlightState objects. - Created flight-api-types for shared types between API responses. Refactor useFlights hook to utilize new flight API client - Updated useFlights hook to fetch flights using the new flight API client. - Removed credit management logic as it is no longer applicable with the new API structure. Fix useFlightMonitors to fetch flight data by hex address - Changed useFlightMonitors to use fetchFlightByHex instead of fetchFlightByIcao24. Update geo utility function for better readability - Refactored splitAtAntimeridian function to improve variable naming and clarity. Enhance OpenSky types with additional fields - Added typeCode and registration fields to FlightState type for better integration with readsb data. * fix: correct 6 files that diverged during rebase (iata code, globe mode ref, terrain attribution, cache eviction, opensky parsing) * fix: improve keyboard shortcuts help focus trapping feat: add showAirspace option to MapAttribution component fix: clear hideTimer on ScrollArea cleanup refactor: change pendingFpvRef to MutableRefObject in useFlightMonitors fix: handle sessionStorage availability in useFlightTrack refactor: increase POLL_INTERVAL_MS in useFlights for better performance fix: optimize keyboard shortcuts dialog check refactor: optimize useMergedTrails by caching selected flight position feat: extend Settings type with airspace options refactor: improve airline logo normalization functions refactor: enhance flight API client with serialized rate limiting refactor: optimize registration country lookup with pre-built maps refactor: enhance logo cache management with size limits feat: update map attribution to include airspace option fix: validate rawState in parseStateRow function refactor: improve utility functions with clamp implementation * feat: add ATC lookup functionality and GPU memory monitoring - Implemented ATC lookup functions in `atc-lookup.ts` for converting IATA to ICAO codes, finding nearby ATC feeds, and looking up ATC feeds by code. - Introduced `atc-types.ts` to define types and priorities for ATC feeds. - Added GPU memory monitoring in `gpu-memory-monitor.ts` to track WebGL resource allocations and provide memory reports. - Enhanced trail stitching logic in `trail-stitching.ts` by adding a function to clear the splined track cache and optimizing altitude checks. * feat: enhance flight data handling and improve API resilience - Implemented a maximum empty response streak guard in useFlights to prevent data loss during transient API failures. - Added immediate fetch on network reconnect in useFlights to ensure timely data retrieval. - Updated useMergedTrails to include timestamps for trail points. - Removed smoothAnimations setting from useSettings as it is no longer needed. - Enhanced useTrailHistory to preserve last-known trails during empty flight responses and added dynamic jump detection for tab resume scenarios. - Improved flight API client with a circuit breaker mechanism to handle provider failures and prevent excessive retries. - Updated flight API parsing to reject non-JSON responses from OpenSky and other providers. - Enhanced trail smoothing and stitching logic to ensure better continuity at junctions between historical and live data. * feat: migrate aircraft models to Cloudinary CDN and update mapping logic * fix: adjust UI component styles and improve trail smoothing parameters * fix: adjust base aircraft size for improved rendering * feat: update changelog with recent enhancements and modify data source attribution * fix: update model optimization details and remove Draco compression dependency * feat: update changelog with recent code review fixes and fallback provider adjustments
This commit is contained in:
15
.gitignore
vendored
15
.gitignore
vendored
@ -46,5 +46,18 @@ next-env.d.ts
|
||||
docs.txt
|
||||
ROADMAP.local.md
|
||||
|
||||
# heap analysis
|
||||
# heap snapshots & analysis artifacts
|
||||
*.heapsnapshot
|
||||
scripts/heap-analysis/output/
|
||||
scripts/heap-analysis/__pycache__/
|
||||
|
||||
# internal planning docs (not for public release)
|
||||
docs/internal/
|
||||
|
||||
# heap analysis tooling & output
|
||||
scripts/
|
||||
scripts/heap-analysis/
|
||||
|
||||
# editor settings
|
||||
.vscode/
|
||||
.idea/
|
||||
55
README.md
55
README.md
@ -6,25 +6,21 @@ Aeris renders live air traffic over the world's busiest airspaces on a premium d
|
||||
|
||||
[Live Demo](https://aeris.edbn.me)
|
||||
|
||||
|
||||
<img width="2559" height="1380" alt="Screenshot 2026-02-15 112222" src="https://github.com/user-attachments/assets/9d1f50ed-be4e-4ef5-95ac-257e9129f8c8" />
|
||||
|
||||
|
||||
<img width="2555" height="1387" alt="image" src="https://github.com/user-attachments/assets/a1d2f673-dfdc-4c82-8ee2-7629d91ad94b" />
|
||||
|
||||
|
||||
|
||||
## Stack
|
||||
|
||||
| Layer | Technology |
|
||||
| --------- | ----------------------------------------------- |
|
||||
| --------- | ---------------------------------------------------------------- |
|
||||
| Framework | Next.js 16 (App Router, Turbopack) |
|
||||
| Language | TypeScript |
|
||||
| Styling | Tailwind CSS v4 |
|
||||
| Map | MapLibre GL JS |
|
||||
| WebGL | Deck.gl 9 (IconLayer, PathLayer, MapboxOverlay) |
|
||||
| WebGL | Deck.gl 9 (ScenegraphLayer, IconLayer, PathLayer, MapboxOverlay) |
|
||||
| Animation | Motion (Framer Motion) |
|
||||
| Data | OpenSky Network API |
|
||||
| Data | Airplanes.live / adsb.lol / OpenSky (3-tier fallback) |
|
||||
| Hosting | Vercel |
|
||||
|
||||
## Getting Started
|
||||
@ -32,7 +28,6 @@ Aeris renders live air traffic over the world's busiest airspaces on a premium d
|
||||
```bash
|
||||
pnpm install
|
||||
cp .env.example .env.local
|
||||
# Optionally add OpenSky credentials — see .env.example
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
@ -46,12 +41,14 @@ src/
|
||||
│ ├── globals.css Tailwind config, theme vars
|
||||
│ ├── layout.tsx Root layout (Inter font)
|
||||
│ ├── page.tsx Entry — renders <FlightTracker />
|
||||
│ └── api/flights/route.ts OpenSky proxy with rate limiting + auth
|
||||
│ └── api/flights/route.ts adsb.lol reverse proxy (CORS workaround + rate limit)
|
||||
├── components/
|
||||
│ ├── flight-tracker.tsx Orchestrator — state, camera, layers, UI
|
||||
│ ├── map/
|
||||
│ │ ├── map.tsx MapLibre GL wrapper with React context
|
||||
│ │ └── flight-layers.tsx Deck.gl overlay — icons, trails, shadows, animation
|
||||
│ │ ├── flight-layers.tsx Deck.gl overlay — icons, trails, shadows, animation
|
||||
│ │ ├── aircraft-model-mapping.ts ADS-B category → 3D model key + bucketing
|
||||
│ │ └── aircraft-model-layers.ts Builds per-model ScenegraphLayers
|
||||
│ └── ui/
|
||||
│ ├── altitude-legend.tsx
|
||||
│ ├── control-panel.tsx Tabbed dialog — search, map style, settings
|
||||
@ -65,9 +62,13 @@ src/
|
||||
│ └── use-trail-history.ts Trail accumulation + Catmull-Rom smoothing
|
||||
└── lib/
|
||||
├── cities.ts Curated aviation hub presets
|
||||
├── flight-api.ts Barrel re-export for the 3-tier flight client
|
||||
├── flight-api-client.ts airplanes.live → adsb.lol → OpenSky fallback chain
|
||||
├── flight-api-parsing.ts readsb JSON → FlightState normalization
|
||||
├── flight-api-types.ts Shared types for ADS-B providers
|
||||
├── flight-utils.ts Altitude→color, unit conversions
|
||||
├── map-styles.ts Map style definitions
|
||||
├── opensky.ts OpenSky API client + types
|
||||
├── opensky.ts OpenSky API client + types (Tier 3 fallback)
|
||||
└── utils.ts cn() utility
|
||||
```
|
||||
|
||||
@ -75,6 +76,30 @@ src/
|
||||
|
||||
- **Dark-first**: CARTO Dark Matter base map, theme-aware UI
|
||||
- **3D depth**: 55° pitch, altitude-based z-displacement via Deck.gl
|
||||
|
||||
## Aircraft Models
|
||||
|
||||
Aeris renders 14 distinct aircraft silhouettes based on ADS-B emitter category and ICAO type code:
|
||||
|
||||
| Model Key | Represents | Assignment |
|
||||
| --------------- | ------------------------------- | ---------------------------------------------- |
|
||||
| `narrowbody` | A320, B737 family | Category 3 (Small), 4 (Large), 5 (High vortex) |
|
||||
| `widebody-2eng` | A330, A350, B777, B787 | Category 6 (Heavy) |
|
||||
| `widebody-4eng` | A380, B747, A340 | — |
|
||||
| `a380` | Airbus A380 | Type codes A38x |
|
||||
| `b737` | Boeing 737 family | Type codes B73x, B3xM |
|
||||
| `regional-jet` | CRJ, E-Jets, Fokker | — |
|
||||
| `light-prop` | Cessna, Piper, Cirrus | Category 2 (Light), 12 (Ultralight) |
|
||||
| `turboprop` | ATR, Dash-8, Saab | — |
|
||||
| `helicopter` | All rotorcraft | Category 8 (Rotorcraft) |
|
||||
| `bizjet` | Gulfstream, Citation, Learjet | — |
|
||||
| `glider` | Sailplanes | Category 9 (Glider) |
|
||||
| `fighter` | Military fast-movers | Category 7 (High-perf) |
|
||||
| `drone` | UAVs | Category 14 (UAV) |
|
||||
| `generic` | Fallback for unknown categories | Category 0, 1, default |
|
||||
|
||||
Models are optimised GLB files (no Draco compression — avoids external WASM decoder dependency) served from Cloudinary CDN (local backups in `public/models/aircraft/`). A second-tier mapping from ICAO type codes (A320, B738, etc.) refines the assignment when type data is available via the readsb feed.
|
||||
|
||||
- **Smooth animation**: Catmull-Rom spline trails, per-frame interpolation between polls
|
||||
- **Glassmorphism**: `backdrop-blur-2xl`, `bg-black/60`, `border-white/[0.08]`
|
||||
- **Spring physics**: All UI transitions use spring easing
|
||||
@ -85,14 +110,10 @@ src/
|
||||
## Environment Variables
|
||||
|
||||
| Variable | Required | Description |
|
||||
| ----------------------- | -------- | ------------------------------- |
|
||||
| `OPENSKY_CLIENT_ID` | No | OAuth2 client ID (recommended) |
|
||||
| `OPENSKY_CLIENT_SECRET` | No | OAuth2 client secret |
|
||||
| `OPENSKY_USERNAME` | No | Basic auth username (legacy) |
|
||||
| `OPENSKY_PASSWORD` | No | Basic auth password (legacy) |
|
||||
| ------------------- | -------- | ------------------------------- |
|
||||
| `NEXT_PUBLIC_GA_ID` | No | Google Analytics measurement ID |
|
||||
|
||||
Without credentials, anonymous access is used (~10 requests/minute).
|
||||
No API keys are needed. Flight data comes from public ADS-B APIs with a built-in 3-tier fallback chain (airplanes.live → adsb.lol → OpenSky).
|
||||
|
||||
## License
|
||||
|
||||
|
||||
92
docs/3D-MODELS.md
Normal file
92
docs/3D-MODELS.md
Normal file
@ -0,0 +1,92 @@
|
||||
# 3D Aircraft Models
|
||||
|
||||
## Overview
|
||||
|
||||
Aeris uses 14 distinct 3D aircraft silhouettes to represent different aircraft types on the globe. Models are assigned based on ICAO type code (when available) or ADS-B emitter category.
|
||||
|
||||
Two iconic aircraft types — the **Airbus A380** and **Boeing 737** — have dedicated models for visual distinction. All other aircraft are mapped to generic silhouette categories.
|
||||
|
||||
## Model Inventory
|
||||
|
||||
| File | Size (KB) | Description | Source |
|
||||
| ------------------- | --------: | ------------------------------------------ | -------------- |
|
||||
| `b737.glb` | 156.1 | Boeing 737 family (incl. MAX) | fr24-3d-models |
|
||||
| `bizjet.glb` | 452.8 | Business jets (Gulfstream, Citation, etc.) | FlightAirMap |
|
||||
| `drone.glb` | 131.0 | Unmanned aerial vehicles | FlightAirMap |
|
||||
| `fighter.glb` | 58.2 | Military high-performance aircraft | FlightAirMap |
|
||||
| `generic.glb` | 401.8 | Default fallback (A320 silhouette) | FlightAirMap |
|
||||
| `glider.glb` | 68.1 | Gliders and sailplanes | FlightAirMap |
|
||||
| `helicopter.glb` | 270.8 | Rotorcraft | FlightAirMap |
|
||||
| `light-prop.glb` | 131.0 | Light GA props (Cessna, Piper, etc.) | FlightAirMap |
|
||||
| `narrowbody.glb` | 401.8 | Narrow-body jets (A320, other non-737) | FlightAirMap |
|
||||
| `regional-jet.glb` | 127.0 | Regional jets (CRJ, Embraer E-Jets) | FlightAirMap |
|
||||
| `turboprop.glb` | 86.4 | Turboprops (ATR, Dash-8) | FlightAirMap |
|
||||
| `widebody-2eng.glb` | 149.3 | Wide-body twin-engine (777, 787, A330) | FlightAirMap |
|
||||
| `widebody-4eng.glb` | 241.8 | Wide-body four-engine (A340, A380) | FlightAirMap |
|
||||
|
||||
### Totals
|
||||
|
||||
| Metric | Value |
|
||||
| --------------------------------- | -------------------: |
|
||||
| **Aircraft models** | 13 files |
|
||||
| **Aircraft total** | 2,676.1 KB (2.61 MB) |
|
||||
| **Legacy model** (`airplane.glb`) | 1,295.2 KB (1.26 MB) |
|
||||
| **All GLB files** | 3,971.3 KB (3.88 MB) |
|
||||
|
||||
## Optimization Pipeline
|
||||
|
||||
All models are optimized for web delivery using `@gltf-transform/cli`:
|
||||
|
||||
1. **Texture stripping** — Materials set to neutral unlit gray
|
||||
2. **Mesh simplification** — Triangle count reduced to ~30% of original
|
||||
3. **B737 format conversion** — Converted from glTF 1.0 → 2.0 via `gltf-pipeline`
|
||||
|
||||
> Note: Draco compression is **not** used for these models, to avoid
|
||||
> introducing an external WASM decoder dependency.
|
||||
> See `public/models/aircraft/NOTICE.md` for details.
|
||||
|
||||
## Model Assignment
|
||||
|
||||
### Priority: TypeCode → Category
|
||||
|
||||
When an aircraft's ICAO type code is available (from readsb providers), it takes priority over the generic ADS-B category mapping.
|
||||
|
||||
**Dedicated model types:**
|
||||
|
||||
| Aircraft Type | TypeCode Pattern | Model Key |
|
||||
| ---------------- | --------------------- | --------- |
|
||||
| Airbus A380 | `A38x` | `a380` |
|
||||
| Boeing 737 (all) | `B73x`, `B37M`–`B39M` | `b737` |
|
||||
|
||||
**Category-based fallback (DO-260B):**
|
||||
|
||||
| ADS-B Category | Weight Class | Model Key |
|
||||
| -------------- | ---------------------- | --------------- |
|
||||
| 2 | Light (<15,500 lbs) | `light-prop` |
|
||||
| 3 | Small (15,500–75,000) | `narrowbody` |
|
||||
| 4 | Large (75,000–300,000) | `narrowbody` |
|
||||
| 5 | High vortex (B757) | `narrowbody` |
|
||||
| 6 | Heavy (>300,000 lbs) | `widebody-2eng` |
|
||||
| 7 | High performance | `fighter` |
|
||||
| 8 | Rotorcraft | `helicopter` |
|
||||
| 9 | Glider/sailplane | `glider` |
|
||||
| 12 | Ultralight | `light-prop` |
|
||||
| 14 | UAV | `drone` |
|
||||
| Other | Unknown | `generic` |
|
||||
|
||||
## Performance
|
||||
|
||||
- Models are **lazy-loaded**: only fetched when an aircraft of that type first appears in data
|
||||
- The 6 most common model types are **prefetched** via `<link rel="prefetch">` on page load
|
||||
- Empty ScenegraphLayers are kept alive with stable empty arrays to avoid shader recompilation
|
||||
- deck.gl caches models by URL, so the `a380` key (which maps to `widebody-4eng.glb`) shares the cache entry
|
||||
|
||||
## Licensing
|
||||
|
||||
All models are licensed under **GPL v2**, compatible with the project's AGPL v3 license.
|
||||
|
||||
- **FlightAirMap-3dmodels**: https://github.com/Ysurac/FlightAirMap-3dmodels
|
||||
- **fr24-3d-models**: https://github.com/Flightradar24/fr24-3d-models
|
||||
- Original sources: [FlightGear FGMEMBERS](https://github.com/FGMEMBERS)
|
||||
|
||||
See [public/models/aircraft/NOTICE.md](../public/models/aircraft/NOTICE.md) for full attribution.
|
||||
@ -4,13 +4,17 @@ const isDev = process.env.NODE_ENV === "development";
|
||||
|
||||
// Content Security Policy — allows only the external resources Aeris actually uses.
|
||||
// https://nextjs.org/docs/app/guides/content-security-policy
|
||||
//
|
||||
// NOTE: planespotters.net, adsbdb.com, airport-data.com, and jetapi.dev are
|
||||
// server-side only (accessed via /api/aircraft-photos proxy route). CSP does
|
||||
// not apply to server-side fetches, so they are not listed in connect-src.
|
||||
const cspHeader = `
|
||||
default-src 'self';
|
||||
script-src 'self' 'unsafe-inline' https://www.googletagmanager.com${isDev ? " 'unsafe-eval'" : ""};
|
||||
style-src 'self' 'unsafe-inline';
|
||||
img-src 'self' blob: data: https: ;
|
||||
font-src 'self';
|
||||
connect-src 'self' data: https://opensky-network.org https://*.basemaps.cartocdn.com https://basemaps.cartocdn.com https://server.arcgisonline.com https://s3.amazonaws.com https://tile.opentopomap.org https://www.google-analytics.com https://www.googletagmanager.com https://api.github.com https://hexdb.io;
|
||||
connect-src 'self' data: https://opensky-network.org https://*.basemaps.cartocdn.com https://basemaps.cartocdn.com https://server.arcgisonline.com https://s3.amazonaws.com https://tile.opentopomap.org https://www.google-analytics.com https://www.googletagmanager.com https://api.github.com https://api.airplanes.live https://api.adsb.lol https://res.cloudinary.com;
|
||||
worker-src 'self' blob:;
|
||||
child-src blob:;
|
||||
object-src 'none';
|
||||
|
||||
@ -28,10 +28,11 @@
|
||||
"lucide-react": "^0.564.0",
|
||||
"maplibre-gl": "^5.18.0",
|
||||
"motion": "^12.34.0",
|
||||
"next": "16.1.6",
|
||||
"next": "16.2.0",
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
@ -40,7 +41,7 @@
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.1.6",
|
||||
"eslint-config-next": "16.2.0",
|
||||
"tailwindcss": "^4",
|
||||
"typescript": "^5"
|
||||
}
|
||||
|
||||
422
pnpm-lock.yaml
generated
422
pnpm-lock.yaml
generated
@ -25,7 +25,7 @@ importers:
|
||||
version: 9.2.11(@deck.gl/core@9.2.11)(@loaders.gl/core@4.3.4)(@luma.gl/core@9.2.6)(@luma.gl/engine@9.2.6(@luma.gl/core@9.2.6)(@luma.gl/shadertools@9.2.6(@luma.gl/core@9.2.6)))(@luma.gl/gltf@9.2.6(@luma.gl/constants@9.2.6)(@luma.gl/core@9.2.6)(@luma.gl/engine@9.2.6(@luma.gl/core@9.2.6)(@luma.gl/shadertools@9.2.6(@luma.gl/core@9.2.6)))(@luma.gl/shadertools@9.2.6(@luma.gl/core@9.2.6)))(@luma.gl/shadertools@9.2.6(@luma.gl/core@9.2.6))
|
||||
'@deck.gl/react':
|
||||
specifier: ^9.2.7
|
||||
version: 9.2.7(@deck.gl/core@9.2.11)(@deck.gl/widgets@9.2.7(@deck.gl/core@9.2.11)(@luma.gl/core@9.2.6))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
version: 9.2.7(@deck.gl/core@9.2.11)(@deck.gl/widgets@9.2.7(@deck.gl/core@9.2.11)(@luma.gl/core@9.2.6))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@loaders.gl/core':
|
||||
specifier: ^4.3.4
|
||||
version: 4.3.4
|
||||
@ -40,34 +40,37 @@ importers:
|
||||
version: 9.2.6(@luma.gl/core@9.2.6)
|
||||
'@radix-ui/react-slider':
|
||||
specifier: ^1.3.6
|
||||
version: 1.3.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
version: 1.3.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
clsx:
|
||||
specifier: ^2.1.1
|
||||
version: 2.1.1
|
||||
cmdk:
|
||||
specifier: ^1.1.1
|
||||
version: 1.1.1(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
version: 1.1.1(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
lucide-react:
|
||||
specifier: ^0.564.0
|
||||
version: 0.564.0(react@19.2.3)
|
||||
version: 0.564.0(react@19.2.4)
|
||||
maplibre-gl:
|
||||
specifier: ^5.18.0
|
||||
version: 5.18.0
|
||||
motion:
|
||||
specifier: ^12.34.0
|
||||
version: 12.34.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
version: 12.34.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
next:
|
||||
specifier: 16.1.6
|
||||
version: 16.1.6(@babel/core@7.29.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
specifier: 16.2.0
|
||||
version: 16.2.0(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
next-themes:
|
||||
specifier: ^0.4.6
|
||||
version: 0.4.6(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
version: 0.4.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
react:
|
||||
specifier: 19.2.3
|
||||
version: 19.2.3
|
||||
specifier: 19.2.4
|
||||
version: 19.2.4
|
||||
react-dom:
|
||||
specifier: 19.2.3
|
||||
version: 19.2.3(react@19.2.3)
|
||||
specifier: 19.2.4
|
||||
version: 19.2.4(react@19.2.4)
|
||||
sonner:
|
||||
specifier: ^2.0.7
|
||||
version: 2.0.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
tailwind-merge:
|
||||
specifier: ^3.4.0
|
||||
version: 3.4.0
|
||||
@ -88,8 +91,8 @@ importers:
|
||||
specifier: ^9
|
||||
version: 9.39.2(jiti@2.6.1)
|
||||
eslint-config-next:
|
||||
specifier: 16.1.6
|
||||
version: 16.1.6(@typescript-eslint/parser@8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
|
||||
specifier: 16.2.0
|
||||
version: 16.2.0(@typescript-eslint/parser@8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
|
||||
tailwindcss:
|
||||
specifier: ^4
|
||||
version: 4.1.18
|
||||
@ -655,60 +658,60 @@ packages:
|
||||
'@napi-rs/wasm-runtime@0.2.12':
|
||||
resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==}
|
||||
|
||||
'@next/env@16.1.6':
|
||||
resolution: {integrity: sha512-N1ySLuZjnAtN3kFnwhAwPvZah8RJxKasD7x1f8shFqhncnWZn4JMfg37diLNuoHsLAlrDfM3g4mawVdtAG8XLQ==}
|
||||
'@next/env@16.2.0':
|
||||
resolution: {integrity: sha512-OZIbODWWAi0epQRCRjNe1VO45LOFBzgiyqmTLzIqWq6u1wrxKnAyz1HH6tgY/Mc81YzIjRPoYsPAEr4QV4l9TA==}
|
||||
|
||||
'@next/eslint-plugin-next@16.1.6':
|
||||
resolution: {integrity: sha512-/Qq3PTagA6+nYVfryAtQ7/9FEr/6YVyvOtl6rZnGsbReGLf0jZU6gkpr1FuChAQpvV46a78p4cmHOVP8mbfSMQ==}
|
||||
'@next/eslint-plugin-next@16.2.0':
|
||||
resolution: {integrity: sha512-3D3pEMcGKfENC9Pzlkr67GOm+205+5hRdYPZvHuNIy5sr9k0ybSU8g+sxOO/R/RLEh/gWZ3UlY+5LmEyZ1xgXQ==}
|
||||
|
||||
'@next/swc-darwin-arm64@16.1.6':
|
||||
resolution: {integrity: sha512-wTzYulosJr/6nFnqGW7FrG3jfUUlEf8UjGA0/pyypJl42ExdVgC6xJgcXQ+V8QFn6niSG2Pb8+MIG1mZr2vczw==}
|
||||
'@next/swc-darwin-arm64@16.2.0':
|
||||
resolution: {integrity: sha512-/JZsqKzKt01IFoiLLAzlNqys7qk2F3JkcUhj50zuRhKDQkZNOz9E5N6wAQWprXdsvjRP4lTFj+/+36NSv5AwhQ==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@next/swc-darwin-x64@16.1.6':
|
||||
resolution: {integrity: sha512-BLFPYPDO+MNJsiDWbeVzqvYd4NyuRrEYVB5k2N3JfWncuHAy2IVwMAOlVQDFjj+krkWzhY2apvmekMkfQR0CUQ==}
|
||||
'@next/swc-darwin-x64@16.2.0':
|
||||
resolution: {integrity: sha512-/hV8erWq4SNlVgglUiW5UmQ5Hwy5EW/AbbXlJCn6zkfKxTy/E/U3V8U1Ocm2YCTUoFgQdoMxRyRMOW5jYy4ygg==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@next/swc-linux-arm64-gnu@16.1.6':
|
||||
resolution: {integrity: sha512-OJYkCd5pj/QloBvoEcJ2XiMnlJkRv9idWA/j0ugSuA34gMT6f5b7vOiCQHVRpvStoZUknhl6/UxOXL4OwtdaBw==}
|
||||
'@next/swc-linux-arm64-gnu@16.2.0':
|
||||
resolution: {integrity: sha512-GkjL/Q7MWOwqWR9zoxu1TIHzkOI2l2BHCf7FzeQG87zPgs+6WDh+oC9Sw9ARuuL/FUk6JNCgKRkA6rEQYadUaw==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@next/swc-linux-arm64-musl@16.1.6':
|
||||
resolution: {integrity: sha512-S4J2v+8tT3NIO9u2q+S0G5KdvNDjXfAv06OhfOzNDaBn5rw84DGXWndOEB7d5/x852A20sW1M56vhC/tRVbccQ==}
|
||||
'@next/swc-linux-arm64-musl@16.2.0':
|
||||
resolution: {integrity: sha512-1ffhC6KY5qWLg5miMlKJp3dZbXelEfjuXt1qcp5WzSCQy36CV3y+JT7OC1WSFKizGQCDOcQbfkH/IjZP3cdRNA==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@next/swc-linux-x64-gnu@16.1.6':
|
||||
resolution: {integrity: sha512-2eEBDkFlMMNQnkTyPBhQOAyn2qMxyG2eE7GPH2WIDGEpEILcBPI/jdSv4t6xupSP+ot/jkfrCShLAa7+ZUPcJQ==}
|
||||
'@next/swc-linux-x64-gnu@16.2.0':
|
||||
resolution: {integrity: sha512-FmbDcZQ8yJRq93EJSL6xaE0KK/Rslraf8fj1uViGxg7K4CKBCRYSubILJPEhjSgZurpcPQq12QNOJQ0DRJl6Hg==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@next/swc-linux-x64-musl@16.1.6':
|
||||
resolution: {integrity: sha512-oicJwRlyOoZXVlxmIMaTq7f8pN9QNbdes0q2FXfRsPhfCi8n8JmOZJm5oo1pwDaFbnnD421rVU409M3evFbIqg==}
|
||||
'@next/swc-linux-x64-musl@16.2.0':
|
||||
resolution: {integrity: sha512-HzjIHVkmGAwRbh/vzvoBWWEbb8BBZPxBvVbDQDvzHSf3D8RP/4vjw7MNLDXFF9Q1WEzeQyEj2zdxBtVAHu5Oyw==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@next/swc-win32-arm64-msvc@16.1.6':
|
||||
resolution: {integrity: sha512-gQmm8izDTPgs+DCWH22kcDmuUp7NyiJgEl18bcr8irXA5N2m2O+JQIr6f3ct42GOs9c0h8QF3L5SzIxcYAAXXw==}
|
||||
'@next/swc-win32-arm64-msvc@16.2.0':
|
||||
resolution: {integrity: sha512-UMiFNQf5H7+1ZsZPxEsA064WEuFbRNq/kEXyepbCnSErp4f5iut75dBA8UeerFIG3vDaQNOfCpevnERPp2V+nA==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@next/swc-win32-x64-msvc@16.1.6':
|
||||
resolution: {integrity: sha512-NRfO39AIrzBnixKbjuo2YiYhB6o9d8v/ymU9m/Xk8cyVk+k7XylniXkHwjs4s70wedVffc6bQNbufk5v0xEm0A==}
|
||||
'@next/swc-win32-x64-msvc@16.2.0':
|
||||
resolution: {integrity: sha512-DRrNJKW+/eimrZgdhVN1uvkN1OI4j6Lpefwr44jKQ0YQzztlmOBUUzHuV5GxOMPK3nmodAYElUVCY8ZXo/IWeA==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
@ -1600,8 +1603,8 @@ packages:
|
||||
resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
eslint-config-next@16.1.6:
|
||||
resolution: {integrity: sha512-vKq40io2B0XtkkNDYyleATwblNt8xuh3FWp8SpSz3pt7P01OkBFlKsJZ2mWt5WsCySlDQLckb1zMY9yE9Qy0LA==}
|
||||
eslint-config-next@16.2.0:
|
||||
resolution: {integrity: sha512-LlVJrWnjIkgQRECjIOELyAtrWFqzn326ARS5ap7swc1YKL4wkry6/gszn6wi5ZDWKxKe7fanxArvhqMoAzbL7w==}
|
||||
peerDependencies:
|
||||
eslint: '>=9.0.0'
|
||||
typescript: '>=3.3.1'
|
||||
@ -2302,8 +2305,8 @@ packages:
|
||||
react: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc
|
||||
react-dom: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc
|
||||
|
||||
next@16.1.6:
|
||||
resolution: {integrity: sha512-hkyRkcu5x/41KoqnROkfTm2pZVbKxvbZRuNvKXLRXxs3VfyO0WhY50TQS40EuKO9SW3rBj/sF3WbVwDACeMZyw==}
|
||||
next@16.2.0:
|
||||
resolution: {integrity: sha512-NLBVrJy1pbV1Yn00L5sU4vFyAHt5XuSjzrNyFnxo6Com0M0KrL6hHM5B99dbqXb2bE9pm4Ow3Zl1xp6HVY9edQ==}
|
||||
engines: {node: '>=20.9.0'}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
@ -2452,10 +2455,10 @@ packages:
|
||||
quickselect@3.0.0:
|
||||
resolution: {integrity: sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g==}
|
||||
|
||||
react-dom@19.2.3:
|
||||
resolution: {integrity: sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==}
|
||||
react-dom@19.2.4:
|
||||
resolution: {integrity: sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==}
|
||||
peerDependencies:
|
||||
react: ^19.2.3
|
||||
react: ^19.2.4
|
||||
|
||||
react-is@16.13.1:
|
||||
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
|
||||
@ -2490,8 +2493,8 @@ packages:
|
||||
'@types/react':
|
||||
optional: true
|
||||
|
||||
react@19.2.3:
|
||||
resolution: {integrity: sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==}
|
||||
react@19.2.4:
|
||||
resolution: {integrity: sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
readable-stream@2.3.8:
|
||||
@ -2607,6 +2610,12 @@ packages:
|
||||
snappyjs@0.6.1:
|
||||
resolution: {integrity: sha512-YIK6I2lsH072UE0aOFxxY1dPDCS43I5ktqHpeAsuLNYWkE5pGxRGWfDM4/vSUfNzXjC1Ivzt3qx31PCLmc9yqg==}
|
||||
|
||||
sonner@2.0.7:
|
||||
resolution: {integrity: sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==}
|
||||
peerDependencies:
|
||||
react: ^18.0.0 || ^19.0.0 || ^19.0.0-rc
|
||||
react-dom: ^18.0.0 || ^19.0.0 || ^19.0.0-rc
|
||||
|
||||
source-map-js@1.2.1:
|
||||
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@ -3036,12 +3045,12 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- '@loaders.gl/core'
|
||||
|
||||
'@deck.gl/react@9.2.7(@deck.gl/core@9.2.11)(@deck.gl/widgets@9.2.7(@deck.gl/core@9.2.11)(@luma.gl/core@9.2.6))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
|
||||
'@deck.gl/react@9.2.7(@deck.gl/core@9.2.11)(@deck.gl/widgets@9.2.7(@deck.gl/core@9.2.11)(@luma.gl/core@9.2.6))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
|
||||
dependencies:
|
||||
'@deck.gl/core': 9.2.11
|
||||
'@deck.gl/widgets': 9.2.7(@deck.gl/core@9.2.11)(@luma.gl/core@9.2.6)
|
||||
react: 19.2.3
|
||||
react-dom: 19.2.3(react@19.2.3)
|
||||
react: 19.2.4
|
||||
react-dom: 19.2.4(react@19.2.4)
|
||||
|
||||
'@deck.gl/widgets@9.2.7(@deck.gl/core@9.2.11)(@luma.gl/core@9.2.6)':
|
||||
dependencies:
|
||||
@ -3541,34 +3550,34 @@ snapshots:
|
||||
'@tybys/wasm-util': 0.10.1
|
||||
optional: true
|
||||
|
||||
'@next/env@16.1.6': {}
|
||||
'@next/env@16.2.0': {}
|
||||
|
||||
'@next/eslint-plugin-next@16.1.6':
|
||||
'@next/eslint-plugin-next@16.2.0':
|
||||
dependencies:
|
||||
fast-glob: 3.3.1
|
||||
|
||||
'@next/swc-darwin-arm64@16.1.6':
|
||||
'@next/swc-darwin-arm64@16.2.0':
|
||||
optional: true
|
||||
|
||||
'@next/swc-darwin-x64@16.1.6':
|
||||
'@next/swc-darwin-x64@16.2.0':
|
||||
optional: true
|
||||
|
||||
'@next/swc-linux-arm64-gnu@16.1.6':
|
||||
'@next/swc-linux-arm64-gnu@16.2.0':
|
||||
optional: true
|
||||
|
||||
'@next/swc-linux-arm64-musl@16.1.6':
|
||||
'@next/swc-linux-arm64-musl@16.2.0':
|
||||
optional: true
|
||||
|
||||
'@next/swc-linux-x64-gnu@16.1.6':
|
||||
'@next/swc-linux-x64-gnu@16.2.0':
|
||||
optional: true
|
||||
|
||||
'@next/swc-linux-x64-musl@16.1.6':
|
||||
'@next/swc-linux-x64-musl@16.2.0':
|
||||
optional: true
|
||||
|
||||
'@next/swc-win32-arm64-msvc@16.1.6':
|
||||
'@next/swc-win32-arm64-msvc@16.2.0':
|
||||
optional: true
|
||||
|
||||
'@next/swc-win32-x64-msvc@16.1.6':
|
||||
'@next/swc-win32-x64-msvc@16.2.0':
|
||||
optional: true
|
||||
|
||||
'@nodelib/fs.scandir@2.1.5':
|
||||
@ -3605,194 +3614,194 @@ snapshots:
|
||||
|
||||
'@radix-ui/primitive@1.1.3': {}
|
||||
|
||||
'@radix-ui/react-collection@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
|
||||
'@radix-ui/react-collection@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
|
||||
dependencies:
|
||||
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.3)
|
||||
'@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.3)
|
||||
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
'@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.3)
|
||||
react: 19.2.3
|
||||
react-dom: 19.2.3(react@19.2.3)
|
||||
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4)
|
||||
'@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4)
|
||||
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.4)
|
||||
react: 19.2.4
|
||||
react-dom: 19.2.4(react@19.2.4)
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.14
|
||||
'@types/react-dom': 19.2.3(@types/react@19.2.14)
|
||||
|
||||
'@radix-ui/react-compose-refs@1.1.2(@types/react@19.2.14)(react@19.2.3)':
|
||||
'@radix-ui/react-compose-refs@1.1.2(@types/react@19.2.14)(react@19.2.4)':
|
||||
dependencies:
|
||||
react: 19.2.3
|
||||
react: 19.2.4
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.14
|
||||
|
||||
'@radix-ui/react-context@1.1.2(@types/react@19.2.14)(react@19.2.3)':
|
||||
'@radix-ui/react-context@1.1.2(@types/react@19.2.14)(react@19.2.4)':
|
||||
dependencies:
|
||||
react: 19.2.3
|
||||
react: 19.2.4
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.14
|
||||
|
||||
'@radix-ui/react-dialog@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
|
||||
'@radix-ui/react-dialog@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
|
||||
dependencies:
|
||||
'@radix-ui/primitive': 1.1.3
|
||||
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.3)
|
||||
'@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.3)
|
||||
'@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
'@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.14)(react@19.2.3)
|
||||
'@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
'@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.3)
|
||||
'@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
'@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
'@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.3)
|
||||
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.3)
|
||||
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4)
|
||||
'@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4)
|
||||
'@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.14)(react@19.2.4)
|
||||
'@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4)
|
||||
'@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.4)
|
||||
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4)
|
||||
aria-hidden: 1.2.6
|
||||
react: 19.2.3
|
||||
react-dom: 19.2.3(react@19.2.3)
|
||||
react-remove-scroll: 2.7.2(@types/react@19.2.14)(react@19.2.3)
|
||||
react: 19.2.4
|
||||
react-dom: 19.2.4(react@19.2.4)
|
||||
react-remove-scroll: 2.7.2(@types/react@19.2.14)(react@19.2.4)
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.14
|
||||
'@types/react-dom': 19.2.3(@types/react@19.2.14)
|
||||
|
||||
'@radix-ui/react-direction@1.1.1(@types/react@19.2.14)(react@19.2.3)':
|
||||
'@radix-ui/react-direction@1.1.1(@types/react@19.2.14)(react@19.2.4)':
|
||||
dependencies:
|
||||
react: 19.2.3
|
||||
react: 19.2.4
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.14
|
||||
|
||||
'@radix-ui/react-dismissable-layer@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
|
||||
'@radix-ui/react-dismissable-layer@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
|
||||
dependencies:
|
||||
'@radix-ui/primitive': 1.1.3
|
||||
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.3)
|
||||
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
'@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.3)
|
||||
'@radix-ui/react-use-escape-keydown': 1.1.1(@types/react@19.2.14)(react@19.2.3)
|
||||
react: 19.2.3
|
||||
react-dom: 19.2.3(react@19.2.3)
|
||||
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4)
|
||||
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4)
|
||||
'@radix-ui/react-use-escape-keydown': 1.1.1(@types/react@19.2.14)(react@19.2.4)
|
||||
react: 19.2.4
|
||||
react-dom: 19.2.4(react@19.2.4)
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.14
|
||||
'@types/react-dom': 19.2.3(@types/react@19.2.14)
|
||||
|
||||
'@radix-ui/react-focus-guards@1.1.3(@types/react@19.2.14)(react@19.2.3)':
|
||||
'@radix-ui/react-focus-guards@1.1.3(@types/react@19.2.14)(react@19.2.4)':
|
||||
dependencies:
|
||||
react: 19.2.3
|
||||
react: 19.2.4
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.14
|
||||
|
||||
'@radix-ui/react-focus-scope@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
|
||||
'@radix-ui/react-focus-scope@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
|
||||
dependencies:
|
||||
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.3)
|
||||
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
'@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.3)
|
||||
react: 19.2.3
|
||||
react-dom: 19.2.3(react@19.2.3)
|
||||
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4)
|
||||
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4)
|
||||
react: 19.2.4
|
||||
react-dom: 19.2.4(react@19.2.4)
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.14
|
||||
'@types/react-dom': 19.2.3(@types/react@19.2.14)
|
||||
|
||||
'@radix-ui/react-id@1.1.1(@types/react@19.2.14)(react@19.2.3)':
|
||||
'@radix-ui/react-id@1.1.1(@types/react@19.2.14)(react@19.2.4)':
|
||||
dependencies:
|
||||
'@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.3)
|
||||
react: 19.2.3
|
||||
'@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4)
|
||||
react: 19.2.4
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.14
|
||||
|
||||
'@radix-ui/react-portal@1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
|
||||
'@radix-ui/react-portal@1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
|
||||
dependencies:
|
||||
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
'@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.3)
|
||||
react: 19.2.3
|
||||
react-dom: 19.2.3(react@19.2.3)
|
||||
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4)
|
||||
react: 19.2.4
|
||||
react-dom: 19.2.4(react@19.2.4)
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.14
|
||||
'@types/react-dom': 19.2.3(@types/react@19.2.14)
|
||||
|
||||
'@radix-ui/react-presence@1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
|
||||
'@radix-ui/react-presence@1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
|
||||
dependencies:
|
||||
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.3)
|
||||
'@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.3)
|
||||
react: 19.2.3
|
||||
react-dom: 19.2.3(react@19.2.3)
|
||||
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4)
|
||||
'@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4)
|
||||
react: 19.2.4
|
||||
react-dom: 19.2.4(react@19.2.4)
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.14
|
||||
'@types/react-dom': 19.2.3(@types/react@19.2.14)
|
||||
|
||||
'@radix-ui/react-primitive@2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
|
||||
'@radix-ui/react-primitive@2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
|
||||
dependencies:
|
||||
'@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.3)
|
||||
react: 19.2.3
|
||||
react-dom: 19.2.3(react@19.2.3)
|
||||
'@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.4)
|
||||
react: 19.2.4
|
||||
react-dom: 19.2.4(react@19.2.4)
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.14
|
||||
'@types/react-dom': 19.2.3(@types/react@19.2.14)
|
||||
|
||||
'@radix-ui/react-slider@1.3.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
|
||||
'@radix-ui/react-slider@1.3.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
|
||||
dependencies:
|
||||
'@radix-ui/number': 1.1.1
|
||||
'@radix-ui/primitive': 1.1.3
|
||||
'@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.3)
|
||||
'@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.3)
|
||||
'@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.3)
|
||||
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.3)
|
||||
'@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.3)
|
||||
'@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.14)(react@19.2.3)
|
||||
'@radix-ui/react-use-size': 1.1.1(@types/react@19.2.14)(react@19.2.3)
|
||||
react: 19.2.3
|
||||
react-dom: 19.2.3(react@19.2.3)
|
||||
'@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4)
|
||||
'@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4)
|
||||
'@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4)
|
||||
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4)
|
||||
'@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4)
|
||||
'@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.14)(react@19.2.4)
|
||||
'@radix-ui/react-use-size': 1.1.1(@types/react@19.2.14)(react@19.2.4)
|
||||
react: 19.2.4
|
||||
react-dom: 19.2.4(react@19.2.4)
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.14
|
||||
'@types/react-dom': 19.2.3(@types/react@19.2.14)
|
||||
|
||||
'@radix-ui/react-slot@1.2.3(@types/react@19.2.14)(react@19.2.3)':
|
||||
'@radix-ui/react-slot@1.2.3(@types/react@19.2.14)(react@19.2.4)':
|
||||
dependencies:
|
||||
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.3)
|
||||
react: 19.2.3
|
||||
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4)
|
||||
react: 19.2.4
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.14
|
||||
|
||||
'@radix-ui/react-use-callback-ref@1.1.1(@types/react@19.2.14)(react@19.2.3)':
|
||||
'@radix-ui/react-use-callback-ref@1.1.1(@types/react@19.2.14)(react@19.2.4)':
|
||||
dependencies:
|
||||
react: 19.2.3
|
||||
react: 19.2.4
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.14
|
||||
|
||||
'@radix-ui/react-use-controllable-state@1.2.2(@types/react@19.2.14)(react@19.2.3)':
|
||||
'@radix-ui/react-use-controllable-state@1.2.2(@types/react@19.2.14)(react@19.2.4)':
|
||||
dependencies:
|
||||
'@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.14)(react@19.2.3)
|
||||
'@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.3)
|
||||
react: 19.2.3
|
||||
'@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.14)(react@19.2.4)
|
||||
'@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4)
|
||||
react: 19.2.4
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.14
|
||||
|
||||
'@radix-ui/react-use-effect-event@0.0.2(@types/react@19.2.14)(react@19.2.3)':
|
||||
'@radix-ui/react-use-effect-event@0.0.2(@types/react@19.2.14)(react@19.2.4)':
|
||||
dependencies:
|
||||
'@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.3)
|
||||
react: 19.2.3
|
||||
'@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4)
|
||||
react: 19.2.4
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.14
|
||||
|
||||
'@radix-ui/react-use-escape-keydown@1.1.1(@types/react@19.2.14)(react@19.2.3)':
|
||||
'@radix-ui/react-use-escape-keydown@1.1.1(@types/react@19.2.14)(react@19.2.4)':
|
||||
dependencies:
|
||||
'@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.3)
|
||||
react: 19.2.3
|
||||
'@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4)
|
||||
react: 19.2.4
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.14
|
||||
|
||||
'@radix-ui/react-use-layout-effect@1.1.1(@types/react@19.2.14)(react@19.2.3)':
|
||||
'@radix-ui/react-use-layout-effect@1.1.1(@types/react@19.2.14)(react@19.2.4)':
|
||||
dependencies:
|
||||
react: 19.2.3
|
||||
react: 19.2.4
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.14
|
||||
|
||||
'@radix-ui/react-use-previous@1.1.1(@types/react@19.2.14)(react@19.2.3)':
|
||||
'@radix-ui/react-use-previous@1.1.1(@types/react@19.2.14)(react@19.2.4)':
|
||||
dependencies:
|
||||
react: 19.2.3
|
||||
react: 19.2.4
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.14
|
||||
|
||||
'@radix-ui/react-use-size@1.1.1(@types/react@19.2.14)(react@19.2.3)':
|
||||
'@radix-ui/react-use-size@1.1.1(@types/react@19.2.14)(react@19.2.4)':
|
||||
dependencies:
|
||||
'@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.3)
|
||||
react: 19.2.3
|
||||
'@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4)
|
||||
react: 19.2.4
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.14
|
||||
|
||||
@ -4266,14 +4275,14 @@ snapshots:
|
||||
|
||||
clsx@2.1.1: {}
|
||||
|
||||
cmdk@1.1.1(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3):
|
||||
cmdk@1.1.1(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
|
||||
dependencies:
|
||||
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.3)
|
||||
'@radix-ui/react-dialog': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
'@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.3)
|
||||
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
react: 19.2.3
|
||||
react-dom: 19.2.3(react@19.2.3)
|
||||
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4)
|
||||
'@radix-ui/react-dialog': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4)
|
||||
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
react: 19.2.4
|
||||
react-dom: 19.2.4(react@19.2.4)
|
||||
transitivePeerDependencies:
|
||||
- '@types/react'
|
||||
- '@types/react-dom'
|
||||
@ -4485,9 +4494,9 @@ snapshots:
|
||||
|
||||
escape-string-regexp@4.0.0: {}
|
||||
|
||||
eslint-config-next@16.1.6(@typescript-eslint/parser@8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3):
|
||||
eslint-config-next@16.2.0(@typescript-eslint/parser@8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3):
|
||||
dependencies:
|
||||
'@next/eslint-plugin-next': 16.1.6
|
||||
'@next/eslint-plugin-next': 16.2.0
|
||||
eslint: 9.39.2(jiti@2.6.1)
|
||||
eslint-import-resolver-node: 0.3.9
|
||||
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.2(jiti@2.6.1))
|
||||
@ -4740,14 +4749,14 @@ snapshots:
|
||||
dependencies:
|
||||
is-callable: 1.2.7
|
||||
|
||||
framer-motion@12.34.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3):
|
||||
framer-motion@12.34.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
|
||||
dependencies:
|
||||
motion-dom: 12.34.0
|
||||
motion-utils: 12.29.2
|
||||
tslib: 2.8.1
|
||||
optionalDependencies:
|
||||
react: 19.2.3
|
||||
react-dom: 19.2.3(react@19.2.3)
|
||||
react: 19.2.4
|
||||
react-dom: 19.2.4(react@19.2.4)
|
||||
|
||||
function-bind@1.1.2: {}
|
||||
|
||||
@ -5135,9 +5144,9 @@ snapshots:
|
||||
dependencies:
|
||||
yallist: 3.1.1
|
||||
|
||||
lucide-react@0.564.0(react@19.2.3):
|
||||
lucide-react@0.564.0(react@19.2.4):
|
||||
dependencies:
|
||||
react: 19.2.3
|
||||
react: 19.2.4
|
||||
|
||||
lz4js@0.2.0:
|
||||
optional: true
|
||||
@ -5206,13 +5215,13 @@ snapshots:
|
||||
|
||||
motion-utils@12.29.2: {}
|
||||
|
||||
motion@12.34.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3):
|
||||
motion@12.34.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
|
||||
dependencies:
|
||||
framer-motion: 12.34.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
framer-motion: 12.34.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
tslib: 2.8.1
|
||||
optionalDependencies:
|
||||
react: 19.2.3
|
||||
react-dom: 19.2.3(react@19.2.3)
|
||||
react: 19.2.4
|
||||
react-dom: 19.2.4(react@19.2.4)
|
||||
|
||||
ms@2.1.3: {}
|
||||
|
||||
@ -5224,30 +5233,30 @@ snapshots:
|
||||
|
||||
natural-compare@1.4.0: {}
|
||||
|
||||
next-themes@0.4.6(react-dom@19.2.3(react@19.2.3))(react@19.2.3):
|
||||
next-themes@0.4.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
|
||||
dependencies:
|
||||
react: 19.2.3
|
||||
react-dom: 19.2.3(react@19.2.3)
|
||||
react: 19.2.4
|
||||
react-dom: 19.2.4(react@19.2.4)
|
||||
|
||||
next@16.1.6(@babel/core@7.29.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3):
|
||||
next@16.2.0(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
|
||||
dependencies:
|
||||
'@next/env': 16.1.6
|
||||
'@next/env': 16.2.0
|
||||
'@swc/helpers': 0.5.15
|
||||
baseline-browser-mapping: 2.9.19
|
||||
caniuse-lite: 1.0.30001769
|
||||
postcss: 8.4.31
|
||||
react: 19.2.3
|
||||
react-dom: 19.2.3(react@19.2.3)
|
||||
styled-jsx: 5.1.6(@babel/core@7.29.0)(react@19.2.3)
|
||||
react: 19.2.4
|
||||
react-dom: 19.2.4(react@19.2.4)
|
||||
styled-jsx: 5.1.6(@babel/core@7.29.0)(react@19.2.4)
|
||||
optionalDependencies:
|
||||
'@next/swc-darwin-arm64': 16.1.6
|
||||
'@next/swc-darwin-x64': 16.1.6
|
||||
'@next/swc-linux-arm64-gnu': 16.1.6
|
||||
'@next/swc-linux-arm64-musl': 16.1.6
|
||||
'@next/swc-linux-x64-gnu': 16.1.6
|
||||
'@next/swc-linux-x64-musl': 16.1.6
|
||||
'@next/swc-win32-arm64-msvc': 16.1.6
|
||||
'@next/swc-win32-x64-msvc': 16.1.6
|
||||
'@next/swc-darwin-arm64': 16.2.0
|
||||
'@next/swc-darwin-x64': 16.2.0
|
||||
'@next/swc-linux-arm64-gnu': 16.2.0
|
||||
'@next/swc-linux-arm64-musl': 16.2.0
|
||||
'@next/swc-linux-x64-gnu': 16.2.0
|
||||
'@next/swc-linux-x64-musl': 16.2.0
|
||||
'@next/swc-win32-arm64-msvc': 16.2.0
|
||||
'@next/swc-win32-x64-msvc': 16.2.0
|
||||
sharp: 0.34.5
|
||||
transitivePeerDependencies:
|
||||
- '@babel/core'
|
||||
@ -5383,41 +5392,41 @@ snapshots:
|
||||
|
||||
quickselect@3.0.0: {}
|
||||
|
||||
react-dom@19.2.3(react@19.2.3):
|
||||
react-dom@19.2.4(react@19.2.4):
|
||||
dependencies:
|
||||
react: 19.2.3
|
||||
react: 19.2.4
|
||||
scheduler: 0.27.0
|
||||
|
||||
react-is@16.13.1: {}
|
||||
|
||||
react-remove-scroll-bar@2.3.8(@types/react@19.2.14)(react@19.2.3):
|
||||
react-remove-scroll-bar@2.3.8(@types/react@19.2.14)(react@19.2.4):
|
||||
dependencies:
|
||||
react: 19.2.3
|
||||
react-style-singleton: 2.2.3(@types/react@19.2.14)(react@19.2.3)
|
||||
react: 19.2.4
|
||||
react-style-singleton: 2.2.3(@types/react@19.2.14)(react@19.2.4)
|
||||
tslib: 2.8.1
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.14
|
||||
|
||||
react-remove-scroll@2.7.2(@types/react@19.2.14)(react@19.2.3):
|
||||
react-remove-scroll@2.7.2(@types/react@19.2.14)(react@19.2.4):
|
||||
dependencies:
|
||||
react: 19.2.3
|
||||
react-remove-scroll-bar: 2.3.8(@types/react@19.2.14)(react@19.2.3)
|
||||
react-style-singleton: 2.2.3(@types/react@19.2.14)(react@19.2.3)
|
||||
react: 19.2.4
|
||||
react-remove-scroll-bar: 2.3.8(@types/react@19.2.14)(react@19.2.4)
|
||||
react-style-singleton: 2.2.3(@types/react@19.2.14)(react@19.2.4)
|
||||
tslib: 2.8.1
|
||||
use-callback-ref: 1.3.3(@types/react@19.2.14)(react@19.2.3)
|
||||
use-sidecar: 1.1.3(@types/react@19.2.14)(react@19.2.3)
|
||||
use-callback-ref: 1.3.3(@types/react@19.2.14)(react@19.2.4)
|
||||
use-sidecar: 1.1.3(@types/react@19.2.14)(react@19.2.4)
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.14
|
||||
|
||||
react-style-singleton@2.2.3(@types/react@19.2.14)(react@19.2.3):
|
||||
react-style-singleton@2.2.3(@types/react@19.2.14)(react@19.2.4):
|
||||
dependencies:
|
||||
get-nonce: 1.0.1
|
||||
react: 19.2.3
|
||||
react: 19.2.4
|
||||
tslib: 2.8.1
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.14
|
||||
|
||||
react@19.2.3: {}
|
||||
react@19.2.4: {}
|
||||
|
||||
readable-stream@2.3.8:
|
||||
dependencies:
|
||||
@ -5596,6 +5605,11 @@ snapshots:
|
||||
|
||||
snappyjs@0.6.1: {}
|
||||
|
||||
sonner@2.0.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
|
||||
dependencies:
|
||||
react: 19.2.4
|
||||
react-dom: 19.2.4(react@19.2.4)
|
||||
|
||||
source-map-js@1.2.1: {}
|
||||
|
||||
sprintf-js@1.0.3: {}
|
||||
@ -5667,10 +5681,10 @@ snapshots:
|
||||
|
||||
strnum@1.1.2: {}
|
||||
|
||||
styled-jsx@5.1.6(@babel/core@7.29.0)(react@19.2.3):
|
||||
styled-jsx@5.1.6(@babel/core@7.29.0)(react@19.2.4):
|
||||
dependencies:
|
||||
client-only: 0.0.1
|
||||
react: 19.2.3
|
||||
react: 19.2.4
|
||||
optionalDependencies:
|
||||
'@babel/core': 7.29.0
|
||||
|
||||
@ -5812,17 +5826,17 @@ snapshots:
|
||||
dependencies:
|
||||
punycode: 2.3.1
|
||||
|
||||
use-callback-ref@1.3.3(@types/react@19.2.14)(react@19.2.3):
|
||||
use-callback-ref@1.3.3(@types/react@19.2.14)(react@19.2.4):
|
||||
dependencies:
|
||||
react: 19.2.3
|
||||
react: 19.2.4
|
||||
tslib: 2.8.1
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.14
|
||||
|
||||
use-sidecar@1.1.3(@types/react@19.2.14)(react@19.2.3):
|
||||
use-sidecar@1.1.3(@types/react@19.2.14)(react@19.2.4):
|
||||
dependencies:
|
||||
detect-node-es: 1.1.0
|
||||
react: 19.2.3
|
||||
react: 19.2.4
|
||||
tslib: 2.8.1
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.14
|
||||
|
||||
24
public/models/aircraft/NOTICE.md
Normal file
24
public/models/aircraft/NOTICE.md
Normal file
@ -0,0 +1,24 @@
|
||||
Aircraft 3D models in this directory are sourced from:
|
||||
|
||||
**FlightAirMap 3D Models**
|
||||
Repository: https://github.com/Ysurac/FlightAirMap-3dmodels
|
||||
License: GNU General Public License v2 (GPL-2.0)
|
||||
Models: narrowbody, widebody-2eng, widebody-4eng (A380), regional-jet, light-prop,
|
||||
turboprop, helicopter, bizjet, glider, fighter, drone, generic
|
||||
|
||||
**Flightradar24 3D Models**
|
||||
Repository: https://github.com/Flightradar24/fr24-3d-models
|
||||
License: GNU General Public License v2 (GPL-2.0)
|
||||
Source: Originally from https://github.com/FGMEMBERS/737NG
|
||||
Models: b737
|
||||
|
||||
All models have been optimized for web delivery:
|
||||
|
||||
- All textures stripped (materials set to neutral unlit white)
|
||||
- Meshes deduplicated and pruned; most models simplified
|
||||
- No Draco compression (avoids external WASM decoder dependency)
|
||||
- B737 model converted from glTF 1.0 to glTF 2.0
|
||||
|
||||
The GPL v2 license is compatible with this project's AGPL v3 license.
|
||||
A copy of the GPL v2 license can be found at:
|
||||
https://www.gnu.org/licenses/old-licenses/gpl-2.0.html
|
||||
BIN
public/models/aircraft/b737.glb
Normal file
BIN
public/models/aircraft/b737.glb
Normal file
Binary file not shown.
BIN
public/models/aircraft/bizjet.glb
Normal file
BIN
public/models/aircraft/bizjet.glb
Normal file
Binary file not shown.
BIN
public/models/aircraft/drone.glb
Normal file
BIN
public/models/aircraft/drone.glb
Normal file
Binary file not shown.
BIN
public/models/aircraft/fighter.glb
Normal file
BIN
public/models/aircraft/fighter.glb
Normal file
Binary file not shown.
BIN
public/models/aircraft/generic.glb
Normal file
BIN
public/models/aircraft/generic.glb
Normal file
Binary file not shown.
BIN
public/models/aircraft/glider.glb
Normal file
BIN
public/models/aircraft/glider.glb
Normal file
Binary file not shown.
BIN
public/models/aircraft/helicopter.glb
Normal file
BIN
public/models/aircraft/helicopter.glb
Normal file
Binary file not shown.
BIN
public/models/aircraft/light-prop.glb
Normal file
BIN
public/models/aircraft/light-prop.glb
Normal file
Binary file not shown.
BIN
public/models/aircraft/narrowbody.glb
Normal file
BIN
public/models/aircraft/narrowbody.glb
Normal file
Binary file not shown.
BIN
public/models/aircraft/regional-jet.glb
Normal file
BIN
public/models/aircraft/regional-jet.glb
Normal file
Binary file not shown.
BIN
public/models/aircraft/turboprop.glb
Normal file
BIN
public/models/aircraft/turboprop.glb
Normal file
Binary file not shown.
BIN
public/models/aircraft/widebody-2eng.glb
Normal file
BIN
public/models/aircraft/widebody-2eng.glb
Normal file
Binary file not shown.
BIN
public/models/aircraft/widebody-4eng.glb
Normal file
BIN
public/models/aircraft/widebody-4eng.glb
Normal file
Binary file not shown.
@ -1,68 +1,437 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
const JETAPI_BASE = "https://www.jetapi.dev/api";
|
||||
const FETCH_TIMEOUT_MS = 12_000;
|
||||
const REG_REGEX = /^[A-Z0-9-]{2,10}$/i;
|
||||
const FETCH_TIMEOUT_MS = 8_000;
|
||||
const AIRPORT_DATA_TIMEOUT_MS = 5_000;
|
||||
const JETAPI_TIMEOUT_MS = 10_000;
|
||||
const HEX_REGEX = /^[0-9a-f]{6}$/;
|
||||
const REG_REGEX = /^[A-Z0-9][-A-Z0-9]{1,6}$/;
|
||||
|
||||
// ── Upstream types ──────────────────────────────────────────────────────────
|
||||
|
||||
type PlanespottersPhoto = {
|
||||
id?: string;
|
||||
thumbnail?:
|
||||
| { src?: string; size?: { width?: number; height?: number } }
|
||||
| string;
|
||||
thumbnail_large?:
|
||||
| { src?: string; size?: { width?: number; height?: number } }
|
||||
| string;
|
||||
link?: string;
|
||||
photographer?: string;
|
||||
};
|
||||
|
||||
type PlanespottersResponse = {
|
||||
photos?: PlanespottersPhoto[];
|
||||
};
|
||||
|
||||
type AdsbdbAircraft = {
|
||||
type?: string;
|
||||
icao_type?: string;
|
||||
manufacturer?: string;
|
||||
mode_s?: string;
|
||||
registration?: string;
|
||||
registered_owner_country_iso_name?: string;
|
||||
registered_owner_country_name?: string;
|
||||
registered_owner_operator_flag_code?: string;
|
||||
registered_owner?: string;
|
||||
url_photo?: string | null;
|
||||
url_photo_thumbnail?: string | null;
|
||||
};
|
||||
|
||||
type AdsbdbResponse = {
|
||||
response?: {
|
||||
aircraft?: AdsbdbAircraft | null;
|
||||
};
|
||||
};
|
||||
|
||||
// ── Output types ────────────────────────────────────────────────────────────
|
||||
|
||||
type NormalizedPhoto = {
|
||||
id: string;
|
||||
url: string;
|
||||
thumbnail: string;
|
||||
photographer: string | null;
|
||||
location: string | null;
|
||||
dateTaken: string | null;
|
||||
link: string | null;
|
||||
};
|
||||
|
||||
type AircraftDetails = {
|
||||
registration: string;
|
||||
manufacturer: string | null;
|
||||
type: string | null;
|
||||
typeCode: string | null;
|
||||
owner: string | null;
|
||||
};
|
||||
|
||||
type AircraftPhotosResponse = {
|
||||
photos: NormalizedPhoto[];
|
||||
aircraft: AircraftDetails | null;
|
||||
};
|
||||
|
||||
// ── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Extract a URL from a value that may be a string or `{ src: string }`. */
|
||||
function extractSrc(value: unknown): string | null {
|
||||
if (typeof value === "string" && value.length > 0) return value;
|
||||
if (
|
||||
typeof value === "object" &&
|
||||
value !== null &&
|
||||
"src" in value &&
|
||||
typeof (value as { src: unknown }).src === "string"
|
||||
) {
|
||||
const src = (value as { src: string }).src;
|
||||
return src.length > 0 ? src : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function fetchWithTimeout(
|
||||
url: string,
|
||||
timeoutMs: number,
|
||||
): Promise<Response> {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
|
||||
try {
|
||||
return await fetch(url, {
|
||||
signal: controller.signal,
|
||||
headers: { Accept: "application/json" },
|
||||
});
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
/** Reject URLs with dangerous schemes (javascript:, data:, vbscript:, etc.).
|
||||
* Only https:// and http:// are allowed. */
|
||||
function isSafeHttpUrl(url: string): boolean {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
return parsed.protocol === "https:" || parsed.protocol === "http:";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Strip unsafe URLs from a normalized photo. Returns null if
|
||||
* the primary url is unsafe (photo is unusable without an image). */
|
||||
function sanitizePhoto(photo: NormalizedPhoto): NormalizedPhoto | null {
|
||||
if (!isSafeHttpUrl(photo.url)) return null;
|
||||
return {
|
||||
...photo,
|
||||
thumbnail: isSafeHttpUrl(photo.thumbnail) ? photo.thumbnail : photo.url,
|
||||
link: photo.link && isSafeHttpUrl(photo.link) ? photo.link : null,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Planespotters.net ───────────────────────────────────────────────────────
|
||||
|
||||
async function fetchPlanespotters(hex: string): Promise<NormalizedPhoto[]> {
|
||||
try {
|
||||
const res = await fetchWithTimeout(
|
||||
`https://api.planespotters.net/pub/photos/hex/${encodeURIComponent(hex)}`,
|
||||
FETCH_TIMEOUT_MS,
|
||||
);
|
||||
if (!res.ok) return [];
|
||||
|
||||
const data = (await res.json()) as PlanespottersResponse;
|
||||
if (!data?.photos || !Array.isArray(data.photos)) return [];
|
||||
|
||||
const photos: NormalizedPhoto[] = [];
|
||||
const seenUrls = new Set<string>();
|
||||
|
||||
for (const p of data.photos) {
|
||||
const largeSrc = extractSrc(p.thumbnail_large);
|
||||
const thumbSrc = extractSrc(p.thumbnail);
|
||||
const src = largeSrc ?? thumbSrc;
|
||||
if (!src) continue;
|
||||
|
||||
const fullUrl = largeSrc ?? thumbSrc ?? src;
|
||||
if (seenUrls.has(fullUrl)) continue;
|
||||
seenUrls.add(fullUrl);
|
||||
|
||||
photos.push({
|
||||
id: `ps-${typeof p.id === "string" || typeof p.id === "number" ? p.id : photos.length}`,
|
||||
url: fullUrl,
|
||||
thumbnail: thumbSrc ?? largeSrc ?? src,
|
||||
photographer:
|
||||
typeof p.photographer === "string" && p.photographer
|
||||
? p.photographer
|
||||
: null,
|
||||
location: null,
|
||||
dateTaken: null,
|
||||
link: typeof p.link === "string" && p.link ? p.link : null,
|
||||
});
|
||||
}
|
||||
|
||||
return photos;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// ── adsbdb.com ──────────────────────────────────────────────────────────────
|
||||
|
||||
async function fetchAdsbdb(hex: string): Promise<{
|
||||
aircraft: AircraftDetails | null;
|
||||
photo: NormalizedPhoto | null;
|
||||
}> {
|
||||
try {
|
||||
const res = await fetchWithTimeout(
|
||||
`https://api.adsbdb.com/v0/aircraft/${encodeURIComponent(hex)}`,
|
||||
FETCH_TIMEOUT_MS,
|
||||
);
|
||||
if (!res.ok) return { aircraft: null, photo: null };
|
||||
|
||||
const data = (await res.json()) as AdsbdbResponse;
|
||||
const ac = data?.response?.aircraft;
|
||||
if (!ac) return { aircraft: null, photo: null };
|
||||
|
||||
const registration =
|
||||
typeof ac.registration === "string" && ac.registration
|
||||
? ac.registration
|
||||
: null;
|
||||
if (!registration) return { aircraft: null, photo: null };
|
||||
|
||||
const aircraft: AircraftDetails = {
|
||||
registration,
|
||||
manufacturer:
|
||||
typeof ac.manufacturer === "string" && ac.manufacturer
|
||||
? ac.manufacturer
|
||||
: null,
|
||||
type: typeof ac.type === "string" && ac.type ? ac.type : null,
|
||||
typeCode:
|
||||
typeof ac.icao_type === "string" && ac.icao_type ? ac.icao_type : null,
|
||||
owner:
|
||||
typeof ac.registered_owner === "string" && ac.registered_owner
|
||||
? ac.registered_owner
|
||||
: null,
|
||||
};
|
||||
|
||||
let photo: NormalizedPhoto | null = null;
|
||||
if (typeof ac.url_photo === "string" && ac.url_photo) {
|
||||
photo = {
|
||||
id: `adb-${hex}`,
|
||||
url: ac.url_photo,
|
||||
thumbnail:
|
||||
typeof ac.url_photo_thumbnail === "string" && ac.url_photo_thumbnail
|
||||
? ac.url_photo_thumbnail
|
||||
: ac.url_photo,
|
||||
photographer: null,
|
||||
location: null,
|
||||
dateTaken: null,
|
||||
link: null,
|
||||
};
|
||||
}
|
||||
|
||||
return { aircraft, photo };
|
||||
} catch {
|
||||
return { aircraft: null, photo: null };
|
||||
}
|
||||
}
|
||||
|
||||
// ── airport-data.com (additional photos) ────────────────────────────────────
|
||||
|
||||
type AirportDataEntry = {
|
||||
image?: string;
|
||||
thumbnail?: string;
|
||||
link?: string;
|
||||
photographer?: string;
|
||||
};
|
||||
|
||||
async function fetchAirportData(hex: string): Promise<NormalizedPhoto[]> {
|
||||
try {
|
||||
const res = await fetchWithTimeout(
|
||||
`https://www.airport-data.com/api/ac_thumb.json?m=${encodeURIComponent(hex)}&n=5`,
|
||||
AIRPORT_DATA_TIMEOUT_MS,
|
||||
);
|
||||
if (!res.ok) return [];
|
||||
|
||||
const raw = (await res.json()) as Record<string, unknown>;
|
||||
if (!raw || typeof raw !== "object") return [];
|
||||
|
||||
// airport-data.com may return `data` as an array or a single object
|
||||
let entries: AirportDataEntry[] = [];
|
||||
if (Array.isArray(raw.data)) {
|
||||
entries = raw.data as AirportDataEntry[];
|
||||
} else if (raw.data && typeof raw.data === "object") {
|
||||
entries = [raw.data as AirportDataEntry];
|
||||
} else if (typeof raw.image === "string") {
|
||||
entries = [raw as unknown as AirportDataEntry];
|
||||
}
|
||||
|
||||
if (entries.length === 0) return [];
|
||||
|
||||
const photos: NormalizedPhoto[] = [];
|
||||
const seenUrls = new Set<string>();
|
||||
|
||||
for (const entry of entries) {
|
||||
const imageUrl =
|
||||
typeof entry.image === "string" && entry.image ? entry.image : null;
|
||||
if (!imageUrl) continue;
|
||||
if (seenUrls.has(imageUrl)) continue;
|
||||
seenUrls.add(imageUrl);
|
||||
|
||||
photos.push({
|
||||
id: `apd-${photos.length}-${hex}`,
|
||||
url: imageUrl,
|
||||
thumbnail:
|
||||
typeof entry.thumbnail === "string" && entry.thumbnail
|
||||
? entry.thumbnail
|
||||
: imageUrl,
|
||||
photographer:
|
||||
typeof entry.photographer === "string" && entry.photographer
|
||||
? entry.photographer
|
||||
: null,
|
||||
location: null,
|
||||
dateTaken: null,
|
||||
link: typeof entry.link === "string" && entry.link ? entry.link : null,
|
||||
});
|
||||
}
|
||||
|
||||
return photos;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// ── JetAPI (JetPhotos via jetapi.dev) ────────────────────────────────────────
|
||||
|
||||
type JetApiImage = {
|
||||
Image?: string;
|
||||
Link?: string;
|
||||
Thumbnail?: string;
|
||||
DateTaken?: string;
|
||||
DateUploaded?: string;
|
||||
Location?: string;
|
||||
Photographer?: string;
|
||||
Aircraft?: string;
|
||||
Serial?: string;
|
||||
Airline?: string;
|
||||
};
|
||||
|
||||
type JetApiResponse = {
|
||||
JetPhotos?: {
|
||||
Reg?: string;
|
||||
Images?: JetApiImage[];
|
||||
};
|
||||
};
|
||||
|
||||
async function fetchJetApi(reg: string): Promise<NormalizedPhoto[]> {
|
||||
try {
|
||||
const res = await fetchWithTimeout(
|
||||
`https://www.jetapi.dev/api?reg=${encodeURIComponent(reg)}&photos=10&only_jp=true`,
|
||||
JETAPI_TIMEOUT_MS,
|
||||
);
|
||||
if (!res.ok) return [];
|
||||
|
||||
const data = (await res.json()) as JetApiResponse;
|
||||
const images = data?.JetPhotos?.Images;
|
||||
if (!images || !Array.isArray(images)) return [];
|
||||
|
||||
const photos: NormalizedPhoto[] = [];
|
||||
const seenUrls = new Set<string>();
|
||||
|
||||
for (const img of images) {
|
||||
const imageUrl =
|
||||
typeof img.Image === "string" && img.Image ? img.Image : null;
|
||||
if (!imageUrl) continue;
|
||||
if (seenUrls.has(imageUrl)) continue;
|
||||
seenUrls.add(imageUrl);
|
||||
|
||||
photos.push({
|
||||
id: `jp-${photos.length}`,
|
||||
url: imageUrl,
|
||||
thumbnail:
|
||||
typeof img.Thumbnail === "string" && img.Thumbnail
|
||||
? img.Thumbnail
|
||||
: imageUrl,
|
||||
photographer:
|
||||
typeof img.Photographer === "string" && img.Photographer
|
||||
? img.Photographer.trim()
|
||||
: null,
|
||||
location:
|
||||
typeof img.Location === "string" && img.Location
|
||||
? img.Location.trim()
|
||||
: null,
|
||||
dateTaken:
|
||||
typeof img.DateTaken === "string" && img.DateTaken
|
||||
? img.DateTaken.trim()
|
||||
: null,
|
||||
link: typeof img.Link === "string" && img.Link ? img.Link : null,
|
||||
});
|
||||
}
|
||||
|
||||
return photos;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// ── Route handler ───────────────────────────────────────────────────────────
|
||||
|
||||
export async function GET(request: NextRequest): Promise<NextResponse> {
|
||||
const reg = request.nextUrl.searchParams.get("reg")?.trim();
|
||||
const hex = request.nextUrl.searchParams.get("hex")?.trim().toLowerCase();
|
||||
const reg =
|
||||
request.nextUrl.searchParams.get("reg")?.trim().toUpperCase() || null;
|
||||
|
||||
if (!reg || !REG_REGEX.test(reg)) {
|
||||
if (!hex || !HEX_REGEX.test(hex)) {
|
||||
return NextResponse.json(
|
||||
{ error: "Missing or invalid 'reg' parameter" },
|
||||
{ error: "Missing or invalid 'hex' parameter (6-char ICAO24 hex)" },
|
||||
{ status: 400, headers: { "Cache-Control": "no-store" } },
|
||||
);
|
||||
}
|
||||
|
||||
const params = new URLSearchParams({
|
||||
reg,
|
||||
photos: "10",
|
||||
flights: "0",
|
||||
});
|
||||
// Validate registration — skip JetAPI if invalid
|
||||
const validReg = reg && REG_REGEX.test(reg) ? reg : null;
|
||||
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
||||
const [psResult, adbResult, apdResult, jpResult] = await Promise.allSettled([
|
||||
fetchPlanespotters(hex),
|
||||
fetchAdsbdb(hex),
|
||||
fetchAirportData(hex),
|
||||
validReg ? fetchJetApi(validReg) : Promise.resolve([] as NormalizedPhoto[]),
|
||||
]);
|
||||
|
||||
try {
|
||||
const upstream = await fetch(`${JETAPI_BASE}?${params.toString()}`, {
|
||||
signal: controller.signal,
|
||||
headers: { Accept: "application/json" },
|
||||
});
|
||||
const planespottersPhotos =
|
||||
psResult.status === "fulfilled" ? psResult.value : [];
|
||||
const adsbdb =
|
||||
adbResult.status === "fulfilled"
|
||||
? adbResult.value
|
||||
: { aircraft: null, photo: null };
|
||||
const airportDataPhotos =
|
||||
apdResult.status === "fulfilled" ? apdResult.value : [];
|
||||
const jetApiPhotos = jpResult.status === "fulfilled" ? jpResult.value : [];
|
||||
|
||||
clearTimeout(timer);
|
||||
// Priority: JetAPI (full-res, multiple) → adsbdb (full-res) →
|
||||
// airport-data (full-res) → planespotters (low-res fallback).
|
||||
// All photos are sanitized to strip dangerous URI schemes (XSS).
|
||||
const seenUrls = new Set<string>();
|
||||
const photos: NormalizedPhoto[] = [];
|
||||
|
||||
if (!upstream.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: "Upstream error" },
|
||||
{
|
||||
status: upstream.status >= 500 ? 502 : upstream.status,
|
||||
headers: { "Cache-Control": "no-store" },
|
||||
},
|
||||
);
|
||||
function addPhoto(raw: NormalizedPhoto) {
|
||||
if (seenUrls.has(raw.url)) return;
|
||||
const safe = sanitizePhoto(raw);
|
||||
if (!safe) return;
|
||||
seenUrls.add(safe.url);
|
||||
photos.push(safe);
|
||||
}
|
||||
|
||||
const data: unknown = await upstream.json();
|
||||
for (const p of jetApiPhotos) addPhoto(p);
|
||||
if (adsbdb.photo) addPhoto(adsbdb.photo);
|
||||
for (const p of airportDataPhotos) addPhoto(p);
|
||||
for (const p of planespottersPhotos) addPhoto(p);
|
||||
|
||||
return NextResponse.json(data, {
|
||||
const response: AircraftPhotosResponse = {
|
||||
photos,
|
||||
aircraft: adsbdb.aircraft,
|
||||
};
|
||||
|
||||
return NextResponse.json(response, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Cache-Control":
|
||||
"public, max-age=1800, s-maxage=1800, stale-while-revalidate=3600",
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
clearTimeout(timer);
|
||||
|
||||
if (err instanceof DOMException && err.name === "AbortError") {
|
||||
return NextResponse.json(
|
||||
{ error: "Upstream timeout" },
|
||||
{ status: 504, headers: { "Cache-Control": "no-store" } },
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: "Proxy error" },
|
||||
{ status: 502, headers: { "Cache-Control": "no-store" } },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
275
src/app/api/airspace-tiles/route.ts
Normal file
275
src/app/api/airspace-tiles/route.ts
Normal file
@ -0,0 +1,275 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
// ── OpenAIP Airspace Tile Proxy ────────────────────────────────────────
|
||||
//
|
||||
// Proxies tile requests to OpenAIP's TMS service, keeping the API key
|
||||
// server-side. Validates z/x/y to prevent SSRF and path traversal.
|
||||
//
|
||||
// Tiles are cached in-memory (24 h TTL, LRU eviction at 2 000 entries)
|
||||
// to avoid hammering OpenAIP. Concurrent in-flight requests for the
|
||||
// same tile are coalesced so only one upstream fetch happens. A simple
|
||||
// queue limits upstream concurrency to 6 and spaces requests by 100 ms.
|
||||
//
|
||||
// OpenAIP TMS endpoint:
|
||||
// https://api.tiles.openaip.net/api/data/{layer}/{z}/{x}/{y}.png
|
||||
// Multi-domain subdomains: a, b, c (round-robined for parallelism)
|
||||
//
|
||||
// Docs: https://docs.openaip.net/?urls.primaryName=Tiles%20API
|
||||
// License: CC BY-NC 4.0 — attribution required.
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
|
||||
const OPENAIP_API_KEY = process.env.OPENAIP_API_KEY ?? "";
|
||||
|
||||
const SUBDOMAINS = ["a", "b", "c"] as const;
|
||||
|
||||
const FETCH_TIMEOUT_MS = 10_000;
|
||||
const CACHE_MAX_AGE = 86_400; // 24 hours
|
||||
const CACHE_TTL_MS = CACHE_MAX_AGE * 1_000;
|
||||
const MAX_CACHE_ENTRIES = 2_000;
|
||||
|
||||
const VALID_TILE_COORD = /^[0-9]{1,7}$/;
|
||||
const VALID_LAYERS = new Set(["openaip", "hotspots"]);
|
||||
|
||||
// ── In-memory tile cache ────────────────────────────────────────────
|
||||
type CachedTile =
|
||||
| { kind: "image"; data: ArrayBuffer; ts: number }
|
||||
| { kind: "empty"; ts: number }; // 204 / transparent
|
||||
|
||||
const tileCache = new Map<string, CachedTile>();
|
||||
|
||||
function getCached(key: string): CachedTile | undefined {
|
||||
const entry = tileCache.get(key);
|
||||
if (!entry) return undefined;
|
||||
if (Date.now() - entry.ts > CACHE_TTL_MS) {
|
||||
tileCache.delete(key);
|
||||
return undefined;
|
||||
}
|
||||
// Move to end for LRU
|
||||
tileCache.delete(key);
|
||||
tileCache.set(key, entry);
|
||||
return entry;
|
||||
}
|
||||
|
||||
function putCache(key: string, entry: CachedTile) {
|
||||
// Evict oldest if at capacity
|
||||
if (tileCache.size >= MAX_CACHE_ENTRIES) {
|
||||
const oldest = tileCache.keys().next().value;
|
||||
if (oldest !== undefined) tileCache.delete(oldest);
|
||||
}
|
||||
tileCache.set(key, entry);
|
||||
}
|
||||
|
||||
// ── Request coalescing ──────────────────────────────────────────────
|
||||
const inflight = new Map<string, Promise<CachedTile | null>>();
|
||||
|
||||
// ── Upstream concurrency queue ──────────────────────────────────────
|
||||
const MAX_CONCURRENT = 6;
|
||||
const MIN_SPACING_MS = 100;
|
||||
let activeCount = 0;
|
||||
let lastFetchMs = 0;
|
||||
const queue: Array<{ resolve: () => void }> = [];
|
||||
|
||||
async function acquireSlot(): Promise<void> {
|
||||
if (activeCount < MAX_CONCURRENT) {
|
||||
activeCount++;
|
||||
} else {
|
||||
// Wait for a slot — releaseSlot will increment activeCount before resolving.
|
||||
await new Promise<void>((resolve) => {
|
||||
queue.push({ resolve });
|
||||
});
|
||||
}
|
||||
|
||||
// Enforce minimum spacing between upstream calls for both fast-path
|
||||
// and queued acquisitions.
|
||||
const now = Date.now();
|
||||
const elapsed = now - lastFetchMs;
|
||||
if (elapsed < MIN_SPACING_MS) {
|
||||
await new Promise<void>((r) => setTimeout(r, MIN_SPACING_MS - elapsed));
|
||||
}
|
||||
lastFetchMs = Date.now();
|
||||
}
|
||||
|
||||
function releaseSlot() {
|
||||
activeCount--;
|
||||
const next = queue.shift();
|
||||
if (next) {
|
||||
activeCount++;
|
||||
next.resolve();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Upstream fetch with retry for 429 ───────────────────────────────
|
||||
async function fetchUpstream(
|
||||
key: string,
|
||||
z: string,
|
||||
x: string,
|
||||
y: string,
|
||||
layer: string,
|
||||
): Promise<CachedTile | null> {
|
||||
await acquireSlot();
|
||||
try {
|
||||
const tileSum = parseInt(x, 10) + parseInt(y, 10);
|
||||
const subdomain = SUBDOMAINS[tileSum % SUBDOMAINS.length];
|
||||
const url = `https://${subdomain}.api.tiles.openaip.net/api/data/${layer}/${z}/${x}/${y}.png`;
|
||||
|
||||
// Try up to 2 times with backoff on 429
|
||||
for (let attempt = 0; attempt < 2; attempt++) {
|
||||
lastFetchMs = Date.now();
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
"x-openaip-api-key": OPENAIP_API_KEY,
|
||||
Accept: "image/png",
|
||||
},
|
||||
});
|
||||
clearTimeout(timer);
|
||||
|
||||
if (res.status === 429 && attempt === 0) {
|
||||
// Back off and retry once
|
||||
await new Promise<void>((r) => setTimeout(r, 2_000));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (res.status === 204 || res.status === 404) {
|
||||
const entry: CachedTile = { kind: "empty", ts: Date.now() };
|
||||
putCache(key, entry);
|
||||
return entry;
|
||||
}
|
||||
|
||||
if (!res.ok) return null;
|
||||
|
||||
const data = await res.arrayBuffer();
|
||||
const entry: CachedTile = { kind: "image", data, ts: Date.now() };
|
||||
putCache(key, entry);
|
||||
return entry;
|
||||
} catch {
|
||||
clearTimeout(timer);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
} finally {
|
||||
releaseSlot();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Route handler ───────────────────────────────────────────────────
|
||||
export async function GET(request: NextRequest): Promise<NextResponse> {
|
||||
if (!OPENAIP_API_KEY) {
|
||||
return new NextResponse(null, {
|
||||
status: 503,
|
||||
headers: { "Cache-Control": "no-store" },
|
||||
});
|
||||
}
|
||||
|
||||
const z = request.nextUrl.searchParams.get("z");
|
||||
const x = request.nextUrl.searchParams.get("x");
|
||||
const y = request.nextUrl.searchParams.get("y");
|
||||
const layer = request.nextUrl.searchParams.get("layer") ?? "openaip";
|
||||
|
||||
if (!z || !x || !y) {
|
||||
return NextResponse.json(
|
||||
{ error: "Missing z, x, or y parameter" },
|
||||
{ status: 400, headers: { "Cache-Control": "no-store" } },
|
||||
);
|
||||
}
|
||||
|
||||
if (!VALID_LAYERS.has(layer)) {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid layer" },
|
||||
{ status: 400, headers: { "Cache-Control": "no-store" } },
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
!VALID_TILE_COORD.test(z) ||
|
||||
!VALID_TILE_COORD.test(x) ||
|
||||
!VALID_TILE_COORD.test(y)
|
||||
) {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid tile coordinates" },
|
||||
{ status: 400, headers: { "Cache-Control": "no-store" } },
|
||||
);
|
||||
}
|
||||
|
||||
const zoomLevel = parseInt(z, 10);
|
||||
if (zoomLevel > 20) {
|
||||
return NextResponse.json(
|
||||
{ error: "Zoom level out of range" },
|
||||
{ status: 400, headers: { "Cache-Control": "no-store" } },
|
||||
);
|
||||
}
|
||||
|
||||
// Validate tile coordinates are within bounds for the given zoom level.
|
||||
// At zoom Z, valid x/y values are in [0, 2^Z - 1].
|
||||
const maxCoord = 1 << zoomLevel; // 2^z
|
||||
if (parseInt(x, 10) >= maxCoord || parseInt(y, 10) >= maxCoord) {
|
||||
return NextResponse.json(
|
||||
{ error: "Tile coordinate out of range for zoom level" },
|
||||
{ status: 400, headers: { "Cache-Control": "no-store" } },
|
||||
);
|
||||
}
|
||||
|
||||
const key = `${layer}/${z}/${x}/${y}`;
|
||||
|
||||
// ── Serve from cache ──────────────────────────────────────────────
|
||||
const cached = getCached(key);
|
||||
if (cached) {
|
||||
if (cached.kind === "empty") {
|
||||
return new NextResponse(null, {
|
||||
status: 204,
|
||||
headers: {
|
||||
"Cache-Control": `public, max-age=${CACHE_MAX_AGE}, immutable`,
|
||||
},
|
||||
});
|
||||
}
|
||||
return new NextResponse(cached.data, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "image/png",
|
||||
"Cache-Control": `public, max-age=${CACHE_MAX_AGE}, immutable`,
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ── Coalesce concurrent requests for the same tile ────────────────
|
||||
let promise = inflight.get(key);
|
||||
if (!promise) {
|
||||
promise = fetchUpstream(key, z, x, y, layer).finally(() => {
|
||||
inflight.delete(key);
|
||||
});
|
||||
inflight.set(key, promise);
|
||||
}
|
||||
|
||||
const result = await promise;
|
||||
|
||||
if (!result) {
|
||||
return new NextResponse(null, {
|
||||
status: 502,
|
||||
headers: { "Cache-Control": "no-store" },
|
||||
});
|
||||
}
|
||||
|
||||
if (result.kind === "empty") {
|
||||
return new NextResponse(null, {
|
||||
status: 204,
|
||||
headers: {
|
||||
"Cache-Control": `public, max-age=${CACHE_MAX_AGE}, immutable`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return new NextResponse(result.data, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "image/png",
|
||||
"Cache-Control": `public, max-age=${CACHE_MAX_AGE}, immutable`,
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
},
|
||||
});
|
||||
}
|
||||
91
src/app/api/atc/feeds/route.ts
Normal file
91
src/app/api/atc/feeds/route.ts
Normal file
@ -0,0 +1,91 @@
|
||||
import { NextResponse, type NextRequest } from "next/server";
|
||||
import { getFeedsByIcao, getIcaoCodesWithFeeds } from "@/lib/atc-feeds";
|
||||
import {
|
||||
findNearbyAtcFeeds,
|
||||
lookupAtcFeeds,
|
||||
iataToIcao,
|
||||
} from "@/lib/atc-lookup";
|
||||
|
||||
/**
|
||||
* GET /api/atc/feeds
|
||||
*
|
||||
* Feed lookup endpoint. Accepts:
|
||||
* ?icao=KJFK — feeds for a specific ICAO code
|
||||
* ?iata=JFK — feeds for a specific IATA code
|
||||
* ?lat=40.6&lng=-73.8 — nearby feeds by coordinates
|
||||
* ?lat=...&lng=...&radius=30 — with custom radius (nm)
|
||||
*
|
||||
* Returns static data from the feed database (no upstream calls).
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
const { searchParams } = request.nextUrl;
|
||||
|
||||
const icao = searchParams.get("icao")?.trim().toUpperCase();
|
||||
const iata = searchParams.get("iata")?.trim().toUpperCase();
|
||||
const latStr = searchParams.get("lat");
|
||||
const lngStr = searchParams.get("lng");
|
||||
const radiusStr = searchParams.get("radius");
|
||||
|
||||
// Lookup by ICAO code
|
||||
if (icao) {
|
||||
if (!/^[A-Z]{4}$/.test(icao)) {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid ICAO code. Must be exactly 4 uppercase letters." },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
const feeds = getFeedsByIcao(icao);
|
||||
return NextResponse.json({ icao, feeds });
|
||||
}
|
||||
|
||||
// Lookup by IATA code
|
||||
if (iata) {
|
||||
if (!/^[A-Z]{3}$/.test(iata)) {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid IATA code. Must be exactly 3 uppercase letters." },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
const resolvedIcao = iataToIcao(iata);
|
||||
const feeds = lookupAtcFeeds(iata);
|
||||
return NextResponse.json({ icao: resolvedIcao, iata, feeds });
|
||||
}
|
||||
|
||||
// Lookup by coordinates
|
||||
if (latStr && lngStr) {
|
||||
const lat = Number(latStr);
|
||||
const lng = Number(lngStr);
|
||||
const radius = radiusStr ? Number(radiusStr) : 60;
|
||||
|
||||
if (!Number.isFinite(lat) || lat < -90 || lat > 90) {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid latitude. Must be between -90 and 90." },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
if (!Number.isFinite(lng) || lng < -180 || lng > 180) {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid longitude. Must be between -180 and 180." },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
if (!Number.isFinite(radius) || radius < 1 || radius > 60) {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid radius. Must be between 1 and 60 nautical miles." },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const results = findNearbyAtcFeeds(lat, lng, radius);
|
||||
return NextResponse.json({ results });
|
||||
}
|
||||
|
||||
// No parameters — return list of available ICAO codes
|
||||
const available = getIcaoCodesWithFeeds();
|
||||
return NextResponse.json({
|
||||
message:
|
||||
"Use ?icao=KJFK, ?iata=JFK, or ?lat=40.6&lng=-73.8 to look up feeds.",
|
||||
availableAirports: available.length,
|
||||
codes: available,
|
||||
});
|
||||
}
|
||||
160
src/app/api/atc/stream/route.ts
Normal file
160
src/app/api/atc/stream/route.ts
Normal file
@ -0,0 +1,160 @@
|
||||
import { type NextRequest } from "next/server";
|
||||
import { VALID_MOUNT_POINTS } from "@/lib/atc-feeds";
|
||||
|
||||
/**
|
||||
* GET /api/atc/stream?mount={mountPoint}
|
||||
*
|
||||
* Fallback audio stream proxy for LiveATC Icecast streams.
|
||||
* Only used when direct browser <audio> playback is blocked.
|
||||
*
|
||||
* Security:
|
||||
* - Mount point validated against static allowlist (SSRF prevention)
|
||||
* - Connection timeout: 30 seconds
|
||||
* - Max stream duration: 4 hours
|
||||
* - Simple per-request rate limiting via headers
|
||||
*/
|
||||
|
||||
/** Maximum stream duration in milliseconds (4 hours). */
|
||||
const MAX_STREAM_DURATION_MS = 4 * 60 * 60 * 1000;
|
||||
/** Connection timeout for upstream fetch (30 seconds). */
|
||||
const CONNECT_TIMEOUT_MS = 30_000;
|
||||
|
||||
/**
|
||||
* Sanitize and validate mount point parameter.
|
||||
* Only alphanumeric characters, underscores, and hyphens are allowed.
|
||||
*/
|
||||
function isValidMountFormat(mount: string): boolean {
|
||||
return /^[a-z0-9_-]{2,64}$/i.test(mount);
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const mount = request.nextUrl.searchParams.get("mount")?.trim();
|
||||
|
||||
if (!mount) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: "Missing required 'mount' parameter." }),
|
||||
{ status: 400, headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
}
|
||||
|
||||
// Validate mount point format
|
||||
if (!isValidMountFormat(mount)) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: "Invalid mount point format." }),
|
||||
{ status: 400, headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
}
|
||||
|
||||
// SSRF prevention: only allow mount points from our static database
|
||||
if (!VALID_MOUNT_POINTS.has(mount)) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: "Unknown mount point. Only verified feeds are allowed.",
|
||||
}),
|
||||
{ status: 403, headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
}
|
||||
|
||||
// Construct the upstream URL from the validated mount point
|
||||
// Using the direct Icecast server URL (d.liveatc.net)
|
||||
const upstreamUrl = `https://d.liveatc.net/${mount}`;
|
||||
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const connectTimer = setTimeout(
|
||||
() => controller.abort(),
|
||||
CONNECT_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
const upstream = await fetch(upstreamUrl, {
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
"User-Agent": "Mozilla/5.0 (compatible; Aeris/1.0)",
|
||||
Referer: "https://www.liveatc.net/",
|
||||
Accept: "audio/mpeg, audio/*, */*",
|
||||
},
|
||||
});
|
||||
|
||||
clearTimeout(connectTimer);
|
||||
|
||||
if (!upstream.ok) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: "Upstream stream unavailable.",
|
||||
status: upstream.status,
|
||||
}),
|
||||
{ status: 502, headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
}
|
||||
|
||||
if (!upstream.body) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: "No stream body from upstream." }),
|
||||
{ status: 502, headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
}
|
||||
|
||||
// Set up max duration cutoff
|
||||
const durationController = new AbortController();
|
||||
const durationTimer = setTimeout(
|
||||
() => durationController.abort(),
|
||||
MAX_STREAM_DURATION_MS,
|
||||
);
|
||||
|
||||
// Pipe the upstream stream through, respecting both abort signals
|
||||
const reader = upstream.body.getReader();
|
||||
const stream = new ReadableStream({
|
||||
async pull(ctrl) {
|
||||
try {
|
||||
if (durationController.signal.aborted) {
|
||||
reader.cancel().catch(() => {});
|
||||
clearTimeout(durationTimer);
|
||||
ctrl.close();
|
||||
return;
|
||||
}
|
||||
const { value, done } = await reader.read();
|
||||
if (done) {
|
||||
ctrl.close();
|
||||
} else {
|
||||
ctrl.enqueue(value);
|
||||
}
|
||||
} catch {
|
||||
reader.cancel().catch(() => {});
|
||||
ctrl.close();
|
||||
}
|
||||
},
|
||||
cancel() {
|
||||
clearTimeout(durationTimer);
|
||||
reader.cancel().catch(() => {});
|
||||
},
|
||||
});
|
||||
|
||||
// Detect client disconnect via request abort signal
|
||||
request.signal.addEventListener("abort", () => {
|
||||
clearTimeout(durationTimer);
|
||||
reader.cancel().catch(() => {});
|
||||
});
|
||||
|
||||
return new Response(stream, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": upstream.headers.get("Content-Type") ?? "audio/mpeg",
|
||||
"Cache-Control": "no-cache, no-store, must-revalidate",
|
||||
"X-Accel-Buffering": "no", // Disable Nginx buffering if behind reverse proxy
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
const isAbort = err instanceof Error && err.name === "AbortError";
|
||||
if (isAbort) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: "Connection to upstream timed out." }),
|
||||
{ status: 504, headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
}
|
||||
return new Response(
|
||||
JSON.stringify({ error: "Failed to connect to upstream stream." }),
|
||||
{ status: 502, headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
}
|
||||
}
|
||||
119
src/app/api/flights/route.ts
Normal file
119
src/app/api/flights/route.ts
Normal file
@ -0,0 +1,119 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { READSB_FETCH_TIMEOUT_MS, MAX_RADIUS_NM } from "@/lib/flight-api-types";
|
||||
|
||||
// ── adsb.lol Proxy ─────────────────────────────────────────────────────
|
||||
//
|
||||
// Proxies requests to adsb.lol which lacks CORS headers.
|
||||
// Validates path patterns to prevent SSRF.
|
||||
|
||||
const ADSB_LOL_BASE = "https://api.adsb.lol/v2";
|
||||
|
||||
// ── Path validation (SSRF prevention) ──────────────────────────────────
|
||||
|
||||
/**
|
||||
* Only allow known readsb endpoint patterns.
|
||||
* - /point/{lat}/{lon}/{radius} — lat/lon can be negative decimals, radius is integer
|
||||
* - /hex/{hex} — 6-char lowercase hex ICAO address
|
||||
* - /callsign/{callsign} — alphanumeric callsign
|
||||
*/
|
||||
const VALID_PATH =
|
||||
/^\/(?:point\/-?\d+(?:\.\d+)?\/-?\d+(?:\.\d+)?\/\d{1,3}|hex\/[0-9a-f]{6}|callsign\/[A-Z0-9-]{1,8})$/i;
|
||||
|
||||
// ── Rate limiter (in-memory) ───────────────────────────────────────────
|
||||
// NOTE: This is per-instance, per-cold-start. In serverless/edge
|
||||
// deployments each instance has its own counter, so the effective global
|
||||
// rate can exceed RATE_MS when multiple instances serve concurrent
|
||||
// traffic. For strict rate limiting, use a shared store (e.g., Upstash
|
||||
// Redis or Vercel KV).
|
||||
|
||||
let lastRequestTime = 0;
|
||||
const RATE_MS = 500; // self-imposed: 2 req/s for adsb.lol
|
||||
|
||||
// ── Handler ────────────────────────────────────────────────────────────
|
||||
|
||||
export async function GET(request: NextRequest): Promise<NextResponse> {
|
||||
const path = request.nextUrl.searchParams.get("path")?.trim();
|
||||
|
||||
if (!path || !VALID_PATH.test(path)) {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid or missing 'path' parameter" },
|
||||
{ status: 400, headers: { "Cache-Control": "no-store" } },
|
||||
);
|
||||
}
|
||||
|
||||
// Validate radius for /point endpoints against max allowed
|
||||
const pointMatch = path.match(/^\/point\/[^/]+\/[^/]+\/(\d+)$/);
|
||||
if (pointMatch && parseInt(pointMatch[1], 10) > MAX_RADIUS_NM) {
|
||||
return NextResponse.json(
|
||||
{ error: `Radius exceeds maximum of ${MAX_RADIUS_NM} NM` },
|
||||
{ status: 400, headers: { "Cache-Control": "no-store" } },
|
||||
);
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const elapsed = now - lastRequestTime;
|
||||
lastRequestTime = now;
|
||||
if (elapsed < RATE_MS) {
|
||||
return NextResponse.json(
|
||||
{ error: "Rate limited" },
|
||||
{
|
||||
status: 429,
|
||||
headers: { "Cache-Control": "no-store", "Retry-After": "1" },
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), READSB_FETCH_TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
const upstream = await fetch(`${ADSB_LOL_BASE}${path}`, {
|
||||
signal: controller.signal,
|
||||
headers: { Accept: "application/json" },
|
||||
});
|
||||
|
||||
clearTimeout(timer);
|
||||
|
||||
if (!upstream.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: `adsb.lol returned ${upstream.status}` },
|
||||
{
|
||||
status: upstream.status >= 500 ? 502 : upstream.status,
|
||||
headers: { "Cache-Control": "no-store" },
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Reject non-JSON responses (CloudFlare challenges, maintenance pages)
|
||||
const ct = upstream.headers.get("content-type") ?? "";
|
||||
if (ct.includes("text/html") || ct.includes("text/xml")) {
|
||||
return NextResponse.json(
|
||||
{ error: "adsb.lol returned a non-JSON response" },
|
||||
{ status: 502, headers: { "Cache-Control": "no-store" } },
|
||||
);
|
||||
}
|
||||
|
||||
const data: unknown = await upstream.json();
|
||||
|
||||
return NextResponse.json(data, {
|
||||
status: 200,
|
||||
headers: { "Cache-Control": "public, max-age=3, s-maxage=3" },
|
||||
});
|
||||
} catch (err) {
|
||||
clearTimeout(timer);
|
||||
|
||||
const isTimeout = err instanceof DOMException && err.name === "AbortError";
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: isTimeout
|
||||
? "adsb.lol request timed out"
|
||||
: "adsb.lol request failed",
|
||||
},
|
||||
{
|
||||
status: isTimeout ? 504 : 502,
|
||||
headers: { "Cache-Control": "no-store" },
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
353
src/app/api/flights/trace/route.ts
Normal file
353
src/app/api/flights/trace/route.ts
Normal file
@ -0,0 +1,353 @@
|
||||
import { type NextRequest, NextResponse } from "next/server";
|
||||
import type { FlightTrack, TrackWaypoint } from "@/lib/opensky-types";
|
||||
|
||||
// ── Constants ──────────────────────────────────────────────────────────
|
||||
|
||||
const HEX_REGEX = /^[0-9a-f]{6}$/;
|
||||
const FT_TO_M = 0.3048;
|
||||
const TRACE_TIMEOUT_MS = 10_000;
|
||||
const OPENSKY_TIMEOUT_MS = 8_000;
|
||||
|
||||
const TARGET_WAYPOINTS = 60;
|
||||
const MAX_AGE_SECONDS = 90 * 60;
|
||||
|
||||
const GLOBE_TRACE_SOURCES = [
|
||||
{
|
||||
name: "airplanes.live",
|
||||
baseUrl: "https://globe.airplanes.live/data/traces",
|
||||
referer: "https://globe.airplanes.live/",
|
||||
origin: "https://globe.airplanes.live",
|
||||
},
|
||||
{
|
||||
name: "adsb.lol",
|
||||
baseUrl: "https://globe.adsb.lol/data/traces",
|
||||
referer: "https://globe.adsb.lol/",
|
||||
origin: "https://globe.adsb.lol",
|
||||
},
|
||||
{
|
||||
name: "adsb.fi",
|
||||
baseUrl: "https://globe.adsb.fi/data/traces",
|
||||
referer: "https://globe.adsb.fi/",
|
||||
origin: "https://globe.adsb.fi",
|
||||
},
|
||||
] as const;
|
||||
|
||||
const OPENSKY_API = "https://opensky-network.org/api";
|
||||
|
||||
const APP_UA = "Aeris/1.0 (flight-tracker; +https://github.com/kewonit/aeris)";
|
||||
|
||||
let lastRequestTime = 0;
|
||||
const RATE_MS = 800;
|
||||
|
||||
// trace[i] = [offset_sec, lat, lng, alt_ft|"ground"|null, gs, track, flags, vrate, ...]
|
||||
// flags bit 0 = stale
|
||||
function parseReadsbTrace(hex: string, data: unknown): FlightTrack | null {
|
||||
if (typeof data !== "object" || data === null) return null;
|
||||
|
||||
const obj = data as Record<string, unknown>;
|
||||
const timestamp =
|
||||
typeof obj.timestamp === "number" && Number.isFinite(obj.timestamp)
|
||||
? obj.timestamp
|
||||
: 0;
|
||||
|
||||
if (timestamp <= 0) return null;
|
||||
|
||||
const rawTrace = Array.isArray(obj.trace) ? obj.trace : null;
|
||||
if (!rawTrace || rawTrace.length < 2) return null;
|
||||
|
||||
let latestOffset = 0;
|
||||
for (const entry of rawTrace) {
|
||||
if (Array.isArray(entry) && typeof entry[0] === "number") {
|
||||
if (entry[0] > latestOffset) latestOffset = entry[0];
|
||||
}
|
||||
}
|
||||
const cutoffOffset = latestOffset - MAX_AGE_SECONDS;
|
||||
|
||||
const waypoints: TrackWaypoint[] = [];
|
||||
|
||||
for (const entry of rawTrace) {
|
||||
if (!Array.isArray(entry) || entry.length < 4) continue;
|
||||
|
||||
const offset = typeof entry[0] === "number" ? entry[0] : null;
|
||||
if (offset === null || !Number.isFinite(offset)) continue;
|
||||
|
||||
if (offset < cutoffOffset) continue;
|
||||
|
||||
const lat = typeof entry[1] === "number" ? entry[1] : null;
|
||||
const lng = typeof entry[2] === "number" ? entry[2] : null;
|
||||
if (lat === null || lng === null) continue;
|
||||
if (!Number.isFinite(lat) || !Number.isFinite(lng)) continue;
|
||||
if (lat < -90 || lat > 90 || lng < -180 || lng > 180) continue;
|
||||
|
||||
const rawAlt = entry[3];
|
||||
const onGround = rawAlt === "ground";
|
||||
let baroAltitude: number | null = null;
|
||||
if (onGround) {
|
||||
baroAltitude = 0;
|
||||
} else if (typeof rawAlt === "number" && Number.isFinite(rawAlt)) {
|
||||
baroAltitude = rawAlt * FT_TO_M;
|
||||
}
|
||||
|
||||
const trueTrack =
|
||||
entry.length > 5 &&
|
||||
typeof entry[5] === "number" &&
|
||||
Number.isFinite(entry[5])
|
||||
? entry[5]
|
||||
: null;
|
||||
|
||||
const flags =
|
||||
entry.length > 6 && typeof entry[6] === "number" ? entry[6] : 0;
|
||||
|
||||
// Skip stale positions (bit 0)
|
||||
if (flags & 1) continue;
|
||||
|
||||
const time = timestamp + offset;
|
||||
|
||||
waypoints.push({
|
||||
time,
|
||||
latitude: lat,
|
||||
longitude: lng,
|
||||
baroAltitude,
|
||||
trueTrack,
|
||||
onGround,
|
||||
});
|
||||
}
|
||||
|
||||
if (waypoints.length < 2) return null;
|
||||
|
||||
waypoints.sort((a, b) => a.time - b.time);
|
||||
|
||||
const deduped: TrackWaypoint[] = [waypoints[0]];
|
||||
for (let i = 1; i < waypoints.length; i++) {
|
||||
const prev = deduped[deduped.length - 1];
|
||||
const curr = waypoints[i];
|
||||
if (prev.latitude === curr.latitude && prev.longitude === curr.longitude) {
|
||||
continue;
|
||||
}
|
||||
deduped.push(curr);
|
||||
}
|
||||
|
||||
if (deduped.length < 2) return null;
|
||||
|
||||
const sampled = downsampleUniform(deduped, TARGET_WAYPOINTS);
|
||||
|
||||
return {
|
||||
icao24: hex.toLowerCase(),
|
||||
startTime: Math.floor(sampled[0].time),
|
||||
endTime: Math.floor(sampled[sampled.length - 1].time),
|
||||
callsign: null,
|
||||
path: sampled,
|
||||
};
|
||||
}
|
||||
|
||||
function downsampleUniform(
|
||||
points: TrackWaypoint[],
|
||||
target: number,
|
||||
): TrackWaypoint[] {
|
||||
if (points.length <= target) return points;
|
||||
|
||||
const result: TrackWaypoint[] = [points[0]];
|
||||
const step = (points.length - 1) / (target - 1);
|
||||
|
||||
for (let i = 1; i < target - 1; i++) {
|
||||
result.push(points[Math.round(i * step)]);
|
||||
}
|
||||
|
||||
result.push(points[points.length - 1]);
|
||||
return result;
|
||||
}
|
||||
|
||||
function parseOpenSkyTrack(hex: string, data: unknown): FlightTrack | null {
|
||||
if (typeof data !== "object" || data === null) return null;
|
||||
|
||||
const obj = data as Record<string, unknown>;
|
||||
const startTime =
|
||||
typeof obj.startTime === "number" && Number.isFinite(obj.startTime)
|
||||
? obj.startTime
|
||||
: 0;
|
||||
const endTime =
|
||||
typeof obj.endTime === "number" && Number.isFinite(obj.endTime)
|
||||
? obj.endTime
|
||||
: 0;
|
||||
|
||||
const callsignRaw =
|
||||
typeof obj.callsign === "string"
|
||||
? obj.callsign
|
||||
: typeof (obj as Record<string, unknown>).calllsign === "string"
|
||||
? ((obj as Record<string, unknown>).calllsign as string)
|
||||
: null;
|
||||
const callsign = callsignRaw ? callsignRaw.trim() || null : null;
|
||||
|
||||
const rawPath = Array.isArray(obj.path) ? obj.path : [];
|
||||
|
||||
const waypoints: TrackWaypoint[] = [];
|
||||
for (const raw of rawPath) {
|
||||
if (!Array.isArray(raw) || raw.length < 6) continue;
|
||||
|
||||
const time =
|
||||
typeof raw[0] === "number" && Number.isFinite(raw[0]) ? raw[0] : null;
|
||||
const rawLat =
|
||||
typeof raw[1] === "number" && Number.isFinite(raw[1]) ? raw[1] : null;
|
||||
const rawLng =
|
||||
typeof raw[2] === "number" && Number.isFinite(raw[2]) ? raw[2] : null;
|
||||
const latitude =
|
||||
rawLat !== null && rawLat >= -90 && rawLat <= 90 ? rawLat : null;
|
||||
const longitude =
|
||||
rawLng !== null && rawLng >= -180 && rawLng <= 180 ? rawLng : null;
|
||||
const baroAltitude =
|
||||
typeof raw[3] === "number" && Number.isFinite(raw[3]) ? raw[3] : null;
|
||||
const trueTrack =
|
||||
typeof raw[4] === "number" && Number.isFinite(raw[4]) ? raw[4] : null;
|
||||
const onGround = raw[5] === true;
|
||||
|
||||
if (time === null || latitude === null || longitude === null) continue;
|
||||
waypoints.push({
|
||||
time,
|
||||
latitude,
|
||||
longitude,
|
||||
baroAltitude,
|
||||
trueTrack,
|
||||
onGround,
|
||||
});
|
||||
}
|
||||
|
||||
waypoints.sort((a, b) => a.time - b.time);
|
||||
|
||||
const deduped: TrackWaypoint[] = [];
|
||||
let lastLng: number | null = null;
|
||||
let lastLat: number | null = null;
|
||||
for (const p of waypoints) {
|
||||
if (lastLng !== null && lastLat !== null) {
|
||||
if (p.longitude === lastLng && p.latitude === lastLat) continue;
|
||||
}
|
||||
deduped.push(p);
|
||||
lastLng = p.longitude;
|
||||
lastLat = p.latitude;
|
||||
}
|
||||
|
||||
if (deduped.length < 2) return null;
|
||||
|
||||
const sampled = downsampleUniform(deduped, TARGET_WAYPOINTS);
|
||||
|
||||
return {
|
||||
icao24: hex,
|
||||
startTime,
|
||||
endTime,
|
||||
callsign,
|
||||
path: sampled,
|
||||
};
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest): Promise<NextResponse> {
|
||||
const hex = request.nextUrl.searchParams.get("hex")?.trim().toLowerCase();
|
||||
|
||||
if (!hex || !HEX_REGEX.test(hex)) {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid or missing 'hex' parameter" },
|
||||
{ status: 400, headers: { "Cache-Control": "no-store" } },
|
||||
);
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const elapsed = now - lastRequestTime;
|
||||
lastRequestTime = now;
|
||||
if (elapsed < RATE_MS) {
|
||||
return NextResponse.json(
|
||||
{ error: "Rate limited" },
|
||||
{
|
||||
status: 429,
|
||||
headers: { "Cache-Control": "no-store", "Retry-After": "1" },
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const lastTwo = hex.slice(-2);
|
||||
|
||||
const traceHeaders = (source: (typeof GLOBE_TRACE_SOURCES)[number]) => ({
|
||||
Accept: "application/json",
|
||||
"User-Agent": APP_UA,
|
||||
Referer: source.referer,
|
||||
Origin: source.origin,
|
||||
});
|
||||
|
||||
for (const source of GLOBE_TRACE_SOURCES) {
|
||||
try {
|
||||
const fullUrl = `${source.baseUrl}/${lastTwo}/trace_full_${hex}.json`;
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), TRACE_TIMEOUT_MS);
|
||||
|
||||
const res = await fetch(fullUrl, {
|
||||
signal: controller.signal,
|
||||
headers: traceHeaders(source),
|
||||
});
|
||||
clearTimeout(timer);
|
||||
|
||||
if (res.ok) {
|
||||
// Skip non-JSON responses (CloudFlare challenges, maintenance pages)
|
||||
const ct = res.headers.get("content-type") ?? "";
|
||||
if (ct.includes("text/html") || ct.includes("text/xml")) continue;
|
||||
|
||||
const data = (await res.json()) as unknown;
|
||||
const track = parseReadsbTrace(hex, data);
|
||||
if (track && track.path.length >= 2) {
|
||||
return NextResponse.json(
|
||||
{ track, source: source.name },
|
||||
{ headers: { "Cache-Control": "private, max-age=30" } },
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Next source
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), OPENSKY_TIMEOUT_MS);
|
||||
|
||||
const res = await fetch(
|
||||
`${OPENSKY_API}/tracks/all?icao24=${encodeURIComponent(hex)}&time=0`,
|
||||
{
|
||||
signal: controller.signal,
|
||||
cache: "no-store",
|
||||
},
|
||||
);
|
||||
|
||||
clearTimeout(timer);
|
||||
|
||||
if (res.status === 429) {
|
||||
return NextResponse.json(
|
||||
{ error: "Rate limited" },
|
||||
{
|
||||
status: 429,
|
||||
headers: { "Cache-Control": "no-store", "Retry-After": "60" },
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if (res.ok) {
|
||||
// Reject non-JSON responses (CloudFlare challenge pages)
|
||||
const ct = res.headers.get("content-type") ?? "";
|
||||
if (ct.includes("text/html") || ct.includes("text/xml")) {
|
||||
return NextResponse.json(
|
||||
{ track: null, source: null },
|
||||
{ status: 200, headers: { "Cache-Control": "private, max-age=30" } },
|
||||
);
|
||||
}
|
||||
|
||||
const data = (await res.json()) as unknown;
|
||||
const track = parseOpenSkyTrack(hex, data);
|
||||
if (track && track.path.length >= 2) {
|
||||
return NextResponse.json(
|
||||
{ track, source: "opensky" },
|
||||
{ headers: { "Cache-Control": "private, max-age=60" } },
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
|
||||
return NextResponse.json(
|
||||
{ track: null, source: null },
|
||||
{ status: 200, headers: { "Cache-Control": "private, max-age=30" } },
|
||||
);
|
||||
}
|
||||
@ -1,6 +1,7 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Inter } from "next/font/google";
|
||||
import Script from "next/script";
|
||||
import { Toaster } from "sonner";
|
||||
import "./globals.css";
|
||||
|
||||
const inter = Inter({
|
||||
@ -14,7 +15,7 @@ const GA_ID = process.env.NEXT_PUBLIC_GA_ID;
|
||||
const title = "Aeris — Real-Time 3D Flight Tracking";
|
||||
const description =
|
||||
"Track live flights in 3D over the world's busiest airspaces. Altitude-aware, beautifully rendered, and completely free.";
|
||||
const siteUrl = "https://aeris-flight.vercel.app";
|
||||
const siteUrl = "https://aeris.edbn.me";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title,
|
||||
@ -79,6 +80,17 @@ export default function RootLayout({
|
||||
</head>
|
||||
<body className={`${inter.variable} font-sans antialiased`}>
|
||||
{children}
|
||||
<Toaster
|
||||
position="bottom-center"
|
||||
theme="dark"
|
||||
offset={16}
|
||||
mobileOffset={8}
|
||||
gap={8}
|
||||
toastOptions={{
|
||||
unstyled: true,
|
||||
className: "w-full",
|
||||
}}
|
||||
/>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
@ -4,7 +4,7 @@ const jsonLd = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "WebApplication",
|
||||
name: "Aeris",
|
||||
url: "https://aeris-flight.vercel.app",
|
||||
url: "https://aeris.edbn.me",
|
||||
description:
|
||||
"Track live flights in 3D over the world's busiest airspaces. Altitude-aware, beautifully rendered, and completely free.",
|
||||
applicationCategory: "TravelApplication",
|
||||
|
||||
59
src/components/flight-tracker-brand.tsx
Normal file
59
src/components/flight-tracker-brand.tsx
Normal file
@ -0,0 +1,59 @@
|
||||
"use client";
|
||||
|
||||
import { Github, Star } from "lucide-react";
|
||||
import {
|
||||
GITHUB_REPO_URL,
|
||||
formatStarCount,
|
||||
} from "@/components/flight-tracker-utils";
|
||||
|
||||
export function Brand({ isDark }: { isDark: boolean }) {
|
||||
return (
|
||||
<span
|
||||
className={`text-sm font-semibold tracking-wide ${
|
||||
isDark ? "text-white/70" : "text-black/70"
|
||||
}`}
|
||||
>
|
||||
aeris
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function GitHubBadge({ stars }: { stars: number | null }) {
|
||||
return (
|
||||
<a
|
||||
href={GITHUB_REPO_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label="Open GitHub repository"
|
||||
className="relative inline-flex h-9 w-9 items-center justify-center rounded-xl backdrop-blur-2xl transition-colors"
|
||||
style={{
|
||||
borderWidth: 1,
|
||||
borderColor: "rgb(var(--ui-fg) / 0.06)",
|
||||
backgroundColor: "rgb(var(--ui-fg) / 0.03)",
|
||||
color: "rgb(var(--ui-fg) / 0.5)",
|
||||
}}
|
||||
title={
|
||||
stars != null
|
||||
? `GitHub · ${formatStarCount(stars)} stars`
|
||||
: "Open GitHub repository"
|
||||
}
|
||||
>
|
||||
<Github className="h-4 w-4" />
|
||||
{stars != null && (
|
||||
<span
|
||||
className="pointer-events-none absolute -bottom-1 -right-1 rounded-full px-1.5 py-0.5 text-[9px] font-semibold tabular-nums"
|
||||
style={{
|
||||
backgroundColor: "rgb(var(--ui-bg) / 0.95)",
|
||||
border: "1px solid rgb(var(--ui-fg) / 0.1)",
|
||||
color: "rgb(var(--ui-fg) / 0.55)",
|
||||
}}
|
||||
>
|
||||
<span className="flex items-center gap-0.5">
|
||||
<Star className="h-2 w-2" />
|
||||
{formatStarCount(stars)}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
@ -65,7 +65,7 @@ export function cityFromFlight(flight: FlightState): City | null {
|
||||
id: `trk-${flight.icao24}`,
|
||||
name: `Flight ${code}`,
|
||||
country: flight.originCountry || "Unknown",
|
||||
iata: code.slice(0, 3),
|
||||
iata: code,
|
||||
coordinates: [flight.longitude, flight.latitude],
|
||||
radius: 2,
|
||||
};
|
||||
|
||||
@ -1,8 +1,9 @@
|
||||
import { CITIES, type City } from "@/lib/cities";
|
||||
import { findByIata, airportToCity } from "@/lib/airports";
|
||||
import { MAP_STYLES, DEFAULT_STYLE, type MapStyle } from "@/lib/map-styles";
|
||||
import { ICAO24_REGEX } from "@/lib/flight-api-types";
|
||||
|
||||
export { DEFAULT_STYLE };
|
||||
export { DEFAULT_STYLE, ICAO24_REGEX };
|
||||
|
||||
export const DEFAULT_CITY_ID = "sfo";
|
||||
export const STYLE_STORAGE_KEY = "aeris:mapStyle";
|
||||
@ -10,7 +11,6 @@ export const DEFAULT_CITY =
|
||||
CITIES.find((c) => c.id === DEFAULT_CITY_ID) ?? CITIES[0];
|
||||
export const GITHUB_REPO_URL = "https://github.com/kewonit/aeris";
|
||||
export const GITHUB_REPO_API = "https://api.github.com/repos/kewonit/aeris";
|
||||
export const ICAO24_REGEX = /^[0-9a-f]{6}$/i;
|
||||
|
||||
export const subscribeNoop = () => () => {};
|
||||
|
||||
@ -43,6 +43,7 @@ export function resolveInitialCity(): City {
|
||||
_cachedInitialCity = DEFAULT_CITY;
|
||||
return DEFAULT_CITY;
|
||||
} catch {
|
||||
// Not in a browser environment (SSR) — fall back to default city
|
||||
_cachedInitialCity = DEFAULT_CITY;
|
||||
return DEFAULT_CITY;
|
||||
}
|
||||
@ -58,7 +59,7 @@ export function syncCityToUrl(city: City): void {
|
||||
url.searchParams.delete("fpv");
|
||||
window.history.replaceState(null, "", url.toString());
|
||||
} catch {
|
||||
/* ignore */
|
||||
// URL parsing or history API may fail in non-browser environments
|
||||
}
|
||||
}
|
||||
|
||||
@ -79,7 +80,7 @@ export function syncFpvToUrl(icao24: string | null, activeCity?: City): void {
|
||||
}
|
||||
window.history.replaceState(null, "", url.toString());
|
||||
} catch {
|
||||
/* ignore */
|
||||
// URL parsing or history API may fail in non-browser environments
|
||||
}
|
||||
}
|
||||
|
||||
@ -89,6 +90,7 @@ export function resolveInitialFpv(): string | null {
|
||||
const raw = params.get("fpv")?.trim().toLowerCase();
|
||||
return raw && /^[0-9a-f]{6}$/.test(raw) ? raw : null;
|
||||
} catch {
|
||||
// Not in a browser environment (SSR)
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@ -99,6 +101,7 @@ export function loadMapStyle(): MapStyle {
|
||||
if (!id) return DEFAULT_STYLE;
|
||||
return MAP_STYLES.find((s) => s.id === id) ?? DEFAULT_STYLE;
|
||||
} catch {
|
||||
// localStorage unavailable (SSR, private browsing, or quota exceeded)
|
||||
return DEFAULT_STYLE;
|
||||
}
|
||||
}
|
||||
@ -108,7 +111,7 @@ export function saveMapStyle(style: MapStyle): void {
|
||||
try {
|
||||
localStorage.setItem(STYLE_STORAGE_KEY, style.id);
|
||||
} catch {
|
||||
/* blocked */
|
||||
// localStorage unavailable (private browsing or quota exceeded)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -9,18 +9,26 @@ import {
|
||||
useSyncExternalStore,
|
||||
} from "react";
|
||||
import { AnimatePresence } from "motion/react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { ErrorBoundary } from "@/components/error-boundary";
|
||||
import { Map as MapView } from "@/components/map/map";
|
||||
import { CameraController } from "@/components/map/camera-controller";
|
||||
import { AirportLayer } from "@/components/map/airport-layer";
|
||||
import { AirspaceLayer } from "@/components/map/airspace-layer";
|
||||
import { FlightLayers } from "@/components/map/flight-layers";
|
||||
import { FlightCard } from "@/components/ui/flight-card";
|
||||
const FlightCard = dynamic(() =>
|
||||
import("@/components/ui/flight-card").then((mod) => mod.FlightCard),
|
||||
);
|
||||
import { FpvHud } from "@/components/ui/fpv-hud";
|
||||
import { ControlPanel } from "@/components/ui/control-panel";
|
||||
const ControlPanel = dynamic(() =>
|
||||
import("@/components/ui/control-panel").then((mod) => mod.ControlPanel),
|
||||
);
|
||||
import { AltitudeLegend } from "@/components/ui/altitude-legend";
|
||||
import { CameraControls } from "@/components/ui/camera-controls";
|
||||
import { StatusBar } from "@/components/ui/status-bar";
|
||||
import { MapAttribution } from "@/components/ui/map-attribution";
|
||||
import { AtcPlayerBar } from "@/components/ui/atc-panel";
|
||||
import { Brand, GitHubBadge } from "@/components/flight-tracker-brand";
|
||||
import { SettingsProvider, useSettings } from "@/hooks/use-settings";
|
||||
import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts";
|
||||
import { useFlights } from "@/hooks/use-flights";
|
||||
@ -28,20 +36,19 @@ import { useTrailHistory } from "@/hooks/use-trail-history";
|
||||
import { useFlightTrack } from "@/hooks/use-flight-track";
|
||||
import { useMergedTrails } from "@/hooks/use-merged-trails";
|
||||
import { useFlightMonitors } from "@/hooks/use-flight-monitors";
|
||||
import { useAtcStream } from "@/hooks/use-atc-stream";
|
||||
import { useIsMobile } from "@/hooks/use-is-mobile";
|
||||
import { MobileFlightToast } from "@/components/ui/mobile-flight-toast";
|
||||
import { toast } from "sonner";
|
||||
import type { MapStyle } from "@/lib/map-styles";
|
||||
import type { City } from "@/lib/cities";
|
||||
import {
|
||||
fetchFlightByIcao24,
|
||||
fetchFlightByCallsign,
|
||||
type FlightState,
|
||||
} from "@/lib/opensky";
|
||||
import type { FlightState } from "@/lib/opensky";
|
||||
import { fetchFlightByHex, fetchFlightByCallsign } from "@/lib/flight-api";
|
||||
import { formatCallsign } from "@/lib/flight-utils";
|
||||
import type { PickingInfo } from "@deck.gl/core";
|
||||
import { Github, Star } from "lucide-react";
|
||||
import {
|
||||
DEFAULT_CITY,
|
||||
DEFAULT_STYLE,
|
||||
GITHUB_REPO_URL,
|
||||
ICAO24_REGEX,
|
||||
subscribeNoop,
|
||||
resolveInitialCity,
|
||||
@ -50,7 +57,6 @@ import {
|
||||
resolveInitialFpv,
|
||||
loadMapStyle,
|
||||
saveMapStyle,
|
||||
formatStarCount,
|
||||
} from "@/components/flight-tracker-utils";
|
||||
import {
|
||||
pickRandomAirportCity,
|
||||
@ -58,6 +64,9 @@ import {
|
||||
} from "@/components/flight-tracker-random";
|
||||
|
||||
function FlightTrackerInner() {
|
||||
// useSyncExternalStore with a no-op subscriber reads localStorage once
|
||||
// on the client while returning DEFAULT_CITY on the server — SSR-safe
|
||||
// hydration without useEffect flicker.
|
||||
const hydratedCity = useSyncExternalStore(
|
||||
subscribeNoop,
|
||||
resolveInitialCity,
|
||||
@ -89,6 +98,8 @@ function FlightTrackerInner() {
|
||||
lat: number;
|
||||
} | null>(null);
|
||||
|
||||
const lookupAbortRef = useRef<AbortController | null>(null);
|
||||
|
||||
const activeCity = cityOverride ?? hydratedCity;
|
||||
const mapStyle = styleOverride ?? hydratedStyle;
|
||||
const { settings, update } = useSettings();
|
||||
@ -106,7 +117,7 @@ function FlightTrackerInner() {
|
||||
saveMapStyle(style);
|
||||
}, []);
|
||||
|
||||
const { flights, loading, rateLimited, retryIn } = useFlights(
|
||||
const { flights, loading, rateLimited, retryIn, source } = useFlights(
|
||||
activeCity,
|
||||
fpvIcao24,
|
||||
fpvSeedCenter,
|
||||
@ -115,10 +126,17 @@ function FlightTrackerInner() {
|
||||
const displayFlights = flights;
|
||||
const displayTrails = useTrailHistory(displayFlights);
|
||||
|
||||
// Single Map for O(1) flight lookups — replaces 4× O(n) find() calls per poll
|
||||
const displayFlightMap = useMemo(() => {
|
||||
const m = new Map<string, FlightState>();
|
||||
for (const f of displayFlights) m.set(f.icao24, f);
|
||||
return m;
|
||||
}, [displayFlights]);
|
||||
|
||||
const selectedFlightForTrack = useMemo(() => {
|
||||
if (!selectedIcao24) return null;
|
||||
return displayFlights.find((f) => f.icao24 === selectedIcao24) ?? null;
|
||||
}, [selectedIcao24, displayFlights]);
|
||||
return displayFlightMap.get(selectedIcao24) ?? null;
|
||||
}, [selectedIcao24, displayFlightMap]);
|
||||
|
||||
const shouldFetchSelectedTrack =
|
||||
!!selectedIcao24 &&
|
||||
@ -140,26 +158,18 @@ function FlightTrackerInner() {
|
||||
|
||||
const selectedFlight = useMemo(() => {
|
||||
if (!selectedIcao24) return null;
|
||||
return (
|
||||
displayFlights.find((f) => f.icao24.toLowerCase() === selectedIcao24) ??
|
||||
null
|
||||
);
|
||||
}, [selectedIcao24, displayFlights]);
|
||||
return displayFlightMap.get(selectedIcao24) ?? null;
|
||||
}, [selectedIcao24, displayFlightMap]);
|
||||
|
||||
const followFlight = useMemo(() => {
|
||||
if (!followIcao24) return null;
|
||||
return (
|
||||
displayFlights.find((f) => f.icao24.toLowerCase() === followIcao24) ??
|
||||
null
|
||||
);
|
||||
}, [followIcao24, displayFlights]);
|
||||
return displayFlightMap.get(followIcao24) ?? null;
|
||||
}, [followIcao24, displayFlightMap]);
|
||||
|
||||
const fpvFlight = useMemo(() => {
|
||||
if (!fpvIcao24) return null;
|
||||
return (
|
||||
displayFlights.find((f) => f.icao24.toLowerCase() === fpvIcao24) ?? null
|
||||
);
|
||||
}, [fpvIcao24, displayFlights]);
|
||||
return displayFlightMap.get(fpvIcao24) ?? null;
|
||||
}, [fpvIcao24, displayFlightMap]);
|
||||
|
||||
useEffect(() => {
|
||||
syncFpvToUrl(fpvIcao24, activeCity);
|
||||
@ -183,12 +193,21 @@ function FlightTrackerInner() {
|
||||
setFpvSeedCenter,
|
||||
});
|
||||
|
||||
const atc = useAtcStream();
|
||||
|
||||
const fpvFlightOrCached = fpvFlight;
|
||||
const displayFlight = selectedFlight;
|
||||
|
||||
const [atcToggle, setAtcToggle] = useState(0);
|
||||
const handleToggleAtc = useCallback(() => {
|
||||
setAtcToggle((c) => c + 1);
|
||||
}, []);
|
||||
|
||||
const handleClick = useCallback(
|
||||
(info: PickingInfo<FlightState> | null) => {
|
||||
if (fpvIcao24) return;
|
||||
lookupAbortRef.current?.abort();
|
||||
lookupAbortRef.current = null;
|
||||
if (info?.object) {
|
||||
const icao24 = info.object.icao24.toLowerCase();
|
||||
setSelectedIcao24((prev) => (prev === icao24 ? null : icao24));
|
||||
@ -212,8 +231,8 @@ function FlightTrackerInner() {
|
||||
(icao24: string) => {
|
||||
const targetIcao24 = icao24.toLowerCase();
|
||||
const flight =
|
||||
displayFlights.find((f) => f.icao24.toLowerCase() === targetIcao24) ??
|
||||
flights.find((f) => f.icao24.toLowerCase() === targetIcao24);
|
||||
displayFlightMap.get(targetIcao24) ??
|
||||
flights.find((f) => f.icao24 === targetIcao24);
|
||||
if (!flight) return;
|
||||
if (flight.longitude == null || flight.latitude == null) return;
|
||||
if (flight.onGround) return;
|
||||
@ -228,7 +247,7 @@ function FlightTrackerInner() {
|
||||
});
|
||||
setFollowIcao24(null);
|
||||
},
|
||||
[displayFlights, flights],
|
||||
[displayFlightMap, flights],
|
||||
);
|
||||
|
||||
const handleExitFpv = useCallback(() => {
|
||||
@ -280,7 +299,7 @@ function FlightTrackerInner() {
|
||||
if (!compactQuery) return false;
|
||||
|
||||
const localMatch =
|
||||
displayFlights.find((f) => f.icao24.toLowerCase() === compactQuery) ??
|
||||
displayFlightMap.get(compactQuery) ??
|
||||
displayFlights.find((f) =>
|
||||
formatCallsign(f.callsign)
|
||||
.toLowerCase()
|
||||
@ -289,28 +308,37 @@ function FlightTrackerInner() {
|
||||
) ??
|
||||
null;
|
||||
|
||||
if (localMatch) {
|
||||
setSelectedIcao24(localMatch.icao24);
|
||||
// Helper: select flight and optionally enter FPV
|
||||
const selectFlight = (f: FlightState) => {
|
||||
setSelectedIcao24(f.icao24);
|
||||
setFollowIcao24(null);
|
||||
if (
|
||||
enterFpv &&
|
||||
!localMatch.onGround &&
|
||||
localMatch.longitude != null &&
|
||||
localMatch.latitude != null
|
||||
!f.onGround &&
|
||||
f.longitude != null &&
|
||||
f.latitude != null
|
||||
) {
|
||||
setFpvSeedCenter({
|
||||
lng: localMatch.longitude,
|
||||
lat: localMatch.latitude,
|
||||
});
|
||||
setFpvIcao24(localMatch.icao24);
|
||||
setFpvSeedCenter({ lng: f.longitude, lat: f.latitude });
|
||||
setFpvIcao24(f.icao24);
|
||||
}
|
||||
};
|
||||
|
||||
if (localMatch) {
|
||||
selectFlight(localMatch);
|
||||
return true;
|
||||
}
|
||||
|
||||
const result = ICAO24_REGEX.test(compactQuery)
|
||||
? await fetchFlightByIcao24(compactQuery)
|
||||
: await fetchFlightByCallsign(compactQuery);
|
||||
// Cancel any previous pending lookup
|
||||
lookupAbortRef.current?.abort();
|
||||
const controller = new AbortController();
|
||||
lookupAbortRef.current = controller;
|
||||
|
||||
try {
|
||||
const result = ICAO24_REGEX.test(compactQuery)
|
||||
? await fetchFlightByHex(compactQuery, controller.signal)
|
||||
: await fetchFlightByCallsign(compactQuery, controller.signal);
|
||||
|
||||
if (controller.signal.aborted) return false;
|
||||
if (!result.flight) return false;
|
||||
|
||||
const focusCity = cityFromFlight(result.flight);
|
||||
@ -319,23 +347,14 @@ function FlightTrackerInner() {
|
||||
syncCityToUrl(focusCity);
|
||||
}
|
||||
|
||||
setSelectedIcao24(result.flight.icao24);
|
||||
setFollowIcao24(null);
|
||||
if (
|
||||
enterFpv &&
|
||||
!result.flight.onGround &&
|
||||
result.flight.longitude != null &&
|
||||
result.flight.latitude != null
|
||||
) {
|
||||
setFpvSeedCenter({
|
||||
lng: result.flight.longitude,
|
||||
lat: result.flight.latitude,
|
||||
});
|
||||
setFpvIcao24(result.flight.icao24);
|
||||
}
|
||||
selectFlight(result.flight);
|
||||
return true;
|
||||
} catch (err) {
|
||||
if (err instanceof Error && err.name === "AbortError") return false;
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[displayFlights],
|
||||
[displayFlights, displayFlightMap],
|
||||
);
|
||||
|
||||
useKeyboardShortcuts({
|
||||
@ -346,9 +365,77 @@ function FlightTrackerInner() {
|
||||
onToggleHelp: handleToggleHelp,
|
||||
onDeselect: handleDeselectFlight,
|
||||
onToggleFpv: handleToggleFpvKey,
|
||||
onToggleAtc: handleToggleAtc,
|
||||
isFpv: fpvIcao24 !== null,
|
||||
});
|
||||
|
||||
const isMobile = useIsMobile();
|
||||
const mobileToastIdRef = useRef<string | number | null>(null);
|
||||
|
||||
// Stable close handler that both dismisses the toast and deselects the flight
|
||||
const handleMobileToastClose = useCallback(() => {
|
||||
if (mobileToastIdRef.current !== null) {
|
||||
toast.dismiss(mobileToastIdRef.current);
|
||||
mobileToastIdRef.current = null;
|
||||
}
|
||||
handleDeselectFlight();
|
||||
}, [handleDeselectFlight]);
|
||||
|
||||
// Show/dismiss mobile flight toast
|
||||
useEffect(() => {
|
||||
// Dismiss when not applicable
|
||||
if (!isMobile || fpvIcao24 || !displayFlight) {
|
||||
if (mobileToastIdRef.current !== null) {
|
||||
toast.dismiss(mobileToastIdRef.current);
|
||||
mobileToastIdRef.current = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Use a stable ID based on the selected flight
|
||||
const stableId = `mobile-flight-${displayFlight.icao24}`;
|
||||
|
||||
// If switching to a different flight, dismiss the old toast first
|
||||
if (
|
||||
mobileToastIdRef.current !== null &&
|
||||
mobileToastIdRef.current !== stableId
|
||||
) {
|
||||
toast.dismiss(mobileToastIdRef.current);
|
||||
}
|
||||
|
||||
toast.custom(
|
||||
() => (
|
||||
<MobileFlightToast
|
||||
flight={displayFlight}
|
||||
onClose={handleMobileToastClose}
|
||||
onToggleFpv={handleToggleFpv}
|
||||
isFpvActive={fpvIcao24 === displayFlight.icao24}
|
||||
/>
|
||||
),
|
||||
{
|
||||
id: stableId,
|
||||
duration: Infinity,
|
||||
dismissible: false,
|
||||
},
|
||||
);
|
||||
mobileToastIdRef.current = stableId;
|
||||
}, [
|
||||
isMobile,
|
||||
displayFlight,
|
||||
fpvIcao24,
|
||||
handleMobileToastClose,
|
||||
handleToggleFpv,
|
||||
]);
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (mobileToastIdRef.current !== null) {
|
||||
toast.dismiss(mobileToastIdRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<main className="relative h-dvh w-screen overflow-hidden bg-black">
|
||||
<MapView
|
||||
@ -368,6 +455,11 @@ function FlightTrackerInner() {
|
||||
onSelectAirport={setActiveCity}
|
||||
isDark={mapStyle.dark}
|
||||
/>
|
||||
<AirspaceLayer
|
||||
visible={settings.showAirspace}
|
||||
opacity={settings.airspaceOpacity}
|
||||
showHotspots={settings.showAirspaceHotspots}
|
||||
/>
|
||||
<FlightLayers
|
||||
flights={displayFlights}
|
||||
trails={mergedTrails}
|
||||
@ -394,7 +486,7 @@ function FlightTrackerInner() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!fpvIcao24 && (
|
||||
{!fpvIcao24 && !isMobile && (
|
||||
<div className="pointer-events-auto absolute left-3 top-14 sm:left-4 sm:top-16">
|
||||
<FlightCard
|
||||
flight={displayFlight}
|
||||
@ -409,41 +501,7 @@ function FlightTrackerInner() {
|
||||
|
||||
{!fpvIcao24 && (
|
||||
<div className="pointer-events-auto absolute right-3 top-3 flex items-center gap-1.5 sm:right-4 sm:top-4 sm:gap-2">
|
||||
<a
|
||||
href={GITHUB_REPO_URL}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
aria-label="Open GitHub repository"
|
||||
className="relative inline-flex h-9 w-9 items-center justify-center rounded-xl backdrop-blur-2xl transition-colors"
|
||||
style={{
|
||||
borderWidth: 1,
|
||||
borderColor: "rgb(var(--ui-fg) / 0.06)",
|
||||
backgroundColor: "rgb(var(--ui-fg) / 0.03)",
|
||||
color: "rgb(var(--ui-fg) / 0.5)",
|
||||
}}
|
||||
title={
|
||||
repoStars != null
|
||||
? `GitHub · ${formatStarCount(repoStars)} stars`
|
||||
: "Open GitHub repository"
|
||||
}
|
||||
>
|
||||
<Github className="h-4 w-4" />
|
||||
{repoStars != null && (
|
||||
<span
|
||||
className="pointer-events-none absolute -bottom-1 -right-1 rounded-full px-1.5 py-0.5 text-[9px] font-semibold tabular-nums"
|
||||
style={{
|
||||
backgroundColor: "rgb(var(--ui-bg) / 0.95)",
|
||||
border: "1px solid rgb(var(--ui-fg) / 0.1)",
|
||||
color: "rgb(var(--ui-fg) / 0.55)",
|
||||
}}
|
||||
>
|
||||
<span className="flex items-center gap-0.5">
|
||||
<Star className="h-2 w-2" />
|
||||
{formatStarCount(repoStars)}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</a>
|
||||
<GitHubBadge stars={repoStars} />
|
||||
<ControlPanel
|
||||
activeCity={activeCity}
|
||||
onSelectCity={setActiveCity}
|
||||
@ -461,16 +519,32 @@ function FlightTrackerInner() {
|
||||
<StatusBar
|
||||
flightCount={flights.length}
|
||||
cityName={activeCity.name}
|
||||
cityIata={activeCity.iata}
|
||||
cityCoordinates={activeCity.coordinates}
|
||||
loading={loading}
|
||||
rateLimited={rateLimited}
|
||||
retryIn={retryIn}
|
||||
onNorthUp={handleNorthUp}
|
||||
onResetView={handleResetView}
|
||||
onRandomAirport={handleRandomAirport}
|
||||
atc={atc}
|
||||
atcToggle={atcToggle}
|
||||
source={source}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ATC Player Bar — top-center on mobile, bottom-center on desktop */}
|
||||
{!fpvIcao24 && (
|
||||
<AnimatePresence>
|
||||
{atc.feed && (
|
||||
<div className="pointer-events-auto absolute left-1/2 top-14 -translate-x-1/2 sm:top-auto sm:bottom-18">
|
||||
<AtcPlayerBar atc={atc} onOpenFeedSelector={handleToggleAtc} />
|
||||
</div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
)}
|
||||
|
||||
{!fpvIcao24 && (
|
||||
<div className="pointer-events-none absolute bottom-[env(safe-area-inset-bottom,0px)] right-3 mb-3 flex flex-col items-end gap-2 sm:bottom-4 sm:right-4 sm:mb-0">
|
||||
<div className="pointer-events-auto">
|
||||
@ -480,7 +554,10 @@ function FlightTrackerInner() {
|
||||
<AltitudeLegend />
|
||||
</div>
|
||||
<div className="pointer-events-auto">
|
||||
<MapAttribution styleId={mapStyle.id} />
|
||||
<MapAttribution
|
||||
styleId={mapStyle.id}
|
||||
showAirspace={settings.showAirspace}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@ -504,15 +581,3 @@ export function FlightTracker() {
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
function Brand({ isDark }: { isDark: boolean }) {
|
||||
return (
|
||||
<span
|
||||
className={`text-sm font-semibold tracking-wide ${
|
||||
isDark ? "text-white/70" : "text-black/70"
|
||||
}`}
|
||||
>
|
||||
aeris
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@ -18,26 +18,26 @@ export const CATEGORY_TINT: Record<number, [number, number, number]> = {
|
||||
export function categorySizeMultiplier(category: number | null): number {
|
||||
switch (category) {
|
||||
case 2:
|
||||
return 0.88;
|
||||
return 0.92;
|
||||
case 3:
|
||||
return 0.96;
|
||||
case 4:
|
||||
return 1.08;
|
||||
case 5:
|
||||
return 1.18;
|
||||
case 6:
|
||||
return 1.28;
|
||||
case 7:
|
||||
return 1.04;
|
||||
case 5:
|
||||
return 1.08;
|
||||
case 6:
|
||||
return 1.12;
|
||||
case 7:
|
||||
return 1.0;
|
||||
case 8:
|
||||
return 0.86;
|
||||
return 0.9;
|
||||
case 9:
|
||||
case 12:
|
||||
return 0.8;
|
||||
return 0.86;
|
||||
case 10:
|
||||
return 1.15;
|
||||
return 1.06;
|
||||
case 14:
|
||||
return 0.72;
|
||||
return 0.82;
|
||||
default:
|
||||
return 1;
|
||||
}
|
||||
|
||||
183
src/components/map/aircraft-model-layers.ts
Normal file
183
src/components/map/aircraft-model-layers.ts
Normal file
@ -0,0 +1,183 @@
|
||||
// ── Aircraft Model Layers ──────────────────────────────────────────────
|
||||
//
|
||||
// Builds one ScenegraphLayer per model type from bucketised flights.
|
||||
// This keeps flight-layers.tsx slim and model logic self-contained.
|
||||
//
|
||||
// Performance strategy:
|
||||
// 1. Bucket raw flights by model key (cached between polls via
|
||||
// bucketFlightsByModel — only recomputes when flightsRef changes).
|
||||
// 2. Pass STABLE data arrays to each ScenegraphLayer (same reference
|
||||
// between animation frames) so deck.gl skips full attribute rebuild.
|
||||
// 3. Use updateTriggers to selectively recompute only position & orientation
|
||||
// each frame. Color and scale are recomputed only on new data.
|
||||
// 4. Layers are created for model keys that have active flights or were
|
||||
// recently active (within MODEL_DEACTIVATE_MS grace period). Truly
|
||||
// inactive models are omitted entirely to reduce overhead.
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
|
||||
import { ScenegraphLayer } from "@deck.gl/mesh-layers";
|
||||
import type { FlightState } from "@/lib/opensky";
|
||||
import { altitudeToColor, altitudeToElevation } from "@/lib/flight-utils";
|
||||
import {
|
||||
categorySizeMultiplier,
|
||||
tintAircraftColor,
|
||||
} from "./aircraft-appearance";
|
||||
import { type PickingInfo } from "@deck.gl/core";
|
||||
import {
|
||||
AIRCRAFT_MIN_PIXELS,
|
||||
AIRCRAFT_MAX_PIXELS,
|
||||
BASE_AIRCRAFT_SIZE,
|
||||
} from "./flight-layer-constants";
|
||||
import {
|
||||
ALL_MODEL_KEYS,
|
||||
bucketFlightsByModel,
|
||||
modelNormScale,
|
||||
modelUrl,
|
||||
modelYawOffset,
|
||||
} from "./aircraft-model-mapping";
|
||||
|
||||
// Stable empty array — same reference every frame so deck.gl skips buffer work
|
||||
const EMPTY_DATA: FlightState[] = [];
|
||||
|
||||
// Track when each model type was last seen in flight data.
|
||||
// Models not seen for MODEL_DEACTIVATE_MS are omitted from the layer array
|
||||
// entirely, avoiding ScenegraphLayer constructor and deck.gl diffing overhead.
|
||||
const modelLastUsed = new Map<string, number>();
|
||||
const MODEL_DEACTIVATE_MS = 5_000; // 5 second grace period (covers 1 poll cycle)
|
||||
const MODEL_LAST_USED_MAX = 50; // bound the Map to prevent unbounded growth
|
||||
|
||||
// ── Types ──────────────────────────────────────────────────────────────
|
||||
|
||||
export interface AircraftLayerParams {
|
||||
/** Raw flights (flightsRef.current) — stable between polls. Used for bucketing. */
|
||||
rawFlights: FlightState[];
|
||||
/** Interpolated flight map (icao24 → interpolated FlightState). Updated every frame. */
|
||||
interpolatedMap: Map<string, FlightState>;
|
||||
/** Animation frame counter — increments every rAF. Drives position/orientation updates. */
|
||||
frameCounter: number;
|
||||
/** Data version — increments on new poll data. Triggers color/scale recomputation. */
|
||||
dataVersion: number;
|
||||
layersVisible: boolean;
|
||||
globeFade: number;
|
||||
elevScale: number;
|
||||
altColors: boolean;
|
||||
defaultColor: [number, number, number, number];
|
||||
pitchByIcao: Map<string, number>;
|
||||
bankByIcao: Map<string, number>;
|
||||
handleHover: (info: PickingInfo<FlightState>) => void;
|
||||
handleClick: (info: PickingInfo<FlightState>) => void;
|
||||
}
|
||||
|
||||
// ── Builder ────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Returns an array of ScenegraphLayers — one per model key.
|
||||
*
|
||||
* Key optimization: `data` uses the CACHED bucket arrays (stable reference
|
||||
* between animation frames). Accessors look up interpolated positions from
|
||||
* the `interpolatedMap`. `updateTriggers` selectively recompute:
|
||||
* - getPosition / getOrientation: every frame (via frameCounter)
|
||||
* - getColor / getScale: only on new data (via dataVersion)
|
||||
*
|
||||
* This eliminates per-frame color/scale attribute recomputation for all
|
||||
* 14 layers and massively reduces GC pressure from array allocations.
|
||||
*/
|
||||
export function buildAircraftModelLayers(
|
||||
params: AircraftLayerParams,
|
||||
): ScenegraphLayer<FlightState>[] {
|
||||
const {
|
||||
rawFlights,
|
||||
interpolatedMap,
|
||||
frameCounter,
|
||||
dataVersion,
|
||||
layersVisible,
|
||||
globeFade,
|
||||
elevScale,
|
||||
altColors,
|
||||
defaultColor,
|
||||
pitchByIcao,
|
||||
bankByIcao,
|
||||
handleHover,
|
||||
handleClick,
|
||||
} = params;
|
||||
|
||||
// Cached bucketing — only recomputes when rawFlights reference changes
|
||||
const buckets = bucketFlightsByModel(rawFlights);
|
||||
const now = performance.now();
|
||||
|
||||
// Only build layers for models that have data or are within the grace
|
||||
// period. Truly inactive models (no data AND expired) are skipped entirely,
|
||||
// avoiding ScenegraphLayer constructor + deck.gl diffing overhead.
|
||||
// Evict stale entries to bound memory growth over long sessions
|
||||
if (modelLastUsed.size > MODEL_LAST_USED_MAX) {
|
||||
for (const [k, ts] of modelLastUsed) {
|
||||
if (now - ts > MODEL_DEACTIVATE_MS) modelLastUsed.delete(k);
|
||||
}
|
||||
}
|
||||
|
||||
return ALL_MODEL_KEYS.filter((key) => {
|
||||
const hasData = (buckets.get(key)?.length ?? 0) > 0;
|
||||
if (hasData) {
|
||||
modelLastUsed.set(key, now);
|
||||
return true;
|
||||
}
|
||||
return now - (modelLastUsed.get(key) ?? 0) < MODEL_DEACTIVATE_MS;
|
||||
}).map((modelKey) => {
|
||||
const flights = buckets.get(modelKey) ?? EMPTY_DATA;
|
||||
const hasData = flights.length > 0;
|
||||
|
||||
// Pre-compute the yaw offset once per layer (not per-flight per-frame)
|
||||
const yawOff = modelYawOffset(modelKey);
|
||||
const normScale = modelNormScale(modelKey);
|
||||
|
||||
return new ScenegraphLayer<FlightState>({
|
||||
id: `flight-aircraft-${modelKey}`,
|
||||
visible: hasData && layersVisible,
|
||||
data: flights,
|
||||
opacity: globeFade,
|
||||
getPosition: (d) => {
|
||||
const interp = interpolatedMap.get(d.icao24);
|
||||
const src = interp ?? d;
|
||||
return [
|
||||
src.longitude ?? 0,
|
||||
src.latitude ?? 0,
|
||||
altitudeToElevation(src.baroAltitude) * elevScale,
|
||||
];
|
||||
},
|
||||
getOrientation: (d) => {
|
||||
const interp = interpolatedMap.get(d.icao24);
|
||||
const src = interp ?? d;
|
||||
const pitch = pitchByIcao.get(d.icao24) ?? 0;
|
||||
const bank = bankByIcao.get(d.icao24) ?? 0;
|
||||
const yaw =
|
||||
yawOff - (Number.isFinite(src.trueTrack) ? src.trueTrack! : 0);
|
||||
return [pitch, yaw, 90 + bank];
|
||||
},
|
||||
getColor: (d) => {
|
||||
const base = altColors ? altitudeToColor(d.baroAltitude) : defaultColor;
|
||||
return tintAircraftColor(base, d.category);
|
||||
},
|
||||
scenegraph: modelUrl(modelKey),
|
||||
getScale: (d) => {
|
||||
const catScale = categorySizeMultiplier(d.category);
|
||||
const s = catScale * normScale;
|
||||
return [s, s, s];
|
||||
},
|
||||
sizeScale: BASE_AIRCRAFT_SIZE,
|
||||
updateTriggers: {
|
||||
getPosition: [frameCounter, elevScale],
|
||||
getOrientation: frameCounter,
|
||||
getColor: [dataVersion, altColors],
|
||||
getScale: dataVersion,
|
||||
},
|
||||
sizeMinPixels: AIRCRAFT_MIN_PIXELS,
|
||||
sizeMaxPixels: AIRCRAFT_MAX_PIXELS,
|
||||
_lighting: "pbr",
|
||||
pickable: hasData,
|
||||
onHover: handleHover,
|
||||
onClick: handleClick,
|
||||
autoHighlight: true,
|
||||
highlightColor: [255, 255, 255, 80],
|
||||
});
|
||||
});
|
||||
}
|
||||
380
src/components/map/aircraft-model-mapping.ts
Normal file
380
src/components/map/aircraft-model-mapping.ts
Normal file
@ -0,0 +1,380 @@
|
||||
// ── Aircraft Model Mapping ─────────────────────────────────────────────
|
||||
//
|
||||
// Maps ADS-B category + ICAO typeCode → 3D model silhouette.
|
||||
// Models are Draco-compressed GLB files served from Cloudinary CDN.
|
||||
// Local backups remain in public/models/aircraft/.
|
||||
//
|
||||
// Category-based fallback assigns generic silhouettes (narrowbody, etc.).
|
||||
// TypeCode-based matching routes iconic types (A380, B737) to dedicated models.
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
|
||||
import type { FlightState } from "@/lib/opensky";
|
||||
|
||||
// ── Model Keys ─────────────────────────────────────────────────────────
|
||||
|
||||
export type AircraftModelKey =
|
||||
| "a380"
|
||||
| "b737"
|
||||
| "narrowbody"
|
||||
| "widebody-2eng"
|
||||
| "widebody-4eng"
|
||||
| "regional-jet"
|
||||
| "light-prop"
|
||||
| "turboprop"
|
||||
| "helicopter"
|
||||
| "bizjet"
|
||||
| "glider"
|
||||
| "fighter"
|
||||
| "drone"
|
||||
| "generic";
|
||||
|
||||
export const ALL_MODEL_KEYS: readonly AircraftModelKey[] = [
|
||||
"a380",
|
||||
"b737",
|
||||
"narrowbody",
|
||||
"widebody-2eng",
|
||||
"widebody-4eng",
|
||||
"regional-jet",
|
||||
"light-prop",
|
||||
"turboprop",
|
||||
"helicopter",
|
||||
"bizjet",
|
||||
"glider",
|
||||
"fighter",
|
||||
"drone",
|
||||
"generic",
|
||||
] as const;
|
||||
|
||||
// ── URL Resolution ─────────────────────────────────────────────────────
|
||||
|
||||
const CLOUDINARY_CLOUD = "dfyrk32ua";
|
||||
const CLOUDINARY_FOLDER = "aeris/models/aircraft";
|
||||
|
||||
// Per-model Cloudinary versions from upload response — ensures optimal
|
||||
// CDN cache (long-lived Cache-Control) and instant busting on re-upload.
|
||||
const MODEL_CDN_VERSIONS: Readonly<Record<string, number>> = {
|
||||
b737: 1774203409,
|
||||
bizjet: 1774203410,
|
||||
fighter: 1774203411,
|
||||
glider: 1774203411,
|
||||
helicopter: 1774203412,
|
||||
"light-prop": 1774203413,
|
||||
narrowbody: 1774203413,
|
||||
"regional-jet": 1774203414,
|
||||
turboprop: 1774203415,
|
||||
"widebody-2eng": 1774203416,
|
||||
"widebody-4eng": 1774203418,
|
||||
};
|
||||
|
||||
// A380 reuses the widebody-4eng mesh (it IS the A380 from FlightAirMap).
|
||||
// generic.glb and narrowbody.glb are identical files; drone.glb and light-prop.glb likewise.
|
||||
const MODEL_FILE_OVERRIDES: Partial<Record<AircraftModelKey, string>> = {
|
||||
a380: "widebody-4eng",
|
||||
generic: "narrowbody",
|
||||
drone: "light-prop",
|
||||
};
|
||||
|
||||
export function modelUrl(key: AircraftModelKey): string {
|
||||
const file = MODEL_FILE_OVERRIDES[key] ?? key;
|
||||
const version = MODEL_CDN_VERSIONS[file] ?? 1;
|
||||
return `https://res.cloudinary.com/${CLOUDINARY_CLOUD}/raw/upload/v${version}/${CLOUDINARY_FOLDER}/${file}.glb`;
|
||||
}
|
||||
|
||||
// ── Per-Model Size Normalization ───────────────────────────────────────
|
||||
//
|
||||
// Factors normalize all models to a consistent visual base (~40 units).
|
||||
// categorySizeMultiplier in aircraft-appearance.ts adds per-category scaling.
|
||||
|
||||
const MODEL_NORMALIZE: Readonly<Record<AircraftModelKey, number>> = {
|
||||
a380: 0.42,
|
||||
b737: 0.55,
|
||||
narrowbody: 1.0,
|
||||
"widebody-2eng": 0.85,
|
||||
"widebody-4eng": 0.42,
|
||||
"regional-jet": 1.0,
|
||||
"light-prop": 2.8,
|
||||
turboprop: 0.9,
|
||||
helicopter: 2.2,
|
||||
bizjet: 2.2,
|
||||
glider: 2.0,
|
||||
fighter: 2.8,
|
||||
drone: 2.8,
|
||||
generic: 1.0,
|
||||
};
|
||||
|
||||
/** Returns the size normalization factor for a model type */
|
||||
export function modelNormScale(key: AircraftModelKey): number {
|
||||
return MODEL_NORMALIZE[key];
|
||||
}
|
||||
|
||||
// ── Per-Model Yaw Offset ───────────────────────────────────────────────
|
||||
//
|
||||
// Each GLB was authored/exported with a different nose direction in model space.
|
||||
// These offsets rotate each model so that at yaw=0 the nose faces North.
|
||||
// Combined formula: yaw = MODEL_YAW_OFFSET[key] - trueTrack
|
||||
//
|
||||
// Determined by analysing each model's node rotations and nose-indicator
|
||||
// node translations (CockpitWindows, pilot_tubes, windscreen, etc.).
|
||||
|
||||
const MODEL_YAW_OFFSET: Readonly<Record<AircraftModelKey, number>> = {
|
||||
b737: 0, // no node rotation, nose at -Z → already faces North
|
||||
narrowbody: 90, // 180° Y rotation, nose raw +X → model +X → East at yaw=0
|
||||
generic: 90, // identical mesh to narrowbody
|
||||
"widebody-2eng": 180, // 90° Y rotation, nose raw +Z → model -X → South
|
||||
"widebody-4eng": 180, // same rotation family
|
||||
a380: 180, // uses widebody-4eng mesh
|
||||
"regional-jet": 180, // 90° Y rotation, nose indicators at +Z
|
||||
bizjet: 180, // 90° Y rotation, Glass.inside near +Z
|
||||
helicopter: 180, // 90° Y rotation, body extends +Z
|
||||
glider: 180, // 90° Y rotation, windowR near +Z
|
||||
fighter: 180, // 90° Y rotation
|
||||
turboprop: 180, // 120° diagonal rotation, cylinder at +Z
|
||||
"light-prop": 180, // 120° diagonal rotation
|
||||
drone: 180, // identical mesh to light-prop
|
||||
};
|
||||
|
||||
/** Returns the yaw offset in degrees to orient the model's nose North */
|
||||
export function modelYawOffset(key: AircraftModelKey): number {
|
||||
return MODEL_YAW_OFFSET[key];
|
||||
}
|
||||
|
||||
// ── Category → Model Key (DO-260B emitter categories) ──────────────────
|
||||
export function categoryToModelKey(category: number | null): AircraftModelKey {
|
||||
switch (category) {
|
||||
case 2:
|
||||
return "light-prop";
|
||||
case 3:
|
||||
return "narrowbody";
|
||||
case 4:
|
||||
return "narrowbody";
|
||||
case 5:
|
||||
return "narrowbody";
|
||||
case 6:
|
||||
return "widebody-2eng";
|
||||
case 7:
|
||||
return "fighter";
|
||||
case 8:
|
||||
return "helicopter";
|
||||
case 9:
|
||||
return "glider";
|
||||
case 12:
|
||||
return "light-prop";
|
||||
case 14:
|
||||
return "drone";
|
||||
default:
|
||||
return "generic";
|
||||
}
|
||||
}
|
||||
|
||||
// ── TypeCode → Model Key ───────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Maps ICAO type designator to a model key. Returns null for unrecognized types.
|
||||
*
|
||||
* Patterns checked in priority order — first match wins. This ordering
|
||||
* prevents false positives (e.g. C919 matching bizjet C[5-9]xx, or
|
||||
* Fokker F28 matching the fighter F-series pattern).
|
||||
*
|
||||
* Sources: ICAO Doc 8643 Aircraft Type Designators.
|
||||
*/
|
||||
export function typeCodeToModelKey(
|
||||
typeCode: string | null | undefined,
|
||||
): AircraftModelKey | null {
|
||||
if (!typeCode) return null;
|
||||
const tc = typeCode.toUpperCase();
|
||||
|
||||
// ── Narrowbody airliners ─────────────────────────────────────────
|
||||
// Airbus A318/A319/A320/A321, neo variants (A19N/A20N/A21N),
|
||||
// Airbus A220 (BCS1/BCS3), Boeing 717, COMAC C919
|
||||
if (/^A31[89]$|^A32\d$|^A(?:19|20|21)N$|^BCS[13]$|^B712$|^C919$/.test(tc))
|
||||
return "narrowbody";
|
||||
|
||||
// ── Widebody twins ───────────────────────────────────────────────
|
||||
// A300/A310, A330, A350 (incl. A35K = A350-1000)
|
||||
if (/^A30[0-9B]$|^A310$|^A33\d$|^A35[0-9K]$/.test(tc)) return "widebody-2eng";
|
||||
|
||||
// Airbus A380
|
||||
if (/^A38\d$/.test(tc)) return "a380";
|
||||
|
||||
// Airbus A340 (four-engine widebody)
|
||||
if (/^A34\d$/.test(tc)) return "widebody-4eng";
|
||||
|
||||
// Boeing 737 family (incl. MAX 7/8/9/10: B37M/B38M/B39M/B3XM)
|
||||
if (/^B73\d$|^B3[789X]M$/.test(tc)) return "b737";
|
||||
|
||||
// Boeing 757
|
||||
if (/^B75\d$/.test(tc)) return "narrowbody";
|
||||
|
||||
// Boeing 767
|
||||
if (/^B76\d$/.test(tc)) return "widebody-2eng";
|
||||
|
||||
// Boeing 777/787
|
||||
if (/^B77\d$|^B77[LW]$|^B78\d$|^B78X$/.test(tc)) return "widebody-2eng";
|
||||
|
||||
// Boeing 747 (incl. SP/SR letter-suffix variants)
|
||||
if (/^B74[0-9FRSP]$/.test(tc)) return "widebody-4eng";
|
||||
|
||||
// ── Regional jets ────────────────────────────────────────────────
|
||||
// CRJ (incl. CRJX = CRJ-1000), Embraer E-Jets (E170/E175/E190/E195,
|
||||
// E2: E275/E290/E295, + E75L/E75S), Fokker F28/F70/F100,
|
||||
// BAe 146 (B461-B463), Antonov An-148/158, Sukhoi Superjet, ARJ21
|
||||
if (
|
||||
/^CRJ[0-9X]?$|^E1[79]\d$|^E[27][79]\d$|^E75[0-9LS]$|^F(?:28|70|10\d)$|^B46[1-3]$|^A148$|^A158$|^SU95$|^AJ27$/.test(
|
||||
tc,
|
||||
)
|
||||
)
|
||||
return "regional-jet";
|
||||
|
||||
// ── Turboprops ───────────────────────────────────────────────────
|
||||
// ATR, Dash-8, Saab 340/2000, Jetstream, Fokker F27/F50,
|
||||
// Beechcraft 1900, Embraer EMB 110/120
|
||||
if (
|
||||
/^AT[47]\d$|^DH8[A-D]?$|^SF34$|^SB20$|^JS[34]\d$|^F(?:27|50)$|^B190$|^E1[12]0$/.test(
|
||||
tc,
|
||||
)
|
||||
)
|
||||
return "turboprop";
|
||||
|
||||
// ── Business jets ────────────────────────────────────────────────
|
||||
// Gulfstream (GLF/G-series), Bombardier Global (GLEX/GL5T/GL7T),
|
||||
// Challenger, Dassault Falcon (FA-series, F2TH, F900),
|
||||
// Learjet, Cessna Citation (C5xx-C9xx + C25A-C), Hawker (H25x),
|
||||
// Embraer Phenom/Legacy (E55P/E550/E545), Pilatus PC-24,
|
||||
// HondaJet, Beechjet (BE40)
|
||||
if (
|
||||
/^GLF\d$|^GL[5-7][T0-9]$|^GLEX$|^G[2-7]\d{2}$|^CL[3-6]\d$|^FA\d[0-9X]$|^F2TH$|^F900$|^LJ\d{2}$|^C[5-9]\d{2}$|^C25[A-C]$|^GA\d[0-9C]$|^H25[0-9A-Z]?$|^E[35]5[0-9P]$|^E545$|^PC24$|^HDJT$|^BE40$/.test(
|
||||
tc,
|
||||
)
|
||||
)
|
||||
return "bizjet";
|
||||
|
||||
// ── Light GA ─────────────────────────────────────────────────────
|
||||
// Cessna single/twin, Piper, Cirrus, Diamond, SOCATA, Mooney, Beechcraft
|
||||
if (
|
||||
/^C[12]\d{2}$|^PA\d{2}$|^SR2\d$|^DA[24]\d$|^TB\d{2}$|^M20\d?$|^BE[3-9]\d$/.test(
|
||||
tc,
|
||||
)
|
||||
) {
|
||||
// Exclude military/utility types that happen to match the Cessna pattern
|
||||
if (/^C130$|^C212$|^C295$/.test(tc)) return null;
|
||||
return "light-prop";
|
||||
}
|
||||
|
||||
// ── Helicopters ──────────────────────────────────────────────────
|
||||
// Airbus/Eurocopter, Sikorsky, Robinson, Aérospatiale, MBB
|
||||
if (/^H[16]\d{2}$|^EC\d{2}$|^S[67]\d$|^R[24]\d$|^AS\d{2}$|^BK\d{2}$/.test(tc))
|
||||
return "helicopter";
|
||||
// Bell: B0xx-B4xx (B190 and B46x already handled in turboprop/regional)
|
||||
if (/^B[0-4]\d{2}$/.test(tc) && !/^B19\d$|^B46\d$/.test(tc))
|
||||
return "helicopter";
|
||||
// AgustaWestland: A10x-A19x (A148/A158 already handled in regional)
|
||||
if (/^A1[0-9]\d$/.test(tc) && tc !== "A148" && tc !== "A158")
|
||||
return "helicopter";
|
||||
|
||||
// ── Military fighters ────────────────────────────────────────────
|
||||
// F-series (Fokker F27/F28/F50 already handled in turboprop/regional)
|
||||
if (/^F\d{1,2}[A-Z]?$/.test(tc)) return "fighter";
|
||||
// Eurofighter, Tornado, Mikoyan
|
||||
if (/^EF\d/.test(tc) || tc === "TOR" || /^MIG\d/.test(tc)) return "fighter";
|
||||
// Sukhoi fighters (SU95 Superjet already handled in regional)
|
||||
if (/^SU\d/.test(tc) && tc !== "SU95") return "fighter";
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// ── Combined Resolver ──────────────────────────────────────────────────
|
||||
|
||||
/** Resolves model key: typeCode match first, then category fallback. */
|
||||
export function resolveModelKey(
|
||||
category: number | null,
|
||||
typeCode?: string | null,
|
||||
): AircraftModelKey {
|
||||
if (typeCode) {
|
||||
const fromType = typeCodeToModelKey(typeCode);
|
||||
if (fromType) return fromType;
|
||||
}
|
||||
return categoryToModelKey(category);
|
||||
}
|
||||
|
||||
// ── Per-Aircraft Model Key Cache ───────────────────────────────────────
|
||||
//
|
||||
// Avoids re-running up to 20 regex tests per flight per frame.
|
||||
// Key = icao24, value = resolved model key.
|
||||
// Cache is wiped when the flight data array changes (new poll).
|
||||
|
||||
const modelKeyCache = new Map<string, AircraftModelKey>();
|
||||
|
||||
/** Resolves model key with per-icao24 caching. */
|
||||
export function resolveModelKeyCached(flight: FlightState): AircraftModelKey {
|
||||
const cached = modelKeyCache.get(flight.icao24);
|
||||
if (cached !== undefined) return cached;
|
||||
const key = resolveModelKey(flight.category, flight.typeCode);
|
||||
modelKeyCache.set(flight.icao24, key);
|
||||
return key;
|
||||
}
|
||||
|
||||
/** Clear the model key cache when flight data changes. */
|
||||
export function invalidateModelKeyCache(): void {
|
||||
modelKeyCache.clear();
|
||||
}
|
||||
|
||||
// ── Flight Bucketing ───────────────────────────────────────────────────
|
||||
//
|
||||
// Cached bucketing: only recomputes when the flights array reference changes.
|
||||
// This prevents 60fps re-bucketing + new array allocations that cause
|
||||
// deck.gl to regenerate GPU buffers every frame.
|
||||
|
||||
let cachedBucketInput: FlightState[] | null = null;
|
||||
let cachedBuckets: Map<AircraftModelKey, FlightState[]> | null = null;
|
||||
|
||||
export function bucketFlightsByModel(
|
||||
flights: FlightState[],
|
||||
): Map<AircraftModelKey, FlightState[]> {
|
||||
// Return cached result if the flights array reference hasn't changed
|
||||
if (flights === cachedBucketInput && cachedBuckets) {
|
||||
return cachedBuckets;
|
||||
}
|
||||
|
||||
// Invalidate model key cache on new data (new aircraft may appear)
|
||||
invalidateModelKeyCache();
|
||||
|
||||
const buckets = new Map<AircraftModelKey, FlightState[]>();
|
||||
|
||||
for (const flight of flights) {
|
||||
const key = resolveModelKeyCached(flight);
|
||||
const bucket = buckets.get(key);
|
||||
if (bucket) {
|
||||
bucket.push(flight);
|
||||
} else {
|
||||
buckets.set(key, [flight]);
|
||||
}
|
||||
}
|
||||
|
||||
cachedBucketInput = flights;
|
||||
cachedBuckets = buckets;
|
||||
return buckets;
|
||||
}
|
||||
|
||||
// ── Preloading ─────────────────────────────────────────────────────────
|
||||
|
||||
let preloaded = false;
|
||||
|
||||
const PREFETCH_KEYS: AircraftModelKey[] = [
|
||||
"narrowbody",
|
||||
"b737",
|
||||
"widebody-2eng",
|
||||
];
|
||||
|
||||
export function preloadAllModels(): void {
|
||||
if (preloaded || typeof document === "undefined") return;
|
||||
preloaded = true;
|
||||
|
||||
for (const key of PREFETCH_KEYS) {
|
||||
const link = document.createElement("link");
|
||||
link.rel = "prefetch";
|
||||
link.href = modelUrl(key);
|
||||
link.as = "fetch";
|
||||
link.crossOrigin = "anonymous";
|
||||
document.head.appendChild(link);
|
||||
}
|
||||
}
|
||||
209
src/components/map/airspace-layer.tsx
Normal file
209
src/components/map/airspace-layer.tsx
Normal file
@ -0,0 +1,209 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useCallback } from "react";
|
||||
import { useMap } from "./map";
|
||||
|
||||
// ── OpenAIP Airspace Tile Overlay ──────────────────────────────────────
|
||||
//
|
||||
// Adds OpenAIP's pre-styled airspace raster tiles as a MapLibre layer.
|
||||
// Tiles are fetched through /api/airspace-tiles to keep the API key
|
||||
// server-side. The layer is inserted below symbol layers so labels
|
||||
// remain readable.
|
||||
//
|
||||
// MEMORY OPTIMISATION: When airspace is hidden the entire source is
|
||||
// removed (not just set to `visibility: none`). This releases all
|
||||
// decoded tile ArrayBuffers from GPU memory — each 256×256 PNG tile
|
||||
// occupies ~262 KB decoded, and 100+ cached tiles can easily add
|
||||
// 26+ MB per source. The proxy sets Cache-Control: immutable so
|
||||
// the browser disk-cache serves tiles instantly when re-enabled.
|
||||
//
|
||||
// Data: openaip.net (CC BY-NC 4.0)
|
||||
// Tiles update daily; cached 24h by the proxy.
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
|
||||
const SOURCE_ID = "openaip-airspace-tiles";
|
||||
const LAYER_ID = "openaip-airspace-layer";
|
||||
|
||||
const AIRSPACE_CONTRAST = 0.3;
|
||||
const AIRSPACE_SATURATION = 0.2;
|
||||
const AIRSPACE_BRIGHTNESS_MIN = 0.08;
|
||||
const AIRSPACE_MIN_ZOOM = 4;
|
||||
const AIRSPACE_MAX_ZOOM = 14;
|
||||
|
||||
const HOTSPOT_SOURCE_ID = "openaip-hotspot-tiles";
|
||||
const HOTSPOT_LAYER_ID = "openaip-hotspot-layer";
|
||||
const HOTSPOT_OPACITY = 0.7;
|
||||
|
||||
type AirspaceLayerProps = {
|
||||
visible: boolean;
|
||||
opacity: number;
|
||||
showHotspots: boolean;
|
||||
};
|
||||
|
||||
export function AirspaceLayer({
|
||||
visible,
|
||||
opacity,
|
||||
showHotspots,
|
||||
}: AirspaceLayerProps) {
|
||||
const { map, isLoaded } = useMap();
|
||||
|
||||
// Refs to let style.load callback read latest prop values
|
||||
const visibleRef = useRef(visible);
|
||||
visibleRef.current = visible;
|
||||
const opacityRef = useRef(opacity);
|
||||
opacityRef.current = opacity;
|
||||
const hotspotsRef = useRef(showHotspots);
|
||||
hotspotsRef.current = showHotspots;
|
||||
|
||||
const mountedRef = useRef(true);
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
return () => {
|
||||
mountedRef.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────
|
||||
|
||||
/** Remove airspace + hotspot layers/sources if they exist. */
|
||||
const removeSources = useCallback(() => {
|
||||
if (!map) return;
|
||||
try {
|
||||
if (map.getLayer(HOTSPOT_LAYER_ID)) map.removeLayer(HOTSPOT_LAYER_ID);
|
||||
if (map.getSource(HOTSPOT_SOURCE_ID)) map.removeSource(HOTSPOT_SOURCE_ID);
|
||||
if (map.getLayer(LAYER_ID)) map.removeLayer(LAYER_ID);
|
||||
if (map.getSource(SOURCE_ID)) map.removeSource(SOURCE_ID);
|
||||
} catch {
|
||||
/* map may already be destroyed */
|
||||
}
|
||||
}, [map]);
|
||||
|
||||
/** Add airspace + hotspot sources and layers. */
|
||||
const addSources = useCallback(() => {
|
||||
if (!map || !mountedRef.current) return;
|
||||
if (map.getSource(SOURCE_ID)) return; // already added
|
||||
|
||||
map.addSource(SOURCE_ID, {
|
||||
type: "raster",
|
||||
tiles: ["/api/airspace-tiles?z={z}&x={x}&y={y}"],
|
||||
tileSize: 256,
|
||||
minzoom: AIRSPACE_MIN_ZOOM,
|
||||
maxzoom: AIRSPACE_MAX_ZOOM,
|
||||
attribution:
|
||||
'© <a href="https://www.openaip.net" target="_blank">OpenAIP</a>',
|
||||
});
|
||||
|
||||
// Insert below the first symbol layer so airspace doesn't occlude
|
||||
// map labels, airport markers, or other overlay text.
|
||||
const layers = map.getStyle()?.layers ?? [];
|
||||
let beforeId: string | undefined;
|
||||
for (const layer of layers) {
|
||||
if (layer.type === "symbol") {
|
||||
beforeId = layer.id;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
map.addLayer(
|
||||
{
|
||||
id: LAYER_ID,
|
||||
type: "raster",
|
||||
source: SOURCE_ID,
|
||||
minzoom: AIRSPACE_MIN_ZOOM,
|
||||
paint: {
|
||||
"raster-opacity": opacityRef.current,
|
||||
"raster-contrast": AIRSPACE_CONTRAST,
|
||||
"raster-saturation": AIRSPACE_SATURATION,
|
||||
"raster-brightness-min": AIRSPACE_BRIGHTNESS_MIN,
|
||||
"raster-fade-duration": 200,
|
||||
},
|
||||
},
|
||||
beforeId,
|
||||
);
|
||||
|
||||
// ── Hotspots layer (thermal/glider activity) ────────────────────
|
||||
if (!map.getSource(HOTSPOT_SOURCE_ID)) {
|
||||
map.addSource(HOTSPOT_SOURCE_ID, {
|
||||
type: "raster",
|
||||
tiles: ["/api/airspace-tiles?layer=hotspots&z={z}&x={x}&y={y}"],
|
||||
tileSize: 256,
|
||||
minzoom: AIRSPACE_MIN_ZOOM,
|
||||
maxzoom: AIRSPACE_MAX_ZOOM,
|
||||
attribution:
|
||||
'© <a href="https://www.openaip.net" target="_blank">OpenAIP</a>',
|
||||
});
|
||||
}
|
||||
|
||||
map.addLayer(
|
||||
{
|
||||
id: HOTSPOT_LAYER_ID,
|
||||
type: "raster",
|
||||
source: HOTSPOT_SOURCE_ID,
|
||||
minzoom: AIRSPACE_MIN_ZOOM,
|
||||
paint: {
|
||||
"raster-opacity": HOTSPOT_OPACITY,
|
||||
"raster-fade-duration": 200,
|
||||
},
|
||||
layout: {
|
||||
visibility: hotspotsRef.current ? "visible" : "none",
|
||||
},
|
||||
},
|
||||
beforeId,
|
||||
);
|
||||
}, [map, removeSources]);
|
||||
|
||||
// ── Add/remove sources based on visibility ─────────────────────────
|
||||
// When hidden → remove sources entirely to free tile ArrayBuffers.
|
||||
// When shown → re-add (browser HTTP cache makes this instant).
|
||||
useEffect(() => {
|
||||
if (!map || !isLoaded) return;
|
||||
|
||||
const onStyleLoad = () => {
|
||||
// After style swap, re-add only if currently visible
|
||||
if (visibleRef.current) addSources();
|
||||
};
|
||||
map.on("style.load", onStyleLoad);
|
||||
|
||||
// Initial add (if visible and style already loaded)
|
||||
if (visible && map.isStyleLoaded()) {
|
||||
addSources();
|
||||
} else if (!visible) {
|
||||
removeSources();
|
||||
}
|
||||
|
||||
return () => {
|
||||
map.off("style.load", onStyleLoad);
|
||||
removeSources();
|
||||
};
|
||||
}, [map, isLoaded, addSources, removeSources, visible]);
|
||||
|
||||
// ── Toggle hotspot layer visibility ────────────────────────────────
|
||||
useEffect(() => {
|
||||
if (!map || !isLoaded || !visible) return;
|
||||
try {
|
||||
if (map.getLayer(HOTSPOT_LAYER_ID)) {
|
||||
map.setLayoutProperty(
|
||||
HOTSPOT_LAYER_ID,
|
||||
"visibility",
|
||||
showHotspots ? "visible" : "none",
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
/* layer may not exist yet after style swap */
|
||||
}
|
||||
}, [map, isLoaded, visible, showHotspots]);
|
||||
|
||||
// ── Dynamic opacity ───────────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
if (!map || !isLoaded || !visible) return;
|
||||
try {
|
||||
if (map.getLayer(LAYER_ID)) {
|
||||
map.setPaintProperty(LAYER_ID, "raster-opacity", opacity);
|
||||
}
|
||||
} catch {
|
||||
/* layer may not exist yet */
|
||||
}
|
||||
}, [map, isLoaded, visible, opacity]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@ -39,6 +39,10 @@ export function CameraController({
|
||||
const orbitFrameRef = useRef<number | null>(null);
|
||||
const isInteractingRef = useRef(false);
|
||||
const isFollowingRef = useRef(false);
|
||||
const followFlyToActiveRef = useRef(false);
|
||||
const followFlyToTimerRef = useRef<ReturnType<typeof setTimeout> | null>(
|
||||
null,
|
||||
);
|
||||
const isFpvActiveRef = useRef(false);
|
||||
const fpvFlightRef = useRef<FlightState | null>(fpvFlight);
|
||||
const fpvPosRef = useRef(fpvPositionRef);
|
||||
@ -75,6 +79,12 @@ export function CameraController({
|
||||
if (followKey === prevFollowRef.current) return;
|
||||
prevFollowRef.current = followKey;
|
||||
|
||||
if (followFlyToTimerRef.current) {
|
||||
clearTimeout(followFlyToTimerRef.current);
|
||||
followFlyToTimerRef.current = null;
|
||||
}
|
||||
followFlyToActiveRef.current = false;
|
||||
|
||||
if (
|
||||
!followFlight ||
|
||||
followFlight.longitude == null ||
|
||||
@ -85,18 +95,25 @@ export function CameraController({
|
||||
}
|
||||
|
||||
isFollowingRef.current = true;
|
||||
followFlyToActiveRef.current = true;
|
||||
const bearing = Number.isFinite(followFlight.trueTrack)
|
||||
? followFlight.trueTrack!
|
||||
: map.getBearing();
|
||||
|
||||
const FOLLOW_FLYTO_MS = 2200;
|
||||
map.flyTo({
|
||||
center: [followFlight.longitude, followFlight.latitude],
|
||||
zoom: FOLLOW_ZOOM,
|
||||
pitch: FOLLOW_PITCH,
|
||||
bearing,
|
||||
duration: 2200,
|
||||
duration: FOLLOW_FLYTO_MS,
|
||||
essential: true,
|
||||
});
|
||||
|
||||
followFlyToTimerRef.current = setTimeout(() => {
|
||||
followFlyToActiveRef.current = false;
|
||||
followFlyToTimerRef.current = null;
|
||||
}, FOLLOW_FLYTO_MS);
|
||||
}, [map, isLoaded, followFlight]);
|
||||
|
||||
// Follow flight continuous update
|
||||
@ -105,6 +122,7 @@ export function CameraController({
|
||||
if (followFlight.longitude == null || followFlight.latitude == null) return;
|
||||
|
||||
if (!isFollowingRef.current) return;
|
||||
if (followFlyToActiveRef.current) return;
|
||||
|
||||
map.easeTo({
|
||||
center: [followFlight.longitude, followFlight.latitude],
|
||||
@ -144,10 +162,18 @@ export function CameraController({
|
||||
const onNorthUp = () => {
|
||||
if (isFpvActiveRef.current) return;
|
||||
if (northUpRafId != null) cancelAnimationFrame(northUpRafId);
|
||||
const startBearing = map.getBearing();
|
||||
if (!map) return;
|
||||
const m = map;
|
||||
|
||||
// Stop any in-progress flyTo/easeTo (e.g. city transition, follow
|
||||
// init) so this RAF setBearing() loop won't fight a parallel
|
||||
// camera animation — which causes visible oscillation.
|
||||
m.stop();
|
||||
|
||||
const startBearing = m.getBearing();
|
||||
const delta = ((0 - startBearing + 540) % 360) - 180;
|
||||
if (Math.abs(delta) < 0.5) {
|
||||
map.setBearing(0);
|
||||
m.setBearing(0);
|
||||
return;
|
||||
}
|
||||
const duration = 650;
|
||||
@ -155,7 +181,7 @@ export function CameraController({
|
||||
function animateBearing() {
|
||||
const t = Math.min((performance.now() - start) / duration, 1);
|
||||
const eased = smoothstep(t);
|
||||
map!.setBearing(startBearing + delta * eased);
|
||||
m.setBearing(startBearing + delta * eased);
|
||||
if (t < 1) {
|
||||
northUpRafId = requestAnimationFrame(animateBearing);
|
||||
} else {
|
||||
|
||||
@ -1,12 +1,11 @@
|
||||
import type { FlightState } from "@/lib/opensky";
|
||||
import type { TrailEntry } from "@/hooks/use-trail-history";
|
||||
import { snapLngToReference, unwrapLngPath } from "@/lib/geo";
|
||||
import {
|
||||
snapLngToReference,
|
||||
unwrapLngPath,
|
||||
greatCircleIntermediate,
|
||||
gcDistanceDeg,
|
||||
} from "@/lib/geo";
|
||||
import { roundSharpCorners2D } from "@/lib/trail-smoothing";
|
||||
removeSpikePoints,
|
||||
roundSharpCorners3D,
|
||||
catmullRomSpline3D,
|
||||
} from "@/lib/trail-smoothing";
|
||||
import type { ElevatedPoint, Snapshot } from "./flight-layer-constants";
|
||||
import {
|
||||
STARTUP_TRAIL_POLLS,
|
||||
@ -22,7 +21,8 @@ export function buildStartupFallbackTrail(f: FlightState): [number, number][] {
|
||||
|
||||
const heading =
|
||||
((Number.isFinite(f.trueTrack) ? f.trueTrack! : 0) * Math.PI) / 180;
|
||||
const speed = Number.isFinite(f.velocity) ? f.velocity! : 200;
|
||||
const speed =
|
||||
Number.isFinite(f.velocity) && f.velocity! > 0 ? f.velocity! : 200;
|
||||
const degPerSecond = speed / 111_320;
|
||||
|
||||
const path: [number, number][] = [];
|
||||
@ -117,9 +117,18 @@ export function smoothElevatedPath(
|
||||
): ElevatedPoint[] {
|
||||
if (points.length < 3 || iterations <= 0) return points;
|
||||
|
||||
const effectiveIters =
|
||||
points.length > 4000
|
||||
? 0
|
||||
: points.length > 2000
|
||||
? Math.min(iterations, 1)
|
||||
: points.length > 500
|
||||
? Math.min(iterations, 2)
|
||||
: iterations;
|
||||
|
||||
let current = points;
|
||||
for (let iter = 0; iter < iterations; iter++) {
|
||||
if (current.length < 3) break;
|
||||
for (let iter = 0; iter < effectiveIters; iter++) {
|
||||
if (current.length < 3 || current.length > 6000) break;
|
||||
|
||||
const next: ElevatedPoint[] = [current[0]];
|
||||
for (let i = 0; i < current.length - 1; i++) {
|
||||
@ -140,62 +149,23 @@ export function smoothElevatedPath(
|
||||
current = next;
|
||||
}
|
||||
|
||||
// Absolute output cap — prevents downstream per-point processing
|
||||
// (color mapping, altitude effects) from becoming a bottleneck.
|
||||
const MAX_SMOOTH_OUTPUT = 6000;
|
||||
if (current.length > MAX_SMOOTH_OUTPUT) {
|
||||
const stride = (current.length - 1) / (MAX_SMOOTH_OUTPUT - 1);
|
||||
const capped: ElevatedPoint[] = [];
|
||||
for (let i = 0; i < MAX_SMOOTH_OUTPUT - 1; i++) {
|
||||
capped.push(current[Math.round(i * stride)]);
|
||||
}
|
||||
capped.push(current[current.length - 1]);
|
||||
current = capped;
|
||||
}
|
||||
|
||||
return current;
|
||||
}
|
||||
|
||||
export function densifyElevatedPath(
|
||||
points: ElevatedPoint[],
|
||||
subdivisions: number = 2,
|
||||
): ElevatedPoint[] {
|
||||
if (points.length < 2 || subdivisions <= 1) return points;
|
||||
|
||||
// Threshold in degrees above which we use great-circle interpolation
|
||||
// instead of linear. ~0.5° ≈ 55 km at the equator.
|
||||
const GC_THRESHOLD_DEG = 0.4;
|
||||
|
||||
const out: ElevatedPoint[] = [];
|
||||
for (let i = 0; i < points.length - 1; i++) {
|
||||
const a = points[i];
|
||||
const b = points[i + 1];
|
||||
out.push(a);
|
||||
|
||||
const dist = gcDistanceDeg(a[0], a[1], b[0], b[1]);
|
||||
const useGC = dist > GC_THRESHOLD_DEG;
|
||||
|
||||
// For longer segments, add extra subdivisions proportional to distance
|
||||
const effectiveSubs = useGC
|
||||
? Math.max(subdivisions, Math.min(16, Math.ceil(dist / 0.3)))
|
||||
: subdivisions;
|
||||
|
||||
for (let j = 1; j < effectiveSubs; j++) {
|
||||
const t = j / effectiveSubs;
|
||||
if (useGC) {
|
||||
const [lng, lat] = greatCircleIntermediate(a[0], a[1], b[0], b[1], t);
|
||||
const alt = a[2] + (b[2] - a[2]) * t;
|
||||
out.push([lng, lat, alt]);
|
||||
} else {
|
||||
out.push([
|
||||
a[0] + (b[0] - a[0]) * t,
|
||||
a[1] + (b[1] - a[1]) * t,
|
||||
a[2] + (b[2] - a[2]) * t,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
out.push(points[points.length - 1]);
|
||||
return out;
|
||||
}
|
||||
|
||||
// ── Numeric & Planar Smoothing ─────────────────────────────────────────
|
||||
|
||||
export function smoothNumericSeries(values: number[]): number[] {
|
||||
if (values.length < 3) return values;
|
||||
const out = [...values];
|
||||
for (let i = 1; i < values.length - 1; i++) {
|
||||
out[i] = values[i - 1] * 0.2 + values[i] * 0.6 + values[i + 1] * 0.2;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
// ── Altitude Smoothing ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Multi-pass altitude smoothing with a wider kernel to prevent
|
||||
@ -221,69 +191,6 @@ export function smoothAnimationAltitudes(
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Remove points that create sharp reversals (V-spikes) in a 2D path. */
|
||||
export function removePlanarSpikes(
|
||||
points: [number, number][],
|
||||
): [number, number][] {
|
||||
if (points.length < 3) return points;
|
||||
|
||||
const keep: boolean[] = new Array(points.length).fill(true);
|
||||
const COS_THRESHOLD = -0.5; // reject turns sharper than 120°
|
||||
|
||||
for (let pass = 0; pass < 2; pass++) {
|
||||
let changed = false;
|
||||
for (let i = 1; i < points.length - 1; i++) {
|
||||
if (!keep[i]) continue;
|
||||
let prevIdx = i - 1;
|
||||
while (prevIdx >= 0 && !keep[prevIdx]) prevIdx--;
|
||||
if (prevIdx < 0) continue;
|
||||
let nextIdx = i + 1;
|
||||
while (nextIdx < points.length && !keep[nextIdx]) nextIdx++;
|
||||
if (nextIdx >= points.length) continue;
|
||||
|
||||
const dx1 = points[i][0] - points[prevIdx][0];
|
||||
const dy1 = points[i][1] - points[prevIdx][1];
|
||||
const dx2 = points[nextIdx][0] - points[i][0];
|
||||
const dy2 = points[nextIdx][1] - points[i][1];
|
||||
const len1 = Math.sqrt(dx1 * dx1 + dy1 * dy1);
|
||||
const len2 = Math.sqrt(dx2 * dx2 + dy2 * dy2);
|
||||
if (len1 < 1e-10 || len2 < 1e-10) continue;
|
||||
|
||||
const cos = (dx1 * dx2 + dy1 * dy2) / (len1 * len2);
|
||||
if (cos < COS_THRESHOLD) {
|
||||
keep[i] = false;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (!changed) break;
|
||||
}
|
||||
|
||||
if (keep.every(Boolean)) return points;
|
||||
return points.filter((_, i) => keep[i]);
|
||||
}
|
||||
|
||||
export function smoothPlanarPath(
|
||||
points: [number, number][],
|
||||
): [number, number][] {
|
||||
if (points.length < 3) return points;
|
||||
|
||||
let current: [number, number][] = removePlanarSpikes(points);
|
||||
current = roundSharpCorners2D(current, 15);
|
||||
|
||||
for (let pass = 0; pass < 6; pass++) {
|
||||
const next = [...current];
|
||||
for (let i = 1; i < current.length - 1; i++) {
|
||||
next[i] = [
|
||||
current[i - 1][0] * 0.2 + current[i][0] * 0.6 + current[i + 1][0] * 0.2,
|
||||
current[i - 1][1] * 0.2 + current[i][1] * 0.6 + current[i + 1][1] * 0.2,
|
||||
];
|
||||
}
|
||||
current = next;
|
||||
}
|
||||
|
||||
return current;
|
||||
}
|
||||
|
||||
// ── Trail Ahead Trimming ───────────────────────────────────────────────
|
||||
|
||||
export function trimPathAheadOfAircraft(
|
||||
@ -297,7 +204,7 @@ export function trimPathAheadOfAircraft(
|
||||
|
||||
let bestIndex = points.length - 2;
|
||||
let bestDistanceSq = Number.POSITIVE_INFINITY;
|
||||
const searchStart = Math.max(0, points.length - 40);
|
||||
const searchStart = Math.max(0, Math.floor(points.length * 0.9));
|
||||
|
||||
for (let i = searchStart; i < points.length - 1; i++) {
|
||||
const a = points[i];
|
||||
@ -323,19 +230,80 @@ export function trimPathAheadOfAircraft(
|
||||
}
|
||||
|
||||
const trimmed = points.slice(0, bestIndex + 1);
|
||||
|
||||
// Smooth transition: insert a quadratic Bézier arc between the trail's
|
||||
// clip point and the aircraft. The control-point lever is scaled by
|
||||
// heading alignment (dot product) so turning aircraft never create loops.
|
||||
const lastPt = trimmed[trimmed.length - 1];
|
||||
if (lastPt && trimmed.length >= 2) {
|
||||
const prevPt = trimmed[trimmed.length - 2];
|
||||
const hdx = lastPt[0] - prevPt[0];
|
||||
const hdy = lastPt[1] - prevPt[1];
|
||||
const hLen = Math.sqrt(hdx * hdx + hdy * hdy);
|
||||
const dx = px - lastPt[0];
|
||||
const dy = py - lastPt[1];
|
||||
const dist = Math.sqrt(dx * dx + dy * dy);
|
||||
|
||||
if (dist > 1e-7) {
|
||||
// How aligned is trail heading → aircraft direction? [-1, 1]
|
||||
const dot = hLen > 1e-10 ? (hdx * dx + hdy * dy) / (hLen * dist) : 0;
|
||||
// Scale lever by alignment: 0 when perpendicular/behind (no loop),
|
||||
// up to 0.4 when heading straight at the aircraft (smooth arc).
|
||||
const lever = Math.max(0, dot) * 0.4;
|
||||
const ux = hLen > 1e-10 ? hdx / hLen : 0;
|
||||
const uy = hLen > 1e-10 ? hdy / hLen : 0;
|
||||
const cx = lastPt[0] + ux * dist * lever;
|
||||
const cy = lastPt[1] + uy * dist * lever;
|
||||
|
||||
// Insert 3 Bézier arc points between trail end and aircraft
|
||||
for (let j = 1; j <= 3; j++) {
|
||||
const t = j / 4;
|
||||
const b0 = (1 - t) * (1 - t);
|
||||
const b1 = 2 * (1 - t) * t;
|
||||
const b2 = t * t;
|
||||
trimmed.push([
|
||||
b0 * lastPt[0] + b1 * cx + b2 * px,
|
||||
b0 * lastPt[1] + b1 * cy + b2 * py,
|
||||
lastPt[2] + (aircraft[2] - lastPt[2]) * t,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
trimmed.push([px, py, aircraft[2]]);
|
||||
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
// ── Visible Trail Point Builder (extracted from component) ─────────────
|
||||
// ── Trail Base Path Cache ──────────────────────────────────────────────
|
||||
|
||||
export function buildVisibleTrailPoints(
|
||||
/**
|
||||
* Generates a cache key for trail base path computation.
|
||||
* The base path only changes when trail data grows, trail distance changes,
|
||||
* or fullHistory mode toggles. Keyed on the last point so appends invalidate.
|
||||
*/
|
||||
export function trailBasePathCacheKey(
|
||||
trail: TrailEntry,
|
||||
trailDistance: number,
|
||||
): string {
|
||||
const n = trail.path.length;
|
||||
const last = n > 0 ? trail.path[n - 1] : null;
|
||||
const lastAlt =
|
||||
trail.altitudes.length > 0
|
||||
? trail.altitudes[trail.altitudes.length - 1]
|
||||
: null;
|
||||
return `${n}|${trailDistance}|${trail.fullHistory ? 1 : 0}|${last?.[0]}|${last?.[1]}|${lastAlt}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes the expensive base path (smoothing + densification) for a trail.
|
||||
* This result is cacheable across animation frames — it only depends on
|
||||
* trail.path, trail.altitudes, trailDistance, and fullHistory.
|
||||
* The per-frame head attachment (trimPathAheadOfAircraft) is NOT included.
|
||||
*/
|
||||
export function buildTrailBasePath(
|
||||
trail: TrailEntry,
|
||||
animFlight: FlightState | undefined,
|
||||
trailDistance: number,
|
||||
smoothingIterations: number,
|
||||
denseSubdivisions: number,
|
||||
): ElevatedPoint[] {
|
||||
const isFullHistory = trail.fullHistory === true;
|
||||
const historyPoints = isFullHistory
|
||||
@ -389,26 +357,97 @@ export function buildVisibleTrailPoints(
|
||||
pathSlice = trimmed.path;
|
||||
altitudeSlice = trimmed.altitudes;
|
||||
|
||||
const smoothPathSlice = isFullHistory
|
||||
? pathSlice
|
||||
: smoothPlanarPath(pathSlice);
|
||||
|
||||
const rawAltitudes = altitudeSlice.map(
|
||||
(a) => a ?? trail.baroAltitude ?? animFlight?.baroAltitude ?? 0,
|
||||
);
|
||||
const altitudeMeters = isFullHistory
|
||||
? rawAltitudes
|
||||
: smoothAnimationAltitudes(rawAltitudes, 3);
|
||||
|
||||
const basePath = smoothPathSlice.map((p, i) => [
|
||||
if (isFullHistory) {
|
||||
// The historical portion is already smooth from the Catmull-Rom
|
||||
// spline in trail-stitching.ts, but the stitched live-tail portion
|
||||
// is raw GPS. Apply roundSharpCorners3D to catch remaining tight
|
||||
// turns (approach patterns, live-tail heading kinks) without
|
||||
// re-running the full kernel pre-smoothing or re-spline.
|
||||
const rawAltitudes = altitudeSlice.map((a) => a ?? trail.baroAltitude ?? 0);
|
||||
const altitudeMeters = smoothAnimationAltitudes(rawAltitudes, 3);
|
||||
const elevated = pathSlice.map(
|
||||
(p, i) =>
|
||||
[
|
||||
p[0],
|
||||
p[1],
|
||||
Math.max(0, altitudeMeters[i] ?? trail.baroAltitude ?? 0),
|
||||
]) as ElevatedPoint[];
|
||||
const denseBasePath = densifyElevatedPath(
|
||||
basePath,
|
||||
isFullHistory ? 1 : denseSubdivisions,
|
||||
] as ElevatedPoint,
|
||||
);
|
||||
return elevated.length >= 3 ? roundSharpCorners3D(elevated, 15) : elevated;
|
||||
}
|
||||
|
||||
// Active trails: remove GPS glitches (V-spikes), smooth positions to
|
||||
// reduce measurement noise, smooth altitudes, then apply Catmull-Rom
|
||||
// spline for consistent visual smoothness with historical trails.
|
||||
const spikeResult = removeSpikePoints(pathSlice, altitudeSlice);
|
||||
|
||||
// Pre-smooth 2D positions: 5 passes of a 0.25/0.5/0.25 kernel removes
|
||||
// GPS measurement jitter (~10-20m noise) while preserving the overall
|
||||
// path shape. Without this, the interpolating Catmull-Rom spline would
|
||||
// amplify noise into visible oscillations between control points.
|
||||
let smoothedPath = spikeResult.path;
|
||||
if (smoothedPath.length >= 3) {
|
||||
for (let pass = 0; pass < 5; pass++) {
|
||||
const next: [number, number][] = [smoothedPath[0]];
|
||||
for (let i = 1; i < smoothedPath.length - 1; i++) {
|
||||
next.push([
|
||||
smoothedPath[i - 1][0] * 0.25 +
|
||||
smoothedPath[i][0] * 0.5 +
|
||||
smoothedPath[i + 1][0] * 0.25,
|
||||
smoothedPath[i - 1][1] * 0.25 +
|
||||
smoothedPath[i][1] * 0.5 +
|
||||
smoothedPath[i + 1][1] * 0.25,
|
||||
]);
|
||||
}
|
||||
next.push(smoothedPath[smoothedPath.length - 1]);
|
||||
smoothedPath = next;
|
||||
}
|
||||
}
|
||||
|
||||
const rawAltitudes = spikeResult.altitudes.map(
|
||||
(a) => a ?? trail.baroAltitude ?? 0,
|
||||
);
|
||||
const altitudeMeters = smoothAnimationAltitudes(rawAltitudes, 3);
|
||||
|
||||
const elevated: ElevatedPoint[] = smoothedPath.map((p, i) => [
|
||||
p[0],
|
||||
p[1],
|
||||
Math.max(0, altitudeMeters[i] ?? trail.baroAltitude ?? 0),
|
||||
]);
|
||||
|
||||
if (elevated.length >= 2) {
|
||||
// Round sharp corners (>15° heading change) before spline to remove
|
||||
// GPS-noise kinks and tight arcs at genuine turns.
|
||||
const rounded = roundSharpCorners3D(elevated, 15);
|
||||
// Moderate density (5-14 pts/seg) produces smooth curves without
|
||||
// the point bloat that higher density would cause across 200+ trails.
|
||||
return catmullRomSpline3D(rounded, 5, 14);
|
||||
}
|
||||
return elevated;
|
||||
}
|
||||
|
||||
// ── Visible Trail Point Builder (extracted from component) ─────────────
|
||||
|
||||
/**
|
||||
* Builds the final visible trail points for rendering.
|
||||
* When cachedBasePath is provided, skips the expensive smoothing/densification
|
||||
* and only performs the cheap per-frame head attachment + final smoothing.
|
||||
*/
|
||||
export function buildVisibleTrailPoints(
|
||||
trail: TrailEntry,
|
||||
animFlight: FlightState | undefined,
|
||||
trailDistance: number,
|
||||
smoothingIterations: number,
|
||||
cachedBasePath?: ElevatedPoint[],
|
||||
): ElevatedPoint[] {
|
||||
const denseBasePath =
|
||||
cachedBasePath ?? buildTrailBasePath(trail, trailDistance);
|
||||
|
||||
// Skip Chaikin subdivision — the Catmull-Rom spline, roundSharpCorners3D,
|
||||
// and Bézier head-arc already produce smooth, dense output. Running
|
||||
// Chaikin on top would bloat ~200 pts → ~1600 per trail per frame,
|
||||
// causing severe lag during orbit with 100+ aircraft.
|
||||
const skipChaikin = true;
|
||||
|
||||
if (
|
||||
animFlight &&
|
||||
@ -425,20 +464,17 @@ export function buildVisibleTrailPoints(
|
||||
]);
|
||||
|
||||
const smoothed =
|
||||
clipped.length < 4
|
||||
skipChaikin || clipped.length < 4
|
||||
? clipped
|
||||
: smoothElevatedPath(clipped, isFullHistory ? 1 : smoothingIterations);
|
||||
: smoothElevatedPath(clipped, smoothingIterations);
|
||||
|
||||
return smoothed.map((p) => [p[0], p[1], Math.max(0, p[2])]);
|
||||
}
|
||||
|
||||
const smoothed =
|
||||
denseBasePath.length < 4
|
||||
skipChaikin || denseBasePath.length < 4
|
||||
? denseBasePath
|
||||
: smoothElevatedPath(
|
||||
denseBasePath,
|
||||
isFullHistory ? 1 : smoothingIterations,
|
||||
);
|
||||
: smoothElevatedPath(denseBasePath, smoothingIterations);
|
||||
|
||||
return smoothed.map((p) => [p[0], p[1], Math.max(0, p[2])]);
|
||||
}
|
||||
@ -450,8 +486,10 @@ export function computePitchByIcao(
|
||||
trailByIcao: Map<string, TrailEntry>,
|
||||
currSnapshots: Map<string, Snapshot>,
|
||||
prevSnapshots: Map<string, Snapshot>,
|
||||
out?: Map<string, number>,
|
||||
): Map<string, number> {
|
||||
const pitchByIcao = new Map<string, number>();
|
||||
const pitchByIcao = out ?? new Map<string, number>();
|
||||
pitchByIcao.clear();
|
||||
|
||||
for (const f of interpolated) {
|
||||
const curr = currSnapshots.get(f.icao24);
|
||||
@ -493,7 +531,8 @@ export function computePitchByIcao(
|
||||
})()
|
||||
: 0;
|
||||
|
||||
const speed = Number.isFinite(f.velocity) ? f.velocity! : 0;
|
||||
const speed =
|
||||
Number.isFinite(f.velocity) && f.velocity! > 0 ? f.velocity! : 0;
|
||||
const verticalRate = Number.isFinite(f.verticalRate) ? f.verticalRate! : 0;
|
||||
const kinematicPitch =
|
||||
speed > 0 ? (-Math.atan2(verticalRate, speed) * 180) / Math.PI : 0;
|
||||
@ -508,6 +547,46 @@ export function computePitchByIcao(
|
||||
return pitchByIcao;
|
||||
}
|
||||
|
||||
// ── Bank (Roll) Calculation ────────────────────────────────────────────
|
||||
|
||||
const MAX_BANK_DEG = 25;
|
||||
|
||||
/**
|
||||
* Compute a turn-coupled bank angle for each aircraft.
|
||||
* The bank follows a sine-bell curve over the animation cycle so it
|
||||
* peaks mid-turn and eases to zero at the start/end — mimicking how
|
||||
* real aircraft roll into and out of turns.
|
||||
*/
|
||||
export function computeBankByIcao(
|
||||
interpolated: FlightState[],
|
||||
prevSnapshots: Map<string, Snapshot>,
|
||||
currSnapshots: Map<string, Snapshot>,
|
||||
tAngle: number,
|
||||
out?: Map<string, number>,
|
||||
): Map<string, number> {
|
||||
const bankByIcao = out ?? new Map<string, number>();
|
||||
bankByIcao.clear();
|
||||
for (const f of interpolated) {
|
||||
const prev = prevSnapshots.get(f.icao24);
|
||||
const curr = currSnapshots.get(f.icao24);
|
||||
if (!prev || !curr) continue;
|
||||
|
||||
// Shortest-path heading delta: positive = turning right
|
||||
const headingDelta = ((curr.track - prev.track + 540) % 360) - 180;
|
||||
|
||||
// Bank proportional to turn magnitude, clamped
|
||||
const bankTarget = Math.max(
|
||||
-MAX_BANK_DEG,
|
||||
Math.min(MAX_BANK_DEG, headingDelta * 0.8),
|
||||
);
|
||||
|
||||
// Sine bell curve: 0 → 1 → 0 over the animation cycle
|
||||
const bankEase = Math.sin(tAngle * Math.PI);
|
||||
bankByIcao.set(f.icao24, bankTarget * bankEase);
|
||||
}
|
||||
return bankByIcao;
|
||||
}
|
||||
|
||||
// ── Flight Interpolation (extracted from RAF loop) ─────────────────────
|
||||
|
||||
export function computeInterpolatedFlights(
|
||||
@ -554,17 +633,97 @@ export function computeInterpolatedFlights(
|
||||
}
|
||||
|
||||
const heading = (curr.track * Math.PI) / 180;
|
||||
const speed = Number.isFinite(f.velocity) ? f.velocity! : 200;
|
||||
const speed =
|
||||
Number.isFinite(f.velocity) && f.velocity! > 0 ? f.velocity! : 200;
|
||||
const extraSec = ((rawT - 1) * animDuration) / 1000;
|
||||
const extraDeg = Math.min((speed * extraSec) / 111_320, 0.03);
|
||||
const moveDx = Math.sin(heading) * extraDeg;
|
||||
const moveDy = Math.cos(heading) * extraDeg;
|
||||
// Continue climb/descent using vertical rate, capped at ±500m
|
||||
const vr = Number.isFinite(f.verticalRate) ? f.verticalRate! : 0;
|
||||
const extraAlt = Math.max(-500, Math.min(500, vr * extraSec));
|
||||
return {
|
||||
...f,
|
||||
longitude: curr.lng + moveDx,
|
||||
latitude: curr.lat + moveDy,
|
||||
baroAltitude: curr.alt,
|
||||
baroAltitude: curr.alt + extraAlt,
|
||||
trueTrack: trackFromDelta(moveDx, moveDy, curr.track),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* In-place position update for an existing interpolated array.
|
||||
*
|
||||
* Called on animation frames between data polls. Instead of creating new
|
||||
* FlightState objects with `{...f}`, this mutates the existing objects'
|
||||
* position fields directly. Combined with a stable array reference this
|
||||
* eliminates ~18K object allocations/sec and ~360K property copies/sec.
|
||||
*
|
||||
* `rawFlights` must be the SAME array that was used to create `out` via
|
||||
* `computeInterpolatedFlights` (i.e. `flightsRef.current` hasn't changed).
|
||||
* Elements where `out[i] === rawFlights[i]` are raw references (no
|
||||
* interpolation was needed) and are left untouched.
|
||||
*/
|
||||
export function updateInterpolatedInPlace(
|
||||
out: FlightState[],
|
||||
rawFlights: FlightState[],
|
||||
prevSnapshots: Map<string, Snapshot>,
|
||||
currSnapshots: Map<string, Snapshot>,
|
||||
tPos: number,
|
||||
tAngle: number,
|
||||
rawT: number,
|
||||
animDuration: number,
|
||||
): void {
|
||||
for (let i = 0; i < out.length; i++) {
|
||||
const o = out[i];
|
||||
const f = rawFlights[i];
|
||||
if (!o || !f) continue;
|
||||
|
||||
// Skip raw references — these flights had no position or snapshot,
|
||||
// so computeInterpolatedFlights returned the raw object directly.
|
||||
// Mutating them would corrupt the source data.
|
||||
if (o === f) continue;
|
||||
|
||||
const curr = currSnapshots.get(f.icao24);
|
||||
if (!curr) continue;
|
||||
|
||||
const prev = prevSnapshots.get(f.icao24);
|
||||
if (!prev) {
|
||||
o.longitude = curr.lng;
|
||||
o.latitude = curr.lat;
|
||||
o.baroAltitude = curr.alt;
|
||||
o.trueTrack = Number.isFinite(f.trueTrack) ? f.trueTrack! : curr.track;
|
||||
continue;
|
||||
}
|
||||
|
||||
const dx = curr.lng - prev.lng;
|
||||
const dy = curr.lat - prev.lat;
|
||||
if (dx * dx + dy * dy > TELEPORT_THRESHOLD * TELEPORT_THRESHOLD) continue;
|
||||
|
||||
if (rawT <= 1) {
|
||||
o.longitude = prev.lng + dx * tPos;
|
||||
o.latitude = prev.lat + dy * tPos;
|
||||
o.baroAltitude = prev.alt + (curr.alt - prev.alt) * tPos;
|
||||
o.trueTrack = trackFromDelta(
|
||||
dx,
|
||||
dy,
|
||||
lerpAngle(prev.track, curr.track, tAngle),
|
||||
);
|
||||
} else {
|
||||
const heading = (curr.track * Math.PI) / 180;
|
||||
const speed =
|
||||
Number.isFinite(f.velocity) && f.velocity! > 0 ? f.velocity! : 200;
|
||||
const extraSec = ((rawT - 1) * animDuration) / 1000;
|
||||
const extraDeg = Math.min((speed * extraSec) / 111_320, 0.03);
|
||||
const moveDx = Math.sin(heading) * extraDeg;
|
||||
const moveDy = Math.cos(heading) * extraDeg;
|
||||
const vr = Number.isFinite(f.verticalRate) ? f.verticalRate! : 0;
|
||||
const extraAlt = Math.max(-500, Math.min(500, vr * extraSec));
|
||||
o.longitude = curr.lng + moveDx;
|
||||
o.latitude = curr.lat + moveDy;
|
||||
o.baroAltitude = curr.alt + extraAlt;
|
||||
o.trueTrack = trackFromDelta(moveDx, moveDy, curr.track);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -17,6 +17,8 @@ import {
|
||||
import {
|
||||
buildStartupFallbackTrail,
|
||||
buildVisibleTrailPoints,
|
||||
buildTrailBasePath,
|
||||
trailBasePathCacheKey,
|
||||
smoothStep,
|
||||
} from "./flight-animation-helpers";
|
||||
|
||||
@ -79,14 +81,36 @@ export interface TrailLayerParams {
|
||||
interpolated: FlightState[];
|
||||
interpolatedMap: Map<string, FlightState>;
|
||||
currentTrails: TrailEntry[];
|
||||
/** Pre-built trail-by-icao24 Map — passed from parent to avoid per-frame allocation */
|
||||
trailMap: Map<string, TrailEntry>;
|
||||
trailDistance: number;
|
||||
trailThickness: number;
|
||||
altColors: boolean;
|
||||
defaultColor: [number, number, number, number];
|
||||
elapsed: number;
|
||||
/** Visual frame counter — throttled counter that only increments on rendered frames */
|
||||
visualFrame: number;
|
||||
globeFade: number;
|
||||
currentZoom: number;
|
||||
/** Pre-computed zoom-dependent elevation scale — avoids recomputing per accessor call */
|
||||
elevScale: number;
|
||||
visible?: boolean;
|
||||
/** Persistent cache for expensive base path computations across frames */
|
||||
trailBasePathCache?: Map<string, { key: string; basePath: ElevatedPoint[] }>;
|
||||
/** Persistent cache for slope-limited trail paths across frames */
|
||||
trailPathCache?: Map<
|
||||
string,
|
||||
{ key: string; result: [number, number, number][] }
|
||||
>;
|
||||
/** Persistent cache for trail colors across frames */
|
||||
trailColorCache?: Map<
|
||||
string,
|
||||
{ key: string; result: [number, number, number, number][] }
|
||||
>;
|
||||
/** Reusable containers — cleared and reused each frame to avoid per-frame allocations */
|
||||
handledIdsSet?: Set<string>;
|
||||
visibleTrailCacheMap?: Map<string, ElevatedPoint[]>;
|
||||
activeIcaosSet?: Set<string>;
|
||||
}
|
||||
|
||||
export function buildTrailLayers(params: TrailLayerParams) {
|
||||
@ -94,36 +118,64 @@ export function buildTrailLayers(params: TrailLayerParams) {
|
||||
interpolated,
|
||||
interpolatedMap,
|
||||
currentTrails,
|
||||
trailMap,
|
||||
trailDistance,
|
||||
trailThickness,
|
||||
altColors,
|
||||
defaultColor,
|
||||
elapsed,
|
||||
visualFrame,
|
||||
globeFade,
|
||||
currentZoom,
|
||||
elevScale,
|
||||
visible = true,
|
||||
trailBasePathCache,
|
||||
trailPathCache,
|
||||
trailColorCache,
|
||||
handledIdsSet,
|
||||
visibleTrailCacheMap,
|
||||
activeIcaosSet,
|
||||
} = params;
|
||||
|
||||
const trailMap = new Map(currentTrails.map((t) => [t.icao24, t]));
|
||||
const handledIds = new Set<string>();
|
||||
const handledIds = handledIdsSet ?? new Set<string>();
|
||||
handledIds.clear();
|
||||
const trailData: TrailEntry[] = [];
|
||||
const denseSubdivisions = 2;
|
||||
const smoothingIters =
|
||||
interpolated.length > 220 ? 2 : TRAIL_SMOOTHING_ITERATIONS;
|
||||
|
||||
const visibleTrailCache = new Map<string, ElevatedPoint[]>();
|
||||
const visibleTrailCache =
|
||||
visibleTrailCacheMap ?? new Map<string, ElevatedPoint[]>();
|
||||
visibleTrailCache.clear();
|
||||
const activeIcaos = trailBasePathCache
|
||||
? (activeIcaosSet ?? new Set<string>())
|
||||
: null;
|
||||
activeIcaos?.clear();
|
||||
|
||||
const getVisibleTrailPoints = (
|
||||
trail: TrailEntry,
|
||||
animFlight: FlightState | undefined,
|
||||
): ElevatedPoint[] => {
|
||||
const cached = visibleTrailCache.get(trail.icao24);
|
||||
if (cached) return cached;
|
||||
|
||||
// Try to use cached base path (expensive smoothing/densification)
|
||||
let basePath: ElevatedPoint[] | undefined;
|
||||
if (trailBasePathCache) {
|
||||
const key = trailBasePathCacheKey(trail, trailDistance);
|
||||
const entry = trailBasePathCache.get(trail.icao24);
|
||||
if (entry && entry.key === key) {
|
||||
basePath = entry.basePath;
|
||||
} else {
|
||||
basePath = buildTrailBasePath(trail, trailDistance);
|
||||
trailBasePathCache.set(trail.icao24, { key, basePath });
|
||||
}
|
||||
activeIcaos?.add(trail.icao24);
|
||||
}
|
||||
|
||||
const computed = buildVisibleTrailPoints(
|
||||
trail,
|
||||
animFlight,
|
||||
trailDistance,
|
||||
smoothingIters,
|
||||
denseSubdivisions,
|
||||
basePath,
|
||||
);
|
||||
visibleTrailCache.set(trail.icao24, computed);
|
||||
return computed;
|
||||
@ -133,6 +185,7 @@ export function buildTrailLayers(params: TrailLayerParams) {
|
||||
if (f.longitude == null || f.latitude == null) continue;
|
||||
const existing = trailMap.get(f.icao24);
|
||||
handledIds.add(f.icao24);
|
||||
activeIcaos?.add(f.icao24);
|
||||
if (existing && existing.path.length >= 2) {
|
||||
trailData.push(existing);
|
||||
continue;
|
||||
@ -144,34 +197,63 @@ export function buildTrailLayers(params: TrailLayerParams) {
|
||||
altitudes: startupPath.map(
|
||||
() => existing?.baroAltitude ?? f.baroAltitude,
|
||||
),
|
||||
timestamps: startupPath.map(() => 0),
|
||||
baroAltitude: existing?.baroAltitude ?? f.baroAltitude,
|
||||
});
|
||||
}
|
||||
|
||||
for (const d of currentTrails) {
|
||||
if (!handledIds.has(d.icao24)) trailData.push(d);
|
||||
if (!handledIds.has(d.icao24)) {
|
||||
trailData.push(d);
|
||||
activeIcaos?.add(d.icao24);
|
||||
}
|
||||
}
|
||||
|
||||
// Sweep stale entries from persistent caches
|
||||
if (trailBasePathCache && activeIcaos) {
|
||||
for (const icao of trailBasePathCache.keys()) {
|
||||
if (!activeIcaos.has(icao)) {
|
||||
trailBasePathCache.delete(icao);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (trailPathCache && activeIcaos) {
|
||||
for (const icao of trailPathCache.keys()) {
|
||||
if (!activeIcaos.has(icao)) trailPathCache.delete(icao);
|
||||
}
|
||||
}
|
||||
if (trailColorCache && activeIcaos) {
|
||||
for (const icao of trailColorCache.keys()) {
|
||||
if (!activeIcaos.has(icao)) trailColorCache.delete(icao);
|
||||
}
|
||||
}
|
||||
|
||||
return new PathLayer<TrailEntry>({
|
||||
id: "flight-trails",
|
||||
pickable: false,
|
||||
visible,
|
||||
data: trailData,
|
||||
opacity: globeFade,
|
||||
updateTriggers: {
|
||||
getPath: [elapsed, trailDistance],
|
||||
getColor: [elapsed, altColors, trailDistance],
|
||||
getPath: [visualFrame, trailDistance, elevScale],
|
||||
getColor: [visualFrame, altColors, trailDistance],
|
||||
},
|
||||
getPath: (d) => {
|
||||
const animFlight = interpolatedMap.get(d.icao24);
|
||||
// Scale elevation exaggeration by zoom:
|
||||
// At globe zoom (<5) altitude spikes look absurd, so reduce.
|
||||
// At city zoom (>8) full exaggeration is needed for visual depth.
|
||||
const elevScale =
|
||||
currentZoom < 5
|
||||
? 0.15 + (currentZoom / 5) * 0.35
|
||||
: currentZoom < 8
|
||||
? 0.5 + ((currentZoom - 5) / 3) * 0.5
|
||||
: 1.0;
|
||||
|
||||
// Cache key: trail point count + rounded head position (~11m grid)
|
||||
// + elevScale. Gives ~6 frame cache hits between invalidations at
|
||||
// typical aircraft speed, reducing slope-limit computation from
|
||||
// 60fps to ~10fps per trail.
|
||||
const headLng = animFlight?.longitude?.toFixed(4) ?? "";
|
||||
const headLat = animFlight?.latitude?.toFixed(4) ?? "";
|
||||
const pathKey = `${d.path.length}_${headLng}_${headLat}_${elevScale.toFixed(3)}_${trailDistance}`;
|
||||
|
||||
if (trailPathCache) {
|
||||
const cached = trailPathCache.get(d.icao24);
|
||||
if (cached && cached.key === pathKey) return cached.result;
|
||||
}
|
||||
|
||||
const raw = getVisibleTrailPoints(d, animFlight).map(
|
||||
(p) =>
|
||||
[
|
||||
@ -184,15 +266,23 @@ export function buildTrailLayers(params: TrailLayerParams) {
|
||||
),
|
||||
] as [number, number, number],
|
||||
);
|
||||
return limitTrailSlope(raw);
|
||||
const result = limitTrailSlope(raw);
|
||||
trailPathCache?.set(d.icao24, { key: pathKey, result });
|
||||
return result;
|
||||
},
|
||||
getColor: (d) => {
|
||||
const animFlight = interpolatedMap.get(d.icao24);
|
||||
const visiblePoints = getVisibleTrailPoints(d, animFlight);
|
||||
const len = visiblePoints.length;
|
||||
const isFullHist = d.fullHistory === true;
|
||||
|
||||
return visiblePoints.map((point, i) => {
|
||||
const colorKey = `${len}_${altColors}_${d.fullHistory ?? false}_${d.baroAltitude != null ? Math.round(d.baroAltitude / 200) : "n"}`;
|
||||
if (trailColorCache) {
|
||||
const cached = trailColorCache.get(d.icao24);
|
||||
if (cached && cached.key === colorKey) return cached.result;
|
||||
}
|
||||
|
||||
const isFullHist = d.fullHistory === true;
|
||||
const result = visiblePoints.map((point, i) => {
|
||||
const tVal = len > 1 ? i / (len - 1) : 1;
|
||||
const fade = isFullHist
|
||||
? 0.35 + 0.65 * Math.pow(tVal, 1.1)
|
||||
@ -203,6 +293,8 @@ export function buildTrailLayers(params: TrailLayerParams) {
|
||||
: Math.round(60 + fade * 160);
|
||||
return [base[0], base[1], base[2], alpha];
|
||||
}) as [number, number, number, number][];
|
||||
trailColorCache?.set(d.icao24, { key: colorKey, result });
|
||||
return result;
|
||||
},
|
||||
getWidth: trailThickness,
|
||||
widthUnits: "pixels",
|
||||
@ -222,9 +314,12 @@ export interface SelectionPulseParams {
|
||||
selectedId: string | null;
|
||||
prevId: string | null;
|
||||
interpolated: FlightState[];
|
||||
interpolatedMap: Map<string, FlightState>;
|
||||
elapsed: number;
|
||||
globeFade: number;
|
||||
currentZoom: number;
|
||||
/** Pre-computed zoom-dependent elevation scale */
|
||||
elevScale: number;
|
||||
haloUrl: string;
|
||||
ringUrl: string;
|
||||
layersVisible?: boolean;
|
||||
@ -245,23 +340,15 @@ export function buildSelectionPulseLayers(
|
||||
selectionChangeTime,
|
||||
selectedId,
|
||||
prevId,
|
||||
interpolated,
|
||||
interpolatedMap,
|
||||
elapsed,
|
||||
globeFade,
|
||||
currentZoom,
|
||||
elevScale,
|
||||
haloUrl,
|
||||
ringUrl,
|
||||
layersVisible = true,
|
||||
} = params;
|
||||
|
||||
// Zoom-dependent elevation scale (matches trail/aircraft scaling)
|
||||
const elevScale =
|
||||
currentZoom < 5
|
||||
? 0.15 + (currentZoom / 5) * 0.35
|
||||
: currentZoom < 8
|
||||
? 0.5 + ((currentZoom - 5) / 3) * 0.5
|
||||
: 1.0;
|
||||
|
||||
const layers: IconLayer[] = [];
|
||||
const fadeElapsed = performance.now() - selectionChangeTime;
|
||||
const fadeT = Math.min(fadeElapsed / SELECTION_FADE_MS, 1);
|
||||
@ -281,19 +368,18 @@ export function buildSelectionPulseLayers(
|
||||
const targetId = isSelected ? selectedId : prevId;
|
||||
const op = isSelected ? fadeIn : fadeOut;
|
||||
|
||||
const flight = targetId
|
||||
? interpolated.find((f) => f.icao24 === targetId)
|
||||
: undefined;
|
||||
const flight = targetId ? interpolatedMap.get(targetId) : undefined;
|
||||
const hasPosition =
|
||||
flight && flight.longitude != null && flight.latitude != null;
|
||||
|
||||
const active = layersVisible && !!targetId && hasPosition && op > 0.01;
|
||||
const pos: [number, number, number] = hasPosition
|
||||
? [
|
||||
flight!.longitude!,
|
||||
flight!.latitude!,
|
||||
altitudeToElevation(flight!.baroAltitude) * elevScale,
|
||||
]
|
||||
const elevation =
|
||||
flight && flight.baroAltitude != null
|
||||
? altitudeToElevation(flight.baroAltitude) * elevScale
|
||||
: 0;
|
||||
const pos: [number, number, number] =
|
||||
flight && flight.longitude != null && flight.latitude != null
|
||||
? [flight.longitude, flight.latitude, elevation]
|
||||
: [0, 0, 0];
|
||||
const data = active ? [{ position: pos }] : EMPTY_PULSE_DATA;
|
||||
|
||||
@ -301,12 +387,13 @@ export function buildSelectionPulseLayers(
|
||||
const breath = Math.sin(breathT * Math.PI * 2);
|
||||
const softBreath = smoothStep(smoothStep((breath + 1) / 2)) * 2 - 1;
|
||||
|
||||
const haloSize = 75 + 8 * softBreath;
|
||||
const haloAlpha = Math.round((18 + 8 * softBreath) * op);
|
||||
const haloSize = 90 + 10 * softBreath;
|
||||
const haloAlpha = Math.round((22 + 10 * softBreath) * op);
|
||||
|
||||
layers.push(
|
||||
new IconLayer({
|
||||
id: `${prefix}-halo`,
|
||||
pickable: false,
|
||||
visible: active && haloAlpha > 0,
|
||||
data,
|
||||
opacity: globeFade,
|
||||
@ -326,13 +413,14 @@ export function buildSelectionPulseLayers(
|
||||
ringOffsets.forEach((offset, i) => {
|
||||
const t = ((elapsed + offset) % RING_PERIOD_MS) / RING_PERIOD_MS;
|
||||
const eased = 1 - (1 - t) ** 5;
|
||||
const ringSize = 30 + 60 * eased;
|
||||
const ringSize = 35 + 70 * eased;
|
||||
const fade = 1 - t;
|
||||
const ringAlpha = Math.round(70 * fade * fade * fade * fade * op);
|
||||
const ringAlpha = Math.round(80 * fade * fade * fade * fade * op);
|
||||
|
||||
layers.push(
|
||||
new IconLayer({
|
||||
id: `${prefix}-ring-${i}`,
|
||||
pickable: false,
|
||||
visible: active && ringAlpha >= 2,
|
||||
data,
|
||||
opacity: globeFade,
|
||||
|
||||
@ -24,10 +24,15 @@ export const TRAIL_BELOW_AIRCRAFT_METERS = 40;
|
||||
export const STARTUP_TRAIL_POLLS = 3;
|
||||
export const STARTUP_TRAIL_STEP_SEC = 12;
|
||||
export const TRACK_DAMPING = 0.18;
|
||||
/** EMA alpha for MLAT position smoothing. MLAT accuracy (~100m) is 10×
|
||||
* worse than ADS-B (~10m), so we blend toward the previous position to
|
||||
* suppress jitter. 0.65 retains responsiveness while cutting noise. */
|
||||
export const MLAT_POSITION_ALPHA = 0.65;
|
||||
export const TRAIL_SMOOTHING_ITERATIONS = 3;
|
||||
export const AIRCRAFT_SCENEGRAPH_URL = "/models/airplane.glb";
|
||||
export const AIRCRAFT_PX_PER_UNIT = 0.3;
|
||||
export const BASE_AIRCRAFT_SIZE = 25;
|
||||
export const BASE_AIRCRAFT_SIZE = 22;
|
||||
export const AIRCRAFT_MIN_PIXELS = 0.8;
|
||||
export const AIRCRAFT_MAX_PIXELS = 18;
|
||||
export const AIRCRAFT_PICK_RADIUS_PX = 14;
|
||||
export const SELECTION_FADE_MS = 600;
|
||||
|
||||
@ -37,6 +42,12 @@ export const GLOBE_FADE_ZOOM_FLOOR = GLOBE_SWITCH_ZOOM - 0.05;
|
||||
export const GLOBE_FADE_ZOOM_CEIL = GLOBE_SWITCH_ZOOM + 0.05;
|
||||
export const GLOBE_NATIVE_ZOOM_CEIL = GLOBE_SWITCH_ZOOM;
|
||||
|
||||
// LOD: switch between 3D ScenegraphLayers and 2D IconLayer.
|
||||
// Uses hysteresis to avoid flickering when hovering near the boundary.
|
||||
// Zoom in past LOD_3D_ZOOM_IN → 3D models; zoom out past LOD_3D_ZOOM_OUT → 2D icons.
|
||||
export const LOD_3D_ZOOM_IN = 6.0;
|
||||
export const LOD_3D_ZOOM_OUT = 5.0;
|
||||
|
||||
// GeoJSON globe dot layer timing
|
||||
export const GEOJSON_THROTTLE_MS = 1500;
|
||||
export const GEOJSON_DEBOUNCE_MS = 200;
|
||||
|
||||
@ -4,25 +4,28 @@ import { useEffect, useRef, useCallback } from "react";
|
||||
import maplibregl from "maplibre-gl";
|
||||
import { MapboxOverlay } from "@deck.gl/mapbox";
|
||||
import { IconLayer } from "@deck.gl/layers";
|
||||
import { ScenegraphLayer } from "@deck.gl/mesh-layers";
|
||||
import { useMap } from "./map";
|
||||
import { altitudeToColor, altitudeToElevation } from "@/lib/flight-utils";
|
||||
import type { FlightState } from "@/lib/opensky";
|
||||
import type { TrailEntry } from "@/hooks/use-trail-history";
|
||||
import { type PickingInfo, MapView } from "@deck.gl/core";
|
||||
|
||||
import type { DeckGLOverlay, Snapshot } from "./flight-layer-constants";
|
||||
import type {
|
||||
DeckGLOverlay,
|
||||
ElevatedPoint,
|
||||
Snapshot,
|
||||
} from "./flight-layer-constants";
|
||||
import {
|
||||
DEFAULT_ANIM_DURATION_MS,
|
||||
MIN_ANIM_DURATION_MS,
|
||||
MAX_ANIM_DURATION_MS,
|
||||
TELEPORT_THRESHOLD,
|
||||
TRACK_DAMPING,
|
||||
AIRCRAFT_SCENEGRAPH_URL,
|
||||
AIRCRAFT_PX_PER_UNIT,
|
||||
BASE_AIRCRAFT_SIZE,
|
||||
MLAT_POSITION_ALPHA,
|
||||
AIRCRAFT_PICK_RADIUS_PX,
|
||||
GLOBE_FADE_ZOOM_FLOOR,
|
||||
GLOBE_FADE_ZOOM_CEIL,
|
||||
LOD_3D_ZOOM_IN,
|
||||
LOD_3D_ZOOM_OUT,
|
||||
type FlightLayerProps,
|
||||
} from "./flight-layer-constants";
|
||||
|
||||
@ -39,11 +42,16 @@ import {
|
||||
lerpAngle,
|
||||
smoothStep,
|
||||
computePitchByIcao,
|
||||
computeBankByIcao,
|
||||
computeInterpolatedFlights,
|
||||
updateInterpolatedInPlace,
|
||||
} from "./flight-animation-helpers";
|
||||
|
||||
import { buildTrailLayers } from "./flight-layer-builders";
|
||||
import { buildSelectionPulseLayers } from "./flight-layer-builders";
|
||||
import { buildAircraftModelLayers } from "./aircraft-model-layers";
|
||||
import { preloadAllModels } from "./aircraft-model-mapping";
|
||||
import { altitudeToColor, altitudeToElevation } from "@/lib/flight-utils";
|
||||
import { useGlobeDots } from "./use-globe-dots";
|
||||
|
||||
export function FlightLayers({
|
||||
@ -71,6 +79,48 @@ export function FlightLayers({
|
||||
const dataTimestampRef = useRef(0);
|
||||
const animDurationRef = useRef(DEFAULT_ANIM_DURATION_MS);
|
||||
const animFrameRef = useRef(0);
|
||||
// Recent poll intervals for median smoothing — prevents event loop
|
||||
// stalls from inflating animDuration beyond the true poll cadence.
|
||||
const recentIntervalsRef = useRef<number[]>([]);
|
||||
|
||||
// Persistent caches reused across animation frames to reduce GC pressure
|
||||
const trailBasePathCacheRef = useRef(
|
||||
new Map<string, { key: string; basePath: ElevatedPoint[] }>(),
|
||||
);
|
||||
const interpolatedMapRef = useRef(new Map<string, FlightState>());
|
||||
const pitchMapRef = useRef(new Map<string, number>());
|
||||
const bankMapRef = useRef(new Map<string, number>());
|
||||
// Reusable containers for buildTrailLayers — clear+reuse each frame
|
||||
const handledIdsRef = useRef(new Set<string>());
|
||||
const visibleTrailCacheRef = useRef(new Map<string, ElevatedPoint[]>());
|
||||
const activeIcaosRef = useRef(new Set<string>());
|
||||
// Persistent caches for slope-limited trail paths and colors across frames
|
||||
const trailPathCacheRef = useRef(
|
||||
new Map<string, { key: string; result: [number, number, number][] }>(),
|
||||
);
|
||||
const trailColorCacheRef = useRef(
|
||||
new Map<
|
||||
string,
|
||||
{ key: string; result: [number, number, number, number][] }
|
||||
>(),
|
||||
);
|
||||
// Cached trail-by-icao24 Map — rebuilt only when trailsRef changes, not every frame
|
||||
const trailMapRef = useRef(new Map<string, TrailEntry>());
|
||||
const lastTrailsForMapRef = useRef<TrailEntry[] | null>(null);
|
||||
|
||||
// Interpolation pool — reuse FlightState objects between animation frames
|
||||
// to avoid ~18K object allocations/sec from spread syntax
|
||||
const interpArrayRef = useRef<FlightState[]>([]);
|
||||
const lastFlightsForInterpRef = useRef<FlightState[] | null>(null);
|
||||
|
||||
// Set on tab resume, cleared when fresh flight data arrives.
|
||||
// While true, the RAF loop clamps rawT to 1 (no dead reckoning)
|
||||
// so aircraft freeze at last-known positions on stale data instead
|
||||
// of extrapolating forward on minutes-old headings.
|
||||
const resumeSnapRef = useRef(false);
|
||||
|
||||
// Data version increments when raw flight data changes — drives color/scale updateTriggers
|
||||
const dataVersionRef = useRef(0);
|
||||
|
||||
const flightsRef = useRef(flights);
|
||||
const trailsRef = useRef(trails);
|
||||
@ -139,7 +189,35 @@ export function FlightLayers({
|
||||
// ── Snapshot interpolation on new data ─────────────────────────────
|
||||
|
||||
useEffect(() => {
|
||||
const elapsed = performance.now() - dataTimestampRef.current;
|
||||
const now = performance.now();
|
||||
const elapsed = now - dataTimestampRef.current;
|
||||
|
||||
// If data is stale (tab was hidden 15s+), snap directly to new
|
||||
// positions instead of slowly interpolating from outdated ones.
|
||||
const STALE_THRESHOLD_MS = 15_000;
|
||||
const isStale =
|
||||
dataTimestampRef.current > 0 && elapsed > STALE_THRESHOLD_MS;
|
||||
|
||||
if (isStale) {
|
||||
const snap = new Map<string, Snapshot>();
|
||||
for (const f of flights) {
|
||||
if (f.longitude != null && f.latitude != null) {
|
||||
snap.set(f.icao24, {
|
||||
lng: f.longitude,
|
||||
lat: f.latitude,
|
||||
alt: Number.isFinite(f.baroAltitude) ? f.baroAltitude! : 0,
|
||||
track: Number.isFinite(f.trueTrack) ? f.trueTrack! : 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
prevSnapshotsRef.current = snap;
|
||||
currSnapshotsRef.current = new Map(snap);
|
||||
animDurationRef.current = DEFAULT_ANIM_DURATION_MS;
|
||||
dataTimestampRef.current = now;
|
||||
lastFlightsForInterpRef.current = null;
|
||||
dataVersionRef.current++;
|
||||
return;
|
||||
}
|
||||
const oldLinearT = Math.min(elapsed / animDurationRef.current, 1);
|
||||
const oldAngleT = smoothStep(oldLinearT);
|
||||
|
||||
@ -175,9 +253,21 @@ export function FlightLayers({
|
||||
const prev = newPrev.get(f.icao24);
|
||||
const rawTrack = Number.isFinite(f.trueTrack) ? f.trueTrack! : 0;
|
||||
const rawAlt = Number.isFinite(f.baroAltitude) ? f.baroAltitude! : 0;
|
||||
|
||||
// MLAT positions (~100m accuracy) jitter visibly compared to
|
||||
// ADS-B (~10m). Apply EMA blending against the previous position
|
||||
// to suppress the noise while tracking real movement.
|
||||
const isMLAT = f.positionSource === 1;
|
||||
let lng = f.longitude;
|
||||
let lat = f.latitude;
|
||||
if (isMLAT && prev) {
|
||||
lng = prev.lng + (lng - prev.lng) * MLAT_POSITION_ALPHA;
|
||||
lat = prev.lat + (lat - prev.lat) * MLAT_POSITION_ALPHA;
|
||||
}
|
||||
|
||||
next.set(f.icao24, {
|
||||
lng: f.longitude,
|
||||
lat: f.latitude,
|
||||
lng,
|
||||
lat,
|
||||
alt: rawAlt,
|
||||
track:
|
||||
prev != null
|
||||
@ -187,15 +277,28 @@ export function FlightLayers({
|
||||
}
|
||||
}
|
||||
currSnapshotsRef.current = next;
|
||||
const now = performance.now();
|
||||
if (dataTimestampRef.current > 0) {
|
||||
const observedInterval = now - dataTimestampRef.current;
|
||||
// Use median of recent intervals to filter event-loop stalls.
|
||||
// A single blocked tick (e.g. heavy parse of 5K aircraft) would
|
||||
// inflate observedInterval → animDuration, making aircraft move
|
||||
// too slowly that cycle. Median is robust to such outliers.
|
||||
const intervals = recentIntervalsRef.current;
|
||||
intervals.push(observedInterval);
|
||||
if (intervals.length > 5) intervals.shift();
|
||||
const sorted = [...intervals].sort((a, b) => a - b);
|
||||
const medianInterval = sorted[Math.floor(sorted.length / 2)];
|
||||
animDurationRef.current = Math.max(
|
||||
MIN_ANIM_DURATION_MS,
|
||||
Math.min(MAX_ANIM_DURATION_MS, observedInterval * 0.94),
|
||||
Math.min(MAX_ANIM_DURATION_MS, medianInterval * 0.94),
|
||||
);
|
||||
}
|
||||
dataTimestampRef.current = now;
|
||||
// Fresh data arrived — allow dead reckoning again (was blocked during
|
||||
// the brief window after tab resume to prevent stale-heading extrapolation).
|
||||
resumeSnapRef.current = false;
|
||||
// Increment data version so model layers know color/scale need recomputation
|
||||
dataVersionRef.current++;
|
||||
}, [flights]);
|
||||
|
||||
// ── Cursor management ──────────────────────────────────────────────
|
||||
@ -222,6 +325,23 @@ export function FlightLayers({
|
||||
[onClick],
|
||||
);
|
||||
|
||||
// Stable refs for event handlers — prevents RAF loop restart when handlers change
|
||||
const handleHoverRef = useRef(handleHover);
|
||||
const handleClickRef = useRef(handleClick);
|
||||
useEffect(() => {
|
||||
handleHoverRef.current = handleHover;
|
||||
handleClickRef.current = handleClick;
|
||||
}, [handleHover, handleClick]);
|
||||
|
||||
const stableHover = useCallback(
|
||||
(info: PickingInfo<FlightState>) => handleHoverRef.current(info),
|
||||
[],
|
||||
);
|
||||
const stableClick = useCallback(
|
||||
(info: PickingInfo<FlightState>) => handleClickRef.current(info),
|
||||
[],
|
||||
);
|
||||
|
||||
// ── Map click pass-through ─────────────────────────────────────────
|
||||
|
||||
useEffect(() => {
|
||||
@ -254,23 +374,62 @@ export function FlightLayers({
|
||||
useEffect(() => {
|
||||
if (!map || !isLoaded) return;
|
||||
|
||||
if (!overlayRef.current) {
|
||||
function createOverlay() {
|
||||
overlayRef.current = new MapboxOverlay({
|
||||
interleaved: false,
|
||||
views: new MapView({ id: "mapbox" }) as never,
|
||||
pickingRadius: AIRCRAFT_PICK_RADIUS_PX,
|
||||
useDevicePixels: 1,
|
||||
_typedArrayManagerProps: { overAlloc: 1.5, poolSize: 0 },
|
||||
layers: [],
|
||||
});
|
||||
map.addControl(overlayRef.current as unknown as maplibregl.IControl);
|
||||
map!.addControl(overlayRef.current as unknown as maplibregl.IControl);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (!overlayRef.current) {
|
||||
createOverlay();
|
||||
preloadAllModels();
|
||||
}
|
||||
|
||||
// ── WebGL context loss recovery ──────────────────────────────
|
||||
// Mobile devices may reclaim GPU memory when the app is backgrounded.
|
||||
// Without explicit handling, the deck.gl overlay becomes permanently
|
||||
// blank. We listen for context events on MapLibre's canvas and
|
||||
// rebuild the overlay when the browser restores the context.
|
||||
const canvas = map.getCanvas();
|
||||
|
||||
function onContextLost(e: Event) {
|
||||
e.preventDefault(); // allow browser to attempt restoration
|
||||
}
|
||||
|
||||
function onContextRestored() {
|
||||
// Tear down the dead overlay and recreate with a fresh context.
|
||||
if (overlayRef.current) {
|
||||
try {
|
||||
map!.removeControl(
|
||||
overlayRef.current as unknown as maplibregl.IControl,
|
||||
);
|
||||
overlayRef.current.finalize();
|
||||
} catch {
|
||||
/* already dead */
|
||||
}
|
||||
overlayRef.current = null;
|
||||
}
|
||||
createOverlay();
|
||||
}
|
||||
|
||||
canvas.addEventListener("webglcontextlost", onContextLost);
|
||||
canvas.addEventListener("webglcontextrestored", onContextRestored);
|
||||
|
||||
return () => {
|
||||
canvas.removeEventListener("webglcontextlost", onContextLost);
|
||||
canvas.removeEventListener("webglcontextrestored", onContextRestored);
|
||||
if (overlayRef.current) {
|
||||
try {
|
||||
map.removeControl(
|
||||
overlayRef.current as unknown as maplibregl.IControl,
|
||||
);
|
||||
overlayRef.current.finalize();
|
||||
} catch {
|
||||
/* unmounted */
|
||||
}
|
||||
@ -279,22 +438,61 @@ export function FlightLayers({
|
||||
};
|
||||
}, [map, isLoaded]);
|
||||
|
||||
// Visual frame counter — increments once per rendered frame.
|
||||
// Used in updateTriggers so deck.gl recomputes attributes only when we push.
|
||||
const visualFrameRef = useRef(0);
|
||||
// LOD state: true = render 3D ScenegraphLayers, false = render 2D IconLayer.
|
||||
// Uses hysteresis to avoid flickering at the zoom boundary.
|
||||
const use3DRef = useRef(true);
|
||||
// Pitch/bank time-based throttle (~10fps regardless of animation frame rate)
|
||||
const lastPitchBankTimeRef = useRef(0);
|
||||
|
||||
// ── Main animation loop ────────────────────────────────────────────
|
||||
|
||||
useEffect(() => {
|
||||
if (!atlasUrl) return;
|
||||
|
||||
// Hoisted constant — avoids allocating a new array every frame
|
||||
const DEFAULT_COLOR: [number, number, number, number] = [
|
||||
180, 220, 255, 200,
|
||||
];
|
||||
|
||||
// Snap aircraft to last-known positions on tab resume so they
|
||||
// don't slowly slide from stale locations. dataTimestampRef is
|
||||
// intentionally NOT reset — keeping it stale ensures the next
|
||||
// data arrival triggers the stale-data guard and snaps directly
|
||||
// to fresh positions instead of interpolating from minutes-old ones.
|
||||
// resumeSnapRef prevents dead reckoning on stale headings during
|
||||
// the brief window before fresh data arrives.
|
||||
function onVisibilityResume() {
|
||||
if (document.visibilityState === "visible") {
|
||||
const curr = currSnapshotsRef.current;
|
||||
if (curr.size > 0) {
|
||||
prevSnapshotsRef.current = new Map(curr);
|
||||
}
|
||||
animDurationRef.current = DEFAULT_ANIM_DURATION_MS;
|
||||
lastFlightsForInterpRef.current = null;
|
||||
resumeSnapRef.current = true;
|
||||
}
|
||||
}
|
||||
document.addEventListener("visibilitychange", onVisibilityResume);
|
||||
|
||||
function buildAndPushLayers() {
|
||||
animFrameRef.current = requestAnimationFrame(buildAndPushLayers);
|
||||
|
||||
// Skip all rendering work when tab is hidden — saves CPU/GPU.
|
||||
// RAF is already throttled to ~1fps in background but each tick
|
||||
// would still construct layers & run interpolation for nothing.
|
||||
if (document.hidden) return;
|
||||
|
||||
const overlay = overlayRef.current;
|
||||
if (!overlay) return;
|
||||
|
||||
const currentZoom = map?.getZoom() ?? 10;
|
||||
const now = performance.now();
|
||||
const isGlobe = globeModeRef.current;
|
||||
visualFrameRef.current++;
|
||||
|
||||
updateGlobeDotsRef.current(isGlobe, currentZoom, now);
|
||||
const currentZoom = map?.getZoom() ?? 10;
|
||||
const isGlobe = globeModeRef.current;
|
||||
|
||||
let globeFade = 1;
|
||||
let layersVisible = true;
|
||||
@ -312,18 +510,23 @@ export function FlightLayers({
|
||||
|
||||
try {
|
||||
const elapsed = performance.now() - dataTimestampRef.current;
|
||||
const rawT = elapsed / animDurationRef.current;
|
||||
// After tab resume, clamp rawT so aircraft freeze at last-known
|
||||
// positions instead of dead-reckoning forward on stale headings.
|
||||
// Cleared when fresh flight data arrives in the flights useEffect.
|
||||
const rawT = resumeSnapRef.current
|
||||
? Math.min(elapsed / animDurationRef.current, 1)
|
||||
: elapsed / animDurationRef.current;
|
||||
const tPos = Math.min(rawT, 1);
|
||||
const tAngle = smoothStep(smoothStep(smoothStep(tPos)));
|
||||
|
||||
const currentFlights = flightsRef.current;
|
||||
const currentTrails = trailsRef.current;
|
||||
const altColors = showAltColorsRef.current;
|
||||
const defaultColor: [number, number, number, number] = [
|
||||
180, 220, 255, 200,
|
||||
];
|
||||
|
||||
const interpolated = computeInterpolatedFlights(
|
||||
// On new poll data: full interpolation (creates new FlightState objects).
|
||||
// Between polls: mutate positions in-place (zero object allocations).
|
||||
let interpolated: FlightState[];
|
||||
if (currentFlights !== lastFlightsForInterpRef.current) {
|
||||
interpolated = computeInterpolatedFlights(
|
||||
currentFlights,
|
||||
prevSnapshotsRef.current,
|
||||
currSnapshotsRef.current,
|
||||
@ -332,19 +535,36 @@ export function FlightLayers({
|
||||
rawT,
|
||||
animDurationRef.current,
|
||||
);
|
||||
interpArrayRef.current = interpolated;
|
||||
lastFlightsForInterpRef.current = currentFlights;
|
||||
|
||||
const interpolatedMap = new Map<string, FlightState>();
|
||||
// Rebuild Map only on new poll — updateInterpolatedInPlace mutates
|
||||
// the same FlightState objects in-place, so existing Map entries
|
||||
// remain valid between polls.
|
||||
const interpolatedMap = interpolatedMapRef.current;
|
||||
interpolatedMap.clear();
|
||||
for (const f of interpolated) {
|
||||
interpolatedMap.set(f.icao24, f);
|
||||
}
|
||||
} else {
|
||||
interpolated = interpArrayRef.current;
|
||||
updateInterpolatedInPlace(
|
||||
interpolated,
|
||||
currentFlights,
|
||||
prevSnapshotsRef.current,
|
||||
currSnapshotsRef.current,
|
||||
tPos,
|
||||
tAngle,
|
||||
rawT,
|
||||
animDurationRef.current,
|
||||
);
|
||||
}
|
||||
|
||||
// FPV position output
|
||||
// FPV position output — O(1) Map lookup instead of O(n) find
|
||||
const fpvId = fpvIcao24Ref.current?.toLowerCase() ?? null;
|
||||
const visibleFlights = interpolated;
|
||||
const fpvPosOut = fpvPosRef.current;
|
||||
if (fpvPosOut && fpvId) {
|
||||
const fpvF =
|
||||
interpolated.find((f) => f.icao24.toLowerCase() === fpvId) ?? null;
|
||||
const fpvF = interpolatedMapRef.current.get(fpvId) ?? null;
|
||||
if (
|
||||
fpvF &&
|
||||
Number.isFinite(fpvF.longitude) &&
|
||||
@ -365,19 +585,63 @@ export function FlightLayers({
|
||||
fpvPosOut.current = null;
|
||||
}
|
||||
|
||||
const pitchByIcao = computePitchByIcao(
|
||||
// Rebuild trail-by-icao24 Map only when trails reference changes
|
||||
if (currentTrails !== lastTrailsForMapRef.current) {
|
||||
trailMapRef.current.clear();
|
||||
for (const t of currentTrails) {
|
||||
trailMapRef.current.set(t.icao24, t);
|
||||
}
|
||||
lastTrailsForMapRef.current = currentTrails;
|
||||
}
|
||||
|
||||
// ── Globe dots ────────────────────────────────────────────────
|
||||
updateGlobeDotsRef.current(isGlobe, currentZoom, now);
|
||||
|
||||
const altColors = showAltColorsRef.current;
|
||||
const visibleFlights = interpolated;
|
||||
|
||||
// Pitch/bank change slowly — recompute at ~10fps regardless of
|
||||
// animation frame rate. Values are retained in pitchMapRef/bankMapRef
|
||||
// between compute frames.
|
||||
const PITCH_BANK_INTERVAL_MS = 100;
|
||||
if (now - lastPitchBankTimeRef.current >= PITCH_BANK_INTERVAL_MS) {
|
||||
lastPitchBankTimeRef.current = now;
|
||||
computePitchByIcao(
|
||||
interpolated,
|
||||
new Map(currentTrails.map((t) => [t.icao24, t])),
|
||||
trailMapRef.current,
|
||||
currSnapshotsRef.current,
|
||||
prevSnapshotsRef.current,
|
||||
pitchMapRef.current,
|
||||
);
|
||||
|
||||
computeBankByIcao(
|
||||
interpolated,
|
||||
prevSnapshotsRef.current,
|
||||
currSnapshotsRef.current,
|
||||
tAngle,
|
||||
bankMapRef.current,
|
||||
);
|
||||
}
|
||||
const pitchByIcao = pitchMapRef.current;
|
||||
const bankByIcao = bankMapRef.current;
|
||||
|
||||
const layers = [];
|
||||
|
||||
// Zoom-dependent elevation scale to prevent absurd altitude spikes
|
||||
// at globe zoom levels. Full exaggeration at city zoom (>8).
|
||||
// Computed once per frame and passed to all builders.
|
||||
const elevScale =
|
||||
currentZoom < 5
|
||||
? 0.15 + (currentZoom / 5) * 0.35
|
||||
: currentZoom < 8
|
||||
? 0.5 + ((currentZoom - 5) / 3) * 0.5
|
||||
: 1.0;
|
||||
|
||||
// Shadow layer — always included, toggled via `visible` to retain WebGL state
|
||||
layers.push(
|
||||
new IconLayer<FlightState>({
|
||||
id: "flight-shadows",
|
||||
pickable: false,
|
||||
visible: layersVisible && showShadowsRef.current,
|
||||
data: visibleFlights,
|
||||
opacity: globeFade,
|
||||
@ -392,6 +656,10 @@ export function FlightLayers({
|
||||
billboard: false,
|
||||
sizeUnits: "pixels",
|
||||
sizeScale: 1,
|
||||
updateTriggers: {
|
||||
getPosition: visualFrameRef.current,
|
||||
getAngle: visualFrameRef.current,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
@ -399,28 +667,42 @@ export function FlightLayers({
|
||||
layers.push(
|
||||
buildTrailLayers({
|
||||
interpolated,
|
||||
interpolatedMap,
|
||||
interpolatedMap: interpolatedMapRef.current,
|
||||
currentTrails,
|
||||
trailMap: trailMapRef.current,
|
||||
trailDistance: trailDistanceRef.current,
|
||||
trailThickness: trailThicknessRef.current,
|
||||
altColors,
|
||||
defaultColor,
|
||||
defaultColor: DEFAULT_COLOR,
|
||||
elapsed,
|
||||
visualFrame: visualFrameRef.current,
|
||||
globeFade,
|
||||
currentZoom,
|
||||
elevScale,
|
||||
visible: layersVisible && showTrailsRef.current,
|
||||
trailBasePathCache: trailBasePathCacheRef.current,
|
||||
trailPathCache: trailPathCacheRef.current,
|
||||
trailColorCache: trailColorCacheRef.current,
|
||||
handledIdsSet: handledIdsRef.current,
|
||||
visibleTrailCacheMap: visibleTrailCacheRef.current,
|
||||
activeIcaosSet: activeIcaosRef.current,
|
||||
}),
|
||||
);
|
||||
|
||||
// Selection pulse layers (halo + rings)
|
||||
// Selection pulse layers (halo + rings) — skip entirely when
|
||||
// nothing is selected and no fade-out is in progress. Saves
|
||||
// constructing 8 IconLayer objects + deck.gl diffing per frame.
|
||||
if (selectedIcao24Ref.current || prevSelectedRef.current) {
|
||||
const pulseResult = buildSelectionPulseLayers({
|
||||
selectionChangeTime: selectionChangeTimeRef.current,
|
||||
selectedId: selectedIcao24Ref.current,
|
||||
prevId: prevSelectedRef.current,
|
||||
interpolated,
|
||||
interpolatedMap: interpolatedMapRef.current,
|
||||
elapsed,
|
||||
globeFade,
|
||||
currentZoom,
|
||||
elevScale,
|
||||
haloUrl,
|
||||
ringUrl,
|
||||
layersVisible,
|
||||
@ -429,21 +711,44 @@ export function FlightLayers({
|
||||
if (pulseResult.shouldClearPrev) {
|
||||
prevSelectedRef.current = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Zoom-dependent elevation scale to prevent absurd altitude spikes
|
||||
// at globe zoom levels. Full exaggeration at city zoom (>8).
|
||||
const elevScale =
|
||||
currentZoom < 5
|
||||
? 0.15 + (currentZoom / 5) * 0.35
|
||||
: currentZoom < 8
|
||||
? 0.5 + ((currentZoom - 5) / 3) * 0.5
|
||||
: 1.0;
|
||||
// ── LOD: 3D models vs 2D icons ────────────────────────────────
|
||||
// At low zoom, aircraft are too small to distinguish 3D silhouettes.
|
||||
// Switch to a single IconLayer (2D) below LOD_3D_ZOOM_OUT and back
|
||||
// to ScenegraphLayers (3D) above LOD_3D_ZOOM_IN. The hysteresis
|
||||
// band (6.5–7.5) prevents rapid flickering at the boundary.
|
||||
if (use3DRef.current && currentZoom < LOD_3D_ZOOM_OUT) {
|
||||
use3DRef.current = false;
|
||||
} else if (!use3DRef.current && currentZoom >= LOD_3D_ZOOM_IN) {
|
||||
use3DRef.current = true;
|
||||
}
|
||||
|
||||
// Aircraft 3D model layer — always included with `visible` to avoid
|
||||
// re-fetching the .glb model on every zoom in/out cycle
|
||||
if (use3DRef.current) {
|
||||
// 3D: one ScenegraphLayer per model type
|
||||
layers.push(
|
||||
new ScenegraphLayer<FlightState>({
|
||||
id: "flight-aircraft",
|
||||
...buildAircraftModelLayers({
|
||||
rawFlights: currentFlights,
|
||||
interpolatedMap: interpolatedMapRef.current,
|
||||
frameCounter: visualFrameRef.current,
|
||||
dataVersion: dataVersionRef.current,
|
||||
layersVisible,
|
||||
globeFade,
|
||||
elevScale,
|
||||
altColors,
|
||||
defaultColor: DEFAULT_COLOR,
|
||||
pitchByIcao,
|
||||
bankByIcao,
|
||||
handleHover: stableHover,
|
||||
handleClick: stableClick,
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
// 2D: single IconLayer using the sprite atlas (much cheaper GPU-wise)
|
||||
layers.push(
|
||||
new IconLayer<FlightState>({
|
||||
id: "flight-aircraft-2d",
|
||||
pickable: true,
|
||||
visible: layersVisible,
|
||||
data: visibleFlights,
|
||||
opacity: globeFade,
|
||||
@ -452,33 +757,33 @@ export function FlightLayers({
|
||||
d.latitude!,
|
||||
altitudeToElevation(d.baroAltitude) * elevScale,
|
||||
],
|
||||
getOrientation: (d) => {
|
||||
const pitch = pitchByIcao.get(d.icao24) ?? 0;
|
||||
const yaw = -(Number.isFinite(d.trueTrack) ? d.trueTrack! : 0);
|
||||
return [pitch, yaw, 90];
|
||||
},
|
||||
getIcon: () => "aircraft",
|
||||
getSize: (d) => 20 * categorySizeMultiplier(d.category),
|
||||
getColor: (d) => {
|
||||
const base = altColors
|
||||
? altitudeToColor(d.baroAltitude)
|
||||
: defaultColor;
|
||||
: DEFAULT_COLOR;
|
||||
return tintAircraftColor(base, d.category);
|
||||
},
|
||||
scenegraph: AIRCRAFT_SCENEGRAPH_URL,
|
||||
getScale: (d) => {
|
||||
const scale = categorySizeMultiplier(d.category);
|
||||
return [scale, scale, scale];
|
||||
},
|
||||
sizeScale: BASE_AIRCRAFT_SIZE,
|
||||
sizeMinPixels: AIRCRAFT_PX_PER_UNIT,
|
||||
sizeMaxPixels: AIRCRAFT_PX_PER_UNIT,
|
||||
_lighting: "pbr",
|
||||
pickable: true,
|
||||
onHover: handleHover,
|
||||
onClick: handleClick,
|
||||
getAngle: (d) =>
|
||||
360 - (Number.isFinite(d.trueTrack) ? d.trueTrack! : 0),
|
||||
iconAtlas: atlasUrl,
|
||||
iconMapping: AIRCRAFT_ICON_MAPPING,
|
||||
billboard: false,
|
||||
sizeUnits: "pixels",
|
||||
sizeScale: 1,
|
||||
onHover: stableHover,
|
||||
onClick: stableClick,
|
||||
autoHighlight: true,
|
||||
highlightColor: [255, 255, 255, 80],
|
||||
updateTriggers: {
|
||||
getPosition: [visualFrameRef.current, elevScale],
|
||||
getAngle: visualFrameRef.current,
|
||||
getColor: [dataVersionRef.current, altColors],
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
overlay.setProps({ layers });
|
||||
} catch (err) {
|
||||
@ -489,8 +794,11 @@ export function FlightLayers({
|
||||
}
|
||||
|
||||
buildAndPushLayers();
|
||||
return () => cancelAnimationFrame(animFrameRef.current);
|
||||
}, [atlasUrl, haloUrl, ringUrl, handleHover, handleClick, map]);
|
||||
return () => {
|
||||
cancelAnimationFrame(animFrameRef.current);
|
||||
document.removeEventListener("visibilitychange", onVisibilityResume);
|
||||
};
|
||||
}, [atlasUrl, haloUrl, ringUrl, stableHover, stableClick, map]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@ -90,9 +90,6 @@ export const Map = forwardRef<MapRef, MapProps>(function Map(
|
||||
const isDarkRef = useRef(isDark);
|
||||
isDarkRef.current = isDark;
|
||||
|
||||
const globeModeRef = useRef(globeMode);
|
||||
globeModeRef.current = globeMode;
|
||||
|
||||
// ── Map creation ──────────────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return;
|
||||
@ -111,8 +108,10 @@ export const Map = forwardRef<MapRef, MapProps>(function Map(
|
||||
maxPitch: GLOBE_MAX_PITCH,
|
||||
attributionControl: false,
|
||||
cancelPendingTileRequestsWhileZooming: true,
|
||||
maxTileCacheZoomLevels: 3, // fewer cached zoom levels = less memory for DEM tiles
|
||||
maxTileCacheZoomLevels: 2, // fewer cached zoom levels = less GPU memory for tile textures
|
||||
renderWorldCopies: false,
|
||||
pixelRatio: 1, // render at 1x regardless of display DPI — significant GPU savings on HiDPI
|
||||
fadeDuration: 0, // disable tile/symbol fade animations — fewer intermediate render frames
|
||||
});
|
||||
|
||||
map.on("load", () => setIsLoaded(true));
|
||||
@ -123,7 +122,7 @@ export const Map = forwardRef<MapRef, MapProps>(function Map(
|
||||
setIsLoaded(false);
|
||||
setMapInstance(null);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- Map initializes once; containerRef is stable, style/terrain/globe applied in separate effects
|
||||
}, []);
|
||||
|
||||
// Inject globe projection into every style change when globe mode is on.
|
||||
@ -165,13 +164,15 @@ export const Map = forwardRef<MapRef, MapProps>(function Map(
|
||||
);
|
||||
|
||||
// Set projection imperatively so it takes effect immediately.
|
||||
mapInstance.once("style.load", () => {
|
||||
const onStyleLoad = () => {
|
||||
mapInstance.setProjection({ type: globeMode ? "globe" : "mercator" });
|
||||
addAerowayLayers(mapInstance, isDarkRef.current);
|
||||
});
|
||||
};
|
||||
|
||||
mapInstance.once("style.load", onStyleLoad);
|
||||
|
||||
return () => {
|
||||
mapInstance.off("style.load", () => {});
|
||||
mapInstance.off("style.load", onStyleLoad);
|
||||
};
|
||||
}, [mapInstance, isLoaded, mapStyle, terrainProfile, globeMode]);
|
||||
|
||||
|
||||
@ -130,12 +130,36 @@ export function useFpvCamera(
|
||||
map.on(t, onMapInteraction);
|
||||
}
|
||||
|
||||
// Reset FPV tracking on tab resume to prevent camera jumps from
|
||||
// stale lerp values accumulated during the hidden period.
|
||||
let wasHidden = false;
|
||||
function onFpvVisibilityResume() {
|
||||
if (document.visibilityState === "visible" && wasHidden) {
|
||||
wasHidden = false;
|
||||
if (map) prevBearing = map.getBearing();
|
||||
fpvOffsetX = 0;
|
||||
fpvOffsetY = 0;
|
||||
lastInteractionTime = 0;
|
||||
recenterStartTime = 0;
|
||||
} else if (document.visibilityState === "hidden") {
|
||||
wasHidden = true;
|
||||
}
|
||||
}
|
||||
document.addEventListener("visibilitychange", onFpvVisibilityResume);
|
||||
|
||||
function keepInFrame() {
|
||||
if (!isFpvActiveRef.current || !map) {
|
||||
frameId = null;
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip camera updates when tab is hidden — saves CPU and
|
||||
// prevents jarring camera jumps from stale alpha lerps on resume.
|
||||
if (document.hidden) {
|
||||
frameId = requestAnimationFrame(keepInFrame);
|
||||
return;
|
||||
}
|
||||
|
||||
const interpPos = fpvPosRef.current?.current ?? null;
|
||||
const live = fpvFlightRef.current;
|
||||
|
||||
@ -263,6 +287,7 @@ export function useFpvCamera(
|
||||
return () => {
|
||||
if (startupTimer) clearTimeout(startupTimer);
|
||||
if (frameId != null) cancelAnimationFrame(frameId);
|
||||
document.removeEventListener("visibilitychange", onFpvVisibilityResume);
|
||||
for (const t of interactionEventTypes) {
|
||||
map.off(t, onMapInteraction);
|
||||
}
|
||||
|
||||
@ -42,6 +42,8 @@ export function useGlobeDots(
|
||||
const lastGeoJsonTimestampRef = useRef(0);
|
||||
const geoJsonClearedRef = useRef(false);
|
||||
const globeZoomEnteredAtRef = useRef(0);
|
||||
// Cache last visibility state to avoid calling setLayoutProperty every frame
|
||||
const lastDotsVisibleRef = useRef<boolean | null>(null);
|
||||
|
||||
// Set up MapLibre source, layer, and event handlers
|
||||
useEffect(() => {
|
||||
@ -197,6 +199,9 @@ export function useGlobeDots(
|
||||
|
||||
// Hide layers unless globe mode AND below switch zoom
|
||||
const dotsVisible = isGlobe && currentZoom < GLOBE_NATIVE_ZOOM_CEIL;
|
||||
// Only call setLayoutProperty when visibility actually changes
|
||||
if (dotsVisible !== lastDotsVisibleRef.current) {
|
||||
lastDotsVisibleRef.current = dotsVisible;
|
||||
try {
|
||||
if (map.getLayer(LAYER_ID)) {
|
||||
map.setLayoutProperty(
|
||||
@ -215,6 +220,7 @@ export function useGlobeDots(
|
||||
} catch {
|
||||
/* layer may not exist yet */
|
||||
}
|
||||
}
|
||||
|
||||
if (isGlobe) {
|
||||
if (currentZoom < GLOBE_NATIVE_ZOOM_CEIL) {
|
||||
|
||||
@ -51,20 +51,25 @@ export function useKeyboardCamera(
|
||||
}
|
||||
|
||||
function applyDelta(type: CameraActionType, delta: number) {
|
||||
if (!map) return;
|
||||
if (type === "zoom") {
|
||||
const z = map!.getZoom() + delta;
|
||||
map!.setZoom(
|
||||
Math.min(Math.max(z, map!.getMinZoom()), map!.getMaxZoom()),
|
||||
);
|
||||
const z = map.getZoom() + delta;
|
||||
map.setZoom(Math.min(Math.max(z, map.getMinZoom()), map.getMaxZoom()));
|
||||
} else if (type === "pitch") {
|
||||
const p = map!.getPitch() + delta;
|
||||
map!.setPitch(Math.min(Math.max(p, 0), map!.getMaxPitch()));
|
||||
const p = map.getPitch() + delta;
|
||||
map.setPitch(Math.min(Math.max(p, 0), map.getMaxPitch()));
|
||||
} else {
|
||||
map!.setBearing(map!.getBearing() + delta);
|
||||
map.setBearing(map.getBearing() + delta);
|
||||
}
|
||||
}
|
||||
|
||||
function tick(now: number) {
|
||||
// Skip camera movement when tab is hidden
|
||||
if (document.hidden) {
|
||||
lastTime = 0; // reset so first visible frame uses default dt
|
||||
frameId = requestAnimationFrame(tick);
|
||||
return;
|
||||
}
|
||||
const dt = lastTime ? Math.min((now - lastTime) / 1000, 0.1) : 0.016;
|
||||
lastTime = now;
|
||||
|
||||
|
||||
@ -50,13 +50,25 @@ export function useOrbitCamera(
|
||||
if (!map || isInteractingRef.current) return;
|
||||
|
||||
const resumeStart = performance.now();
|
||||
let lastTime = 0;
|
||||
|
||||
function tick() {
|
||||
function tick(now: number) {
|
||||
if (!map || isInteractingRef.current) return;
|
||||
// Skip orbit rotation when tab is hidden — saves CPU and
|
||||
// prevents large bearing jumps on resume.
|
||||
if (document.hidden) {
|
||||
lastTime = 0;
|
||||
orbitFrameRef.current = requestAnimationFrame(tick);
|
||||
return;
|
||||
}
|
||||
const dt = lastTime ? Math.min((now - lastTime) / 1000, 0.1) : 1 / 60;
|
||||
lastTime = now;
|
||||
|
||||
const resumeElapsed = performance.now() - resumeStart;
|
||||
const t = Math.min(resumeElapsed / ORBIT_EASE_IN_MS, 1);
|
||||
const easeFactor = smoothstep(t);
|
||||
const bearing = map.getBearing() + speedRef.current * easeFactor;
|
||||
const bearing =
|
||||
map.getBearing() + speedRef.current * easeFactor * dt * 60;
|
||||
map.setBearing(bearing % 360);
|
||||
orbitFrameRef.current = requestAnimationFrame(tick);
|
||||
}
|
||||
|
||||
@ -10,6 +10,7 @@ import {
|
||||
X,
|
||||
Plane,
|
||||
ImageOff,
|
||||
Plus,
|
||||
} from "lucide-react";
|
||||
import type {
|
||||
NormalizedPhoto,
|
||||
@ -53,7 +54,7 @@ const Thumbnail = memo(function Thumbnail({
|
||||
ref={ref}
|
||||
type="button"
|
||||
onClick={() => onClick(index)}
|
||||
className="group relative h-16 w-24 shrink-0 cursor-pointer overflow-hidden rounded-lg border border-white/8 bg-white/5 transition-all hover:border-white/20 hover:brightness-110 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-white/30"
|
||||
className="group relative h-20 w-32 shrink-0 cursor-pointer overflow-hidden rounded-lg border border-white/8 bg-white/5 transition-all hover:border-white/20 hover:brightness-110 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-white/30"
|
||||
aria-label={`View photo ${index + 1}${photo.photographer ? ` by ${photo.photographer}` : ""}`}
|
||||
>
|
||||
{!loaded && (
|
||||
@ -64,7 +65,7 @@ const Thumbnail = memo(function Thumbnail({
|
||||
)}
|
||||
{visible && (
|
||||
<img
|
||||
src={photo.thumbnail}
|
||||
src={photo.url}
|
||||
alt={`Aircraft photo ${index + 1}`}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
@ -94,8 +95,12 @@ export function Lightbox({
|
||||
const [imgError, setImgError] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Reset image state when navigating between photos
|
||||
const reset = () => {
|
||||
setLoaded(false);
|
||||
setImgError(false);
|
||||
};
|
||||
reset();
|
||||
}, [index]);
|
||||
|
||||
const goPrev = useCallback(() => {
|
||||
@ -201,7 +206,10 @@ export function Lightbox({
|
||||
</>
|
||||
)}
|
||||
|
||||
{(photo.photographer || photo.location || photo.dateTaken) && (
|
||||
{(photo.photographer ||
|
||||
photo.location ||
|
||||
photo.dateTaken ||
|
||||
photo.link) && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
@ -226,6 +234,22 @@ export function Lightbox({
|
||||
{photo.dateTaken && (
|
||||
<span className="text-white/45">{photo.dateTaken}</span>
|
||||
)}
|
||||
{photo.link && (
|
||||
<>
|
||||
{(photo.photographer || photo.location || photo.dateTaken) && (
|
||||
<span className="text-white/25">|</span>
|
||||
)}
|
||||
<a
|
||||
href={photo.link}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-white/40 underline decoration-white/20 underline-offset-2 transition-colors hover:text-white/60"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
Source
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</motion.div>
|
||||
)}
|
||||
@ -254,8 +278,18 @@ export function AircraftPhotos({
|
||||
}: AircraftPhotosProps) {
|
||||
const [expanded, setExpanded] = useState(defaultExpanded);
|
||||
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null);
|
||||
const [showAllPhotos, setShowAllPhotos] = useState(false);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const PREVIEW_COUNT = 3;
|
||||
|
||||
// Reset "show all" when photos change (new aircraft selected)
|
||||
const photoKey = photos.map((p) => p.id).join(",");
|
||||
useEffect(() => {
|
||||
const reset = () => setShowAllPhotos(false);
|
||||
reset();
|
||||
}, [photoKey]);
|
||||
|
||||
const handlePhotoClick = useCallback(
|
||||
(index: number) => {
|
||||
if (onPhotoClick) {
|
||||
@ -277,6 +311,10 @@ export function AircraftPhotos({
|
||||
? loading || hasPhotos
|
||||
: loading || hasPhotos || hasAircraft;
|
||||
|
||||
const visiblePhotos = showAllPhotos ? photos : photos.slice(0, PREVIEW_COUNT);
|
||||
const hiddenCount = photos.length - PREVIEW_COUNT;
|
||||
const hasMore = hiddenCount > 0;
|
||||
|
||||
if (!showSection) return null;
|
||||
|
||||
const detailParts: string[] = [];
|
||||
@ -333,7 +371,7 @@ export function AircraftPhotos({
|
||||
{[0, 1, 2].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="h-16 w-24 shrink-0 animate-pulse rounded-lg bg-white/5"
|
||||
className="h-20 w-32 shrink-0 animate-pulse rounded-lg bg-white/5"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@ -345,7 +383,7 @@ export function AircraftPhotos({
|
||||
className="mt-2 flex gap-2 overflow-x-auto pb-1 scrollbar-none"
|
||||
style={{ scrollbarWidth: "none" }}
|
||||
>
|
||||
{photos.map((photo, i) => (
|
||||
{visiblePhotos.map((photo, i) => (
|
||||
<Thumbnail
|
||||
key={photo.id}
|
||||
photo={photo}
|
||||
@ -353,6 +391,19 @@ export function AircraftPhotos({
|
||||
onClick={handlePhotoClick}
|
||||
/>
|
||||
))}
|
||||
{hasMore && !showAllPhotos && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAllPhotos(true)}
|
||||
className="flex h-20 w-20 shrink-0 flex-col items-center justify-center gap-0.5 rounded-lg border border-white/8 bg-white/5 text-white/40 transition-all hover:border-white/20 hover:bg-white/8 hover:text-white/60 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-white/30"
|
||||
aria-label={`Show ${hiddenCount} more photo${hiddenCount === 1 ? "" : "s"}`}
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
<span className="text-[9px] font-medium tabular-nums">
|
||||
{hiddenCount} more
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef, useMemo } from "react";
|
||||
import { useState, useEffect, useRef, useMemo, memo } from "react";
|
||||
import { motion, AnimatePresence } from "motion/react";
|
||||
import { Search, X, MapPin, ChevronRight } from "lucide-react";
|
||||
import { CITIES, type City } from "@/lib/cities";
|
||||
@ -226,7 +226,7 @@ export function AirportSearchInput({
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownRow({
|
||||
const DropdownRow = memo(function DropdownRow({
|
||||
name,
|
||||
detail,
|
||||
isActive,
|
||||
@ -254,4 +254,4 @@ function DropdownRow({
|
||||
<ChevronRight className="h-3 w-3 shrink-0 text-white/10 group-hover:text-white/20" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
408
src/components/ui/atc-panel.tsx
Normal file
408
src/components/ui/atc-panel.tsx
Normal file
@ -0,0 +1,408 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useCallback, useRef } from "react";
|
||||
import { motion, AnimatePresence } from "motion/react";
|
||||
import {
|
||||
Radio,
|
||||
Play,
|
||||
Square,
|
||||
Loader2,
|
||||
X,
|
||||
AlertTriangle,
|
||||
Server,
|
||||
ChevronUp,
|
||||
} from "lucide-react";
|
||||
import type { AtcFeed, AtcFeedType } from "@/lib/atc-types";
|
||||
import { FEED_TYPE_PRIORITY } from "@/lib/atc-types";
|
||||
import { lookupAtcFeeds, findNearbyAtcFeeds } from "@/lib/atc-lookup";
|
||||
import { AtcWaveform } from "@/components/ui/atc-waveform";
|
||||
import type { UseAtcStreamReturn } from "@/hooks/use-atc-stream";
|
||||
import { useDropdownDismiss } from "@/hooks/use-dropdown-dismiss";
|
||||
|
||||
// ── Feed helpers ───────────────────────────────────────────────────────
|
||||
|
||||
const TYPE_LABELS: Record<AtcFeedType, string> = {
|
||||
tower: "TWR",
|
||||
ground: "GND",
|
||||
approach: "APP",
|
||||
departure: "DEP",
|
||||
atis: "ATIS",
|
||||
center: "CTR",
|
||||
combined: "CMB",
|
||||
};
|
||||
|
||||
const TYPE_COLORS: Record<AtcFeedType, string> = {
|
||||
tower: "rgb(52, 211, 153)",
|
||||
ground: "rgb(251, 191, 36)",
|
||||
approach: "rgb(96, 165, 250)",
|
||||
departure: "rgb(167, 139, 250)",
|
||||
atis: "rgb(148, 163, 184)",
|
||||
center: "rgb(244, 114, 182)",
|
||||
combined: "rgb(156, 163, 175)",
|
||||
};
|
||||
|
||||
function sortFeeds(feeds: AtcFeed[]): AtcFeed[] {
|
||||
return [...feeds].sort(
|
||||
(a, b) => FEED_TYPE_PRIORITY[a.type] - FEED_TYPE_PRIORITY[b.type],
|
||||
);
|
||||
}
|
||||
|
||||
export function useAvailableFeeds(
|
||||
cityIata: string,
|
||||
cityCoordinates: [number, number],
|
||||
): AtcFeed[] {
|
||||
return useMemo(() => {
|
||||
const byCode = lookupAtcFeeds(cityIata);
|
||||
if (byCode.length > 0) return sortFeeds(byCode);
|
||||
const [lng, lat] = cityCoordinates;
|
||||
const nearby = findNearbyAtcFeeds(lat, lng, 30);
|
||||
return sortFeeds(nearby.flatMap((r) => r.feeds));
|
||||
}, [cityIata, cityCoordinates]);
|
||||
}
|
||||
|
||||
// Waveform is in atc-waveform.tsx
|
||||
|
||||
// ── Feed Dropdown (opens upward) ───────────────────────────────────────
|
||||
|
||||
export type AtcFeedDropdownProps = {
|
||||
feeds: AtcFeed[];
|
||||
atc: UseAtcStreamReturn;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export function AtcFeedDropdown({
|
||||
feeds,
|
||||
atc,
|
||||
open,
|
||||
onClose,
|
||||
}: AtcFeedDropdownProps) {
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
useDropdownDismiss(dropdownRef, open, onClose);
|
||||
|
||||
const handleSelectFeed = useCallback(
|
||||
(feed: AtcFeed) => {
|
||||
if (atc.feed?.id === feed.id && atc.status === "playing") {
|
||||
atc.stop();
|
||||
} else {
|
||||
atc.play(feed);
|
||||
}
|
||||
onClose();
|
||||
},
|
||||
[atc, onClose],
|
||||
);
|
||||
|
||||
// Group feeds by type for visual hierarchy
|
||||
const groupedFeeds = useMemo(() => {
|
||||
const groups: { type: AtcFeedType; label: string; feeds: AtcFeed[] }[] = [];
|
||||
const typeOrder: AtcFeedType[] = [
|
||||
"tower",
|
||||
"ground",
|
||||
"approach",
|
||||
"departure",
|
||||
"center",
|
||||
"atis",
|
||||
"combined",
|
||||
];
|
||||
for (const type of typeOrder) {
|
||||
const matching = feeds.filter((f) => f.type === type);
|
||||
if (matching.length > 0) {
|
||||
groups.push({ type, label: TYPE_LABELS[type], feeds: matching });
|
||||
}
|
||||
}
|
||||
return groups;
|
||||
}, [feeds]);
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<motion.div
|
||||
ref={dropdownRef}
|
||||
initial={{ opacity: 0, y: 8, scale: 0.97 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: 8, scale: 0.97 }}
|
||||
transition={{ type: "spring", stiffness: 500, damping: 30 }}
|
||||
className="absolute bottom-full left-0 z-50 mb-2 w-[calc(100vw-2rem)] max-w-70 overflow-hidden rounded-xl border shadow-2xl shadow-black/60 backdrop-blur-2xl sm:w-70 sm:max-w-none"
|
||||
style={{
|
||||
borderColor: "rgb(var(--ui-fg) / 0.08)",
|
||||
backgroundColor: "rgb(var(--ui-bg) / 0.75)",
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<div
|
||||
className="flex items-center justify-between px-4 py-2.5"
|
||||
style={{ borderBottom: "1px solid rgb(var(--ui-fg) / 0.06)" }}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Radio className="h-3 w-3 text-emerald-400/70" />
|
||||
<span
|
||||
className="text-[10px] font-semibold tracking-widest uppercase"
|
||||
style={{ color: "rgb(var(--ui-fg) / 0.35)" }}
|
||||
>
|
||||
Frequencies
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="flex h-5 w-5 items-center justify-center rounded-md transition-colors hover:bg-white/5 active:bg-white/10"
|
||||
aria-label="Close feed selector"
|
||||
>
|
||||
<X
|
||||
className="h-3 w-3"
|
||||
style={{ color: "rgb(var(--ui-fg) / 0.3)" }}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Feed list */}
|
||||
{feeds.length === 0 ? (
|
||||
<div className="px-3.5 py-5 text-center">
|
||||
<span
|
||||
className="text-[10px]"
|
||||
style={{ color: "rgb(var(--ui-fg) / 0.3)" }}
|
||||
>
|
||||
No feeds for this area
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="max-h-65 overflow-y-auto py-1">
|
||||
{groupedFeeds.map((group) => (
|
||||
<div key={group.type}>
|
||||
{group.feeds.map((feed) => {
|
||||
const isPlaying =
|
||||
atc.feed?.id === feed.id && atc.status === "playing";
|
||||
const isLoading =
|
||||
atc.feed?.id === feed.id && atc.status === "loading";
|
||||
const isFeedError =
|
||||
atc.feed?.id === feed.id &&
|
||||
(atc.status === "error" || atc.status === "blocked");
|
||||
const isSelected = atc.feed?.id === feed.id;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={feed.id}
|
||||
type="button"
|
||||
onClick={() => handleSelectFeed(feed)}
|
||||
className={`group flex w-full items-center gap-2.5 px-3.5 py-2 transition-colors ${
|
||||
isSelected
|
||||
? "bg-white/6"
|
||||
: "hover:bg-white/3 active:bg-white/6"
|
||||
}`}
|
||||
>
|
||||
{/* Inline icon */}
|
||||
<div className="flex h-4 w-4 shrink-0 items-center justify-center">
|
||||
{isLoading ? (
|
||||
<Loader2 className="h-3 w-3 animate-spin text-emerald-400/70" />
|
||||
) : isFeedError ? (
|
||||
<AlertTriangle className="h-3 w-3 text-amber-400/70" />
|
||||
) : isPlaying ? (
|
||||
<Square className="h-2.5 w-2.5 text-emerald-400" />
|
||||
) : (
|
||||
<Play
|
||||
className="h-3 w-3 opacity-40 transition-opacity group-hover:opacity-80"
|
||||
style={{ color: "rgb(var(--ui-fg) / 0.5)" }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Feed name + frequency */}
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-0 text-left">
|
||||
<span
|
||||
className="truncate text-[11px] font-medium leading-snug"
|
||||
style={{
|
||||
color: isPlaying
|
||||
? "rgb(var(--ui-fg) / 0.85)"
|
||||
: isFeedError
|
||||
? "rgb(251 191 36 / 0.7)"
|
||||
: "rgb(var(--ui-fg) / 0.55)",
|
||||
}}
|
||||
>
|
||||
{feed.name}
|
||||
</span>
|
||||
{isFeedError && atc.error ? (
|
||||
<span className="truncate text-[9px] text-amber-300/50">
|
||||
{atc.error}
|
||||
</span>
|
||||
) : (
|
||||
<span
|
||||
className="font-mono text-[9px] tabular-nums leading-snug"
|
||||
style={{ color: "rgb(var(--ui-fg) / 0.25)" }}
|
||||
>
|
||||
{feed.frequency}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Type badge */}
|
||||
<span
|
||||
className="shrink-0 rounded px-1.5 py-px text-[8px] font-bold tracking-wider"
|
||||
style={{
|
||||
backgroundColor: `${TYPE_COLORS[feed.type]}12`,
|
||||
color: `${TYPE_COLORS[feed.type]}`,
|
||||
}}
|
||||
>
|
||||
{TYPE_LABELS[feed.type]}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Bottom Player Bar (ElevenLabs-style) ───────────────────────────────
|
||||
|
||||
export type AtcPlayerBarProps = {
|
||||
atc: UseAtcStreamReturn;
|
||||
onOpenFeedSelector: () => void;
|
||||
};
|
||||
|
||||
export function AtcPlayerBar({ atc, onOpenFeedSelector }: AtcPlayerBarProps) {
|
||||
const isStreaming = atc.status === "playing" || atc.status === "loading";
|
||||
const isError = atc.status === "error" || atc.status === "blocked";
|
||||
const isBlocked = atc.status === "blocked";
|
||||
|
||||
if (!atc.feed) return null;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -12 }}
|
||||
transition={{ type: "spring", stiffness: 400, damping: 28 }}
|
||||
className="flex w-[calc(100vw-2rem)] max-w-sm items-center gap-3 rounded-2xl border px-3.5 py-3 backdrop-blur-2xl sm:w-auto sm:max-w-none sm:gap-3.5 sm:px-4"
|
||||
style={{
|
||||
borderColor: isError
|
||||
? "rgb(251 191 36 / 0.12)"
|
||||
: "rgb(var(--ui-fg) / 0.06)",
|
||||
backgroundColor: "rgb(var(--ui-bg) / 0.5)",
|
||||
}}
|
||||
>
|
||||
{/* Waveform or blocked play icon (left) */}
|
||||
{isBlocked ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => atc.resume()}
|
||||
className="flex h-7 w-13 shrink-0 items-center justify-center rounded-lg transition-colors hover:bg-white/5 active:bg-white/10"
|
||||
aria-label="Tap to start"
|
||||
>
|
||||
<Play className="h-4 w-4 text-emerald-400/80" />
|
||||
</button>
|
||||
) : (
|
||||
<AtcWaveform
|
||||
audioElement={atc.audioElement}
|
||||
active={atc.status === "playing"}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Feed name + frequency (stacked, center) — clickable to open selector */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={isBlocked ? () => atc.resume() : onOpenFeedSelector}
|
||||
className="flex min-w-0 flex-1 flex-col gap-0.5 text-left"
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{atc.status === "loading" ? (
|
||||
<Loader2 className="h-3 w-3 shrink-0 animate-spin text-emerald-400/70" />
|
||||
) : isError ? (
|
||||
<AlertTriangle className="h-3 w-3 shrink-0 text-amber-400/70" />
|
||||
) : null}
|
||||
<span
|
||||
className="truncate text-[12px] font-medium leading-tight"
|
||||
style={{
|
||||
color: isBlocked
|
||||
? "rgb(var(--ui-fg) / 0.55)"
|
||||
: isError
|
||||
? "rgb(251 191 36 / 0.7)"
|
||||
: isStreaming
|
||||
? "rgb(var(--ui-fg) / 0.75)"
|
||||
: "rgb(var(--ui-fg) / 0.45)",
|
||||
}}
|
||||
>
|
||||
{isBlocked
|
||||
? "Tap to listen"
|
||||
: isError && atc.error
|
||||
? atc.error
|
||||
: atc.feed.name}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span
|
||||
className="font-mono text-[9px] tabular-nums"
|
||||
style={{ color: "rgb(var(--ui-fg) / 0.25)" }}
|
||||
>
|
||||
{atc.feed.frequency}
|
||||
</span>
|
||||
{atc.usingProxy && atc.status === "playing" && (
|
||||
<span
|
||||
className="flex items-center gap-0.5 text-[9px]"
|
||||
style={{ color: "rgb(var(--ui-fg) / 0.2)" }}
|
||||
>
|
||||
<Server className="h-1.5 w-1.5" />
|
||||
proxy
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Close / Stop (right) */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => atc.stop()}
|
||||
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg transition-colors hover:bg-white/5 active:bg-white/10"
|
||||
aria-label="Stop and close"
|
||||
>
|
||||
<X
|
||||
className="h-3.5 w-3.5"
|
||||
style={{ color: "rgb(var(--ui-fg) / 0.25)" }}
|
||||
/>
|
||||
</button>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Status Bar ATC Trigger Button ──────────────────────────────────────
|
||||
|
||||
export type AtcTriggerProps = {
|
||||
hasFeeds: boolean;
|
||||
isPlaying: boolean;
|
||||
isError: boolean;
|
||||
onClick: () => void;
|
||||
};
|
||||
|
||||
export function AtcTrigger({
|
||||
hasFeeds,
|
||||
isPlaying,
|
||||
isError,
|
||||
onClick,
|
||||
}: AtcTriggerProps) {
|
||||
if (!hasFeeds) return null;
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className="inline-flex items-center rounded p-1 transition-colors hover:bg-white/5 active:bg-white/10 sm:p-0.5"
|
||||
aria-label="Live ATC (A)"
|
||||
title="Live ATC (A)"
|
||||
>
|
||||
<ChevronUp
|
||||
className={`h-3 w-3 transition-colors ${isError ? "animate-pulse" : ""}`}
|
||||
style={{
|
||||
color: isPlaying
|
||||
? "rgb(52, 211, 153)"
|
||||
: isError
|
||||
? "rgb(251, 191, 36)"
|
||||
: "rgb(var(--ui-fg) / 0.35)",
|
||||
}}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
204
src/components/ui/atc-waveform.tsx
Normal file
204
src/components/ui/atc-waveform.tsx
Normal file
@ -0,0 +1,204 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useEffect } from "react";
|
||||
|
||||
const BAR_COUNT = 12;
|
||||
const BAR_WIDTH = 2.5;
|
||||
const BAR_GAP = 2;
|
||||
const CANVAS_W = BAR_COUNT * BAR_WIDTH + (BAR_COUNT - 1) * BAR_GAP;
|
||||
const CANVAS_H = 28;
|
||||
const MIN_BAR_H = 2.5;
|
||||
const LERP = 0.22;
|
||||
|
||||
// ── Module-level Web Audio singleton ────────────────────────────────
|
||||
// A single AudioContext and WeakMap of captured elements survive across
|
||||
// component mounts/unmounts. This prevents:
|
||||
// 1. InvalidStateError from double-capturing the same <audio> element
|
||||
// 2. AudioContext leak (Chrome limits ~6 concurrent contexts)
|
||||
let sharedCtx: AudioContext | null = null;
|
||||
|
||||
const capturedElements = new WeakMap<
|
||||
HTMLAudioElement,
|
||||
{ source: MediaElementAudioSourceNode; analyser: AnalyserNode }
|
||||
>();
|
||||
|
||||
function getOrCreateConnection(
|
||||
audioElement: HTMLAudioElement,
|
||||
): AnalyserNode | null {
|
||||
if (!sharedCtx || sharedCtx.state === "closed") {
|
||||
sharedCtx = new AudioContext();
|
||||
}
|
||||
if (sharedCtx.state === "suspended") {
|
||||
sharedCtx.resume().catch(() => {});
|
||||
}
|
||||
|
||||
const existing = capturedElements.get(audioElement);
|
||||
if (existing) return existing.analyser;
|
||||
|
||||
try {
|
||||
const source = sharedCtx.createMediaElementSource(audioElement);
|
||||
const analyser = sharedCtx.createAnalyser();
|
||||
analyser.fftSize = 256;
|
||||
analyser.smoothingTimeConstant = 0.75;
|
||||
source.connect(analyser);
|
||||
analyser.connect(sharedCtx.destination);
|
||||
capturedElements.set(audioElement, { source, analyser });
|
||||
return analyser;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build bin ranges that spread bars across the voice-relevant spectrum.
|
||||
*
|
||||
* ATC audio is narrow-band voice (300–3 400 Hz). Icecast streams are
|
||||
* typically 8–16 kHz MP3 decoded to 44 100 Hz by the browser, so real
|
||||
* content lives in the lower ~20–25 % of FFT bins. We restrict mapping
|
||||
* to bins 1–maxBin (skip DC at bin 0) and distribute bars evenly so
|
||||
* every bar picks up voice energy.
|
||||
*/
|
||||
function buildBinRanges(
|
||||
binCount: number,
|
||||
barCount: number,
|
||||
): [number, number][] {
|
||||
// Only use the lower portion where voice/content actually lives
|
||||
// For 128 bins at 44100 Hz: bin 30 ≈ 5 160 Hz — covers voice + harmonics
|
||||
const maxBin = Math.min(Math.ceil(binCount * 0.25), binCount);
|
||||
const usable = maxBin - 1; // bins 1..maxBin
|
||||
const ranges: [number, number][] = [];
|
||||
for (let i = 0; i < barCount; i++) {
|
||||
const start = 1 + Math.floor((i / barCount) * usable);
|
||||
const end = 1 + Math.floor(((i + 1) / barCount) * usable);
|
||||
ranges.push([start, Math.max(end, start + 1)]);
|
||||
}
|
||||
return ranges;
|
||||
}
|
||||
|
||||
/**
|
||||
* ElevenLabs-style audio-reactive waveform.
|
||||
*
|
||||
* Reads frequency data from a Web Audio AnalyserNode connected to
|
||||
* the given <audio> element, then draws smooth rounded bars on a
|
||||
* tiny canvas. When no signal is present the bars settle to their
|
||||
* minimum height with a dim tint.
|
||||
*/
|
||||
export function AtcWaveform({
|
||||
audioElement,
|
||||
active,
|
||||
}: {
|
||||
audioElement: HTMLAudioElement | null;
|
||||
active: boolean;
|
||||
}) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const analyserRef = useRef<AnalyserNode | null>(null);
|
||||
const rafRef = useRef<number>(0);
|
||||
const barsRef = useRef<number[]>(new Array(BAR_COUNT).fill(0));
|
||||
|
||||
// ── Connect to Web Audio API ──────────────────────────────────────
|
||||
useEffect(() => {
|
||||
if (!active || !audioElement) {
|
||||
barsRef.current = new Array(BAR_COUNT).fill(0);
|
||||
analyserRef.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
analyserRef.current = getOrCreateConnection(audioElement);
|
||||
|
||||
// Resume AudioContext when tab returns from background.
|
||||
function onVisibilityResume() {
|
||||
if (
|
||||
document.visibilityState === "visible" &&
|
||||
sharedCtx?.state === "suspended"
|
||||
) {
|
||||
sharedCtx.resume().catch(() => {});
|
||||
}
|
||||
}
|
||||
document.addEventListener("visibilitychange", onVisibilityResume);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("visibilitychange", onVisibilityResume);
|
||||
};
|
||||
}, [active, audioElement]);
|
||||
|
||||
// ── Animation loop (always runs — idle or active) ────────────────
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
const draw2d = canvas.getContext("2d");
|
||||
if (!draw2d) return;
|
||||
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
canvas.width = CANVAS_W * dpr;
|
||||
canvas.height = CANVAS_H * dpr;
|
||||
draw2d.scale(dpr, dpr);
|
||||
|
||||
// Hoist allocations out of draw loop — only reallocate when binCount changes
|
||||
let dataArray: Uint8Array<ArrayBuffer> | null = null;
|
||||
let binRanges: [number, number][] | null = null;
|
||||
let lastBinCount = 0;
|
||||
|
||||
function draw() {
|
||||
rafRef.current = requestAnimationFrame(draw);
|
||||
|
||||
const now = performance.now();
|
||||
const analyser = analyserRef.current;
|
||||
const binCount = analyser?.frequencyBinCount ?? 128;
|
||||
|
||||
if (binCount !== lastBinCount) {
|
||||
dataArray = new Uint8Array(binCount) as Uint8Array<ArrayBuffer>;
|
||||
binRanges = buildBinRanges(binCount, BAR_COUNT);
|
||||
lastBinCount = binCount;
|
||||
}
|
||||
if (analyser && dataArray) analyser.getByteFrequencyData(dataArray);
|
||||
|
||||
draw2d!.clearRect(0, 0, CANVAS_W, CANVAS_H);
|
||||
|
||||
for (let i = 0; i < BAR_COUNT; i++) {
|
||||
// Average frequency bins in this bar's range
|
||||
const [startBin, endBin] = binRanges![i];
|
||||
let sum = 0;
|
||||
const count = endBin - startBin;
|
||||
for (let b = startBin; b < endBin; b++) {
|
||||
sum += dataArray![b];
|
||||
}
|
||||
const raw = analyser && count > 0 ? sum / count / 255 : 0;
|
||||
|
||||
// Idle breathing: gentle sine wave per bar when no signal
|
||||
const breathPhase = (now / 1200 + i * 0.35) % (Math.PI * 2);
|
||||
const breathVal = 0.08 + Math.sin(breathPhase) * 0.05;
|
||||
const target = raw > 0.02 ? raw : breathVal;
|
||||
|
||||
barsRef.current[i] += (target - barsRef.current[i]) * LERP;
|
||||
const val = barsRef.current[i];
|
||||
|
||||
const barH = Math.max(MIN_BAR_H, val * (CANVAS_H - 2));
|
||||
const x = i * (BAR_WIDTH + BAR_GAP);
|
||||
const y = CANVAS_H - barH;
|
||||
|
||||
// Emerald when signal, dim white breathing when idle
|
||||
if (raw > 0.04) {
|
||||
const intensity = Math.min(val * 1.6, 1);
|
||||
draw2d!.fillStyle = `rgba(52, 211, 153, ${0.5 + intensity * 0.5})`;
|
||||
} else {
|
||||
draw2d!.fillStyle = "rgba(255, 255, 255, 0.1)";
|
||||
}
|
||||
draw2d!.beginPath();
|
||||
draw2d!.roundRect(x, y, BAR_WIDTH, barH, 1);
|
||||
draw2d!.fill();
|
||||
}
|
||||
}
|
||||
|
||||
draw();
|
||||
return () => cancelAnimationFrame(rafRef.current);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="h-7 shrink-0"
|
||||
style={{ width: `${CANVAS_W}px`, imageRendering: "auto" }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -56,6 +56,7 @@ function getRecents(): string[] {
|
||||
}
|
||||
return valid.map((e) => e.q);
|
||||
} catch {
|
||||
// localStorage unavailable or corrupted — return empty recent list
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@ -76,7 +77,7 @@ function addRecent(query: string) {
|
||||
const next = [{ q, ts: Date.now() }, ...filtered].slice(0, RECENT_MAX);
|
||||
localStorage.setItem(RECENT_KEY, JSON.stringify(next));
|
||||
} catch {
|
||||
/* quota exceeded — ignore */
|
||||
// localStorage unavailable or quota exceeded
|
||||
}
|
||||
}
|
||||
|
||||
@ -93,7 +94,7 @@ function removeRecent(query: string) {
|
||||
);
|
||||
localStorage.setItem(RECENT_KEY, JSON.stringify(next));
|
||||
} catch {
|
||||
/* ignore */
|
||||
// localStorage unavailable or corrupted
|
||||
}
|
||||
}
|
||||
|
||||
@ -101,7 +102,7 @@ function clearRecents() {
|
||||
try {
|
||||
localStorage.removeItem(RECENT_KEY);
|
||||
} catch {
|
||||
/* ignore */
|
||||
// localStorage unavailable
|
||||
}
|
||||
}
|
||||
|
||||
@ -304,8 +305,6 @@ export function SearchContent({
|
||||
.slice(0, 15);
|
||||
}, [flights, compactQuery]);
|
||||
|
||||
const hasResults =
|
||||
featured.length > 0 || airports.length > 0 || flightMatches.length > 0;
|
||||
const showRecents = !query && recents.length > 0;
|
||||
|
||||
// Total result count for screen reader
|
||||
|
||||
@ -9,8 +9,16 @@ import {
|
||||
Palette,
|
||||
Globe,
|
||||
ArrowLeftRight,
|
||||
Shield,
|
||||
Flame,
|
||||
Eye,
|
||||
} from "lucide-react";
|
||||
import { useSettings, type OrbitDirection } from "@/hooks/use-settings";
|
||||
import {
|
||||
useSettings,
|
||||
AIRSPACE_OPACITY_MIN,
|
||||
AIRSPACE_OPACITY_MAX,
|
||||
type OrbitDirection,
|
||||
} from "@/hooks/use-settings";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Slider } from "@/components/ui/slider";
|
||||
import { SHORTCUTS } from "@/components/ui/keyboard-shortcuts-help";
|
||||
@ -40,6 +48,9 @@ export function SettingsContent() {
|
||||
return (
|
||||
<ScrollArea className="h-full">
|
||||
<div className="space-y-0.5 p-3 pt-1">
|
||||
{/* ── Camera ── */}
|
||||
<SectionHeader title="Camera" />
|
||||
|
||||
<SettingRow
|
||||
icon={<RotateCw className="h-4 w-4" />}
|
||||
title="Auto-orbit"
|
||||
@ -64,7 +75,8 @@ export function SettingsContent() {
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="mx-3 my-2 h-px bg-white/4" />
|
||||
{/* ── Visuals ── */}
|
||||
<SectionHeader title="Visuals" />
|
||||
|
||||
<SettingRow
|
||||
icon={<Route className="h-4 w-4" />}
|
||||
@ -100,7 +112,35 @@ export function SettingsContent() {
|
||||
onChange={(v) => update("showAltitudeColors", v)}
|
||||
/>
|
||||
|
||||
<div className="mx-3 my-2 h-px bg-white/4" />
|
||||
{/* ── Airspace ── */}
|
||||
<SectionHeader title="Airspace" />
|
||||
|
||||
<SettingRow
|
||||
icon={<Shield className="h-4 w-4" />}
|
||||
title="Airspace overlay"
|
||||
description="Show classified airspace boundaries (OpenAIP)"
|
||||
checked={settings.showAirspace}
|
||||
onChange={(v) => update("showAirspace", v)}
|
||||
/>
|
||||
|
||||
{settings.showAirspace && (
|
||||
<>
|
||||
<AirspaceOpacitySlider
|
||||
value={settings.airspaceOpacity}
|
||||
onChange={(v) => update("airspaceOpacity", v)}
|
||||
/>
|
||||
<SettingRow
|
||||
icon={<Flame className="h-4 w-4" />}
|
||||
title="Thermal hotspots"
|
||||
description="Glider & paraglider thermal activity areas"
|
||||
checked={settings.showAirspaceHotspots}
|
||||
onChange={(v) => update("showAirspaceHotspots", v)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── Performance ── */}
|
||||
<SectionHeader title="Performance" />
|
||||
|
||||
<SettingRow
|
||||
icon={<Globe className="h-4 w-4" />}
|
||||
@ -290,6 +330,51 @@ function TrailDistanceSlider({
|
||||
);
|
||||
}
|
||||
|
||||
function AirspaceOpacitySlider({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: number;
|
||||
onChange: (v: number) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex w-full items-center gap-3.5 rounded-xl px-3 py-2.5 text-left">
|
||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-white/5 text-white/35 ring-1 ring-white/6">
|
||||
<Eye className="h-4 w-4" />
|
||||
</div>
|
||||
<div className="flex flex-1 min-w-0 flex-col gap-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-[13px] font-medium text-white/80">
|
||||
Airspace opacity
|
||||
</p>
|
||||
<span className="text-[11px] font-semibold text-white/40 tabular-nums">
|
||||
{Math.round(value * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
<Slider
|
||||
min={AIRSPACE_OPACITY_MIN}
|
||||
max={AIRSPACE_OPACITY_MAX}
|
||||
step={0.05}
|
||||
value={[value]}
|
||||
onValueChange={(vals) => onChange(vals[0])}
|
||||
aria-label="Airspace opacity"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionHeader({ title }: { title: string }) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 px-3 pt-3 pb-1">
|
||||
<span className="text-[10px] font-bold tracking-widest text-white/25 uppercase">
|
||||
{title}
|
||||
</span>
|
||||
<div className="h-px flex-1 bg-white/4" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SettingRow({
|
||||
icon,
|
||||
title,
|
||||
@ -410,6 +495,42 @@ function Toggle({ checked }: { checked: boolean }) {
|
||||
}
|
||||
|
||||
const CHANGELOG = [
|
||||
{
|
||||
date: "Mar 22",
|
||||
title: "3D aircraft models & smoother trails",
|
||||
description:
|
||||
"14 distinct 3D aircraft silhouettes assigned by ADS-B category and ICAO type code — from wide-bodies to helicopters. Models hosted on Cloudinary CDN with lazy loading and prefetch. Trail smoothing overhauled: 5-pass kernel filter, tighter corner rounding (15°), denser Catmull–Rom splines, and wider junction blending between historical and live data. Aircraft rendered 12% smaller for better proportions.",
|
||||
},
|
||||
{
|
||||
date: "Mar 22",
|
||||
title: "Multi-source flight data & circuit breaker",
|
||||
description:
|
||||
"Switched from OpenSky-only to a 2-tier fallback: adsb.lol → OpenSky (airplanes.live available via override). Each provider has its own parser normalising into a shared FlightState format. Circuit breaker tracks failures per provider and temporarily disables broken ones. Empty-response guard prevents data wipe-outs during transient failures, and an immediate re-fetch fires on network reconnect.",
|
||||
},
|
||||
{
|
||||
date: "Mar 22",
|
||||
title: "Code review fixes",
|
||||
description:
|
||||
"Fixed GPU memory monitor (duplicate WebGL enum cases, wrong byte sizes). Selection pulse halos now match aircraft height at all zoom levels. ATC stream properly cancels upstream on timeout. Airspace tile rate-limiter enforces spacing for queued requests. Photo fetch errors now surface to the UI. Spline cache clearing moved from useMemo to useEffect for React strict mode safety.",
|
||||
},
|
||||
{
|
||||
date: "Mar 21",
|
||||
title: "ATC feed lookup & GPU memory monitor",
|
||||
description:
|
||||
"New ATC lookup module — converts IATA to ICAO codes, finds nearby feeds by geographic proximity, and looks up feeds by airport or centre code. GPU memory monitor tracks WebGL resource allocations (textures, buffers, framebuffers) for debugging resource leaks.",
|
||||
},
|
||||
{
|
||||
date: "Mar 20",
|
||||
title: "Reliability & polish",
|
||||
description:
|
||||
"Serialised rate limiting in the flight API client. Logo cache with size limits and eviction. Registration country lookup via pre-built O(1) maps. Keyboard shortcuts focus trapping fix. SessionStorage guard for incognito mode. Airspace display toggle in map attribution. Utility functions extended with clamp().",
|
||||
},
|
||||
{
|
||||
date: "Mar 13",
|
||||
title: "Flight API client & rebase fixes",
|
||||
description:
|
||||
"New flight-api-client, flight-api-parsing, and flight-api-types modules. useFlights refactored to use the multi-source client — removed legacy credit management. useFlightMonitors switched to hex-based lookups. Fixed 6 files that diverged during rebase (IATA codes, globe mode ref, terrain attribution, cache eviction, OpenSky parsing).",
|
||||
},
|
||||
{
|
||||
date: "Mar 11",
|
||||
title: "Globe mode & aircraft photos",
|
||||
@ -459,8 +580,9 @@ export function AboutContent() {
|
||||
<div className="space-y-3 text-[13px] leading-relaxed text-white/40">
|
||||
<p>
|
||||
Live flight tracking in 3D. The planes you see are real — position
|
||||
data comes from the OpenSky Network, updated every few seconds via
|
||||
ADS-B receivers people run on their roofs worldwide.
|
||||
data comes from ADS-B Exchange, adsb.lol, and OpenSky Network,
|
||||
updated every few seconds via ADS-B receivers people run on their
|
||||
roofs worldwide.
|
||||
</p>
|
||||
<p>
|
||||
You can search through 9,000+ airports, jump into first-person view
|
||||
|
||||
@ -29,7 +29,7 @@ export function StyleContent({
|
||||
</div>
|
||||
<div className="border-t border-white/4 px-5 py-3">
|
||||
<p className="text-[11px] font-medium text-white/12">
|
||||
Satellite © Esri · Terrain © OpenTopoMap / Terrain Tiles · Base maps ©
|
||||
Satellite © Esri · Terrain © AWS/Mapzen Terrain Tiles · Base maps ©
|
||||
CARTO
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@ -195,7 +195,8 @@ function PanelDialog({
|
||||
|
||||
function trapFocus(e: KeyboardEvent) {
|
||||
if (e.key !== "Tab") return;
|
||||
const elements = dialog!.querySelectorAll<HTMLElement>(
|
||||
if (!dialog) return;
|
||||
const elements = dialog.querySelectorAll<HTMLElement>(
|
||||
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
|
||||
);
|
||||
const f = elements[0];
|
||||
@ -299,7 +300,7 @@ function PanelDialog({
|
||||
</a>
|
||||
<div className="border-t border-white/3 pt-2 px-2.5">
|
||||
<p className="text-[10px] font-medium text-white/10 tracking-wide">
|
||||
Powered by OpenSky Network
|
||||
Data from ADS-B Exchange, adsb.lol & OpenSky
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -30,6 +30,7 @@ import { aircraftTypeHint } from "@/lib/aircraft";
|
||||
import { airlineLogoCandidates } from "@/lib/airline-logos";
|
||||
import {
|
||||
loadedAirlineLogoUrls,
|
||||
trackAirlineLogoLoaded,
|
||||
markAirlineLogoFailed,
|
||||
wasAirlineLogoRecentlyFailed,
|
||||
} from "@/lib/logo-cache";
|
||||
@ -93,7 +94,7 @@ export function FlightCard({
|
||||
aircraft: photoAircraft,
|
||||
loading: photosLoading,
|
||||
error: photosError,
|
||||
} = useAircraftPhotos(flight?.icao24 ?? null);
|
||||
} = useAircraftPhotos(flight?.icao24 ?? null, flight?.registration);
|
||||
const heroPhoto = photos[0] ?? null;
|
||||
|
||||
return (
|
||||
@ -139,7 +140,7 @@ export function FlightCard({
|
||||
}`}
|
||||
unoptimized
|
||||
onLoad={() => {
|
||||
if (logoUrl) loadedAirlineLogoUrls.add(logoUrl);
|
||||
if (logoUrl) trackAirlineLogoLoaded(logoUrl);
|
||||
setLogoLoadedByKey((current) => ({
|
||||
...current,
|
||||
[logoLoadKey]: true,
|
||||
|
||||
@ -10,6 +10,7 @@ import { lookupAirline } from "@/lib/airlines";
|
||||
import { airlineLogoCandidates } from "@/lib/airline-logos";
|
||||
import {
|
||||
loadedAirlineLogoUrls,
|
||||
trackAirlineLogoLoaded,
|
||||
markAirlineLogoFailed,
|
||||
wasAirlineLogoRecentlyFailed,
|
||||
} from "@/lib/logo-cache";
|
||||
@ -232,7 +233,7 @@ export function FpvHud({ flight, onExit }: FpvHudProps) {
|
||||
className="relative object-contain p-1"
|
||||
unoptimized
|
||||
onLoad={() => {
|
||||
if (logoUrl) loadedAirlineLogoUrls.add(logoUrl);
|
||||
if (logoUrl) trackAirlineLogoLoaded(logoUrl);
|
||||
setLogoLoadedByKey((current) => ({
|
||||
...current,
|
||||
[logoLoadKey]: true,
|
||||
|
||||
@ -14,8 +14,12 @@ export function HeroBanner({ photo, loading }: HeroBannerProps) {
|
||||
const [failed, setFailed] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Reset load state when photo changes
|
||||
const reset = () => {
|
||||
setLoaded(false);
|
||||
setFailed(false);
|
||||
};
|
||||
reset();
|
||||
}, [photo?.id]);
|
||||
|
||||
const hasPhoto = photo != null && !failed;
|
||||
@ -48,7 +52,7 @@ export function HeroBanner({ photo, loading }: HeroBannerProps) {
|
||||
/>
|
||||
)}
|
||||
<img
|
||||
src={photo.thumbnail}
|
||||
src={photo.url}
|
||||
alt="Aircraft"
|
||||
onLoad={() => setLoaded(true)}
|
||||
onError={() => setFailed(true)}
|
||||
|
||||
@ -12,6 +12,7 @@ export const SHORTCUTS = [
|
||||
{ key: "⌘K", description: "Open search (anywhere)" },
|
||||
{ key: "F", description: "First person view" },
|
||||
{ key: "?", description: "Shortcuts help" },
|
||||
{ key: "A", description: "Toggle ATC panel" },
|
||||
{ key: "Esc", description: "Close / Deselect" },
|
||||
] as const;
|
||||
|
||||
@ -49,7 +50,8 @@ export function KeyboardShortcutsHelp({
|
||||
|
||||
function trapFocus(e: KeyboardEvent) {
|
||||
if (e.key !== "Tab") return;
|
||||
const elements = dialog!.querySelectorAll<HTMLElement>(
|
||||
if (!dialog) return;
|
||||
const elements = dialog.querySelectorAll<HTMLElement>(
|
||||
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
|
||||
);
|
||||
if (elements.length === 0) return;
|
||||
|
||||
@ -7,13 +7,14 @@ import { getAttributions, type AttributionEntry } from "@/lib/map-styles";
|
||||
|
||||
type MapAttributionProps = {
|
||||
styleId: string;
|
||||
showAirspace?: boolean;
|
||||
};
|
||||
|
||||
const SM_BREAKPOINT = 640;
|
||||
|
||||
export function MapAttribution({ styleId }: MapAttributionProps) {
|
||||
export function MapAttribution({ styleId, showAirspace }: MapAttributionProps) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const attributions = getAttributions(styleId);
|
||||
const attributions = getAttributions(styleId, { showAirspace });
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const toggle = useCallback(() => setExpanded((prev) => !prev), []);
|
||||
|
||||
547
src/components/ui/mobile-flight-toast.tsx
Normal file
547
src/components/ui/mobile-flight-toast.tsx
Normal file
@ -0,0 +1,547 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState, useEffect, useCallback, useRef } from "react";
|
||||
import Image from "next/image";
|
||||
import {
|
||||
ArrowUp,
|
||||
ArrowDown,
|
||||
Gauge,
|
||||
Compass,
|
||||
Eye,
|
||||
X,
|
||||
Building2,
|
||||
Globe,
|
||||
Navigation,
|
||||
Camera,
|
||||
ImageOff,
|
||||
Plane,
|
||||
} from "lucide-react";
|
||||
import { useAircraftPhotos } from "@/hooks/use-aircraft-photos";
|
||||
import type { FlightState } from "@/lib/opensky";
|
||||
import {
|
||||
metersToFeet,
|
||||
msToKnots,
|
||||
formatCallsign,
|
||||
headingToCardinal,
|
||||
} from "@/lib/flight-utils";
|
||||
import { lookupAirline, parseFlightNumber } from "@/lib/airlines";
|
||||
import { aircraftTypeHint } from "@/lib/aircraft";
|
||||
import { airlineLogoCandidates } from "@/lib/airline-logos";
|
||||
import {
|
||||
loadedAirlineLogoUrls,
|
||||
trackAirlineLogoLoaded,
|
||||
markAirlineLogoFailed,
|
||||
wasAirlineLogoRecentlyFailed,
|
||||
} from "@/lib/logo-cache";
|
||||
|
||||
type MobileFlightToastProps = {
|
||||
flight: FlightState;
|
||||
onClose: () => void;
|
||||
onToggleFpv?: (icao24: string) => void;
|
||||
isFpvActive?: boolean;
|
||||
};
|
||||
|
||||
const EMERGENCY_SQUAWKS = new Set(["7500", "7600", "7700"]);
|
||||
|
||||
function isEmergencySquawk(squawk: string | null): boolean {
|
||||
if (!squawk) return false;
|
||||
return EMERGENCY_SQUAWKS.has(squawk.trim());
|
||||
}
|
||||
|
||||
function squawkLabel(squawk: string): string {
|
||||
switch (squawk.trim()) {
|
||||
case "7500":
|
||||
return "Hijack";
|
||||
case "7600":
|
||||
return "Radio fail";
|
||||
case "7700":
|
||||
return "Emergency";
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
export function MobileFlightToast({
|
||||
flight,
|
||||
onClose,
|
||||
onToggleFpv,
|
||||
isFpvActive = false,
|
||||
}: MobileFlightToastProps) {
|
||||
const airline = lookupAirline(flight.callsign);
|
||||
const flightNum = parseFlightNumber(flight.callsign);
|
||||
const company = airline ?? `${flight.originCountry} operator`;
|
||||
const model = aircraftTypeHint(flight.category);
|
||||
const heading = flight.trueTrack;
|
||||
const cardinal = heading !== null ? headingToCardinal(heading) : null;
|
||||
const canEnterFpv =
|
||||
flight.longitude != null && flight.latitude != null && !flight.onGround;
|
||||
|
||||
// ── Airline logo with fallback chain ──────────────────────────────
|
||||
const logoCandidates = airlineLogoCandidates(airline);
|
||||
const [logoIndexByAirline, setLogoIndexByAirline] = useState<
|
||||
Record<string, number>
|
||||
>({});
|
||||
const [logoLoadedByKey, setLogoLoadedByKey] = useState<
|
||||
Record<string, boolean>
|
||||
>({});
|
||||
const [genericLogoFailed, setGenericLogoFailed] = useState(false);
|
||||
|
||||
const airlineKey = airline ?? "__none__";
|
||||
const baseLogoIndex = logoIndexByAirline[airlineKey] ?? 0;
|
||||
const resolvedLogoIndex = useMemo(() => {
|
||||
let idx = baseLogoIndex;
|
||||
while (
|
||||
idx < logoCandidates.length &&
|
||||
wasAirlineLogoRecentlyFailed(logoCandidates[idx] ?? "")
|
||||
) {
|
||||
idx += 1;
|
||||
}
|
||||
return idx;
|
||||
}, [baseLogoIndex, logoCandidates]);
|
||||
|
||||
const logoLoadKey = `${airlineKey}:${resolvedLogoIndex}`;
|
||||
const logoUrl = logoCandidates[resolvedLogoIndex] ?? null;
|
||||
const logoLoaded =
|
||||
(logoUrl ? loadedAirlineLogoUrls.has(logoUrl) : false) ||
|
||||
(logoLoadedByKey[logoLoadKey] ?? false);
|
||||
const showLogo = Boolean(logoUrl);
|
||||
const genericLogoUrl = "/airline-logos/envoy-air.png";
|
||||
|
||||
// ── Aircraft photos & details ──────────────────────────────────────
|
||||
const {
|
||||
photos,
|
||||
aircraft: aircraftDetails,
|
||||
loading: photosLoading,
|
||||
} = useAircraftPhotos(flight.icao24, flight.registration);
|
||||
|
||||
// ── Photo carousel state ───────────────────────────────────────────
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const [activeSlide, setActiveSlide] = useState(0);
|
||||
const [slideLoadState, setSlideLoadState] = useState<
|
||||
Record<number, "loaded" | "error">
|
||||
>({});
|
||||
// Progressive loading: only mount <img> for slides the user has reached
|
||||
const [mountedSlides, setMountedSlides] = useState<Set<number>>(
|
||||
() => new Set([0]),
|
||||
);
|
||||
|
||||
// Reset carousel when photos change (new aircraft)
|
||||
const photoKey = photos.map((p) => p.id).join(",");
|
||||
useEffect(() => {
|
||||
setActiveSlide(0);
|
||||
setSlideLoadState({});
|
||||
setMountedSlides(new Set([0]));
|
||||
if (scrollRef.current) scrollRef.current.scrollLeft = 0;
|
||||
}, [photoKey]);
|
||||
|
||||
// When the active slide changes, mount that slide's image
|
||||
useEffect(() => {
|
||||
setMountedSlides((prev) => {
|
||||
if (prev.has(activeSlide)) return prev;
|
||||
const next = new Set(prev);
|
||||
next.add(activeSlide);
|
||||
return next;
|
||||
});
|
||||
}, [activeSlide]);
|
||||
|
||||
const handleScroll = useCallback(() => {
|
||||
const el = scrollRef.current;
|
||||
if (!el || el.clientWidth === 0) return;
|
||||
const idx = Math.round(el.scrollLeft / el.clientWidth);
|
||||
setActiveSlide(idx);
|
||||
}, []);
|
||||
|
||||
const handleSlideLoad = useCallback((index: number) => {
|
||||
setSlideLoadState((s) => ({ ...s, [index]: "loaded" }));
|
||||
}, []);
|
||||
|
||||
const handleSlideError = useCallback((index: number) => {
|
||||
setSlideLoadState((s) => ({ ...s, [index]: "error" }));
|
||||
}, []);
|
||||
|
||||
const hasPhotos = photos.length > 0;
|
||||
const showPhotos = !photosLoading && hasPhotos;
|
||||
|
||||
return (
|
||||
<div className="w-full overflow-hidden rounded-2xl border border-white/8 bg-black/80 shadow-2xl shadow-black/50 backdrop-blur-2xl">
|
||||
{/* Photo carousel / hero banner */}
|
||||
<div className="relative h-36 w-full overflow-hidden bg-white/5">
|
||||
{/* Skeleton while loading */}
|
||||
{photosLoading && !hasPhotos && (
|
||||
<span
|
||||
aria-hidden
|
||||
className="absolute inset-0 animate-pulse bg-linear-to-br from-white/5 via-white/8 to-white/5"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* No image placeholder */}
|
||||
{!photosLoading && !hasPhotos && (
|
||||
<div className="flex h-full w-full flex-col items-center justify-center gap-1 text-white/15">
|
||||
<ImageOff className="h-4 w-4" />
|
||||
<span className="text-[9px] font-medium">No photo</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Swipeable photo slider */}
|
||||
{showPhotos && (
|
||||
<div
|
||||
ref={scrollRef}
|
||||
onScroll={handleScroll}
|
||||
className="flex h-full snap-x snap-mandatory overflow-x-auto scrollbar-none"
|
||||
style={{ scrollSnapType: "x mandatory", scrollbarWidth: "none" }}
|
||||
>
|
||||
{photos.map((photo, i) => (
|
||||
<div
|
||||
key={photo.id}
|
||||
className="relative h-full w-full shrink-0 snap-center"
|
||||
>
|
||||
{/* Show skeleton until this slide's image is loaded */}
|
||||
{slideLoadState[i] !== "loaded" &&
|
||||
slideLoadState[i] !== "error" && (
|
||||
<span
|
||||
aria-hidden
|
||||
className="absolute inset-0 animate-pulse bg-linear-to-br from-white/5 via-white/8 to-white/5"
|
||||
/>
|
||||
)}
|
||||
{slideLoadState[i] === "error" ? (
|
||||
<div className="flex h-full w-full items-center justify-center text-white/15">
|
||||
<ImageOff className="h-5 w-5" />
|
||||
</div>
|
||||
) : mountedSlides.has(i) ? (
|
||||
<img
|
||||
src={photo.url}
|
||||
alt={`Aircraft photo ${i + 1}`}
|
||||
decoding="async"
|
||||
onLoad={() => handleSlideLoad(i)}
|
||||
onError={() => handleSlideError(i)}
|
||||
className={`h-full w-full object-cover transition-opacity duration-300 ${
|
||||
slideLoadState[i] === "loaded"
|
||||
? "opacity-100"
|
||||
: "opacity-0"
|
||||
}`}
|
||||
draggable={false}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Gradient overlay */}
|
||||
{showPhotos && (
|
||||
<span className="pointer-events-none absolute inset-0 bg-linear-to-t from-black/40 via-black/5 to-transparent" />
|
||||
)}
|
||||
|
||||
{/* Photographer attribution */}
|
||||
{showPhotos && photos[activeSlide]?.photographer && (
|
||||
<span className="absolute bottom-1.5 right-2 z-10 flex items-center gap-0.5 rounded-full bg-black/45 px-1.5 py-0.5 text-[8px] font-medium text-white/55 backdrop-blur-sm">
|
||||
<Camera className="h-2 w-2" />
|
||||
{photos[activeSlide].photographer}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Dot indicators */}
|
||||
{showPhotos && photos.length > 1 && (
|
||||
<div className="absolute bottom-1.5 left-1/2 z-10 flex -translate-x-1/2 gap-1">
|
||||
{photos.slice(0, 10).map((_, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className={`h-1 w-1 rounded-full transition-colors duration-200 ${
|
||||
i === activeSlide ? "bg-white/80" : "bg-white/30"
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
{photos.length > 10 && (
|
||||
<span className="text-[7px] leading-none text-white/30">
|
||||
+{photos.length - 10}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Slide counter */}
|
||||
{showPhotos && photos.length > 1 && (
|
||||
<span className="absolute top-1.5 right-2 z-10 rounded-full bg-black/45 px-1.5 py-0.5 text-[8px] font-semibold tabular-nums text-white/60 backdrop-blur-sm">
|
||||
{activeSlide + 1}/{photos.length}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="p-3.5 pt-3">
|
||||
{/* Header row: logo + callsign + close */}
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Airline logo */}
|
||||
<div className="relative flex h-12 w-12 shrink-0 items-center justify-center rounded-xl border border-white/14 bg-white/10 shadow-md shadow-black/25">
|
||||
{showLogo ? (
|
||||
<span className="relative flex h-10 w-10 items-center justify-center overflow-hidden rounded-lg border border-black/10 bg-white/95 p-2 shadow-sm">
|
||||
{!logoLoaded && (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="absolute inset-0 animate-pulse bg-linear-to-br from-white/85 via-neutral-200/65 to-white/80"
|
||||
/>
|
||||
)}
|
||||
<Image
|
||||
src={logoUrl ?? undefined}
|
||||
alt={company ? `${company} logo` : "Airline logo"}
|
||||
width={40}
|
||||
height={40}
|
||||
className={`relative h-8 w-8 object-contain transition-opacity duration-200 ${
|
||||
logoLoaded ? "opacity-100" : "opacity-0"
|
||||
}`}
|
||||
unoptimized
|
||||
onLoad={() => {
|
||||
if (logoUrl) trackAirlineLogoLoaded(logoUrl);
|
||||
setLogoLoadedByKey((current) => ({
|
||||
...current,
|
||||
[logoLoadKey]: true,
|
||||
}));
|
||||
}}
|
||||
onError={() => {
|
||||
if (logoUrl) markAirlineLogoFailed(logoUrl);
|
||||
if (resolvedLogoIndex + 1 < logoCandidates.length) {
|
||||
setLogoIndexByAirline((current) => ({
|
||||
...current,
|
||||
[airlineKey]: resolvedLogoIndex + 1,
|
||||
}));
|
||||
return;
|
||||
}
|
||||
setLogoIndexByAirline((current) => ({
|
||||
...current,
|
||||
[airlineKey]: logoCandidates.length,
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
) : (
|
||||
<span className="relative flex h-10 w-10 items-center justify-center overflow-hidden rounded-lg border border-white/10 bg-white/95 p-2 shadow-sm">
|
||||
{genericLogoFailed ? (
|
||||
<span className="text-[16px] font-semibold text-black/25">
|
||||
—
|
||||
</span>
|
||||
) : (
|
||||
<Image
|
||||
src={genericLogoUrl}
|
||||
alt="Generic airline logo"
|
||||
width={40}
|
||||
height={40}
|
||||
className="h-8 w-8 object-contain grayscale opacity-80"
|
||||
unoptimized
|
||||
onError={() => setGenericLogoFailed(true)}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Callsign + identifiers */}
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-[15px] font-bold leading-tight text-white">
|
||||
{formatCallsign(flight.callsign)}
|
||||
</p>
|
||||
<p className="mt-0.5 truncate text-[10px] font-medium tracking-widest text-white/30 uppercase">
|
||||
{flight.icao24}
|
||||
{flightNum ? ` · #${flightNum}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Close button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg bg-white/5 transition-colors active:bg-white/10"
|
||||
aria-label="Close flight details"
|
||||
>
|
||||
<X className="h-3.5 w-3.5 text-white/40" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Airline / model */}
|
||||
{company && (
|
||||
<div className="mt-2 flex items-center gap-1.5">
|
||||
<Building2 className="h-3 w-3 shrink-0 text-white/20" />
|
||||
<p className="truncate text-[11px] font-medium text-white/45">
|
||||
{company}
|
||||
{model ? <span className="text-white/25"> · {model}</span> : null}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Aircraft details (registration, type, owner) */}
|
||||
{aircraftDetails &&
|
||||
(aircraftDetails.registration ||
|
||||
aircraftDetails.type ||
|
||||
aircraftDetails.typeCode ||
|
||||
aircraftDetails.owner) && (
|
||||
<div className="mt-1.5 flex items-center gap-1.5">
|
||||
<Plane className="h-3 w-3 shrink-0 text-white/20" />
|
||||
<p className="truncate text-[11px] text-white/35">
|
||||
{[
|
||||
aircraftDetails.registration,
|
||||
aircraftDetails.type ?? aircraftDetails.typeCode,
|
||||
aircraftDetails.owner,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Metrics 4-column grid */}
|
||||
<div className="grid grid-cols-4 gap-px border-t border-white/5 bg-white/[0.02]">
|
||||
<MiniMetric
|
||||
icon={<ArrowUp className="h-2.5 w-2.5" />}
|
||||
label="ALT"
|
||||
value={metersToFeet(flight.baroAltitude)}
|
||||
/>
|
||||
<MiniMetric
|
||||
icon={<Gauge className="h-2.5 w-2.5" />}
|
||||
label="SPD"
|
||||
value={msToKnots(flight.velocity)}
|
||||
/>
|
||||
<MiniMetric
|
||||
icon={<Compass className="h-2.5 w-2.5" />}
|
||||
label="HDG"
|
||||
value={
|
||||
heading !== null && Number.isFinite(heading)
|
||||
? `${Math.round(heading)}° ${cardinal}`
|
||||
: "—"
|
||||
}
|
||||
/>
|
||||
<MiniMetric
|
||||
icon={<ArrowDown className="h-2.5 w-2.5" />}
|
||||
label="V/S"
|
||||
value={
|
||||
flight.verticalRate !== null && Number.isFinite(flight.verticalRate)
|
||||
? `${flight.verticalRate > 0 ? "+" : ""}${Math.round(flight.verticalRate)}`
|
||||
: "—"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Info section: origin, heading + coords, squawk */}
|
||||
<div className="flex flex-col gap-1.5 border-t border-white/5 px-3.5 py-2.5">
|
||||
{/* Origin country */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Globe className="h-3 w-3 text-white/25" />
|
||||
<p className="text-[11px] text-white/40">{flight.originCountry}</p>
|
||||
</div>
|
||||
|
||||
{/* Heading direction + coordinates */}
|
||||
{cardinal && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Navigation
|
||||
className="h-3 w-3 text-white/25"
|
||||
style={{
|
||||
transform:
|
||||
heading !== null && Number.isFinite(heading)
|
||||
? `rotate(${heading}deg)`
|
||||
: undefined,
|
||||
}}
|
||||
/>
|
||||
<p className="text-[11px] text-white/40">
|
||||
Heading {cardinal}
|
||||
{flight.latitude !== null &&
|
||||
flight.longitude !== null &&
|
||||
Number.isFinite(flight.latitude) &&
|
||||
Number.isFinite(flight.longitude) && (
|
||||
<span className="text-white/20">
|
||||
{" "}
|
||||
· {Math.abs(flight.latitude).toFixed(2)}°
|
||||
{flight.latitude >= 0 ? "N" : "S"},{" "}
|
||||
{Math.abs(flight.longitude).toFixed(2)}°
|
||||
{flight.longitude >= 0 ? "E" : "W"}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Squawk code */}
|
||||
{flight.squawk && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span
|
||||
className={`h-3 w-3 text-center text-[8px] font-bold leading-3 ${
|
||||
isEmergencySquawk(flight.squawk)
|
||||
? "text-red-400"
|
||||
: "text-white/25"
|
||||
}`}
|
||||
>
|
||||
SQ
|
||||
</span>
|
||||
<p
|
||||
className={`font-mono text-[11px] tabular-nums ${
|
||||
isEmergencySquawk(flight.squawk)
|
||||
? "text-red-400"
|
||||
: "text-white/40"
|
||||
}`}
|
||||
>
|
||||
{flight.squawk}
|
||||
{isEmergencySquawk(flight.squawk) && (
|
||||
<span className="ml-1.5 rounded bg-red-500/15 px-1.5 py-0.5 text-[9px] font-semibold tracking-wider text-red-400 uppercase">
|
||||
{squawkLabel(flight.squawk)}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* FPV button */}
|
||||
{onToggleFpv && (
|
||||
<div className="border-t border-white/5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
(isFpvActive || canEnterFpv) && onToggleFpv(flight.icao24)
|
||||
}
|
||||
disabled={!isFpvActive && !canEnterFpv}
|
||||
className={`flex w-full items-center justify-center gap-1.5 py-2.5 transition-colors active:bg-white/5 ${
|
||||
!isFpvActive && !canEnterFpv
|
||||
? "cursor-not-allowed opacity-30"
|
||||
: ""
|
||||
}`}
|
||||
aria-label={
|
||||
isFpvActive ? "Exit first person view" : "Enter first person view"
|
||||
}
|
||||
>
|
||||
<Eye
|
||||
className={`h-3 w-3 ${isFpvActive ? "text-emerald-400" : "text-white/30"}`}
|
||||
/>
|
||||
<span
|
||||
className={`text-[10px] font-semibold tracking-wider uppercase ${
|
||||
isFpvActive ? "text-emerald-400/70" : "text-white/35"
|
||||
}`}
|
||||
>
|
||||
{isFpvActive ? "Exit FPV" : "First Person View"}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MiniMetric({
|
||||
icon,
|
||||
label,
|
||||
value,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
value: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-0.5 py-2.5">
|
||||
<div className="flex items-center gap-1 text-white/20">
|
||||
{icon}
|
||||
<span className="text-[8px] font-bold tracking-widest uppercase">
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-[12px] font-semibold tabular-nums text-white/85">
|
||||
{value}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
340
src/components/ui/provider-panel.tsx
Normal file
340
src/components/ui/provider-panel.tsx
Normal file
@ -0,0 +1,340 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState, useCallback } from "react";
|
||||
import { motion, AnimatePresence } from "motion/react";
|
||||
import { Satellite, X, ChevronUp, Circle } from "lucide-react";
|
||||
import {
|
||||
getCircuitState,
|
||||
getProviderOverride,
|
||||
type CircuitState,
|
||||
} from "@/lib/flight-api-client";
|
||||
import type { ProviderName } from "@/lib/flight-api";
|
||||
import { useDropdownDismiss } from "@/hooks/use-dropdown-dismiss";
|
||||
|
||||
// ── Provider definitions ───────────────────────────────────────────────
|
||||
|
||||
interface ProviderInfo {
|
||||
id: ProviderName;
|
||||
label: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
const PROVIDERS: ProviderInfo[] = [
|
||||
{ id: "adsb", label: "adsb.lol", description: "Primary — server proxy" },
|
||||
{
|
||||
id: "opensky",
|
||||
label: "OpenSky",
|
||||
description: "Fallback — limited credits",
|
||||
},
|
||||
{
|
||||
id: "airplanes",
|
||||
label: "Airplanes.live",
|
||||
description: "Direct — CORS restricted",
|
||||
},
|
||||
];
|
||||
|
||||
const SOURCE_LABELS: Record<string, string> = {
|
||||
adsb: "adsb.lol",
|
||||
opensky: "OpenSky",
|
||||
airplanes: "Airplanes.live",
|
||||
none: "Unavailable",
|
||||
};
|
||||
|
||||
const SOURCE_COLORS: Record<string, string> = {
|
||||
adsb: "rgb(52, 211, 153)", // emerald
|
||||
opensky: "rgb(251, 191, 36)", // amber
|
||||
airplanes: "rgb(96, 165, 250)", // blue
|
||||
none: "rgb(248, 113, 113)", // red
|
||||
};
|
||||
|
||||
function circuitBadge(
|
||||
state: CircuitState,
|
||||
cooldownMs: number,
|
||||
): { label: string; color: string } {
|
||||
switch (state) {
|
||||
case "closed":
|
||||
return { label: "OK", color: "rgb(52, 211, 153)" };
|
||||
case "open":
|
||||
return {
|
||||
label: `DOWN ${Math.ceil(cooldownMs / 1000)}s`,
|
||||
color: "rgb(248, 113, 113)",
|
||||
};
|
||||
case "half-open":
|
||||
return { label: "PROBING", color: "rgb(251, 191, 36)" };
|
||||
}
|
||||
}
|
||||
|
||||
function setProviderOverride(provider: ProviderName | "auto"): void {
|
||||
const url = new URL(window.location.href);
|
||||
if (provider === "auto") {
|
||||
url.searchParams.delete("provider");
|
||||
} else {
|
||||
url.searchParams.set("provider", provider);
|
||||
}
|
||||
window.history.replaceState({}, "", url.toString());
|
||||
}
|
||||
|
||||
// ── Provider Dropdown ──────────────────────────────────────────────────
|
||||
|
||||
export type ProviderDropdownProps = {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
currentSource: string | null;
|
||||
};
|
||||
|
||||
export function ProviderDropdown({
|
||||
open,
|
||||
onClose,
|
||||
currentSource,
|
||||
}: ProviderDropdownProps) {
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
useDropdownDismiss(dropdownRef, open, onClose);
|
||||
|
||||
const [override, setOverride] = useState(() => getProviderOverride());
|
||||
const isAutoMode = override === "auto";
|
||||
const isDev =
|
||||
typeof window !== "undefined" &&
|
||||
(window.location.hostname === "localhost" ||
|
||||
window.location.hostname === "127.0.0.1");
|
||||
|
||||
const handleSelect = useCallback(
|
||||
(provider: ProviderName | "auto") => {
|
||||
setProviderOverride(provider);
|
||||
setOverride(provider === "auto" ? "auto" : provider);
|
||||
onClose();
|
||||
},
|
||||
[onClose],
|
||||
);
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<motion.div
|
||||
ref={dropdownRef}
|
||||
initial={{ opacity: 0, y: 8, scale: 0.97 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: 8, scale: 0.97 }}
|
||||
transition={{ type: "spring", stiffness: 500, damping: 30 }}
|
||||
className="absolute bottom-full left-0 z-50 mb-2 w-[calc(100vw-2rem)] max-w-70 overflow-hidden rounded-xl border shadow-2xl shadow-black/60 backdrop-blur-2xl sm:w-70 sm:max-w-none"
|
||||
style={{
|
||||
borderColor: "rgb(var(--ui-fg) / 0.08)",
|
||||
backgroundColor: "rgb(var(--ui-bg) / 0.75)",
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<div
|
||||
className="flex items-center justify-between px-4 py-2.5"
|
||||
style={{ borderBottom: "1px solid rgb(var(--ui-fg) / 0.06)" }}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Satellite className="h-3 w-3 text-emerald-400/70" />
|
||||
<span
|
||||
className="text-[10px] font-semibold tracking-widest uppercase"
|
||||
style={{ color: "rgb(var(--ui-fg) / 0.35)" }}
|
||||
>
|
||||
Providers
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="flex h-5 w-5 items-center justify-center rounded-md transition-colors hover:bg-white/5 active:bg-white/10"
|
||||
aria-label="Close provider selector"
|
||||
>
|
||||
<X
|
||||
className="h-3 w-3"
|
||||
style={{ color: "rgb(var(--ui-fg) / 0.3)" }}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Provider list */}
|
||||
<div className="py-1">
|
||||
{/* Auto option */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSelect("auto")}
|
||||
className={`group flex w-full items-center gap-2.5 px-3.5 py-2 transition-colors ${
|
||||
isAutoMode ? "bg-white/6" : "hover:bg-white/3 active:bg-white/6"
|
||||
}`}
|
||||
>
|
||||
<div className="flex h-4 w-4 shrink-0 items-center justify-center">
|
||||
<Circle
|
||||
className="h-2.5 w-2.5"
|
||||
style={{
|
||||
color: isAutoMode
|
||||
? "rgb(52, 211, 153)"
|
||||
: "rgb(var(--ui-fg) / 0.2)",
|
||||
}}
|
||||
fill={isAutoMode ? "rgb(52, 211, 153)" : "transparent"}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-0 text-left">
|
||||
<span
|
||||
className="truncate text-[11px] font-medium leading-snug"
|
||||
style={{
|
||||
color: isAutoMode
|
||||
? "rgb(var(--ui-fg) / 0.85)"
|
||||
: "rgb(var(--ui-fg) / 0.55)",
|
||||
}}
|
||||
>
|
||||
Auto
|
||||
</span>
|
||||
<span
|
||||
className="text-[9px] leading-snug"
|
||||
style={{ color: "rgb(var(--ui-fg) / 0.25)" }}
|
||||
>
|
||||
Uses best available
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
className="shrink-0 rounded px-1.5 py-px text-[8px] font-bold tracking-wider"
|
||||
style={{
|
||||
backgroundColor: "rgb(52, 211, 153, 0.07)",
|
||||
color: "rgb(52, 211, 153)",
|
||||
}}
|
||||
>
|
||||
REC
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Individual providers */}
|
||||
{PROVIDERS.map((provider) => {
|
||||
const isSelected = override === provider.id;
|
||||
const isActive = currentSource === provider.id;
|
||||
const circuit = getCircuitState(provider.id);
|
||||
const badge = circuitBadge(
|
||||
circuit.state,
|
||||
circuit.cooldownRemaining,
|
||||
);
|
||||
const isAvailable = provider.id !== "airplanes" || isDev;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={provider.id}
|
||||
type="button"
|
||||
onClick={() => isAvailable && handleSelect(provider.id)}
|
||||
disabled={!isAvailable}
|
||||
className={`group flex w-full items-center gap-2.5 px-3.5 py-2 transition-colors ${
|
||||
isSelected
|
||||
? "bg-white/6"
|
||||
: isAvailable
|
||||
? "hover:bg-white/3 active:bg-white/6"
|
||||
: "cursor-not-allowed opacity-40"
|
||||
}`}
|
||||
>
|
||||
<div className="flex h-4 w-4 shrink-0 items-center justify-center">
|
||||
<Circle
|
||||
className="h-2.5 w-2.5"
|
||||
style={{
|
||||
color: isActive
|
||||
? (SOURCE_COLORS[provider.id] ??
|
||||
"rgb(var(--ui-fg) / 0.2)")
|
||||
: isSelected
|
||||
? "rgb(var(--ui-fg) / 0.5)"
|
||||
: "rgb(var(--ui-fg) / 0.2)",
|
||||
}}
|
||||
fill={
|
||||
isActive
|
||||
? (SOURCE_COLORS[provider.id] ?? "transparent")
|
||||
: "transparent"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-0 text-left">
|
||||
<span
|
||||
className="truncate text-[11px] font-medium leading-snug"
|
||||
style={{
|
||||
color: isActive
|
||||
? "rgb(var(--ui-fg) / 0.85)"
|
||||
: "rgb(var(--ui-fg) / 0.55)",
|
||||
}}
|
||||
>
|
||||
{provider.label}
|
||||
</span>
|
||||
<span
|
||||
className="text-[9px] leading-snug"
|
||||
style={{ color: "rgb(var(--ui-fg) / 0.25)" }}
|
||||
>
|
||||
{!isAvailable
|
||||
? "CORS restricted — dev only"
|
||||
: provider.description}
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
className="shrink-0 rounded px-1.5 py-px text-[8px] font-bold tracking-wider"
|
||||
style={{
|
||||
backgroundColor: `${badge.color}12`,
|
||||
color: isAvailable
|
||||
? badge.color
|
||||
: "rgb(var(--ui-fg) / 0.25)",
|
||||
}}
|
||||
>
|
||||
{isAvailable ? badge.label : "CORS"}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Provider Trigger (for status bar) ──────────────────────────────────
|
||||
|
||||
export type ProviderTriggerProps = {
|
||||
source: string | null;
|
||||
loading: boolean;
|
||||
rateLimited: boolean;
|
||||
onClick: () => void;
|
||||
};
|
||||
|
||||
export function ProviderTrigger({
|
||||
source,
|
||||
loading,
|
||||
rateLimited,
|
||||
onClick,
|
||||
}: ProviderTriggerProps) {
|
||||
const label = rateLimited
|
||||
? "Paused"
|
||||
: loading && !source
|
||||
? "Connecting…"
|
||||
: source
|
||||
? (SOURCE_LABELS[source] ?? source)
|
||||
: "Connecting…";
|
||||
|
||||
const dotColor = rateLimited
|
||||
? "text-amber-400/80"
|
||||
: source === "none"
|
||||
? "text-red-400/80"
|
||||
: source === "opensky"
|
||||
? "text-amber-400/80"
|
||||
: source === "airplanes"
|
||||
? "text-blue-400/80"
|
||||
: "text-emerald-400/80";
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className="flex items-center gap-2"
|
||||
aria-label="Select ADS-B provider"
|
||||
>
|
||||
<div className="relative">
|
||||
<Satellite className={`h-3 w-3 ${dotColor}`} />
|
||||
</div>
|
||||
<span
|
||||
className="text-[11px] font-medium tracking-wide"
|
||||
style={{ color: "rgb(var(--ui-fg) / 0.4)" }}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
<ChevronUp
|
||||
className="h-3 w-3 transition-colors"
|
||||
style={{ color: "rgb(var(--ui-fg) / 0.35)" }}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@ -55,6 +55,7 @@ export const ScrollArea = forwardRef<HTMLDivElement, ScrollAreaProps>(
|
||||
return () => {
|
||||
vp.removeEventListener("scroll", onScroll);
|
||||
observer.disconnect();
|
||||
if (hideTimer.current) clearTimeout(hideTimer.current);
|
||||
};
|
||||
}, [updateThumb]);
|
||||
|
||||
|
||||
@ -1,31 +1,86 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback, useEffect, useRef } from "react";
|
||||
import { motion, AnimatePresence } from "motion/react";
|
||||
import { Dices, Plane, Radio, ShieldAlert } from "lucide-react";
|
||||
import { Dices, Plane, ShieldAlert } from "lucide-react";
|
||||
import {
|
||||
AtcTrigger,
|
||||
AtcFeedDropdown,
|
||||
useAvailableFeeds,
|
||||
} from "@/components/ui/atc-panel";
|
||||
import {
|
||||
ProviderTrigger,
|
||||
ProviderDropdown,
|
||||
} from "@/components/ui/provider-panel";
|
||||
import type { UseAtcStreamReturn } from "@/hooks/use-atc-stream";
|
||||
|
||||
type StatusBarProps = {
|
||||
flightCount: number;
|
||||
cityName: string;
|
||||
cityIata: string;
|
||||
cityCoordinates: [number, number];
|
||||
loading: boolean;
|
||||
rateLimited?: boolean;
|
||||
retryIn?: number;
|
||||
onNorthUp?: () => void;
|
||||
onResetView?: () => void;
|
||||
onRandomAirport?: () => void;
|
||||
atc: UseAtcStreamReturn;
|
||||
/** Incremented externally to toggle the feed dropdown (e.g. from keyboard shortcut) */
|
||||
atcToggle?: number;
|
||||
/** Current ADS-B data source (e.g. "adsb", "opensky", "none") */
|
||||
source?: string | null;
|
||||
};
|
||||
|
||||
export function StatusBar({
|
||||
flightCount,
|
||||
cityName,
|
||||
cityIata,
|
||||
cityCoordinates,
|
||||
loading,
|
||||
rateLimited = false,
|
||||
retryIn = 0,
|
||||
onNorthUp,
|
||||
onResetView,
|
||||
onRandomAirport,
|
||||
atc,
|
||||
atcToggle,
|
||||
source,
|
||||
}: StatusBarProps) {
|
||||
const [feedDropdownOpen, setFeedDropdownOpen] = useState(false);
|
||||
const [providerDropdownOpen, setProviderDropdownOpen] = useState(false);
|
||||
const availableFeeds = useAvailableFeeds(cityIata, cityCoordinates);
|
||||
const prevToggleRef = useRef(atcToggle);
|
||||
|
||||
// React to external toggle (keyboard shortcut)
|
||||
useEffect(() => {
|
||||
if (atcToggle !== undefined && atcToggle !== prevToggleRef.current) {
|
||||
prevToggleRef.current = atcToggle;
|
||||
setFeedDropdownOpen((p) => !p);
|
||||
}
|
||||
}, [atcToggle]);
|
||||
|
||||
const toggleFeedDropdown = useCallback(() => {
|
||||
setProviderDropdownOpen(false);
|
||||
setFeedDropdownOpen((p) => !p);
|
||||
}, []);
|
||||
|
||||
const closeFeedDropdown = useCallback(() => {
|
||||
setFeedDropdownOpen(false);
|
||||
}, []);
|
||||
|
||||
const toggleProviderDropdown = useCallback(() => {
|
||||
setFeedDropdownOpen(false);
|
||||
setProviderDropdownOpen((p) => !p);
|
||||
}, []);
|
||||
|
||||
const closeProviderDropdown = useCallback(() => {
|
||||
setProviderDropdownOpen(false);
|
||||
}, []);
|
||||
|
||||
const isAtcPlaying = atc.status === "playing";
|
||||
return (
|
||||
<div className="flex flex-col items-start gap-2">
|
||||
<div className="relative flex flex-col items-start gap-2">
|
||||
<AnimatePresence>
|
||||
{rateLimited && (
|
||||
<motion.div
|
||||
@ -70,19 +125,12 @@ export function StatusBar({
|
||||
aria-live="polite"
|
||||
aria-atomic="true"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative">
|
||||
<Radio
|
||||
className={`h-3 w-3 ${rateLimited ? "text-amber-400/80" : "text-emerald-400/80"}`}
|
||||
<ProviderTrigger
|
||||
source={source ?? null}
|
||||
loading={loading}
|
||||
rateLimited={rateLimited}
|
||||
onClick={toggleProviderDropdown}
|
||||
/>
|
||||
</div>
|
||||
<span
|
||||
className="text-[11px] font-medium tracking-wide"
|
||||
style={{ color: "rgb(var(--ui-fg) / 0.4)" }}
|
||||
>
|
||||
{rateLimited ? "Paused" : loading ? "Scanning..." : "Live"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="h-3 w-px"
|
||||
@ -113,6 +161,14 @@ export function StatusBar({
|
||||
>
|
||||
{cityName}
|
||||
</span>
|
||||
|
||||
{/* ATC trigger */}
|
||||
<AtcTrigger
|
||||
hasFeeds={availableFeeds.length > 0}
|
||||
isPlaying={isAtcPlaying}
|
||||
isError={atc.status === "error" || atc.status === "blocked"}
|
||||
onClick={toggleFeedDropdown}
|
||||
/>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
@ -176,6 +232,19 @@ export function StatusBar({
|
||||
</button>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{/* Dropdowns — positioned above entire status bar */}
|
||||
<ProviderDropdown
|
||||
open={providerDropdownOpen}
|
||||
onClose={closeProviderDropdown}
|
||||
currentSource={source ?? null}
|
||||
/>
|
||||
<AtcFeedDropdown
|
||||
feeds={availableFeeds}
|
||||
atc={atc}
|
||||
open={feedDropdownOpen}
|
||||
onClose={closeFeedDropdown}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -2,6 +2,8 @@
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
// ── Exported types (unchanged for backward compatibility) ───────────────────
|
||||
|
||||
export type NormalizedPhoto = {
|
||||
id: string;
|
||||
url: string;
|
||||
@ -28,10 +30,12 @@ export type UseAircraftPhotosResult = {
|
||||
error: boolean;
|
||||
};
|
||||
|
||||
// ── Cache ───────────────────────────────────────────────────────────────────
|
||||
|
||||
const CACHE_TTL_MS = 10 * 60_000;
|
||||
const NEGATIVE_TTL_MS = 2 * 60_000;
|
||||
const CACHE_MAX = 200;
|
||||
const FETCH_TIMEOUT_MS = 10_000;
|
||||
const FETCH_TIMEOUT_MS = 15_000;
|
||||
|
||||
type CacheEntry = {
|
||||
aircraft: AircraftDetails | null;
|
||||
@ -69,172 +73,109 @@ function putCache(
|
||||
});
|
||||
}
|
||||
|
||||
// ── API response types ───────────────────────────────────────────────────────────
|
||||
|
||||
type ApiPhoto = {
|
||||
id: string;
|
||||
url: string;
|
||||
thumbnail: string;
|
||||
photographer: string | null;
|
||||
location: string | null;
|
||||
dateTaken: string | null;
|
||||
link: string | null;
|
||||
};
|
||||
|
||||
type ApiAircraft = {
|
||||
registration: string;
|
||||
manufacturer: string | null;
|
||||
type: string | null;
|
||||
typeCode: string | null;
|
||||
owner: string | null;
|
||||
};
|
||||
|
||||
type ApiResponse = {
|
||||
photos?: ApiPhoto[];
|
||||
aircraft?: ApiAircraft | null;
|
||||
};
|
||||
|
||||
// ── Fetcher ─────────────────────────────────────────────────────────────────
|
||||
|
||||
type FetchResult = {
|
||||
aircraft: AircraftDetails | null;
|
||||
photos: NormalizedPhoto[];
|
||||
};
|
||||
|
||||
async function fetchJson<T>(
|
||||
url: string,
|
||||
async function fetchPhotos(
|
||||
icao24: string,
|
||||
reg: string | null,
|
||||
signal?: AbortSignal,
|
||||
): Promise<T | null> {
|
||||
): Promise<FetchResult> {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
||||
const onAbort = () => controller.abort();
|
||||
signal?.addEventListener("abort", onAbort);
|
||||
|
||||
try {
|
||||
let url = `/api/aircraft-photos?hex=${encodeURIComponent(icao24)}`;
|
||||
if (reg) url += `®=${encodeURIComponent(reg)}`;
|
||||
|
||||
const res = await fetch(url, {
|
||||
signal: controller.signal,
|
||||
headers: { Accept: "application/json" },
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
return (await res.json()) as T;
|
||||
} catch {
|
||||
return null;
|
||||
|
||||
if (!res.ok) return { aircraft: null, photos: [] };
|
||||
|
||||
const data = (await res.json()) as ApiResponse;
|
||||
|
||||
const photos: NormalizedPhoto[] = (data.photos ?? [])
|
||||
.filter(
|
||||
(p): p is ApiPhoto =>
|
||||
typeof p?.id === "string" &&
|
||||
typeof p?.url === "string" &&
|
||||
p.url.length > 0,
|
||||
)
|
||||
.map((p) => ({
|
||||
id: p.id,
|
||||
url: p.url,
|
||||
thumbnail: p.thumbnail || p.url,
|
||||
photographer: p.photographer ?? null,
|
||||
location: p.location ?? null,
|
||||
dateTaken: p.dateTaken ?? null,
|
||||
link: p.link ?? null,
|
||||
}));
|
||||
|
||||
let aircraft: AircraftDetails | null = null;
|
||||
if (data.aircraft && typeof data.aircraft.registration === "string") {
|
||||
aircraft = {
|
||||
registration: data.aircraft.registration,
|
||||
manufacturer: data.aircraft.manufacturer ?? null,
|
||||
type: data.aircraft.type ?? null,
|
||||
typeCode: data.aircraft.typeCode ?? null,
|
||||
owner: data.aircraft.owner ?? null,
|
||||
airline: data.aircraft.owner ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
return { aircraft, photos };
|
||||
} catch (err) {
|
||||
// Don't throw on intentional aborts — return empty result
|
||||
if (err instanceof DOMException && err.name === "AbortError") {
|
||||
return { aircraft: null, photos: [] };
|
||||
}
|
||||
// Re-throw network/parse failures so callers can set error state
|
||||
throw err;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
signal?.removeEventListener("abort", onAbort);
|
||||
}
|
||||
}
|
||||
|
||||
type HexDbAircraft = {
|
||||
ModeS?: string;
|
||||
Registration?: string;
|
||||
Manufacturer?: string;
|
||||
ICAOTypeCode?: string;
|
||||
Type?: string;
|
||||
RegisteredOwners?: string;
|
||||
OperatorFlagCode?: string;
|
||||
};
|
||||
|
||||
async function fetchAircraftDetails(
|
||||
icao24: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<AircraftDetails | null> {
|
||||
const data = await fetchJson<HexDbAircraft>(
|
||||
`https://hexdb.io/api/v1/aircraft/${encodeURIComponent(icao24)}`,
|
||||
signal,
|
||||
);
|
||||
|
||||
if (!data?.Registration) return null;
|
||||
|
||||
return {
|
||||
registration: data.Registration,
|
||||
manufacturer: data.Manufacturer ?? null,
|
||||
type: data.Type ?? null,
|
||||
typeCode: data.ICAOTypeCode ?? null,
|
||||
owner: data.RegisteredOwners ?? null,
|
||||
airline: null,
|
||||
};
|
||||
}
|
||||
|
||||
type JetApiImage = {
|
||||
Image?: string;
|
||||
Thumbnail?: string;
|
||||
Link?: string;
|
||||
Photographer?: string;
|
||||
Location?: string;
|
||||
DateTaken?: string;
|
||||
Aircraft?: string;
|
||||
Airline?: string;
|
||||
};
|
||||
|
||||
type JetApiResponse = {
|
||||
JetPhotos?: {
|
||||
Reg?: string;
|
||||
Images?: JetApiImage[];
|
||||
};
|
||||
FlightRadar?: {
|
||||
Aircraft?: string;
|
||||
Airline?: string;
|
||||
Operator?: string;
|
||||
TypeCode?: string;
|
||||
ModeS?: string;
|
||||
};
|
||||
};
|
||||
|
||||
function normalizePhotos(raw: JetApiImage[] | undefined): NormalizedPhoto[] {
|
||||
if (!raw || !Array.isArray(raw)) return [];
|
||||
|
||||
const seen = new Set<string>();
|
||||
const out: NormalizedPhoto[] = [];
|
||||
|
||||
for (const img of raw) {
|
||||
const fullUrl = typeof img.Image === "string" ? img.Image : null;
|
||||
if (!fullUrl) continue;
|
||||
|
||||
if (seen.has(fullUrl)) continue;
|
||||
seen.add(fullUrl);
|
||||
|
||||
const thumb =
|
||||
typeof img.Thumbnail === "string" && img.Thumbnail
|
||||
? img.Thumbnail
|
||||
: fullUrl;
|
||||
|
||||
out.push({
|
||||
id: `jp-${out.length}-${fullUrl.slice(-16).replace(/[^a-zA-Z0-9]/g, "")}`,
|
||||
url: fullUrl,
|
||||
thumbnail: thumb,
|
||||
photographer:
|
||||
typeof img.Photographer === "string" && img.Photographer
|
||||
? img.Photographer
|
||||
: null,
|
||||
location:
|
||||
typeof img.Location === "string" && img.Location ? img.Location : null,
|
||||
dateTaken:
|
||||
typeof img.DateTaken === "string" && img.DateTaken
|
||||
? img.DateTaken
|
||||
: null,
|
||||
link: typeof img.Link === "string" && img.Link ? img.Link : null,
|
||||
});
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
async function fetchPhotosViaProxy(
|
||||
reg: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<{ photos: NormalizedPhoto[]; airline: string | null }> {
|
||||
const data = await fetchJson<JetApiResponse>(
|
||||
`/api/aircraft-photos?reg=${encodeURIComponent(reg)}`,
|
||||
signal,
|
||||
);
|
||||
|
||||
if (!data) return { photos: [], airline: null };
|
||||
|
||||
const photos = normalizePhotos(data.JetPhotos?.Images);
|
||||
const airline =
|
||||
typeof data.FlightRadar?.Airline === "string"
|
||||
? data.FlightRadar.Airline
|
||||
: null;
|
||||
|
||||
return { photos, airline };
|
||||
}
|
||||
|
||||
async function fetchAll(
|
||||
icao24: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<FetchResult> {
|
||||
const aircraft = await fetchAircraftDetails(icao24, signal);
|
||||
if (!aircraft) return { aircraft: null, photos: [] };
|
||||
|
||||
const { photos, airline } = await fetchPhotosViaProxy(
|
||||
aircraft.registration,
|
||||
signal,
|
||||
);
|
||||
|
||||
const enriched: AircraftDetails = {
|
||||
...aircraft,
|
||||
airline: airline ?? aircraft.owner,
|
||||
};
|
||||
|
||||
return { aircraft: enriched, photos };
|
||||
}
|
||||
// ── Hook ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export function useAircraftPhotos(
|
||||
icao24: string | null,
|
||||
registration?: string | null,
|
||||
): UseAircraftPhotosResult {
|
||||
const [photos, setPhotos] = useState<NormalizedPhoto[]>([]);
|
||||
const [aircraft, setAircraft] = useState<AircraftDetails | null>(null);
|
||||
@ -251,8 +192,11 @@ export function useAircraftPhotos(
|
||||
}
|
||||
|
||||
const normalized = icao24.toLowerCase();
|
||||
const reg = registration?.trim().toUpperCase() || null;
|
||||
const cacheKey = reg ? `${normalized}:${reg}` : normalized;
|
||||
|
||||
const cached = getCached(normalized);
|
||||
// Check cache — full key first (includes JetAPI results)
|
||||
const cached = getCached(cacheKey);
|
||||
if (cached) {
|
||||
setPhotos(cached.photos);
|
||||
setAircraft(cached.aircraft);
|
||||
@ -261,15 +205,80 @@ export function useAircraftPhotos(
|
||||
return;
|
||||
}
|
||||
|
||||
// If we have a reg key, also check hex-only cache for instant display
|
||||
const hexCached = reg ? getCached(normalized) : null;
|
||||
|
||||
let cancelled = false;
|
||||
const controller = new AbortController();
|
||||
|
||||
setLoading(true);
|
||||
setError(false);
|
||||
|
||||
// Show hex-only cached results immediately while JetAPI loads
|
||||
if (hexCached) {
|
||||
setPhotos(hexCached.photos);
|
||||
setAircraft(hexCached.aircraft);
|
||||
setLoading(false);
|
||||
} else {
|
||||
setPhotos([]);
|
||||
setAircraft(null);
|
||||
}
|
||||
|
||||
fetchAll(normalized, controller.signal).then(
|
||||
if (reg && !hexCached) {
|
||||
// Phase 1: Fast sources (no JetAPI) → show immediately
|
||||
// Phase 2: All sources including JetAPI → upgrade
|
||||
fetchPhotos(normalized, null, controller.signal).then(
|
||||
(fastResult) => {
|
||||
if (cancelled) return;
|
||||
putCache(normalized, fastResult.aircraft, fastResult.photos);
|
||||
setPhotos(fastResult.photos);
|
||||
setAircraft(fastResult.aircraft);
|
||||
setLoading(false);
|
||||
|
||||
// Phase 2: fetch with registration to include JetAPI
|
||||
fetchPhotos(normalized, reg, controller.signal).then(
|
||||
(fullResult) => {
|
||||
if (cancelled) return;
|
||||
const mergedAircraft = fullResult.aircraft ?? fastResult.aircraft;
|
||||
putCache(cacheKey, mergedAircraft, fullResult.photos);
|
||||
setPhotos(fullResult.photos);
|
||||
setAircraft(mergedAircraft);
|
||||
},
|
||||
() => {
|
||||
// JetAPI failed — keep fast results
|
||||
if (!cancelled) {
|
||||
putCache(cacheKey, fastResult.aircraft, fastResult.photos);
|
||||
}
|
||||
},
|
||||
);
|
||||
},
|
||||
() => {
|
||||
if (cancelled) return;
|
||||
putCache(normalized, null, []);
|
||||
setLoading(false);
|
||||
setError(true);
|
||||
},
|
||||
);
|
||||
} else if (reg && hexCached) {
|
||||
// Already showing hex-only cache — just fetch JetAPI enhancement
|
||||
fetchPhotos(normalized, reg, controller.signal).then(
|
||||
(fullResult) => {
|
||||
if (cancelled) return;
|
||||
const mergedAircraft = fullResult.aircraft ?? hexCached.aircraft;
|
||||
putCache(cacheKey, mergedAircraft, fullResult.photos);
|
||||
setPhotos(fullResult.photos);
|
||||
setAircraft(mergedAircraft);
|
||||
},
|
||||
() => {
|
||||
// JetAPI failed — keep cached results
|
||||
if (!cancelled) {
|
||||
putCache(cacheKey, hexCached.aircraft, hexCached.photos);
|
||||
}
|
||||
},
|
||||
);
|
||||
} else {
|
||||
// No registration — fast sources only
|
||||
fetchPhotos(normalized, null, controller.signal).then(
|
||||
(result) => {
|
||||
if (cancelled) return;
|
||||
putCache(normalized, result.aircraft, result.photos);
|
||||
@ -284,12 +293,13 @@ export function useAircraftPhotos(
|
||||
setError(true);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
controller.abort();
|
||||
};
|
||||
}, [icao24]);
|
||||
}, [icao24, registration]);
|
||||
|
||||
return { photos, aircraft, loading, error };
|
||||
}
|
||||
|
||||
432
src/hooks/use-atc-stream.ts
Normal file
432
src/hooks/use-atc-stream.ts
Normal file
@ -0,0 +1,432 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import type { AtcFeed, AtcStreamStatus } from "@/lib/atc-types";
|
||||
import { VALID_MOUNT_POINTS } from "@/lib/atc-feeds";
|
||||
|
||||
// ── Constants ──────────────────────────────────────────────────────────
|
||||
|
||||
const VOLUME_STORAGE_KEY = "aeris:atc:volume";
|
||||
const DEFAULT_VOLUME = 0.7;
|
||||
const RECONNECT_BASE_MS = 1000;
|
||||
const RECONNECT_MAX_MS = 30_000;
|
||||
const MAX_RECONNECT_ATTEMPTS = 5;
|
||||
const BROADCAST_CHANNEL_NAME = "aeris:atc-playback";
|
||||
|
||||
// ── Volume persistence ─────────────────────────────────────────────────
|
||||
|
||||
function loadVolume(): number {
|
||||
if (typeof window === "undefined") return DEFAULT_VOLUME;
|
||||
try {
|
||||
const raw = localStorage.getItem(VOLUME_STORAGE_KEY);
|
||||
if (!raw) return DEFAULT_VOLUME;
|
||||
const v = Number(raw);
|
||||
return Number.isFinite(v) && v >= 0 && v <= 1 ? v : DEFAULT_VOLUME;
|
||||
} catch {
|
||||
return DEFAULT_VOLUME;
|
||||
}
|
||||
}
|
||||
|
||||
function saveVolume(v: number): void {
|
||||
if (typeof window === "undefined") return;
|
||||
try {
|
||||
localStorage.setItem(VOLUME_STORAGE_KEY, String(v));
|
||||
} catch {
|
||||
// localStorage may be unavailable
|
||||
}
|
||||
}
|
||||
|
||||
// ── BroadcastChannel for single-tab playback ───────────────────────────
|
||||
|
||||
type BroadcastMessage =
|
||||
| { type: "playing"; tabId: string; feedId: string }
|
||||
| { type: "stopped"; tabId: string };
|
||||
|
||||
function createTabId(): string {
|
||||
return `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
}
|
||||
|
||||
// ── Hook ───────────────────────────────────────────────────────────────
|
||||
|
||||
export interface UseAtcStreamReturn {
|
||||
/** Currently active feed */
|
||||
feed: AtcFeed | null;
|
||||
/** Playback status */
|
||||
status: AtcStreamStatus;
|
||||
/** Error message (when status is 'error' or 'blocked') */
|
||||
error: string | null;
|
||||
/** Whether proxy fallback is active */
|
||||
usingProxy: boolean;
|
||||
/** Current volume 0–1 */
|
||||
volume: number;
|
||||
/** Reference to the underlying HTMLAudioElement (for Web Audio API) */
|
||||
audioElement: HTMLAudioElement | null;
|
||||
/** Start playing a feed */
|
||||
play: (feed: AtcFeed) => void;
|
||||
/** Stop playback */
|
||||
stop: () => void;
|
||||
/** Resume after browser autoplay block (requires user gesture) */
|
||||
resume: () => void;
|
||||
/** Set volume 0–1 */
|
||||
setVolume: (v: number) => void;
|
||||
}
|
||||
|
||||
export function useAtcStream(): UseAtcStreamReturn {
|
||||
const [feed, setFeed] = useState<AtcFeed | null>(null);
|
||||
const [status, setStatus] = useState<AtcStreamStatus>("idle");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [usingProxy, setUsingProxy] = useState(false);
|
||||
const [volume, setVolumeState] = useState(DEFAULT_VOLUME);
|
||||
const [audioElement, setAudioElement] = useState<HTMLAudioElement | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||
const feedRef = useRef<AtcFeed | null>(null);
|
||||
const reconnectTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const reconnectAttemptsRef = useRef(0);
|
||||
const tabIdRef = useRef<string>("");
|
||||
const broadcastRef = useRef<BroadcastChannel | null>(null);
|
||||
const stalledTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const proxyAttemptedRef = useRef(false);
|
||||
const stoppedManuallyRef = useRef(false);
|
||||
|
||||
// Initialize volume from localStorage
|
||||
useEffect(() => {
|
||||
setVolumeState(loadVolume());
|
||||
tabIdRef.current = createTabId();
|
||||
}, []);
|
||||
|
||||
// ── BroadcastChannel setup ─────────────────────────────────────────
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof BroadcastChannel === "undefined") return;
|
||||
|
||||
const bc = new BroadcastChannel(BROADCAST_CHANNEL_NAME);
|
||||
broadcastRef.current = bc;
|
||||
|
||||
bc.onmessage = (event: MessageEvent<BroadcastMessage>) => {
|
||||
const msg = event.data;
|
||||
if (!msg || typeof msg !== "object" || !msg.type) return;
|
||||
|
||||
// Another tab started playing — stop our playback
|
||||
if (
|
||||
msg.type === "playing" &&
|
||||
msg.tabId !== tabIdRef.current &&
|
||||
audioRef.current
|
||||
) {
|
||||
cleanupAudio();
|
||||
setFeed(null);
|
||||
feedRef.current = null;
|
||||
setStatus("idle");
|
||||
setError(null);
|
||||
}
|
||||
};
|
||||
|
||||
return () => {
|
||||
bc.close();
|
||||
broadcastRef.current = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// ── Cleanup helper ─────────────────────────────────────────────────
|
||||
|
||||
const cleanupAudio = useCallback(() => {
|
||||
if (reconnectTimerRef.current) {
|
||||
clearTimeout(reconnectTimerRef.current);
|
||||
reconnectTimerRef.current = null;
|
||||
}
|
||||
if (stalledTimerRef.current) {
|
||||
clearTimeout(stalledTimerRef.current);
|
||||
stalledTimerRef.current = null;
|
||||
}
|
||||
|
||||
const audio = audioRef.current;
|
||||
if (audio) {
|
||||
audio.pause();
|
||||
audio.removeAttribute("src");
|
||||
audio.load(); // release internal resources
|
||||
audioRef.current = null;
|
||||
}
|
||||
|
||||
setAudioElement(null);
|
||||
reconnectAttemptsRef.current = 0;
|
||||
proxyAttemptedRef.current = false;
|
||||
}, []);
|
||||
|
||||
// ── Media Session API ──────────────────────────────────────────────
|
||||
|
||||
const updateMediaSession = useCallback(
|
||||
(activeFeed: AtcFeed | null, isPlaying: boolean) => {
|
||||
if (typeof navigator === "undefined" || !("mediaSession" in navigator))
|
||||
return;
|
||||
|
||||
if (!activeFeed || !isPlaying) {
|
||||
navigator.mediaSession.playbackState = "none";
|
||||
return;
|
||||
}
|
||||
|
||||
navigator.mediaSession.metadata = new MediaMetadata({
|
||||
title: activeFeed.name,
|
||||
artist: `${activeFeed.icao} · ${activeFeed.frequency}`,
|
||||
album: "Aeris ATC",
|
||||
});
|
||||
|
||||
navigator.mediaSession.playbackState = "playing";
|
||||
|
||||
navigator.mediaSession.setActionHandler("pause", () => {
|
||||
stop();
|
||||
});
|
||||
|
||||
navigator.mediaSession.setActionHandler("stop", () => {
|
||||
stop();
|
||||
});
|
||||
|
||||
// No seek/track actions for live streams
|
||||
navigator.mediaSession.setActionHandler("play", null);
|
||||
navigator.mediaSession.setActionHandler("seekbackward", null);
|
||||
navigator.mediaSession.setActionHandler("seekforward", null);
|
||||
navigator.mediaSession.setActionHandler("previoustrack", null);
|
||||
navigator.mediaSession.setActionHandler("nexttrack", null);
|
||||
},
|
||||
[], // stop is stable due to useCallback
|
||||
);
|
||||
|
||||
// ── Reconnection logic ────────────────────────────────────────────
|
||||
|
||||
const scheduleReconnect = useCallback(
|
||||
(targetFeed: AtcFeed, useProxy: boolean) => {
|
||||
if (stoppedManuallyRef.current) return;
|
||||
|
||||
// Give up after too many failures
|
||||
if (reconnectAttemptsRef.current >= MAX_RECONNECT_ATTEMPTS) {
|
||||
setStatus("error");
|
||||
setError("Feed unavailable. Try another frequency.");
|
||||
return;
|
||||
}
|
||||
|
||||
const attempt = reconnectAttemptsRef.current++;
|
||||
const delay = Math.min(
|
||||
RECONNECT_BASE_MS * Math.pow(2, attempt),
|
||||
RECONNECT_MAX_MS,
|
||||
);
|
||||
|
||||
// Don't flash status — keep the error visible while we wait
|
||||
reconnectTimerRef.current = setTimeout(() => {
|
||||
if (feedRef.current?.id !== targetFeed.id) return;
|
||||
startPlayback(targetFeed, useProxy, true);
|
||||
}, delay);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// ── Core playback ─────────────────────────────────────────────────
|
||||
|
||||
const startPlayback = useCallback(
|
||||
(
|
||||
targetFeed: AtcFeed,
|
||||
useProxy: boolean = false,
|
||||
isReconnect: boolean = false,
|
||||
) => {
|
||||
cleanupAudio();
|
||||
stoppedManuallyRef.current = false;
|
||||
|
||||
const audio = new Audio();
|
||||
audioRef.current = audio;
|
||||
setAudioElement(audio);
|
||||
audio.volume = loadVolume();
|
||||
// Allow audio to play in background — do NOT add visibility listeners
|
||||
audio.preload = "none";
|
||||
|
||||
// Build stream URL
|
||||
let src: string;
|
||||
if (useProxy) {
|
||||
// Same-origin proxy — enable CORS for Web Audio API analysis
|
||||
audio.crossOrigin = "anonymous";
|
||||
// Validate mount point exists in our allowlist before proxying
|
||||
if (!VALID_MOUNT_POINTS.has(targetFeed.mountPoint)) {
|
||||
setStatus("error");
|
||||
setError("Invalid feed configuration.");
|
||||
return;
|
||||
}
|
||||
src = `/api/atc/stream?mount=${encodeURIComponent(targetFeed.mountPoint)}`;
|
||||
setUsingProxy(true);
|
||||
} else {
|
||||
src = targetFeed.streamUrl;
|
||||
setUsingProxy(false);
|
||||
}
|
||||
|
||||
// Only flash "loading" on fresh plays, not silent reconnects
|
||||
if (!isReconnect) {
|
||||
setStatus("loading");
|
||||
setError(null);
|
||||
}
|
||||
|
||||
audio.src = src;
|
||||
|
||||
audio.addEventListener("playing", () => {
|
||||
if (audioRef.current !== audio) return;
|
||||
// Clear any pending stall timer
|
||||
if (stalledTimerRef.current) {
|
||||
clearTimeout(stalledTimerRef.current);
|
||||
stalledTimerRef.current = null;
|
||||
}
|
||||
setStatus("playing");
|
||||
setError(null);
|
||||
reconnectAttemptsRef.current = 0;
|
||||
updateMediaSession(targetFeed, true);
|
||||
|
||||
// Notify other tabs
|
||||
try {
|
||||
broadcastRef.current?.postMessage({
|
||||
type: "playing",
|
||||
tabId: tabIdRef.current,
|
||||
feedId: targetFeed.id,
|
||||
} satisfies BroadcastMessage);
|
||||
} catch {
|
||||
// BroadcastChannel may be closed
|
||||
}
|
||||
});
|
||||
|
||||
audio.addEventListener("waiting", () => {
|
||||
if (audioRef.current !== audio) return;
|
||||
// Debounce — only show "loading" if buffering persists >1.2s
|
||||
if (!stalledTimerRef.current) {
|
||||
stalledTimerRef.current = setTimeout(() => {
|
||||
stalledTimerRef.current = null;
|
||||
if (audioRef.current === audio) setStatus("loading");
|
||||
}, 1200);
|
||||
}
|
||||
});
|
||||
|
||||
audio.addEventListener("error", () => {
|
||||
if (audioRef.current !== audio) return;
|
||||
|
||||
// If direct playback failed, try proxy fallback
|
||||
if (!useProxy && !proxyAttemptedRef.current) {
|
||||
proxyAttemptedRef.current = true;
|
||||
setError("Direct stream blocked. Trying proxy...");
|
||||
startPlayback(targetFeed, true);
|
||||
return;
|
||||
}
|
||||
|
||||
// Both direct and proxy failed — stay in "error" (not "blocked")
|
||||
setUsingProxy(useProxy);
|
||||
setStatus("error");
|
||||
|
||||
if (proxyAttemptedRef.current && useProxy) {
|
||||
setError("Stream unavailable — try another frequency.");
|
||||
} else {
|
||||
setError("Stream connection failed.");
|
||||
}
|
||||
|
||||
// Try to reconnect (silently, up to MAX_RECONNECT_ATTEMPTS)
|
||||
scheduleReconnect(targetFeed, useProxy);
|
||||
});
|
||||
|
||||
audio.addEventListener("stalled", () => {
|
||||
if (audioRef.current !== audio) return;
|
||||
// Debounce — only show "loading" if stall persists >1.2s
|
||||
if (!stalledTimerRef.current) {
|
||||
stalledTimerRef.current = setTimeout(() => {
|
||||
stalledTimerRef.current = null;
|
||||
if (audioRef.current === audio) setStatus("loading");
|
||||
}, 1200);
|
||||
}
|
||||
});
|
||||
|
||||
audio.addEventListener("ended", () => {
|
||||
if (audioRef.current !== audio) return;
|
||||
// Live streams shouldn't end, but if they do, reconnect
|
||||
scheduleReconnect(targetFeed, useProxy);
|
||||
});
|
||||
|
||||
// Start playback — requires user gesture (handled by UI click)
|
||||
audio.play().catch(() => {
|
||||
// Autoplay blocked — user must interact first
|
||||
if (audioRef.current !== audio) return;
|
||||
setStatus("blocked");
|
||||
setError("Tap to listen — browser requires interaction.");
|
||||
});
|
||||
},
|
||||
[cleanupAudio, updateMediaSession, scheduleReconnect],
|
||||
);
|
||||
|
||||
// ── Public API ────────────────────────────────────────────────────
|
||||
|
||||
const play = useCallback(
|
||||
(newFeed: AtcFeed) => {
|
||||
feedRef.current = newFeed;
|
||||
setFeed(newFeed);
|
||||
proxyAttemptedRef.current = false;
|
||||
stoppedManuallyRef.current = false;
|
||||
startPlayback(newFeed, false);
|
||||
},
|
||||
[startPlayback],
|
||||
);
|
||||
|
||||
const stop = useCallback(() => {
|
||||
stoppedManuallyRef.current = true;
|
||||
cleanupAudio();
|
||||
setFeed(null);
|
||||
feedRef.current = null;
|
||||
setStatus("idle");
|
||||
setError(null);
|
||||
setUsingProxy(false);
|
||||
updateMediaSession(null, false);
|
||||
|
||||
// Notify other tabs
|
||||
try {
|
||||
broadcastRef.current?.postMessage({
|
||||
type: "stopped",
|
||||
tabId: tabIdRef.current,
|
||||
} satisfies BroadcastMessage);
|
||||
} catch {
|
||||
// BroadcastChannel may be closed
|
||||
}
|
||||
}, [cleanupAudio, updateMediaSession]);
|
||||
|
||||
const setVolume = useCallback((v: number) => {
|
||||
const clamped = Math.max(0, Math.min(1, v));
|
||||
setVolumeState(clamped);
|
||||
saveVolume(clamped);
|
||||
if (audioRef.current) {
|
||||
audioRef.current.volume = clamped;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Resume after autoplay block — must be called from a user gesture
|
||||
const resume = useCallback(() => {
|
||||
const audio = audioRef.current;
|
||||
if (!audio) return;
|
||||
|
||||
setStatus("loading");
|
||||
setError(null);
|
||||
audio.play().catch(() => {
|
||||
setStatus("blocked");
|
||||
setError("Tap to listen — browser requires interaction.");
|
||||
});
|
||||
}, []);
|
||||
|
||||
// ── Cleanup on unmount ────────────────────────────────────────────
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
cleanupAudio();
|
||||
updateMediaSession(null, false);
|
||||
};
|
||||
}, [cleanupAudio, updateMediaSession]);
|
||||
|
||||
return {
|
||||
feed,
|
||||
status,
|
||||
error,
|
||||
usingProxy,
|
||||
volume,
|
||||
audioElement,
|
||||
play,
|
||||
stop,
|
||||
resume,
|
||||
setVolume,
|
||||
};
|
||||
}
|
||||
35
src/hooks/use-dropdown-dismiss.ts
Normal file
35
src/hooks/use-dropdown-dismiss.ts
Normal file
@ -0,0 +1,35 @@
|
||||
import { useEffect, type RefObject } from "react";
|
||||
|
||||
export function useDropdownDismiss(
|
||||
ref: RefObject<HTMLElement | null>,
|
||||
open: boolean,
|
||||
onClose: () => void,
|
||||
): void {
|
||||
// Close on click outside
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
function handleClick(e: MouseEvent) {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
// Delay to avoid closing immediately on the same click that opened it
|
||||
const timer = setTimeout(() => {
|
||||
document.addEventListener("mousedown", handleClick);
|
||||
}, 0);
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
document.removeEventListener("mousedown", handleClick);
|
||||
};
|
||||
}, [ref, open, onClose]);
|
||||
|
||||
// Close on Escape
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
function handleKey(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") onClose();
|
||||
}
|
||||
window.addEventListener("keydown", handleKey);
|
||||
return () => window.removeEventListener("keydown", handleKey);
|
||||
}, [open, onClose]);
|
||||
}
|
||||
@ -1,7 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { fetchFlightByIcao24, type FlightState } from "@/lib/opensky";
|
||||
import type { FlightState } from "@/lib/opensky";
|
||||
import { fetchFlightByHex } from "@/lib/flight-api";
|
||||
import { cityFromFlight } from "@/components/flight-tracker-random";
|
||||
import {
|
||||
syncFpvToUrl,
|
||||
@ -12,7 +13,7 @@ import type { City } from "@/lib/cities";
|
||||
// ── Types ──────────────────────────────────────────────────────────────
|
||||
|
||||
export interface UseFlightMonitorsOptions {
|
||||
pendingFpvRef: React.RefObject<string | null>;
|
||||
pendingFpvRef: React.MutableRefObject<string | null>;
|
||||
fpvIcao24: string | null;
|
||||
fpvFlight: FlightState | null;
|
||||
followIcao24: string | null;
|
||||
@ -70,12 +71,12 @@ export function useFlightMonitors(
|
||||
);
|
||||
if (match && match.longitude != null && match.latitude != null) {
|
||||
if (match.onGround) {
|
||||
(pendingFpvRef as React.MutableRefObject<string | null>).current = null;
|
||||
pendingFpvRef.current = null;
|
||||
syncFpvToUrl(null, activeCity);
|
||||
setSelectedIcao24(match.icao24);
|
||||
return;
|
||||
}
|
||||
(pendingFpvRef as React.MutableRefObject<string | null>).current = null;
|
||||
pendingFpvRef.current = null;
|
||||
fpvLookupDoneRef.current = false;
|
||||
setFpvSeedCenter({ lng: match.longitude, lat: match.latitude });
|
||||
setFpvIcao24(pending);
|
||||
@ -86,7 +87,7 @@ export function useFlightMonitors(
|
||||
if (!fpvLookupDoneRef.current && displayFlights.length > 0) {
|
||||
fpvLookupDoneRef.current = true;
|
||||
const controller = new AbortController();
|
||||
fetchFlightByIcao24(pending, controller.signal)
|
||||
fetchFlightByHex(pending, controller.signal)
|
||||
.then((result) => {
|
||||
if (
|
||||
result.flight &&
|
||||
@ -103,13 +104,11 @@ export function useFlightMonitors(
|
||||
lng: result.flight.longitude,
|
||||
lat: result.flight.latitude,
|
||||
});
|
||||
(pendingFpvRef as React.MutableRefObject<string | null>).current =
|
||||
null;
|
||||
pendingFpvRef.current = null;
|
||||
setFpvIcao24(pending);
|
||||
setFollowIcao24(null);
|
||||
} else if (pendingFpvRef.current === pending) {
|
||||
(pendingFpvRef as React.MutableRefObject<string | null>).current =
|
||||
null;
|
||||
pendingFpvRef.current = null;
|
||||
syncFpvToUrl(null, activeCity);
|
||||
if (result.flight) {
|
||||
setSelectedIcao24(result.flight.icao24);
|
||||
@ -117,9 +116,9 @@ export function useFlightMonitors(
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// Flight lookup failed (network error, timeout, or abort) — reset pending state
|
||||
if (pendingFpvRef.current === pending) {
|
||||
(pendingFpvRef as React.MutableRefObject<string | null>).current =
|
||||
null;
|
||||
pendingFpvRef.current = null;
|
||||
}
|
||||
});
|
||||
return () => controller.abort();
|
||||
@ -139,12 +138,18 @@ export function useFlightMonitors(
|
||||
// ── FPV miss counting ────────────────────────────────────────────
|
||||
|
||||
const fpvMissCountRef = useRef(0);
|
||||
const fpvActivatedAtRef = useRef(0);
|
||||
useEffect(() => {
|
||||
if (!fpvIcao24) {
|
||||
fpvMissCountRef.current = 0;
|
||||
fpvActivatedAtRef.current = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
if (fpvActivatedAtRef.current === 0) {
|
||||
fpvActivatedAtRef.current = Date.now();
|
||||
}
|
||||
|
||||
if (fpvFlight) {
|
||||
fpvMissCountRef.current = 0;
|
||||
if (fpvFlight.onGround) {
|
||||
@ -156,7 +161,11 @@ export function useFlightMonitors(
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
} else {
|
||||
if (!rateLimited) {
|
||||
// Grace period: don't count misses for the first 30s after FPV
|
||||
// activation. This allows time for city changes and initial polls
|
||||
// to complete when entering FPV from a deep-link.
|
||||
const elapsed = Date.now() - fpvActivatedAtRef.current;
|
||||
if (!rateLimited && elapsed > 30_000) {
|
||||
fpvMissCountRef.current += 1;
|
||||
}
|
||||
if (fpvMissCountRef.current >= 3) {
|
||||
@ -180,21 +189,40 @@ export function useFlightMonitors(
|
||||
// ── Follow miss counting ─────────────────────────────────────────
|
||||
|
||||
const followMissCountRef = useRef(0);
|
||||
const followActivatedAtRef = useRef(0);
|
||||
useEffect(() => {
|
||||
if (!followIcao24) {
|
||||
followMissCountRef.current = 0;
|
||||
followActivatedAtRef.current = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
if (followActivatedAtRef.current === 0) {
|
||||
followActivatedAtRef.current = Date.now();
|
||||
}
|
||||
|
||||
if (followFlight) {
|
||||
followMissCountRef.current = 0;
|
||||
} else {
|
||||
// Grace period: don't count misses for the first 15s after follow
|
||||
// activation or during rate limiting. Prevents auto-deselection
|
||||
// during city transitions or transient API failures.
|
||||
const elapsed = Date.now() - followActivatedAtRef.current;
|
||||
if (!rateLimited && elapsed > 15_000) {
|
||||
followMissCountRef.current += 1;
|
||||
}
|
||||
if (followMissCountRef.current >= 3) {
|
||||
const timer = setTimeout(() => setFollowIcao24(null), 0);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
}, [followIcao24, followFlight, displayFlights, setFollowIcao24]);
|
||||
}, [
|
||||
followIcao24,
|
||||
followFlight,
|
||||
rateLimited,
|
||||
displayFlights,
|
||||
setFollowIcao24,
|
||||
]);
|
||||
|
||||
// ── Selected flight missing timeout ──────────────────────────────
|
||||
|
||||
@ -227,14 +255,14 @@ export function useFlightMonitors(
|
||||
|
||||
async function loadRepoStars() {
|
||||
try {
|
||||
const res = await fetch(GITHUB_REPO_API, { cache: "no-store" });
|
||||
const res = await fetch(GITHUB_REPO_API);
|
||||
if (!res.ok) return;
|
||||
const data = (await res.json()) as { stargazers_count?: number };
|
||||
if (mounted && typeof data.stargazers_count === "number") {
|
||||
setRepoStars(data.stargazers_count);
|
||||
}
|
||||
} catch {
|
||||
/* silent fallback */
|
||||
// GitHub API failures are non-critical — star count is cosmetic
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -1,66 +1,79 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { fetchTrackByIcao24, type FlightTrack } from "@/lib/opensky";
|
||||
import type { FlightTrack } from "@/lib/opensky";
|
||||
|
||||
type TrackCacheEntry = {
|
||||
fetchedAt: number;
|
||||
nextAllowedAt: number;
|
||||
track: FlightTrack | null;
|
||||
};
|
||||
|
||||
// /tracks is expensive + rate-limited; cache aggressively.
|
||||
const DEFAULT_REFRESH_MS = 0;
|
||||
const TRACK_CACHE_TTL_MS_EFFECTIVE = 10 * 60_000;
|
||||
const NEGATIVE_CACHE_TTL_MS_EFFECTIVE = 60_000;
|
||||
const TRACK_CACHE_TTL_MS = 10 * 60_000;
|
||||
const NEGATIVE_CACHE_TTL_MS = 60_000;
|
||||
const TRACK_CACHE_MAX_ENTRIES = 100;
|
||||
const SELECTION_DEBOUNCE_MS = 350;
|
||||
const FETCH_TIMEOUT_MS = 15_000;
|
||||
const MIN_RETRY_MS = 2_000;
|
||||
const MAX_RETRY_MS = 60_000;
|
||||
const DEFAULT_RETRY_MS = 5_000;
|
||||
|
||||
const trackCache = new Map<string, TrackCacheEntry>();
|
||||
|
||||
// Global backoff for /tracks 429s.
|
||||
let globalNextAllowedAt = 0;
|
||||
let globalBackoffMs = 5 * 60_000;
|
||||
const GLOBAL_BACKOFF_MAX_MS = 24 * 60 * 60_000;
|
||||
const GLOBAL_BACKOFF_KEY = "aeris:opensky:tracksGlobalNextAllowedAt";
|
||||
const GLOBAL_BACKOFF_MS_KEY = "aeris:opensky:tracksGlobalBackoffMs";
|
||||
const SELECTION_DEBOUNCE_MS = 350;
|
||||
|
||||
function loadGlobalBackoff(): void {
|
||||
if (typeof window === "undefined") return;
|
||||
try {
|
||||
const nextAllowedRaw = sessionStorage.getItem(GLOBAL_BACKOFF_KEY);
|
||||
const nextAllowed = nextAllowedRaw
|
||||
? Number.parseInt(nextAllowedRaw, 10)
|
||||
: 0;
|
||||
if (Number.isFinite(nextAllowed) && nextAllowed > 0) {
|
||||
globalNextAllowedAt = Math.max(globalNextAllowedAt, nextAllowed);
|
||||
}
|
||||
|
||||
const backoffRaw = sessionStorage.getItem(GLOBAL_BACKOFF_MS_KEY);
|
||||
const backoff = backoffRaw ? Number.parseInt(backoffRaw, 10) : 0;
|
||||
if (Number.isFinite(backoff) && backoff > 0) {
|
||||
globalBackoffMs = Math.min(
|
||||
GLOBAL_BACKOFF_MAX_MS,
|
||||
Math.max(60_000, backoff),
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
function persistGlobalBackoff(): void {
|
||||
if (typeof window === "undefined") return;
|
||||
try {
|
||||
sessionStorage.setItem(GLOBAL_BACKOFF_KEY, String(globalNextAllowedAt));
|
||||
sessionStorage.setItem(GLOBAL_BACKOFF_MS_KEY, String(globalBackoffMs));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
let rateLimitedUntil = 0;
|
||||
|
||||
function cacheTtlMs(track: FlightTrack | null): number {
|
||||
return track ? TRACK_CACHE_TTL_MS_EFFECTIVE : NEGATIVE_CACHE_TTL_MS_EFFECTIVE;
|
||||
return track ? TRACK_CACHE_TTL_MS : NEGATIVE_CACHE_TTL_MS;
|
||||
}
|
||||
|
||||
function parseRetryAfter(res: Response): number {
|
||||
const header = res.headers.get("Retry-After");
|
||||
if (!header) return DEFAULT_RETRY_MS;
|
||||
const sec = Number.parseFloat(header);
|
||||
if (!Number.isFinite(sec) || sec <= 0) return DEFAULT_RETRY_MS;
|
||||
const ms = sec * 1000;
|
||||
return Math.max(MIN_RETRY_MS, Math.min(MAX_RETRY_MS, ms));
|
||||
}
|
||||
|
||||
async function fetchTrace(
|
||||
hex: string,
|
||||
signal: AbortSignal,
|
||||
): Promise<{
|
||||
track: FlightTrack | null;
|
||||
rateLimited: boolean;
|
||||
retryAfterMs: number;
|
||||
}> {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
||||
|
||||
const onAbort = () => controller.abort();
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
if (signal.aborted) {
|
||||
onAbort();
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/flights/trace?hex=${encodeURIComponent(hex)}`,
|
||||
{ signal: controller.signal, cache: "no-store" },
|
||||
);
|
||||
|
||||
if (res.status === 429) {
|
||||
return {
|
||||
track: null,
|
||||
rateLimited: true,
|
||||
retryAfterMs: parseRetryAfter(res),
|
||||
};
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
return { track: null, rateLimited: false, retryAfterMs: 0 };
|
||||
}
|
||||
|
||||
const data = (await res.json()) as { track: FlightTrack | null };
|
||||
return { track: data.track ?? null, rateLimited: false, retryAfterMs: 0 };
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
}
|
||||
}
|
||||
|
||||
export function useFlightTrack(
|
||||
@ -69,20 +82,24 @@ export function useFlightTrack(
|
||||
refreshMs?: number;
|
||||
enabled?: boolean;
|
||||
},
|
||||
): { track: FlightTrack | null; loading: boolean; fetchedAtMs: number } {
|
||||
const refreshMs = options?.refreshMs ?? DEFAULT_REFRESH_MS;
|
||||
): {
|
||||
track: FlightTrack | null;
|
||||
loading: boolean;
|
||||
fetchedAtMs: number;
|
||||
rateLimited: boolean;
|
||||
} {
|
||||
const refreshMs = options?.refreshMs ?? 0;
|
||||
const enabled = options?.enabled ?? true;
|
||||
|
||||
const [track, setTrack] = useState<FlightTrack | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [fetchedAtMs, setFetchedAtMs] = useState(0);
|
||||
const [rateLimited, setRateLimited] = useState(false);
|
||||
|
||||
const requestIdRef = useRef(0);
|
||||
const activeKeyRef = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
loadGlobalBackoff();
|
||||
|
||||
if (!icao24) {
|
||||
setTrack(null);
|
||||
setLoading(false);
|
||||
@ -98,10 +115,9 @@ export function useFlightTrack(
|
||||
const cached = trackCache.get(key);
|
||||
const hasCachedTrack = cached?.track != null;
|
||||
|
||||
// Stale-while-revalidate: keep cached track visible.
|
||||
if (hasCachedTrack) {
|
||||
setTrack(cached!.track);
|
||||
setFetchedAtMs(cached!.fetchedAt);
|
||||
if (cached && hasCachedTrack) {
|
||||
setTrack(cached.track);
|
||||
setFetchedAtMs(cached.fetchedAt);
|
||||
} else if (isKeyChange) {
|
||||
setTrack(null);
|
||||
setFetchedAtMs(0);
|
||||
@ -114,26 +130,20 @@ export function useFlightTrack(
|
||||
|
||||
let alive = true;
|
||||
const controller = new AbortController();
|
||||
let retryTimer: number | null = null;
|
||||
|
||||
async function load() {
|
||||
const now = Date.now();
|
||||
|
||||
// Sweep stale entries to bound memory growth over time
|
||||
for (const [k, entry] of trackCache) {
|
||||
if (now - entry.fetchedAt > cacheTtlMs(entry.track)) {
|
||||
trackCache.delete(k);
|
||||
}
|
||||
}
|
||||
|
||||
if (now < globalNextAllowedAt) {
|
||||
return;
|
||||
}
|
||||
if (now < rateLimitedUntil) return;
|
||||
|
||||
const existing = trackCache.get(key);
|
||||
if (existing && now < existing.nextAllowedAt) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (existing && now - existing.fetchedAt <= cacheTtlMs(existing.track)) {
|
||||
return;
|
||||
}
|
||||
@ -141,63 +151,46 @@ export function useFlightTrack(
|
||||
const requestId = ++requestIdRef.current;
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await fetchTrackByIcao24(key, 0, controller.signal);
|
||||
const result = await fetchTrace(key, controller.signal);
|
||||
if (!alive || requestId !== requestIdRef.current) return;
|
||||
|
||||
const fetchedAt = Date.now();
|
||||
const retryAfterSeconds =
|
||||
typeof result.retryAfterSeconds === "number" &&
|
||||
Number.isFinite(result.retryAfterSeconds)
|
||||
? result.retryAfterSeconds
|
||||
: null;
|
||||
|
||||
const rateLimitedBackoffMs =
|
||||
retryAfterSeconds && retryAfterSeconds > 0
|
||||
? Math.max(1, retryAfterSeconds) * 1000
|
||||
: globalBackoffMs;
|
||||
|
||||
const nextAllowedAt = result.rateLimited
|
||||
? fetchedAt + rateLimitedBackoffMs
|
||||
: fetchedAt;
|
||||
|
||||
if (result.rateLimited) {
|
||||
globalNextAllowedAt = Math.max(globalNextAllowedAt, nextAllowedAt);
|
||||
globalBackoffMs = Math.min(
|
||||
GLOBAL_BACKOFF_MAX_MS,
|
||||
Math.max(60_000, Math.floor(globalBackoffMs * 1.6)),
|
||||
);
|
||||
persistGlobalBackoff();
|
||||
rateLimitedUntil = fetchedAt + result.retryAfterMs;
|
||||
setRateLimited(true);
|
||||
// Schedule a one-shot retry after the cooldown so we recover
|
||||
// automatically even without a refreshMs interval.
|
||||
retryTimer = window.setTimeout(() => {
|
||||
if (!alive) return;
|
||||
setRateLimited(false);
|
||||
void load();
|
||||
}, result.retryAfterMs);
|
||||
return;
|
||||
}
|
||||
|
||||
const existing = trackCache.get(key)?.track ?? null;
|
||||
const nextTrack = result.track ?? existing;
|
||||
setRateLimited(false);
|
||||
|
||||
trackCache.set(key, {
|
||||
fetchedAt,
|
||||
nextAllowedAt,
|
||||
track: nextTrack,
|
||||
});
|
||||
const nextTrack = result.track;
|
||||
|
||||
// Evict oldest entries when cache exceeds max size (FIFO via Map insertion order)
|
||||
if (trackCache.size > TRACK_CACHE_MAX_ENTRIES) {
|
||||
trackCache.delete(key);
|
||||
trackCache.set(key, { fetchedAt, track: nextTrack });
|
||||
|
||||
while (trackCache.size > TRACK_CACHE_MAX_ENTRIES) {
|
||||
const oldestKey = trackCache.keys().next().value as
|
||||
| string
|
||||
| undefined;
|
||||
if (oldestKey && oldestKey !== key) trackCache.delete(oldestKey);
|
||||
if (!oldestKey) break;
|
||||
trackCache.delete(oldestKey);
|
||||
}
|
||||
|
||||
setFetchedAtMs(fetchedAt);
|
||||
|
||||
setTrack(nextTrack);
|
||||
} catch (err) {
|
||||
if (err instanceof Error && err.name === "AbortError") {
|
||||
return;
|
||||
}
|
||||
if (err instanceof Error && err.name === "AbortError") return;
|
||||
if (process.env.NODE_ENV !== "production") {
|
||||
console.error("useFlightTrack: failed to fetch track", err);
|
||||
console.error("useFlightTrack: failed to fetch trace", err);
|
||||
}
|
||||
|
||||
return;
|
||||
} finally {
|
||||
if (alive && requestId === requestIdRef.current) {
|
||||
setLoading(false);
|
||||
@ -213,6 +206,7 @@ export function useFlightTrack(
|
||||
let interval: number | null = null;
|
||||
if (refreshMs > 0) {
|
||||
interval = window.setInterval(() => {
|
||||
if (typeof document !== "undefined" && document.hidden) return;
|
||||
void load();
|
||||
}, refreshMs);
|
||||
}
|
||||
@ -221,10 +215,11 @@ export function useFlightTrack(
|
||||
alive = false;
|
||||
controller.abort();
|
||||
window.clearTimeout(loadTimer);
|
||||
if (retryTimer !== null) window.clearTimeout(retryTimer);
|
||||
if (interval !== null) window.clearInterval(interval);
|
||||
setLoading(false);
|
||||
};
|
||||
}, [icao24, refreshMs, enabled]);
|
||||
|
||||
return { track, loading, fetchedAtMs };
|
||||
return { track, loading, fetchedAtMs, rateLimited };
|
||||
}
|
||||
|
||||
@ -1,37 +1,35 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import {
|
||||
fetchFlightsByBbox,
|
||||
bboxFromCenter,
|
||||
type FlightState,
|
||||
} from "@/lib/opensky";
|
||||
import type { FlightState } from "@/lib/opensky";
|
||||
import { fetchFlightsByPoint } from "@/lib/flight-api";
|
||||
import type { City } from "@/lib/cities";
|
||||
|
||||
const BASE_POLL_MS = 30_000;
|
||||
const CONSERVATIVE_POLL_MS = 60_000;
|
||||
const CAUTIOUS_POLL_MS = 120_000;
|
||||
const EMERGENCY_POLL_MS = 300_000;
|
||||
/** Normal polling interval — readsb allows 1 req/s; 10s balances freshness vs compute. */
|
||||
const POLL_INTERVAL_MS = 10_000;
|
||||
|
||||
const CREDIT_TIER_CONSERVATIVE = 2_000;
|
||||
const CREDIT_TIER_CAUTIOUS = 800;
|
||||
const CREDIT_TIER_EMERGENCY = 200;
|
||||
/** Backoff on rate limit (429) or repeated errors. */
|
||||
const RATE_LIMIT_BACKOFF_MS = 15_000;
|
||||
|
||||
const RATE_LIMIT_BACKOFF_MS = 30_000;
|
||||
const VISIBILITY_RESUME_STALE_MS = 60_000;
|
||||
const FPV_BBOX_RADIUS = 2;
|
||||
/** If tab was hidden longer than this, fetch immediately on resume. */
|
||||
const VISIBILITY_RESUME_STALE_MS = 15_000;
|
||||
|
||||
function adaptiveInterval(creditsRemaining: number | null): number {
|
||||
if (creditsRemaining === null) return BASE_POLL_MS;
|
||||
if (creditsRemaining < CREDIT_TIER_EMERGENCY) return EMERGENCY_POLL_MS;
|
||||
if (creditsRemaining < CREDIT_TIER_CAUTIOUS) return CAUTIOUS_POLL_MS;
|
||||
if (creditsRemaining < CREDIT_TIER_CONSERVATIVE) return CONSERVATIVE_POLL_MS;
|
||||
return BASE_POLL_MS;
|
||||
}
|
||||
/** Radius (degrees) for FPV point queries — ~120 nautical miles. */
|
||||
const FPV_POINT_RADIUS = 2;
|
||||
|
||||
/**
|
||||
* Fetches flights via OpenSky. In FPV mode the bbox moves with the tracked
|
||||
* aircraft (4×4° = 1 API credit). City changes are ignored while in FPV.
|
||||
* Number of consecutive empty API responses before we accept that the area
|
||||
* genuinely has zero flights. Protects against transient API failures that
|
||||
* return valid JSON with an empty aircraft list — without this guard,
|
||||
* a single empty response would wipe all flights and trigger mass-teleport
|
||||
* artifacts when data returns on the next poll.
|
||||
*/
|
||||
const MAX_EMPTY_STREAK = 3;
|
||||
|
||||
/**
|
||||
* Fetches flights via readsb (Airplanes.live → adsb.lol fallback).
|
||||
* In FPV mode the query center moves with the tracked aircraft.
|
||||
* City changes are ignored while in FPV.
|
||||
*/
|
||||
export function useFlights(
|
||||
city: City | null,
|
||||
@ -43,14 +41,14 @@ export function useFlights(
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [rateLimited, setRateLimited] = useState(false);
|
||||
const [retryIn, setRetryIn] = useState(0);
|
||||
const [creditsRemaining, setCreditsRemaining] = useState<number | null>(null);
|
||||
const [source, setSource] = useState<string | null>(null);
|
||||
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const countdownRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
|
||||
const creditsRef = useRef<number | null>(null);
|
||||
const lastFetchRef = useRef(0);
|
||||
const emptyStreakRef = useRef(0);
|
||||
const fpvCenterRef = useRef<{ lng: number; lat: number } | null>(null);
|
||||
const fpvSeedCenterRef = useRef<{ lng: number; lat: number } | null>(
|
||||
fpvSeedCenter,
|
||||
@ -69,9 +67,7 @@ export function useFlights(
|
||||
}
|
||||
if (fpvSeedRef.current === fpvIcao24) return;
|
||||
|
||||
const match = flights.find(
|
||||
(f) => f.icao24.toLowerCase() === fpvIcao24,
|
||||
);
|
||||
const match = flights.find((f) => f.icao24.toLowerCase() === fpvIcao24);
|
||||
if (match?.longitude != null && match?.latitude != null) {
|
||||
fpvCenterRef.current = { lng: match.longitude, lat: match.latitude };
|
||||
}
|
||||
@ -110,7 +106,10 @@ export function useFlights(
|
||||
const scheduleNext = useCallback(
|
||||
(target: City, delayMs: number) => {
|
||||
clearSchedule();
|
||||
if (typeof document !== "undefined" && document.visibilityState !== "visible") {
|
||||
if (
|
||||
typeof document !== "undefined" &&
|
||||
document.visibilityState !== "visible"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
@ -130,57 +129,82 @@ export function useFlights(
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
let bbox: [number, number, number, number];
|
||||
|
||||
const inFpv = fpvIcao24Ref.current !== null;
|
||||
let lat: number;
|
||||
let lon: number;
|
||||
let radiusDeg: number;
|
||||
|
||||
if (inFpv && fpvCenterRef.current) {
|
||||
bbox = bboxFromCenter(
|
||||
fpvCenterRef.current.lng,
|
||||
fpvCenterRef.current.lat,
|
||||
FPV_BBOX_RADIUS,
|
||||
);
|
||||
lat = fpvCenterRef.current.lat;
|
||||
lon = fpvCenterRef.current.lng;
|
||||
radiusDeg = FPV_POINT_RADIUS;
|
||||
} else if (inFpv && fpvSeedCenterRef.current) {
|
||||
fpvCenterRef.current = fpvSeedCenterRef.current;
|
||||
bbox = bboxFromCenter(
|
||||
fpvSeedCenterRef.current.lng,
|
||||
fpvSeedCenterRef.current.lat,
|
||||
FPV_BBOX_RADIUS,
|
||||
);
|
||||
lat = fpvSeedCenterRef.current.lat;
|
||||
lon = fpvSeedCenterRef.current.lng;
|
||||
radiusDeg = FPV_POINT_RADIUS;
|
||||
} else if (inFpv) {
|
||||
fpvCenterRef.current = {
|
||||
lng: target.coordinates[0],
|
||||
lat: target.coordinates[1],
|
||||
};
|
||||
bbox = bboxFromCenter(
|
||||
target.coordinates[0],
|
||||
target.coordinates[1],
|
||||
FPV_BBOX_RADIUS,
|
||||
);
|
||||
lat = target.coordinates[1];
|
||||
lon = target.coordinates[0];
|
||||
radiusDeg = FPV_POINT_RADIUS;
|
||||
} else {
|
||||
bbox = bboxFromCenter(
|
||||
target.coordinates[0],
|
||||
target.coordinates[1],
|
||||
target.radius,
|
||||
);
|
||||
lat = target.coordinates[1];
|
||||
lon = target.coordinates[0];
|
||||
radiusDeg = target.radius;
|
||||
}
|
||||
|
||||
const result = await fetchFlightsByBbox(...bbox, controller.signal);
|
||||
const result = await fetchFlightsByPoint(
|
||||
lat,
|
||||
lon,
|
||||
radiusDeg,
|
||||
controller.signal,
|
||||
);
|
||||
|
||||
setSource(result.source ?? null);
|
||||
|
||||
if (result.rateLimited) {
|
||||
const retryDelayMs =
|
||||
result.retryAfterSeconds && result.retryAfterSeconds > 0
|
||||
? result.retryAfterSeconds * 1000
|
||||
: RATE_LIMIT_BACKOFF_MS;
|
||||
setRateLimited(true);
|
||||
startCountdown(retryDelayMs);
|
||||
scheduleNext(target, retryDelayMs);
|
||||
startCountdown(RATE_LIMIT_BACKOFF_MS);
|
||||
scheduleNext(target, RATE_LIMIT_BACKOFF_MS);
|
||||
return;
|
||||
}
|
||||
|
||||
setRateLimited(false);
|
||||
clearCountdown();
|
||||
|
||||
// All circuits open — preserve last-known flights
|
||||
if (result.source === "none" && result.flights.length === 0) {
|
||||
scheduleNext(target, POLL_INTERVAL_MS);
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Guard against transient empty API responses ─────────────
|
||||
// If we previously had flights but this response is empty, it's
|
||||
// likely a transient API failure. Keep last-known state to avoid
|
||||
// mass-teleport artifacts when real data returns next poll.
|
||||
if (result.flights.length === 0) {
|
||||
emptyStreakRef.current += 1;
|
||||
// After MAX_EMPTY_STREAK consecutive empties, accept it as
|
||||
// genuinely empty (e.g. user panned to an empty ocean area).
|
||||
if (emptyStreakRef.current < MAX_EMPTY_STREAK) {
|
||||
// Preserve existing flights — schedule next poll normally.
|
||||
lastFetchRef.current = Date.now();
|
||||
scheduleNext(target, POLL_INTERVAL_MS);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
emptyStreakRef.current = 0;
|
||||
}
|
||||
|
||||
setFlights(result.flights);
|
||||
lastFetchRef.current = Date.now();
|
||||
|
||||
// Update FPV center to follow tracked aircraft
|
||||
if (inFpv && fpvIcao24Ref.current) {
|
||||
const tracked = result.flights.find(
|
||||
(f) => f.icao24.toLowerCase() === fpvIcao24Ref.current,
|
||||
@ -193,13 +217,7 @@ export function useFlights(
|
||||
}
|
||||
}
|
||||
|
||||
if (result.creditsRemaining !== null) {
|
||||
creditsRef.current = result.creditsRemaining;
|
||||
setCreditsRemaining(result.creditsRemaining);
|
||||
}
|
||||
|
||||
const nextInterval = adaptiveInterval(creditsRef.current);
|
||||
scheduleNext(target, nextInterval);
|
||||
scheduleNext(target, POLL_INTERVAL_MS);
|
||||
} catch (err) {
|
||||
const isAbort = err instanceof Error && err.name === "AbortError";
|
||||
if (isAbort) return;
|
||||
@ -225,7 +243,6 @@ export function useFlights(
|
||||
|
||||
function onVisibilityChange() {
|
||||
if (document.visibilityState !== "visible") {
|
||||
// Fully pause polling while hidden.
|
||||
clearSchedule();
|
||||
abortRef.current?.abort();
|
||||
return;
|
||||
@ -237,8 +254,7 @@ export function useFlights(
|
||||
clearSchedule();
|
||||
fetchData(activeCity);
|
||||
} else {
|
||||
const interval = adaptiveInterval(creditsRef.current);
|
||||
const remaining = Math.max(1_000, interval - elapsed);
|
||||
const remaining = Math.max(1_000, POLL_INTERVAL_MS - elapsed);
|
||||
clearSchedule();
|
||||
scheduleNext(activeCity, remaining);
|
||||
}
|
||||
@ -251,7 +267,13 @@ export function useFlights(
|
||||
}, [city, fetchData, scheduleNext, clearSchedule]);
|
||||
|
||||
useEffect(() => {
|
||||
if (fpvIcao24Ref.current !== null) return;
|
||||
if (fpvIcao24Ref.current !== null) {
|
||||
// In FPV mode, the FPV effect handles fetching. Clear any stale
|
||||
// old-city timer that might still be pending to prevent concurrent
|
||||
// fetches from different regions.
|
||||
clearSchedule();
|
||||
return;
|
||||
}
|
||||
|
||||
clearSchedule();
|
||||
|
||||
@ -271,6 +293,7 @@ export function useFlights(
|
||||
clearTimeout(deferred);
|
||||
clearSchedule();
|
||||
abortRef.current?.abort();
|
||||
abortRef.current = null;
|
||||
clearCountdown();
|
||||
};
|
||||
}, [city, fetchData, clearCountdown, clearSchedule]);
|
||||
@ -291,6 +314,18 @@ export function useFlights(
|
||||
}
|
||||
}, [fpvIcao24, city, clearSchedule, fetchData]);
|
||||
|
||||
// Trigger immediate fetch on network reconnect
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined" || !city) return;
|
||||
const activeCity = city;
|
||||
const onOnline = () => {
|
||||
clearSchedule();
|
||||
fetchData(activeCity);
|
||||
};
|
||||
window.addEventListener("online", onOnline);
|
||||
return () => window.removeEventListener("online", onOnline);
|
||||
}, [city, fetchData, clearSchedule]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
clearSchedule();
|
||||
@ -299,5 +334,12 @@ export function useFlights(
|
||||
};
|
||||
}, [clearSchedule, clearCountdown]);
|
||||
|
||||
return { flights, loading, error, rateLimited, retryIn, creditsRemaining };
|
||||
return {
|
||||
flights,
|
||||
loading,
|
||||
error,
|
||||
rateLimited,
|
||||
retryIn,
|
||||
source,
|
||||
};
|
||||
}
|
||||
|
||||
25
src/hooks/use-is-mobile.ts
Normal file
25
src/hooks/use-is-mobile.ts
Normal file
@ -0,0 +1,25 @@
|
||||
"use client";
|
||||
|
||||
import { useSyncExternalStore } from "react";
|
||||
|
||||
function subscribe(callback: () => void): () => void {
|
||||
const mql = window.matchMedia("(max-width: 639px)");
|
||||
mql.addEventListener("change", callback);
|
||||
return () => mql.removeEventListener("change", callback);
|
||||
}
|
||||
|
||||
function getSnapshot(): boolean {
|
||||
return window.matchMedia("(max-width: 639px)").matches;
|
||||
}
|
||||
|
||||
function getServerSnapshot(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true when viewport width is below the sm breakpoint (640px).
|
||||
* SSR-safe — returns false on server.
|
||||
*/
|
||||
export function useIsMobile(): boolean {
|
||||
return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
|
||||
}
|
||||
@ -10,6 +10,7 @@ type ShortcutActions = {
|
||||
onToggleHelp: () => void;
|
||||
onDeselect: () => void;
|
||||
onToggleFpv: () => void;
|
||||
onToggleAtc?: () => void;
|
||||
isFpv?: boolean;
|
||||
};
|
||||
|
||||
@ -26,10 +27,6 @@ export function useKeyboardShortcuts(actions: ShortcutActions) {
|
||||
function handler(e: KeyboardEvent) {
|
||||
const target = e.target as HTMLElement;
|
||||
|
||||
const dialogOpen = !!document.querySelector(
|
||||
'[role="dialog"][aria-modal="true"]',
|
||||
);
|
||||
|
||||
const a = ref.current;
|
||||
|
||||
// Ctrl/Cmd+K opens search from anywhere (even inside inputs)
|
||||
@ -44,6 +41,12 @@ export function useKeyboardShortcuts(actions: ShortcutActions) {
|
||||
|
||||
if (e.ctrlKey || e.metaKey || e.altKey) return;
|
||||
|
||||
// Deferred dialog check — only query DOM when we actually need it
|
||||
// (most keydowns short-circuit above, avoiding unnecessary DOM traversal)
|
||||
const dialogOpen = !!document.querySelector(
|
||||
'[role="dialog"][aria-modal="true"]',
|
||||
);
|
||||
|
||||
if (e.key === "Escape") {
|
||||
if (!dialogOpen) a.onDeselect();
|
||||
return;
|
||||
@ -88,6 +91,11 @@ export function useKeyboardShortcuts(actions: ShortcutActions) {
|
||||
e.preventDefault();
|
||||
a.onToggleHelp();
|
||||
break;
|
||||
case "a":
|
||||
case "A":
|
||||
e.preventDefault();
|
||||
a.onToggleAtc?.();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -1,7 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { stitchHistoricalTrail } from "@/lib/trail-stitching";
|
||||
import { useMemo, useEffect } from "react";
|
||||
import {
|
||||
stitchHistoricalTrail,
|
||||
clearSplinedTrackCache,
|
||||
} from "@/lib/trail-stitching";
|
||||
import type { TrailEntry } from "@/hooks/use-trail-history";
|
||||
import type { FlightState } from "@/lib/opensky";
|
||||
import type { FlightTrack } from "@/lib/opensky";
|
||||
@ -18,16 +21,37 @@ export function useMergedTrails(
|
||||
displayTrails: TrailEntry[],
|
||||
displayFlights: FlightState[],
|
||||
): TrailEntry[] {
|
||||
return useMemo(() => {
|
||||
if (!selectedIcao24 || !selectedTrack) return displayTrails;
|
||||
|
||||
// Extract stable position for the selected flight so that the main memo
|
||||
// doesn't depend on the entire displayFlights array (which changes every poll).
|
||||
const selectedLivePos = useMemo((): {
|
||||
pos: [number, number] | null;
|
||||
flight: FlightState | null;
|
||||
} => {
|
||||
if (!selectedIcao24) return { pos: null, flight: null };
|
||||
const flight =
|
||||
displayFlights.find((f) => f.icao24 === selectedIcao24) ?? null;
|
||||
|
||||
const livePos: [number, number] | null =
|
||||
const pos: [number, number] | null =
|
||||
flight && flight.longitude != null && flight.latitude != null
|
||||
? [flight.longitude, flight.latitude]
|
||||
: null;
|
||||
return { pos, flight };
|
||||
}, [selectedIcao24, displayFlights]);
|
||||
|
||||
// Clear the spline cache when the selected flight is deselected or the
|
||||
// track is unloaded. This is a side effect (mutates module-level state)
|
||||
// so it belongs in useEffect, not useMemo.
|
||||
useEffect(() => {
|
||||
if (!selectedIcao24 || !selectedTrack) {
|
||||
clearSplinedTrackCache();
|
||||
}
|
||||
}, [selectedIcao24, selectedTrack]);
|
||||
|
||||
return useMemo(() => {
|
||||
if (!selectedIcao24 || !selectedTrack) {
|
||||
return displayTrails;
|
||||
}
|
||||
|
||||
const { pos: livePos, flight } = selectedLivePos;
|
||||
|
||||
const existingTrail =
|
||||
displayTrails.find((t) => t.icao24 === selectedIcao24) ?? null;
|
||||
@ -64,6 +88,7 @@ export function useMergedTrails(
|
||||
icao24: selectedIcao24,
|
||||
path: trackPositions,
|
||||
altitudes: trackAltitudes,
|
||||
timestamps: [],
|
||||
baroAltitude: trackAltitudes[trackAltitudes.length - 1] ?? null,
|
||||
fullHistory: true,
|
||||
});
|
||||
@ -75,6 +100,6 @@ export function useMergedTrails(
|
||||
selectedTrack,
|
||||
selectedTrackFetchedAtMs,
|
||||
displayTrails,
|
||||
displayFlights,
|
||||
selectedLivePos,
|
||||
]);
|
||||
}
|
||||
|
||||
@ -11,6 +11,8 @@ import {
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
|
||||
import { clamp } from "@/lib/utils";
|
||||
|
||||
export type OrbitDirection = "clockwise" | "counter-clockwise";
|
||||
|
||||
export type Settings = {
|
||||
@ -24,6 +26,10 @@ export type Settings = {
|
||||
showAltitudeColors: boolean;
|
||||
fpvChaseDistance: number;
|
||||
globeMode: boolean;
|
||||
showAirspace: boolean;
|
||||
airspaceOpacity: number;
|
||||
showAirspaceHotspots: boolean;
|
||||
showAtcPanel: boolean;
|
||||
};
|
||||
|
||||
const TRAIL_THICKNESS_MIN = 0.5;
|
||||
@ -32,10 +38,8 @@ const TRAIL_DISTANCE_MIN = 12;
|
||||
const TRAIL_DISTANCE_MAX = 100;
|
||||
const FPV_CHASE_DISTANCE_MIN = 0.003;
|
||||
const FPV_CHASE_DISTANCE_MAX = 0.01;
|
||||
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
return Math.max(min, Math.min(max, value));
|
||||
}
|
||||
export const AIRSPACE_OPACITY_MIN = 0.25;
|
||||
export const AIRSPACE_OPACITY_MAX = 1.0;
|
||||
|
||||
function normalizeSettings(input: Settings): Settings {
|
||||
return {
|
||||
@ -54,6 +58,11 @@ function normalizeSettings(input: Settings): Settings {
|
||||
FPV_CHASE_DISTANCE_MIN,
|
||||
FPV_CHASE_DISTANCE_MAX,
|
||||
),
|
||||
airspaceOpacity: clamp(
|
||||
input.airspaceOpacity,
|
||||
AIRSPACE_OPACITY_MIN,
|
||||
AIRSPACE_OPACITY_MAX,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@ -68,6 +77,10 @@ const DEFAULT_SETTINGS: Settings = {
|
||||
showAltitudeColors: true,
|
||||
fpvChaseDistance: 0.0048,
|
||||
globeMode: false,
|
||||
showAirspace: false,
|
||||
airspaceOpacity: 0.78,
|
||||
showAirspaceHotspots: false,
|
||||
showAtcPanel: false,
|
||||
};
|
||||
|
||||
const STORAGE_KEY = "aeris:settings";
|
||||
@ -102,7 +115,14 @@ function isValidSettings(obj: unknown): obj is Settings {
|
||||
Number.isFinite(s.fpvChaseDistance) &&
|
||||
s.fpvChaseDistance >= FPV_CHASE_DISTANCE_MIN &&
|
||||
s.fpvChaseDistance <= FPV_CHASE_DISTANCE_MAX &&
|
||||
typeof s.globeMode === "boolean"
|
||||
typeof s.globeMode === "boolean" &&
|
||||
typeof s.showAirspace === "boolean" &&
|
||||
typeof s.airspaceOpacity === "number" &&
|
||||
Number.isFinite(s.airspaceOpacity) &&
|
||||
s.airspaceOpacity >= AIRSPACE_OPACITY_MIN &&
|
||||
s.airspaceOpacity <= AIRSPACE_OPACITY_MAX &&
|
||||
typeof s.showAirspaceHotspots === "boolean" &&
|
||||
typeof s.showAtcPanel === "boolean"
|
||||
);
|
||||
}
|
||||
|
||||
@ -126,6 +146,7 @@ function loadSettings(): Settings {
|
||||
}
|
||||
return normalizeSettings({ ...DEFAULT_SETTINGS, ...envelope.data });
|
||||
} catch {
|
||||
// Corrupted or unreadable localStorage — fall back to defaults
|
||||
return DEFAULT_SETTINGS;
|
||||
}
|
||||
}
|
||||
@ -136,7 +157,7 @@ function saveSettings(settings: Settings): void {
|
||||
const envelope: StorageEnvelope = { v: STORAGE_VERSION, data: settings };
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(envelope));
|
||||
} catch {
|
||||
/* noop */
|
||||
// localStorage may be full or unavailable (private browsing)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -8,17 +8,19 @@ type Position = [lng: number, lat: number];
|
||||
type TrailPoint = {
|
||||
position: Position;
|
||||
baroAltitude: number | null;
|
||||
timestamp: number;
|
||||
};
|
||||
|
||||
export type TrailEntry = {
|
||||
icao24: string;
|
||||
path: Position[];
|
||||
altitudes: Array<number | null>;
|
||||
timestamps: number[];
|
||||
baroAltitude: number | null;
|
||||
fullHistory?: boolean;
|
||||
};
|
||||
|
||||
const MAX_POINTS = 40;
|
||||
const MAX_POINTS = 55;
|
||||
const JUMP_THRESHOLD_DEG = 0.3;
|
||||
const HISTORICAL_BOOTSTRAP_POLLS = 3;
|
||||
const HISTORICAL_BOOTSTRAP_STEP_SEC = 12;
|
||||
@ -31,6 +33,21 @@ const ALTITUDE_OUTLIER_SCALE = 3;
|
||||
const ALTITUDE_SMOOTHING_ALPHA_TRUSTED = 0.9;
|
||||
const ALTITUDE_SMOOTHING_ALPHA_GUARDED = 0.5;
|
||||
|
||||
/**
|
||||
* If the interval between consecutive update() calls exceeds this value,
|
||||
* the tab was likely hidden. Jump detection switches to a dynamic threshold
|
||||
* based on elapsed time and per-aircraft speed to avoid destroying trails
|
||||
* for legitimate movement during the absence.
|
||||
*/
|
||||
const RESUME_GAP_MS = 20_000;
|
||||
|
||||
/**
|
||||
* Conservative ceiling speed (m/s) for dynamic jump threshold when the
|
||||
* flight's actual velocity is unknown. ~350 m/s ≈ Mach 1 — covers all
|
||||
* commercial traffic with generous headroom.
|
||||
*/
|
||||
const MAX_REASONABLE_SPEED_MPS = 350;
|
||||
|
||||
type AltitudeState = {
|
||||
filtered: number | null;
|
||||
recent: number[];
|
||||
@ -54,14 +71,29 @@ function synthesizeHistoricalPolls(f: FlightState): Position[] {
|
||||
const speed = f.velocity ?? 200;
|
||||
const degPerSecond = speed / 111_320;
|
||||
|
||||
// Perpendicular direction for GPS-like lateral jitter.
|
||||
// Without jitter, synthetic trails are ruler-straight which looks
|
||||
// artificial until real GPS data arrives.
|
||||
const perpHeading = heading + Math.PI / 2;
|
||||
const GPS_JITTER_DEG = 0.00018; // ~20 m at mid-latitudes
|
||||
|
||||
const polls: Position[] = [];
|
||||
for (let i = HISTORICAL_BOOTSTRAP_POLLS; i >= 1; i--) {
|
||||
const tSec = HISTORICAL_BOOTSTRAP_STEP_SEC * i;
|
||||
const decay = 1 - (HISTORICAL_BOOTSTRAP_POLLS - i) * 0.08;
|
||||
const distanceDeg = Math.min(degPerSecond * tSec * decay, 0.06);
|
||||
|
||||
// Alternating perpendicular offset — creates a subtle S-curve that
|
||||
// mimics real GPS measurement noise.
|
||||
const jitterSign = i % 2 === 0 ? 1 : -1;
|
||||
const jitter =
|
||||
GPS_JITTER_DEG *
|
||||
jitterSign *
|
||||
(0.4 + (i / HISTORICAL_BOOTSTRAP_POLLS) * 0.6);
|
||||
|
||||
polls.push([
|
||||
lng - Math.sin(heading) * distanceDeg,
|
||||
lat - Math.cos(heading) * distanceDeg,
|
||||
lng - Math.sin(heading) * distanceDeg + Math.sin(perpHeading) * jitter,
|
||||
lat - Math.cos(heading) * distanceDeg + Math.cos(perpHeading) * jitter,
|
||||
]);
|
||||
}
|
||||
return polls;
|
||||
@ -72,6 +104,10 @@ class TrailStore {
|
||||
private altitudeStates = new Map<string, AltitudeState>();
|
||||
private seen = new Set<string>();
|
||||
private bootstrapUpdatesRemaining = BOOTSTRAP_UPDATES;
|
||||
private lastUpdateTime = 0;
|
||||
/** Cached result from the last non-empty update — returned when empty
|
||||
* flights would otherwise wipe all trail data. */
|
||||
private lastResult: TrailEntry[] = [];
|
||||
|
||||
private filterAltitude(
|
||||
id: string,
|
||||
@ -122,6 +158,25 @@ class TrailStore {
|
||||
}
|
||||
|
||||
update(flights: FlightState[]): TrailEntry[] {
|
||||
const now = Date.now();
|
||||
|
||||
// ── Guard: empty flights with existing trail data ─────────────
|
||||
// If the flights array is empty but we already have trail data, a
|
||||
// transient API failure likely produced the empty set. Preserve
|
||||
// last-known trails instead of purging everything.
|
||||
if (flights.length === 0 && this.trails.size > 0) {
|
||||
return this.lastResult;
|
||||
}
|
||||
|
||||
// ── Tab-resume awareness ──────────────────────────────────────
|
||||
// When the gap between updates exceeds 2× the normal poll interval,
|
||||
// the tab was probably hidden. Compute a dynamic per-flight jump
|
||||
// threshold so legitimate movement during absence is preserved.
|
||||
const elapsed = this.lastUpdateTime > 0 ? now - this.lastUpdateTime : 0;
|
||||
const isResuming = elapsed > RESUME_GAP_MS;
|
||||
const elapsedSec = elapsed / 1000;
|
||||
this.lastUpdateTime = now;
|
||||
|
||||
const current = new Set<string>();
|
||||
let processedFlightCount = 0;
|
||||
|
||||
@ -130,41 +185,76 @@ class TrailStore {
|
||||
processedFlightCount += 1;
|
||||
const id = f.icao24;
|
||||
current.add(id);
|
||||
|
||||
let trail = this.trails.get(id);
|
||||
const isNewEntry = !trail;
|
||||
|
||||
// When an aircraft appears for the first time (or returns after
|
||||
// being absent), clear any stale altitude state. Without this,
|
||||
// a recycled icao24 would inherit the previous aircraft's
|
||||
// median/outlier history, clamping the new aircraft's real
|
||||
// altitude as an outlier for several polls.
|
||||
if (isNewEntry) {
|
||||
this.altitudeStates.delete(id);
|
||||
}
|
||||
|
||||
const filteredAltitude = this.filterAltitude(id, f.baroAltitude);
|
||||
|
||||
const pos: TrailPoint = {
|
||||
position: [f.longitude, f.latitude],
|
||||
baroAltitude: filteredAltitude,
|
||||
timestamp: now,
|
||||
};
|
||||
let trail = this.trails.get(id);
|
||||
|
||||
if (!trail) {
|
||||
if (isNewEntry) {
|
||||
trail =
|
||||
this.bootstrapUpdatesRemaining > 0
|
||||
? synthesizeHistoricalPolls(f).map((position) => ({
|
||||
? synthesizeHistoricalPolls(f).map((position, i) => ({
|
||||
position,
|
||||
baroAltitude: filteredAltitude,
|
||||
timestamp:
|
||||
now -
|
||||
(HISTORICAL_BOOTSTRAP_POLLS - i) *
|
||||
HISTORICAL_BOOTSTRAP_STEP_SEC *
|
||||
1000,
|
||||
}))
|
||||
: [];
|
||||
this.trails.set(id, trail);
|
||||
}
|
||||
|
||||
if (trail.length === 0) {
|
||||
trail.push(pos);
|
||||
// After the branch above, trail is guaranteed to be defined.
|
||||
const t = trail!;
|
||||
|
||||
if (t.length === 0) {
|
||||
t.push(pos);
|
||||
continue;
|
||||
}
|
||||
|
||||
const last = trail[trail.length - 1].position;
|
||||
// ── Jump detection with tab-resume dynamic threshold ──────
|
||||
const last = t[t.length - 1].position;
|
||||
const dx = pos.position[0] - last[0];
|
||||
const dy = pos.position[1] - last[1];
|
||||
if (dx * dx + dy * dy > JUMP_THRESHOLD_DEG * JUMP_THRESHOLD_DEG) {
|
||||
trail.length = 0;
|
||||
const distSq = dx * dx + dy * dy;
|
||||
|
||||
let effectiveThreshold = JUMP_THRESHOLD_DEG;
|
||||
if (isResuming) {
|
||||
// Use per-aircraft speed if available, else conservative ceiling.
|
||||
const speed =
|
||||
f.velocity != null && Number.isFinite(f.velocity) && f.velocity > 0
|
||||
? f.velocity
|
||||
: MAX_REASONABLE_SPEED_MPS;
|
||||
const maxLegitMoveDeg = (speed * elapsedSec * 1.5) / 111_320;
|
||||
effectiveThreshold = Math.max(JUMP_THRESHOLD_DEG, maxLegitMoveDeg);
|
||||
}
|
||||
|
||||
if (distSq > effectiveThreshold * effectiveThreshold) {
|
||||
t.length = 0;
|
||||
this.altitudeStates.delete(id);
|
||||
}
|
||||
|
||||
trail.push(pos);
|
||||
if (trail.length > MAX_POINTS) {
|
||||
trail.splice(0, trail.length - MAX_POINTS);
|
||||
t.push(pos);
|
||||
if (t.length > MAX_POINTS) {
|
||||
t.splice(0, t.length - MAX_POINTS);
|
||||
}
|
||||
}
|
||||
|
||||
@ -186,15 +276,19 @@ class TrailStore {
|
||||
if (trail && trail.length >= 2) {
|
||||
const path = trail.map((p) => p.position);
|
||||
const altitudes = trail.map((p) => p.baroAltitude);
|
||||
const timestamps = trail.map((p) => p.timestamp);
|
||||
|
||||
result.push({
|
||||
icao24: f.icao24,
|
||||
path: [...path],
|
||||
altitudes,
|
||||
timestamps,
|
||||
baroAltitude: altitudes[altitudes.length - 1] ?? null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
this.lastResult = result;
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,19 +1,19 @@
|
||||
const DIACRITICS_RE = /[\u0300-\u036f]/g;
|
||||
const NON_ALNUM_RE = /[^a-z0-9]+/g;
|
||||
const LEADING_TRAILING_DASH_RE = /^-+|-+$/g;
|
||||
|
||||
function normalizeAirlineText(value: string): string {
|
||||
return value
|
||||
.normalize("NFD")
|
||||
.replace(/[\u0300-\u036f]/g, "")
|
||||
.toLowerCase()
|
||||
.trim();
|
||||
return value.normalize("NFD").replace(DIACRITICS_RE, "").toLowerCase().trim();
|
||||
}
|
||||
|
||||
function toAirlineLogoSlug(airlineName: string): string {
|
||||
return normalizeAirlineText(airlineName)
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
function slugFromNormalized(normalized: string): string {
|
||||
return normalized
|
||||
.replace(NON_ALNUM_RE, "-")
|
||||
.replace(LEADING_TRAILING_DASH_RE, "");
|
||||
}
|
||||
|
||||
function toAirlineAliasKey(airlineName: string): string {
|
||||
return normalizeAirlineText(airlineName).replace(/[^a-z0-9]+/g, "");
|
||||
function aliasKeyFromNormalized(normalized: string): string {
|
||||
return normalized.replace(NON_ALNUM_RE, "");
|
||||
}
|
||||
|
||||
const LOGO_SLUG_ALIASES: Record<string, string> = {
|
||||
@ -54,8 +54,10 @@ function buildSlugVariants(baseSlug: string): string[] {
|
||||
export function airlineLogoCandidates(airlineName: string | null): string[] {
|
||||
if (!airlineName) return [];
|
||||
|
||||
const slug = toAirlineLogoSlug(airlineName);
|
||||
const aliasKey = toAirlineAliasKey(airlineName);
|
||||
// Normalize once, derive both slug and alias key from it
|
||||
const normalized = normalizeAirlineText(airlineName);
|
||||
const slug = slugFromNormalized(normalized);
|
||||
const aliasKey = aliasKeyFromNormalized(normalized);
|
||||
const aliasSlug = LOGO_SLUG_ALIASES[aliasKey] ?? null;
|
||||
|
||||
const orderedSlugs = Array.from(
|
||||
|
||||
800
src/lib/atc-feeds.ts
Normal file
800
src/lib/atc-feeds.ts
Normal file
@ -0,0 +1,800 @@
|
||||
import type { AtcFeed } from "./atc-types";
|
||||
|
||||
/**
|
||||
* Static ATC feed database mapping ICAO codes to LiveATC feed entries.
|
||||
*
|
||||
* ── Data Sources & Verification ──────────────────────────────────────
|
||||
*
|
||||
* Mount points verified against three independent sources:
|
||||
*
|
||||
* 1. Direct HTTP testing against d.liveatc.net (confirmed 200 responses):
|
||||
* ksfo_twr, ksfo_gnd, kjfk_twr, kjfk_gnd, klax_twr, klax_gnd,
|
||||
* katl_twr, katl_gnd
|
||||
*
|
||||
* 2. LiveATC feed database (RoMinjun/lofiatc.ps1 atc_sources.csv):
|
||||
* Comprehensive CSV scraped from LiveATC website pages containing
|
||||
* all publicly listed feed mount points.
|
||||
*
|
||||
* 3. amsterdam-flight-vibe (frankwiersma) verified feeds:
|
||||
* Feed names explicitly verified against liveatc.net hlisten.php
|
||||
* mount= URLs by the project author.
|
||||
*
|
||||
* ── Important Notes ──────────────────────────────────────────────────
|
||||
*
|
||||
* - Airports NOT covered by LiveATC have been removed.
|
||||
* Many major European airports (CDG, Frankfurt, Munich,
|
||||
* Madrid, Barcelona, Rome, etc.) have NO LiveATC feeds — ATC radio
|
||||
* broadcasting is restricted or illegal in those countries.
|
||||
*
|
||||
* - LiveATC mount point naming is NOT standardized. It varies wildly
|
||||
* per airport (e.g., KORD uses kord1n2_twr_n, not kord_twr).
|
||||
*
|
||||
* - Stream URLs use the .pls playlist format which resolves to Icecast
|
||||
* endpoints (d.liveatc.net / d2.liveatc.net).
|
||||
*
|
||||
* Last verified: March 2026
|
||||
*/
|
||||
|
||||
function feed(
|
||||
icao: string,
|
||||
type: AtcFeed["type"],
|
||||
name: string,
|
||||
frequency: string,
|
||||
mountPoint: string,
|
||||
): AtcFeed {
|
||||
return {
|
||||
id: `${icao.toLowerCase()}-${type}`,
|
||||
icao,
|
||||
name,
|
||||
frequency,
|
||||
type,
|
||||
mountPoint,
|
||||
streamUrl: `https://www.liveatc.net/play/${mountPoint}.pls`,
|
||||
};
|
||||
}
|
||||
|
||||
// Helper for airports with multiple feeds of the same type
|
||||
function feedN(
|
||||
icao: string,
|
||||
type: AtcFeed["type"],
|
||||
name: string,
|
||||
frequency: string,
|
||||
mountPoint: string,
|
||||
suffix: string,
|
||||
): AtcFeed {
|
||||
return {
|
||||
id: `${icao.toLowerCase()}-${type}-${suffix}`,
|
||||
icao,
|
||||
name,
|
||||
frequency,
|
||||
type,
|
||||
mountPoint,
|
||||
streamUrl: `https://www.liveatc.net/play/${mountPoint}.pls`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* ATC feed database indexed by ICAO code.
|
||||
* Only includes airports with verified LiveATC mount points.
|
||||
*/
|
||||
export const ATC_FEEDS: Record<string, AtcFeed[]> = {
|
||||
// ── United States ────────────────────────────────────────────────
|
||||
// Sources: direct HTTP 200 testing + lofiatc CSV + amsterdam-flight-vibe
|
||||
|
||||
KJFK: [
|
||||
// HTTP 200 confirmed; CSV; amsterdam-flight-vibe verified
|
||||
feed("KJFK", "tower", "JFK Tower", "119.100", "kjfk_twr"),
|
||||
feed("KJFK", "ground", "JFK Ground", "121.900", "kjfk_gnd"),
|
||||
feedN(
|
||||
"KJFK",
|
||||
"approach",
|
||||
"NY Approach (Final)",
|
||||
"128.125",
|
||||
"kjfk_bw_app_final",
|
||||
"final",
|
||||
),
|
||||
feed("KJFK", "departure", "NY Departure", "135.900", "kjfk_dep"),
|
||||
feed("KJFK", "atis", "JFK D-ATIS", "128.725", "kjfk_atis"),
|
||||
],
|
||||
|
||||
KLAX: [
|
||||
// HTTP 200 confirmed; CSV verified
|
||||
feed("KLAX", "tower", "LAX Tower N/S", "133.900", "klax_twr"),
|
||||
feed("KLAX", "ground", "LAX Ground N/S", "121.650", "klax_gnd"),
|
||||
feedN("KLAX", "tower", "LAX Tower South", "120.950", "klax4", "south"),
|
||||
feedN("KLAX", "approach", "LAX Final App N/S", "124.500", "klax6", "final"),
|
||||
feedN("KLAX", "departure", "LAX Dep/West App", "125.200", "klax7", "west"),
|
||||
feed("KLAX", "atis", "LAX ATIS (Arrival)", "133.800", "klax4n_atis_arr"),
|
||||
],
|
||||
|
||||
KORD: [
|
||||
// amsterdam-flight-vibe verified; CSV confirmed
|
||||
feed("KORD", "tower", "O'Hare Tower North", "132.700", "kord1n2_twr_n"),
|
||||
feedN(
|
||||
"KORD",
|
||||
"tower",
|
||||
"O'Hare Towers S Side",
|
||||
"120.750",
|
||||
"kord1s1_twr_s",
|
||||
"south",
|
||||
),
|
||||
feed(
|
||||
"KORD",
|
||||
"ground",
|
||||
"O'Hare Ground Outbound",
|
||||
"121.750",
|
||||
"kord1n2_gnd_out",
|
||||
),
|
||||
feedN(
|
||||
"KORD",
|
||||
"approach",
|
||||
"Chicago App 10R/28C",
|
||||
"133.625",
|
||||
"kord1n2_app_133625",
|
||||
"28c",
|
||||
),
|
||||
feedN(
|
||||
"KORD",
|
||||
"approach",
|
||||
"Chicago App 27L/09R",
|
||||
"119.000",
|
||||
"kord1n2_app_119000",
|
||||
"27l",
|
||||
),
|
||||
feed("KORD", "atis", "O'Hare ATIS", "135.400", "kord1s1_atis"),
|
||||
],
|
||||
|
||||
KATL: [
|
||||
// HTTP 200 confirmed; CSV; amsterdam-flight-vibe verified
|
||||
feed("KATL", "tower", "Atlanta Tower", "119.100", "katl_twr"),
|
||||
feedN(
|
||||
"KATL",
|
||||
"tower",
|
||||
"Atlanta Tower 8R/26L",
|
||||
"119.500",
|
||||
"katl_twr_08r26l",
|
||||
"08r26l",
|
||||
),
|
||||
feed("KATL", "ground", "Atlanta Ground", "121.900", "katl_gnd"),
|
||||
feed(
|
||||
"KATL",
|
||||
"approach",
|
||||
"Atlanta Final 28/10",
|
||||
"127.250",
|
||||
"katl_app_fin_a",
|
||||
),
|
||||
feed("KATL", "atis", "Atlanta ATIS (Arrival)", "127.900", "katl_atis_arr"),
|
||||
feed("KATL", "departure", "Atlanta Departure N", "125.325", "katl_dep_n"),
|
||||
],
|
||||
|
||||
KDFW: [
|
||||
// CSV verified
|
||||
feed("KDFW", "tower", "DFW Tower East", "126.550", "kdfw1_twr1_e"),
|
||||
feed("KDFW", "ground", "DFW Ground East", "121.650", "kdfw1_gnd_e_12165"),
|
||||
feedN(
|
||||
"KDFW",
|
||||
"ground",
|
||||
"DFW Ground West",
|
||||
"121.800",
|
||||
"kdfw1_gnd_w",
|
||||
"west",
|
||||
),
|
||||
feed(
|
||||
"KDFW",
|
||||
"approach",
|
||||
"DFW Final 17C/35C",
|
||||
"118.050",
|
||||
"kdfw1_app_fin_17c",
|
||||
),
|
||||
feed(
|
||||
"KDFW",
|
||||
"departure",
|
||||
"DFW Departure East",
|
||||
"126.925",
|
||||
"kdfw1_dep_east",
|
||||
),
|
||||
feed("KDFW", "atis", "DFW ATIS (Arrival)", "134.900", "kdfw1_atis_arr"),
|
||||
],
|
||||
|
||||
KSFO: [
|
||||
// HTTP 200 confirmed; CSV verified
|
||||
feed("KSFO", "tower", "SFO Tower", "120.500", "ksfo_twr"),
|
||||
feed("KSFO", "ground", "SFO Ground", "121.800", "ksfo_gnd"),
|
||||
feed("KSFO", "approach", "NORCAL App 28L/R", "135.650", "ksfo_app2"),
|
||||
feed("KSFO", "departure", "NORCAL Departure", "120.900", "ksfo_dep1"),
|
||||
feed("KSFO", "atis", "SFO D-ATIS", "118.850", "ksfo_atis"),
|
||||
],
|
||||
|
||||
KMIA: [
|
||||
// CSV verified
|
||||
feed("KMIA", "tower", "Miami Tower", "118.300", "kmia3_twr"),
|
||||
feedN(
|
||||
"KMIA",
|
||||
"tower",
|
||||
"Miami Tower North",
|
||||
"118.300",
|
||||
"kmia3_twr_1183",
|
||||
"north",
|
||||
),
|
||||
feed("KMIA", "ground", "Miami Ground", "121.800", "kmia3_gnd"),
|
||||
feed("KMIA", "departure", "Miami Departure S", "125.500", "kmia3_dep_1255"),
|
||||
feed("KMIA", "atis", "Miami ATIS (Arrival)", "128.175", "kmia3_atis_arr"),
|
||||
],
|
||||
|
||||
KEWR: [
|
||||
// CSV verified
|
||||
feed("KEWR", "tower", "Newark Tower", "118.300", "kewr_twr"),
|
||||
feed("KEWR", "ground", "Newark Ground", "121.800", "kewr_gnd_pri"),
|
||||
feed("KEWR", "approach", "Newark App (Final)", "124.350", "kewr_app_final"),
|
||||
feed("KEWR", "departure", "Newark Departure", "129.425", "kewr_dep"),
|
||||
feed("KEWR", "atis", "Newark ATIS", "115.500", "kewr_atis"),
|
||||
],
|
||||
|
||||
KSEA: [
|
||||
// CSV verified
|
||||
feed("KSEA", "tower", "Seattle Tower East", "119.900", "ksea3_twr_east"),
|
||||
feedN(
|
||||
"KSEA",
|
||||
"tower",
|
||||
"Seattle Tower West",
|
||||
"120.400",
|
||||
"ksea3_twr_west",
|
||||
"west",
|
||||
),
|
||||
feed("KSEA", "ground", "Seattle Ground", "121.700", "ksea3_gnd"),
|
||||
feed("KSEA", "approach", "Seattle Approach E", "124.200", "ksea3_app_e"),
|
||||
feed(
|
||||
"KSEA",
|
||||
"departure",
|
||||
"Seattle Departure E",
|
||||
"120.200",
|
||||
"ksea3_dep_east",
|
||||
),
|
||||
],
|
||||
|
||||
KBOS: [
|
||||
// CSV verified
|
||||
feed("KBOS", "tower", "Boston Tower", "128.800", "kbos_twr"),
|
||||
feed("KBOS", "ground", "Boston Del/Gnd", "121.900", "kbos_gnd"),
|
||||
feedN(
|
||||
"KBOS",
|
||||
"approach",
|
||||
"Boston App (Final)",
|
||||
"120.600",
|
||||
"kbos_final",
|
||||
"final",
|
||||
),
|
||||
feedN(
|
||||
"KBOS",
|
||||
"approach",
|
||||
"Boston App North",
|
||||
"124.400",
|
||||
"kbos_app_north",
|
||||
"north",
|
||||
),
|
||||
feed("KBOS", "departure", "Boston Departure", "133.000", "kbos_dep"),
|
||||
],
|
||||
|
||||
KMSP: [
|
||||
// CSV verified
|
||||
feed("KMSP", "tower", "Minneapolis Tower 12L", "126.700", "kmsp3_twr_12l"),
|
||||
feedN(
|
||||
"KMSP",
|
||||
"tower",
|
||||
"Minneapolis Tower 17/35",
|
||||
"119.300",
|
||||
"kmsp3_twr_1735",
|
||||
"1735",
|
||||
),
|
||||
feed("KMSP", "approach", "Minneapolis App NE", "119.350", "kmsp3_app_ne"),
|
||||
feedN(
|
||||
"KMSP",
|
||||
"approach",
|
||||
"Minneapolis App SW",
|
||||
"123.825",
|
||||
"kmsp3_app_sw",
|
||||
"sw",
|
||||
),
|
||||
feed("KMSP", "departure", "Minneapolis Dep NE", "135.350", "kmsp3_dep_ne"),
|
||||
],
|
||||
|
||||
KPHX: [
|
||||
// CSV verified
|
||||
feed("KPHX", "tower", "Phoenix Tower N/S", "118.700", "kphx_twr_both"),
|
||||
feed("KPHX", "ground", "Phoenix Ground N", "119.750", "kphx_gnd_n1"),
|
||||
feed(
|
||||
"KPHX",
|
||||
"approach",
|
||||
"Phoenix App Pima/Willy",
|
||||
"123.700",
|
||||
"kphx4_app_pima_willy",
|
||||
),
|
||||
feedN(
|
||||
"KPHX",
|
||||
"approach",
|
||||
"Phoenix App SDL/West",
|
||||
"120.700",
|
||||
"kphx_app_sat",
|
||||
"sat",
|
||||
),
|
||||
feed("KPHX", "departure", "Phoenix Departure", "132.550", "kphx_dep"),
|
||||
],
|
||||
|
||||
KDTW: [
|
||||
// CSV verified
|
||||
feed("KDTW", "tower", "Detroit Tower", "135.000", "kdtw_twr"),
|
||||
feed("KDTW", "ground", "Detroit Ground", "121.800", "kdtw_gnd"),
|
||||
feed("KDTW", "approach", "Detroit Approach", "118.400", "kdtw_app"),
|
||||
],
|
||||
|
||||
KIAD: [
|
||||
// CSV verified
|
||||
feed("KIAD", "tower", "Dulles Tower Combined", "120.100", "kiad1_3_4"),
|
||||
feed("KIAD", "ground", "Dulles Del/Gnd", "121.900", "kiad1_1"),
|
||||
feed("KIAD", "approach", "Dulles App South", "120.450", "kiad1_6"),
|
||||
feedN(
|
||||
"KIAD",
|
||||
"approach",
|
||||
"Dulles App North",
|
||||
"127.325",
|
||||
"kiad2_1",
|
||||
"north",
|
||||
),
|
||||
feed("KIAD", "departure", "Dulles Departure", "128.475", "kiad2_2"),
|
||||
feed("KIAD", "atis", "Dulles ATIS", "134.850", "kiad1_8"),
|
||||
],
|
||||
|
||||
KCLT: [
|
||||
// CSV verified
|
||||
feed("KCLT", "tower", "Charlotte Tower 18R/36L", "118.100", "kclt6_twr"),
|
||||
feedN(
|
||||
"KCLT",
|
||||
"tower",
|
||||
"Charlotte Tower 18L/36R",
|
||||
"118.100",
|
||||
"kclt7_twr_118100",
|
||||
"18l",
|
||||
),
|
||||
feed(
|
||||
"KCLT",
|
||||
"approach",
|
||||
"Charlotte App (Final)",
|
||||
"120.050",
|
||||
"kclt6_app_final",
|
||||
),
|
||||
feedN(
|
||||
"KCLT",
|
||||
"approach",
|
||||
"Charlotte App (Arrival)",
|
||||
"126.500",
|
||||
"kclt4_arr",
|
||||
"arrival",
|
||||
),
|
||||
feed(
|
||||
"KCLT",
|
||||
"departure",
|
||||
"Charlotte Departure",
|
||||
"119.000",
|
||||
"kclt7_dep_119000",
|
||||
),
|
||||
],
|
||||
|
||||
PHNL: [
|
||||
// CSV verified
|
||||
feed("PHNL", "tower", "Honolulu Tower", "118.100", "phnl1_twr"),
|
||||
feed("PHNL", "ground", "Honolulu Ground", "121.900", "phnl1_gnd"),
|
||||
feed("PHNL", "approach", "Honolulu HCF App/Dep", "118.300", "phnl1_app"),
|
||||
feed("PHNL", "atis", "Honolulu ATIS", "127.900", "phnl1_atis"),
|
||||
],
|
||||
|
||||
PANC: [
|
||||
// CSV verified
|
||||
feed("PANC", "tower", "Anchorage Tower", "118.300", "panc_twr"),
|
||||
feed(
|
||||
"PANC",
|
||||
"combined",
|
||||
"Anchorage Del/Gnd/App",
|
||||
"118.600",
|
||||
"panc_del_gnd_app",
|
||||
),
|
||||
],
|
||||
|
||||
KIAH: [
|
||||
// CSV verified
|
||||
feed("KIAH", "tower", "Houston IAH Tower", "118.700", "kiah1_1"),
|
||||
feed("KIAH", "ground", "Houston IAH Ground N", "121.700", "kiah2_gnd_n"),
|
||||
feed("KIAH", "approach", "Houston IAH Approach", "120.050", "kiah1_2"),
|
||||
feed("KIAH", "atis", "Houston IAH ATIS", "124.050", "kiah2_atis_main"),
|
||||
],
|
||||
|
||||
KMCO: [
|
||||
// CSV verified (via korl_ prefix feeds)
|
||||
feed(
|
||||
"KMCO",
|
||||
"approach",
|
||||
"Orlando App (Final)",
|
||||
"124.800",
|
||||
"korl_kmco_app_final",
|
||||
),
|
||||
feedN(
|
||||
"KMCO",
|
||||
"tower",
|
||||
"Orlando Tower West",
|
||||
"124.300",
|
||||
"korl_kmco_twr_west",
|
||||
"west",
|
||||
),
|
||||
],
|
||||
|
||||
KSAN: [
|
||||
// CSV verified
|
||||
feed("KSAN", "tower", "San Diego Tower", "118.300", "ksan1_twr"),
|
||||
feed("KSAN", "ground", "San Diego Ground", "123.900", "ksan1_gnd"),
|
||||
feed("KSAN", "approach", "SOCAL App West", "119.600", "ksan_app_west"),
|
||||
feed("KSAN", "atis", "San Diego ATIS", "134.800", "ksan_atis"),
|
||||
],
|
||||
|
||||
KLGA: [
|
||||
// CSV verified
|
||||
feed("KLGA", "tower", "LaGuardia Tower", "118.700", "klga_twr"),
|
||||
feed("KLGA", "ground", "LaGuardia Ground", "121.700", "klga_gnd"),
|
||||
feed("KLGA", "approach", "LaGuardia NY App", "125.700", "klga_ny_app"),
|
||||
feed("KLGA", "departure", "LaGuardia NY Dep", "120.400", "klga_ny_dep"),
|
||||
],
|
||||
|
||||
KPHL: [
|
||||
// CSV verified
|
||||
feed("KPHL", "tower", "Philadelphia Tower", "118.500", "kphl_twr_both"),
|
||||
feed("KPHL", "ground", "Philadelphia Ground", "121.900", "kphl_gnd"),
|
||||
feed(
|
||||
"KPHL",
|
||||
"approach",
|
||||
"Philadelphia App (Final)",
|
||||
"124.350",
|
||||
"kphl_final",
|
||||
),
|
||||
feed("KPHL", "departure", "Philadelphia Departure", "128.400", "kphl_dep"),
|
||||
],
|
||||
|
||||
KPDX: [
|
||||
// CSV verified
|
||||
feed("KPDX", "tower", "Portland Tower", "123.775", "kpdx3_twr"),
|
||||
feed("KPDX", "ground", "Portland Ground", "121.900", "kpdx3_gnd"),
|
||||
feed(
|
||||
"KPDX",
|
||||
"approach",
|
||||
"Portland App (Final)",
|
||||
"119.000",
|
||||
"kpdx_app_final",
|
||||
),
|
||||
feed("KPDX", "atis", "Portland ATIS", "128.350", "kpdx3_atis"),
|
||||
],
|
||||
|
||||
KMDW: [
|
||||
// CSV verified
|
||||
feed("KMDW", "tower", "Midway Tower", "120.650", "kmdw_1"),
|
||||
feed("KMDW", "combined", "Midway Gnd/Twr", "121.650", "kmdw_gnd_twr"),
|
||||
],
|
||||
|
||||
KAUS: [
|
||||
// CSV verified
|
||||
feed("KAUS", "tower", "Austin Tower", "121.000", "kaus3_twr"),
|
||||
feed("KAUS", "ground", "Austin Ground", "121.900", "kaus3_gnd"),
|
||||
feed(
|
||||
"KAUS",
|
||||
"approach",
|
||||
"Austin App (Final)",
|
||||
"119.950",
|
||||
"kaus3_app_final",
|
||||
),
|
||||
feed("KAUS", "combined", "Austin App/Dep", "120.900", "kaus3_app_dep"),
|
||||
],
|
||||
|
||||
KMSY: [
|
||||
// CSV verified
|
||||
feed("KMSY", "tower", "New Orleans Tower", "119.450", "kmsy2_twr"),
|
||||
feed("KMSY", "ground", "New Orleans Del/Gnd", "121.900", "kmsy2_del_gnd"),
|
||||
feed("KMSY", "approach", "New Orleans App/Dep", "128.200", "kmsy2_app"),
|
||||
],
|
||||
|
||||
// ── Canada ──────────────────────────────────────────────────────
|
||||
|
||||
CYYZ: [
|
||||
// CSV verified
|
||||
feed("CYYZ", "tower", "Toronto Pearson Tower", "118.700", "cyyz7"),
|
||||
feed("CYYZ", "ground", "Toronto Pearson Ground", "121.900", "cyyz5"),
|
||||
feed("CYYZ", "approach", "Toronto Arrival", "119.350", "cyyz6"),
|
||||
feed("CYYZ", "departure", "Toronto Departure", "128.925", "cyyz8"),
|
||||
feed("CYYZ", "atis", "Toronto ATIS", "120.025", "cyyz3"),
|
||||
],
|
||||
|
||||
CYVR: [
|
||||
// CSV verified
|
||||
feed("CYVR", "tower", "Vancouver Tower", "118.700", "cyvr1_gnd_twr"),
|
||||
feed("CYVR", "ground", "Vancouver Del/Gnd", "121.900", "cyvr1_gnd"),
|
||||
feed("CYVR", "approach", "Vancouver App/Dep", "119.550", "cyvr1_app"),
|
||||
],
|
||||
|
||||
CYUL: [
|
||||
// CSV verified
|
||||
feed("CYUL", "approach", "Montreal App/Dep", "119.100", "cyul_app"),
|
||||
],
|
||||
|
||||
// ── Europe ───────────────────────────────────────────────────────
|
||||
// NOTE: Many major European airports (CDG, Frankfurt, Munich, Madrid,
|
||||
// Barcelona, Rome, Gatwick, Vienna, Copenhagen, Stockholm, Warsaw)
|
||||
// do NOT have LiveATC feeds — ATC radio reception/broadcasting is
|
||||
// restricted or illegal in France, Germany, Spain, Italy, Austria,
|
||||
// Denmark, Sweden, Poland, and others.
|
||||
|
||||
EGLL: [
|
||||
// CSV verified — single combined Tower/Approach feed
|
||||
feed("EGLL", "combined", "Heathrow Tower/Approach", "118.700", "egll_twr"),
|
||||
],
|
||||
|
||||
EHAM: [
|
||||
// amsterdam-flight-vibe verified
|
||||
feed("EHAM", "approach", "Schiphol Approach", "119.055", "eham_app_119055"),
|
||||
feedN(
|
||||
"EHAM",
|
||||
"tower",
|
||||
"Schiphol Tower 18C/36C",
|
||||
"118.100",
|
||||
"eham_twr_18c36c",
|
||||
"18c36c",
|
||||
),
|
||||
feedN(
|
||||
"EHAM",
|
||||
"tower",
|
||||
"Schiphol Tower 06/24",
|
||||
"119.225",
|
||||
"eham_twr_0624",
|
||||
"0624",
|
||||
),
|
||||
],
|
||||
|
||||
LSZH: [
|
||||
// CSV + amsterdam-flight-vibe verified
|
||||
feed("LSZH", "tower", "Zurich Tower", "118.100", "lszh1_twr"),
|
||||
feed(
|
||||
"LSZH",
|
||||
"approach",
|
||||
"Zurich Approach East",
|
||||
"118.000",
|
||||
"lszh1_app_east",
|
||||
),
|
||||
feedN(
|
||||
"LSZH",
|
||||
"approach",
|
||||
"Zurich Approach Final",
|
||||
"120.225",
|
||||
"lszh1_app_fin2",
|
||||
"final",
|
||||
),
|
||||
feed("LSZH", "atis", "Zurich ATIS (Arrival)", "128.525", "lszh1_atis_arr"),
|
||||
],
|
||||
|
||||
EIDW: [
|
||||
// CSV + amsterdam-flight-vibe verified
|
||||
feed("EIDW", "combined", "Dublin Gnd/Twr/App", "118.600", "eidw3"),
|
||||
feedN(
|
||||
"EIDW",
|
||||
"combined",
|
||||
"Dublin Gnd/Twr/App/Centre",
|
||||
"121.100",
|
||||
"eidw8",
|
||||
"centre",
|
||||
),
|
||||
feedN("EIDW", "tower", "Dublin Tower N/S App", "124.650", "eidw82", "ns"),
|
||||
],
|
||||
|
||||
ENGM: [
|
||||
// amsterdam-flight-vibe verified
|
||||
feed("ENGM", "combined", "Oslo Gardermoen", "119.200", "engm4"),
|
||||
],
|
||||
|
||||
LKPR: [
|
||||
// amsterdam-flight-vibe verified
|
||||
feed("LKPR", "tower", "Prague Tower", "118.100", "lkpr_twr"),
|
||||
feed("LKPR", "approach", "Prague Approach", "119.050", "lkpr_app"),
|
||||
feedN("LKPR", "approach", "Prague Director", "120.525", "lkpr_dir", "dir"),
|
||||
],
|
||||
|
||||
LPPT: [
|
||||
// amsterdam-flight-vibe verified
|
||||
feed("LPPT", "approach", "Lisbon Approach", "119.100", "lppt_app"),
|
||||
],
|
||||
|
||||
EBBR: [
|
||||
// amsterdam-flight-vibe verified
|
||||
feed("EBBR", "tower", "Brussels Tower East", "118.600", "ebbr_twr_e"),
|
||||
feed("EBBR", "approach", "Brussels Arrival", "120.600", "ebbr_arr"),
|
||||
feed("EBBR", "center", "Brussels EBBU Control", "129.075", "ebbr_ebbu"),
|
||||
],
|
||||
|
||||
LTFJ: [
|
||||
// CSV verified — Sabiha Gokcen (NOT Istanbul IST/LTFM main)
|
||||
feed("LTFJ", "combined", "Sabiha Gokcen Del/Gnd/Twr", "118.100", "ltfj2"),
|
||||
],
|
||||
|
||||
LHBP: [
|
||||
// CSV verified
|
||||
feed("LHBP", "combined", "Budapest Gnd/Twr/App", "118.100", "lhbp1"),
|
||||
feedN(
|
||||
"LHBP",
|
||||
"approach",
|
||||
"Budapest Approach",
|
||||
"119.700",
|
||||
"lhbp_app2",
|
||||
"app",
|
||||
),
|
||||
],
|
||||
|
||||
LSGG: [
|
||||
// CSV verified
|
||||
feed("LSGG", "approach", "Geneva Arrival", "131.325", "lsgg_arr"),
|
||||
feed("LSGG", "departure", "Geneva Departure", "129.100", "lsgg_dep"),
|
||||
],
|
||||
|
||||
// ── Middle East ──────────────────────────────────────────────────
|
||||
|
||||
OBBI: [
|
||||
// CSV verified
|
||||
feed("OBBI", "combined", "Bahrain Gnd/Twr/App", "118.000", "obbi"),
|
||||
],
|
||||
|
||||
// ── Asia Pacific ─────────────────────────────────────────────────
|
||||
|
||||
RJTT: [
|
||||
// CSV verified
|
||||
feed("RJTT", "tower", "Haneda Tower/TCA", "118.100", "rjtt_twr"),
|
||||
feed("RJTT", "approach", "Tokyo Approach", "119.100", "rjtt_app"),
|
||||
feed("RJTT", "departure", "Haneda Departure", "126.000", "rjtt_dep"),
|
||||
feed("RJTT", "ground", "Haneda Ground", "121.700", "rjtt_gnd"),
|
||||
],
|
||||
|
||||
RJAA: [
|
||||
// CSV + amsterdam-flight-vibe verified
|
||||
feed("RJAA", "tower", "Narita Tower (Both)", "118.350", "rjaa_twr"),
|
||||
feedN("RJAA", "tower", "Narita Tower #1", "118.350", "rjaa_twr1", "1"),
|
||||
feed("RJAA", "approach", "Narita Approach", "119.600", "rjaa_app_s"),
|
||||
feed("RJAA", "ground", "Narita Ground #1", "121.850", "rjaa_gnd1"),
|
||||
feed("RJAA", "atis", "Narita ATIS", "128.250", "rjaa_atis"),
|
||||
],
|
||||
|
||||
WSSS: [
|
||||
// CSV verified — single combined feed
|
||||
feed("WSSS", "combined", "Changi Del/Gnd/App/Radar", "119.800", "wsss3"),
|
||||
],
|
||||
|
||||
VHHH: [
|
||||
// CSV verified
|
||||
feed("VHHH", "combined", "Hong Kong App/Dep/Dir/Zone", "119.100", "vhhh5"),
|
||||
],
|
||||
|
||||
RPLL: [
|
||||
// CSV verified
|
||||
feed("RPLL", "tower", "Manila Tower", "118.100", "rpll"),
|
||||
feed("RPLL", "ground", "Manila Ground", "121.800", "rpll_gnd"),
|
||||
feedN(
|
||||
"RPLL",
|
||||
"approach",
|
||||
"Manila App/Dep 119.9",
|
||||
"119.900",
|
||||
"rpll_app_119900",
|
||||
"119900",
|
||||
),
|
||||
],
|
||||
|
||||
OPKC: [
|
||||
// CSV verified
|
||||
feed("OPKC", "combined", "Karachi Gnd/Twr/Radar", "118.300", "opkc"),
|
||||
],
|
||||
|
||||
RCKH: [
|
||||
// CSV verified
|
||||
feed("RCKH", "combined", "Kaohsiung Del/Gnd/Twr/App", "118.700", "rckh2"),
|
||||
],
|
||||
|
||||
// ── Australia / Oceania ──────────────────────────────────────────
|
||||
|
||||
YSSY: [
|
||||
// CSV verified
|
||||
feed("YSSY", "tower", "Sydney Tower (Both)", "120.500", "yssy1_twr"),
|
||||
feed("YSSY", "ground", "Sydney Del/Gnd", "121.700", "yssy1_del_gnd"),
|
||||
feed("YSSY", "departure", "Sydney Departure NE", "129.700", "yssy1_dep_ne"),
|
||||
feedN(
|
||||
"YSSY",
|
||||
"approach",
|
||||
"Sydney Director East",
|
||||
"124.400",
|
||||
"yssy1_dir_e",
|
||||
"director",
|
||||
),
|
||||
feed("YSSY", "center", "Sydney Center South", "129.300", "yssy1_ctr_s"),
|
||||
],
|
||||
|
||||
YMML: [
|
||||
// CSV verified — single combined feed
|
||||
feed("YMML", "combined", "Melbourne Tower/App/Center", "120.500", "ymml3"),
|
||||
],
|
||||
|
||||
YPPH: [
|
||||
// CSV verified
|
||||
feed("YPPH", "tower", "Perth Tower", "127.400", "ypph_twr"),
|
||||
feed("YPPH", "ground", "Perth Ground", "121.700", "ypph_gnd"),
|
||||
feed("YPPH", "approach", "Perth Approach", "123.600", "ypph_app"),
|
||||
feed("YPPH", "departure", "Perth Departure", "118.700", "ypph_dep"),
|
||||
],
|
||||
|
||||
// ── Americas (non-US) ───────────────────────────────────────────
|
||||
|
||||
MMMX: [
|
||||
// CSV verified
|
||||
feed("MMMX", "tower", "Mexico City Tower", "118.100", "mmmx1_twr"),
|
||||
feed("MMMX", "ground", "Mexico City Ground N/S", "121.900", "mmmx1_gnd"),
|
||||
feed("MMMX", "approach", "Mexico City Approach", "119.900", "mmmx1_app"),
|
||||
feedN(
|
||||
"MMMX",
|
||||
"approach",
|
||||
"Mexico City App (Final)",
|
||||
"121.200",
|
||||
"mmmx1_app_final",
|
||||
"final",
|
||||
),
|
||||
feed("MMMX", "atis", "Mexico City ATIS", "118.750", "mmmx1_atis"),
|
||||
],
|
||||
|
||||
MPTO: [
|
||||
// CSV verified
|
||||
feed(
|
||||
"MPTO",
|
||||
"combined",
|
||||
"Panama Tocumen Del/Gnd/Twr/App/Ctr",
|
||||
"118.100",
|
||||
"mpto2_misc",
|
||||
),
|
||||
],
|
||||
|
||||
SBPA: [
|
||||
// CSV verified
|
||||
feed(
|
||||
"SBPA",
|
||||
"combined",
|
||||
"Porto Alegre Gnd/Twr/App/Center",
|
||||
"118.200",
|
||||
"sbpa2",
|
||||
),
|
||||
],
|
||||
|
||||
// ── Africa ───────────────────────────────────────────────────────
|
||||
|
||||
FIMP: [
|
||||
// CSV verified
|
||||
feed("FIMP", "combined", "Mauritius Tower/App/Control", "118.400", "fimp"),
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* Set of all valid mount points for SSRF prevention.
|
||||
* Only mount points in this set can be proxied.
|
||||
*/
|
||||
export const VALID_MOUNT_POINTS: ReadonlySet<string> = new Set(
|
||||
Object.values(ATC_FEEDS)
|
||||
.flat()
|
||||
.map((f) => f.mountPoint),
|
||||
);
|
||||
|
||||
/**
|
||||
* Get feeds for a specific ICAO code.
|
||||
*/
|
||||
export function getFeedsByIcao(icao: string): AtcFeed[] {
|
||||
return ATC_FEEDS[icao.toUpperCase()] ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all ICAO codes that have ATC feeds.
|
||||
*/
|
||||
export function getIcaoCodesWithFeeds(): string[] {
|
||||
return Object.keys(ATC_FEEDS);
|
||||
}
|
||||
227
src/lib/atc-lookup.ts
Normal file
227
src/lib/atc-lookup.ts
Normal file
@ -0,0 +1,227 @@
|
||||
import type { AtcFeed, AtcFeedType } from "./atc-types";
|
||||
import { FEED_TYPE_PRIORITY } from "./atc-types";
|
||||
import { ATC_FEEDS, getFeedsByIcao } from "./atc-feeds";
|
||||
import { AIRPORTS, type Airport } from "./airports";
|
||||
|
||||
// ── Constants ──────────────────────────────────────────────────────────
|
||||
|
||||
/** Approximate nautical miles per degree of latitude. */
|
||||
const NM_PER_DEG = 60;
|
||||
|
||||
/** Maximum search radius in nautical miles for auto-feed discovery. */
|
||||
const MAX_SEARCH_RADIUS_NM = 60;
|
||||
|
||||
// ── IATA → ICAO mapping ───────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Common IATA → ICAO mapping for airports in the feed database.
|
||||
* Only includes airports that have ATC feeds to keep the map small.
|
||||
*/
|
||||
const IATA_TO_ICAO: Record<string, string> = {
|
||||
// United States
|
||||
JFK: "KJFK",
|
||||
LAX: "KLAX",
|
||||
ORD: "KORD",
|
||||
ATL: "KATL",
|
||||
DFW: "KDFW",
|
||||
DEN: "KDEN",
|
||||
SFO: "KSFO",
|
||||
LAS: "KLAS",
|
||||
MIA: "KMIA",
|
||||
EWR: "KEWR",
|
||||
SEA: "KSEA",
|
||||
BOS: "KBOS",
|
||||
MSP: "KMSP",
|
||||
PHX: "KPHX",
|
||||
DTW: "KDTW",
|
||||
FLL: "KFLL",
|
||||
IAD: "KIAD",
|
||||
CLT: "KCLT",
|
||||
DCA: "KDCA",
|
||||
HNL: "PHNL",
|
||||
ANC: "PANC",
|
||||
// Europe
|
||||
LHR: "EGLL",
|
||||
CDG: "LFPG",
|
||||
AMS: "EHAM",
|
||||
FRA: "EDDF",
|
||||
MAD: "LEMD",
|
||||
MUC: "EDDM",
|
||||
IST: "LTFM",
|
||||
LGW: "EGKK",
|
||||
BCN: "LEBL",
|
||||
FCO: "LIRF",
|
||||
ZRH: "LSZH",
|
||||
DUB: "EIDW",
|
||||
VIE: "LOWW",
|
||||
OSL: "ENGM",
|
||||
CPH: "EKCH",
|
||||
ARN: "ESSA",
|
||||
WAW: "EPWA",
|
||||
LIS: "LPPT",
|
||||
// Middle East
|
||||
DXB: "OMDB",
|
||||
DOH: "OTHH",
|
||||
JED: "OEJN",
|
||||
// Asia Pacific
|
||||
HND: "RJTT",
|
||||
NRT: "RJAA",
|
||||
SIN: "WSSS",
|
||||
HKG: "VHHH",
|
||||
ICN: "RKSI",
|
||||
BKK: "VTBS",
|
||||
KUL: "WMKK",
|
||||
DEL: "VIDP",
|
||||
BOM: "VABB",
|
||||
// Australia / Oceania
|
||||
SYD: "YSSY",
|
||||
MEL: "YMML",
|
||||
AKL: "NZAA",
|
||||
// Americas (non-US)
|
||||
YYZ: "CYYZ",
|
||||
YVR: "CYVR",
|
||||
MEX: "MMMX",
|
||||
GRU: "SBGR",
|
||||
EZE: "SAEZ",
|
||||
SCL: "SCEL",
|
||||
BOG: "SKBO",
|
||||
// Africa
|
||||
JNB: "FAOR",
|
||||
CAI: "HECA",
|
||||
};
|
||||
|
||||
const ICAO_TO_IATA: Record<string, string> = {};
|
||||
for (const [iata, icao] of Object.entries(IATA_TO_ICAO)) {
|
||||
ICAO_TO_IATA[icao] = iata;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert IATA code to ICAO code for airports in the feed database.
|
||||
*/
|
||||
export function iataToIcao(iata: string): string | null {
|
||||
return IATA_TO_ICAO[iata.toUpperCase()] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert ICAO code to IATA code for airports in the feed database.
|
||||
*/
|
||||
export function icaoToIata(icao: string): string | null {
|
||||
return ICAO_TO_IATA[icao.toUpperCase()] ?? null;
|
||||
}
|
||||
|
||||
/** ICAO codes of airports that have ATC feeds. */
|
||||
const ICAO_SET = new Set(Object.keys(ATC_FEEDS));
|
||||
|
||||
/** Precomputed list of airports that have ATC feeds. */
|
||||
const ATC_AIRPORTS: Airport[] = AIRPORTS.filter((a) => {
|
||||
// Match by converting IATA → ICAO convention for known airports
|
||||
// LiveATC uses ICAO codes; our airport DB uses IATA
|
||||
const icao = iataToIcao(a.iata);
|
||||
return icao !== null && ICAO_SET.has(icao);
|
||||
});
|
||||
|
||||
// ── Lookup Functions ───────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Simple distance approximation in nautical miles (good enough for feed lookup).
|
||||
* Uses equirectangular approximation — accurate to ~1% within 60nm.
|
||||
*/
|
||||
function approxDistanceNm(
|
||||
lat1: number,
|
||||
lng1: number,
|
||||
lat2: number,
|
||||
lng2: number,
|
||||
): number {
|
||||
const dLat = (lat2 - lat1) * NM_PER_DEG;
|
||||
const dLng =
|
||||
(lng2 - lng1) *
|
||||
NM_PER_DEG *
|
||||
Math.cos(((lat1 + lat2) / 2) * (Math.PI / 180));
|
||||
return Math.sqrt(dLat * dLat + dLng * dLng);
|
||||
}
|
||||
|
||||
export type NearbyAtcResult = {
|
||||
/** Airport ICAO code */
|
||||
icao: string;
|
||||
/** Airport IATA code (if known) */
|
||||
iata: string | null;
|
||||
/** Airport name */
|
||||
name: string;
|
||||
/** Distance in nautical miles */
|
||||
distanceNm: number;
|
||||
/** Available ATC feeds, sorted by type priority */
|
||||
feeds: AtcFeed[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Find airports with ATC feeds near a given latitude/longitude.
|
||||
* Returns results sorted by distance (nearest first).
|
||||
*
|
||||
* @param lat Latitude in degrees
|
||||
* @param lng Longitude in degrees
|
||||
* @param radiusNm Search radius in nautical miles (default: 60)
|
||||
* @param limit Maximum results to return (default: 5)
|
||||
*/
|
||||
export function findNearbyAtcFeeds(
|
||||
lat: number,
|
||||
lng: number,
|
||||
radiusNm: number = MAX_SEARCH_RADIUS_NM,
|
||||
limit: number = 5,
|
||||
): NearbyAtcResult[] {
|
||||
const clampedRadius = Math.min(radiusNm, MAX_SEARCH_RADIUS_NM);
|
||||
const results: NearbyAtcResult[] = [];
|
||||
|
||||
for (const airport of ATC_AIRPORTS) {
|
||||
const dist = approxDistanceNm(lat, lng, airport.lat, airport.lng);
|
||||
if (dist > clampedRadius) continue;
|
||||
|
||||
const icao = iataToIcao(airport.iata);
|
||||
if (!icao) continue;
|
||||
|
||||
const feeds = getFeedsByIcao(icao).sort(
|
||||
(a, b) => FEED_TYPE_PRIORITY[a.type] - FEED_TYPE_PRIORITY[b.type],
|
||||
);
|
||||
|
||||
if (feeds.length === 0) continue;
|
||||
|
||||
results.push({
|
||||
icao,
|
||||
iata: airport.iata,
|
||||
name: airport.name,
|
||||
distanceNm: Math.round(dist * 10) / 10,
|
||||
feeds,
|
||||
});
|
||||
}
|
||||
|
||||
results.sort((a, b) => a.distanceNm - b.distanceNm);
|
||||
return results.slice(0, limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the single nearest airport with ATC feeds.
|
||||
* Convenience wrapper around findNearbyAtcFeeds.
|
||||
*/
|
||||
export function findNearestAtcFeed(
|
||||
lat: number,
|
||||
lng: number,
|
||||
): NearbyAtcResult | null {
|
||||
const results = findNearbyAtcFeeds(lat, lng, MAX_SEARCH_RADIUS_NM, 1);
|
||||
return results[0] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up ATC feeds by IATA or ICAO code.
|
||||
*/
|
||||
export function lookupAtcFeeds(code: string): AtcFeed[] {
|
||||
const upper = code.toUpperCase();
|
||||
|
||||
// Try ICAO first
|
||||
const icaoFeeds = getFeedsByIcao(upper);
|
||||
if (icaoFeeds.length > 0) return icaoFeeds;
|
||||
|
||||
// Try IATA → ICAO
|
||||
const icao = iataToIcao(upper);
|
||||
if (icao) return getFeedsByIcao(icao);
|
||||
|
||||
return [];
|
||||
}
|
||||
74
src/lib/atc-types.ts
Normal file
74
src/lib/atc-types.ts
Normal file
@ -0,0 +1,74 @@
|
||||
// ── ATC Feed Types ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Feed type classification matching aviation ATC frequency assignments.
|
||||
* Used for sorting feeds by relevance (tower > approach > ground > etc).
|
||||
*/
|
||||
export type AtcFeedType =
|
||||
| "tower"
|
||||
| "ground"
|
||||
| "approach"
|
||||
| "departure"
|
||||
| "atis"
|
||||
| "center"
|
||||
| "combined";
|
||||
|
||||
/**
|
||||
* A single ATC audio feed from LiveATC.
|
||||
* Mount points and stream URLs are sourced from LiveATC's public feed list.
|
||||
*/
|
||||
export interface AtcFeed {
|
||||
/** Unique feed identifier (e.g., "kjfk-twr") */
|
||||
id: string;
|
||||
/** Airport ICAO code (e.g., "KJFK") */
|
||||
icao: string;
|
||||
/** Display name (e.g., "JFK Tower") */
|
||||
name: string;
|
||||
/** Frequency string (e.g., "119.100") */
|
||||
frequency: string;
|
||||
/** Feed type classification */
|
||||
type: AtcFeedType;
|
||||
/** LiveATC mount point identifier (e.g., "kjfk_twr") */
|
||||
mountPoint: string;
|
||||
/** Direct Icecast stream URL for <audio> src */
|
||||
streamUrl: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream playback status.
|
||||
*/
|
||||
export type AtcStreamStatus =
|
||||
| "idle"
|
||||
| "loading"
|
||||
| "playing"
|
||||
| "error"
|
||||
| "blocked";
|
||||
|
||||
/**
|
||||
* Full state of the ATC audio stream.
|
||||
*/
|
||||
export interface AtcStreamState {
|
||||
/** Currently active feed, or null if nothing selected */
|
||||
feed: AtcFeed | null;
|
||||
/** Current playback status */
|
||||
status: AtcStreamStatus;
|
||||
/** Volume level 0–1 */
|
||||
volume: number;
|
||||
/** Error message when status is 'error' or 'blocked' */
|
||||
error?: string;
|
||||
/** Whether the fallback proxy is being used */
|
||||
usingProxy: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Feed type priority for sorting (lower = higher priority).
|
||||
*/
|
||||
export const FEED_TYPE_PRIORITY: Record<AtcFeedType, number> = {
|
||||
tower: 0,
|
||||
approach: 1,
|
||||
ground: 2,
|
||||
departure: 3,
|
||||
combined: 4,
|
||||
center: 5,
|
||||
atis: 6,
|
||||
};
|
||||
498
src/lib/flight-api-client.ts
Normal file
498
src/lib/flight-api-client.ts
Normal file
@ -0,0 +1,498 @@
|
||||
// ── readsb API Client ────────────────────────────────────────────────
|
||||
//
|
||||
// 2-tier fallback: adsb.lol proxy → OpenSky.
|
||||
// Dev/override: ?provider=airplanes|adsb|opensky in the URL.
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
|
||||
import type { FlightState } from "./opensky-types";
|
||||
import type { ReadsbApiResponse } from "./flight-api-types";
|
||||
import { MAX_RADIUS_NM, NM_PER_DEG_LAT } from "./flight-api-types";
|
||||
import { parseAircraftList, type ParseOptions } from "./flight-api-parsing";
|
||||
import {
|
||||
bboxFromCenter,
|
||||
fetchFlightsByBbox,
|
||||
fetchFlightByIcao24 as openskyFetchByIcao24,
|
||||
} from "./opensky-flights";
|
||||
|
||||
// ── Types ──────────────────────────────────────────────────────────────
|
||||
|
||||
export type ProviderName = "airplanes" | "adsb" | "opensky" | "auto";
|
||||
|
||||
export interface FlightApiFetchResult {
|
||||
flights: FlightState[];
|
||||
rateLimited: boolean;
|
||||
source?: string;
|
||||
}
|
||||
|
||||
// ── Circuit Breaker ────────────────────────────────────────────────────
|
||||
//
|
||||
// Prevents hammering a dead provider. After 3 consecutive non-abort,
|
||||
// non-rate-limit failures the circuit OPENS — the tier is skipped for a
|
||||
// cooldown window. After the window elapses the state transitions to
|
||||
// HALF-OPEN and a single probe request is allowed through:
|
||||
// • probe succeeds → CLOSED (reset)
|
||||
// • probe fails → OPEN (cooldown doubles, capped at 120 s)
|
||||
//
|
||||
// What counts as a failure:
|
||||
// ✓ Timeout, HTTP 5xx, non-JSON response, network error
|
||||
// ✗ AbortError (tab switch / navigation)
|
||||
// ✗ 429 rate-limit (server is alive, handled separately)
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export type CircuitState = "closed" | "open" | "half-open";
|
||||
|
||||
interface TierCircuit {
|
||||
state: CircuitState;
|
||||
failures: number;
|
||||
/** Timestamp after which OPEN → HALF-OPEN */
|
||||
openUntil: number;
|
||||
}
|
||||
|
||||
const CIRCUIT_FAILURE_THRESHOLD = 3;
|
||||
const CIRCUIT_BASE_COOLDOWN_MS = 30_000; // 30 s
|
||||
const CIRCUIT_MAX_COOLDOWN_MS = 120_000; // 2 min
|
||||
|
||||
const circuits = new Map<string, TierCircuit>();
|
||||
|
||||
function shouldSkipTier(tierId: string): boolean {
|
||||
const c = circuits.get(tierId);
|
||||
if (!c || c.state === "closed") return false;
|
||||
if (c.state === "open" && Date.now() >= c.openUntil) {
|
||||
// Cooldown expired — allow one probe
|
||||
c.state = "half-open";
|
||||
return false;
|
||||
}
|
||||
return c.state === "open";
|
||||
}
|
||||
|
||||
function recordSuccess(tierId: string): void {
|
||||
circuits.set(tierId, { state: "closed", failures: 0, openUntil: 0 });
|
||||
}
|
||||
|
||||
function recordFailure(tierId: string): void {
|
||||
const c = circuits.get(tierId) ?? {
|
||||
state: "closed" as CircuitState,
|
||||
failures: 0,
|
||||
openUntil: 0,
|
||||
};
|
||||
c.failures++;
|
||||
if (c.failures >= CIRCUIT_FAILURE_THRESHOLD) {
|
||||
// Cooldown: 30s → 60s → 120s → 120s …
|
||||
const exponent = c.failures - CIRCUIT_FAILURE_THRESHOLD;
|
||||
const cooldown = Math.min(
|
||||
CIRCUIT_BASE_COOLDOWN_MS * Math.pow(2, exponent),
|
||||
CIRCUIT_MAX_COOLDOWN_MS,
|
||||
);
|
||||
c.state = "open";
|
||||
c.openUntil = Date.now() + cooldown;
|
||||
}
|
||||
circuits.set(tierId, c);
|
||||
}
|
||||
|
||||
/** Returns true if this error should NOT trip the circuit breaker. */
|
||||
function isNonCircuitError(err: unknown): boolean {
|
||||
// Abort = tab switch / navigation — not a provider failure
|
||||
if (err instanceof DOMException && err.name === "AbortError") return true;
|
||||
// 429 = server is alive, just rate-limiting — already handled via rateLimited flag
|
||||
const msg =
|
||||
err instanceof Error
|
||||
? err.message.toLowerCase()
|
||||
: String(err).toLowerCase();
|
||||
if (msg.includes("429") || msg.includes("rate limit")) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
// ── Circuit State API (for UI consumption) ─────────────────────────────
|
||||
|
||||
/** Read the circuit breaker state for a specific tier. */
|
||||
export function getCircuitState(tierId: string): {
|
||||
state: CircuitState;
|
||||
failures: number;
|
||||
cooldownRemaining: number;
|
||||
} {
|
||||
const c = circuits.get(tierId);
|
||||
if (!c || c.state === "closed")
|
||||
return { state: "closed", failures: 0, cooldownRemaining: 0 };
|
||||
return {
|
||||
state: c.state,
|
||||
failures: c.failures,
|
||||
cooldownRemaining: Math.max(0, c.openUntil - Date.now()),
|
||||
};
|
||||
}
|
||||
|
||||
/** Reset all circuits (e.g. on network reconnect). */
|
||||
export function resetAllCircuits(): void {
|
||||
circuits.clear();
|
||||
}
|
||||
|
||||
let _onlineListenerRegistered = false;
|
||||
if (typeof window !== "undefined" && !_onlineListenerRegistered) {
|
||||
_onlineListenerRegistered = true;
|
||||
window.addEventListener("online", resetAllCircuits);
|
||||
}
|
||||
|
||||
// ── Provider Override (dev testing) ────────────────────────────────────
|
||||
|
||||
export function getProviderOverride(): ProviderName {
|
||||
if (typeof window === "undefined") return "auto";
|
||||
const p = new URLSearchParams(window.location.search)
|
||||
.get("provider")
|
||||
?.toLowerCase();
|
||||
if (p === "airplanes" || p === "adsb" || p === "opensky") return p;
|
||||
return "auto";
|
||||
}
|
||||
|
||||
// ── Constants ──────────────────────────────────────────────────────────
|
||||
|
||||
const AIRPLANES_LIVE_BASE = "https://api.airplanes.live/v2";
|
||||
const DIRECT_TIMEOUT_MS = 10_000;
|
||||
const PROXY_TIMEOUT_MS = 15_000;
|
||||
|
||||
// Client-side rate limiter for direct airplanes.live (1 req/s + margin).
|
||||
// Uses a Promise chain to serialize slot acquisition — concurrent callers
|
||||
// queue up instead of both reading the same timestamp and firing together.
|
||||
const DIRECT_RATE_MS = 1_100;
|
||||
let lastDirectTime = 0;
|
||||
let rateQueue: Promise<void> = Promise.resolve();
|
||||
|
||||
async function acquireDirectSlot(): Promise<void> {
|
||||
const slot = rateQueue.then(async () => {
|
||||
const elapsed = Date.now() - lastDirectTime;
|
||||
const wait = Math.max(0, DIRECT_RATE_MS - elapsed);
|
||||
if (wait > 0) await new Promise((r) => setTimeout(r, wait));
|
||||
lastDirectTime = Date.now();
|
||||
});
|
||||
rateQueue = slot;
|
||||
await slot;
|
||||
}
|
||||
|
||||
// ── Internal Helpers ───────────────────────────────────────────────────
|
||||
|
||||
function degreesToNm(degrees: number): number {
|
||||
if (!Number.isFinite(degrees) || degrees <= 0) return 150;
|
||||
const nm = Math.round(degrees * NM_PER_DEG_LAT);
|
||||
return Math.min(Math.max(nm, 1), MAX_RADIUS_NM);
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs `fn` with a timeout. External abort signals are propagated.
|
||||
*/
|
||||
async function withTimeout<T>(
|
||||
fn: (signal: AbortSignal) => Promise<T>,
|
||||
timeoutMs: number,
|
||||
externalSignal?: AbortSignal,
|
||||
): Promise<T> {
|
||||
if (externalSignal?.aborted) throw new DOMException("Aborted", "AbortError");
|
||||
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
const onAbort = () => controller.abort();
|
||||
externalSignal?.addEventListener("abort", onAbort);
|
||||
|
||||
try {
|
||||
return await fn(controller.signal);
|
||||
} catch (err) {
|
||||
// If the external signal fired, surface as AbortError
|
||||
if (externalSignal?.aborted)
|
||||
throw new DOMException("Aborted", "AbortError");
|
||||
throw err;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
externalSignal?.removeEventListener("abort", onAbort);
|
||||
}
|
||||
}
|
||||
|
||||
function validateReadsb(payload: unknown): ReadsbApiResponse {
|
||||
if (
|
||||
!payload ||
|
||||
typeof payload !== "object" ||
|
||||
!Array.isArray((payload as ReadsbApiResponse).ac)
|
||||
) {
|
||||
throw new Error("Invalid readsb response shape");
|
||||
}
|
||||
return payload as ReadsbApiResponse;
|
||||
}
|
||||
|
||||
// ── Tier 1: Direct to airplanes.live ───────────────────────────────────
|
||||
//
|
||||
// Avoid headers that trigger CORS preflight (Cache-Control, Pragma, etc.)
|
||||
// since airplanes.live returns 405 for OPTIONS. Use cache-busting query
|
||||
// param instead of cache: "no-store".
|
||||
|
||||
async function fetchDirectAirplanesLive(
|
||||
path: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<ReadsbApiResponse> {
|
||||
// Serialized rate limiting — concurrent callers queue up
|
||||
await acquireDirectSlot();
|
||||
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
|
||||
|
||||
return withTimeout(
|
||||
async (innerSignal) => {
|
||||
const sep = path.includes("?") ? "&" : "?";
|
||||
const url = `${AIRPLANES_LIVE_BASE}${path}${sep}_t=${Date.now()}`;
|
||||
|
||||
const res = await fetch(url, { signal: innerSignal });
|
||||
if (!res.ok) throw new Error(`airplanes.live ${res.status}`);
|
||||
|
||||
const ct = res.headers.get("content-type") ?? "";
|
||||
if (ct.includes("text/html") || ct.includes("text/xml")) {
|
||||
throw new Error("airplanes.live returned non-JSON response");
|
||||
}
|
||||
|
||||
return validateReadsb(await res.json());
|
||||
},
|
||||
DIRECT_TIMEOUT_MS,
|
||||
signal,
|
||||
);
|
||||
}
|
||||
|
||||
// ── Tier 2: adsb.lol via server proxy ──────────────────────────────────
|
||||
|
||||
async function fetchViaProxy(
|
||||
path: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<ReadsbApiResponse> {
|
||||
return withTimeout(
|
||||
async (innerSignal) => {
|
||||
const url = `/api/flights?path=${encodeURIComponent(path)}`;
|
||||
const res = await fetch(url, { cache: "no-store", signal: innerSignal });
|
||||
|
||||
if (!res.ok) throw new Error(`adsb.lol proxy ${res.status}`);
|
||||
|
||||
const ct = res.headers.get("content-type") ?? "";
|
||||
if (ct.includes("text/html") || ct.includes("text/xml")) {
|
||||
throw new Error("adsb.lol proxy returned non-JSON response");
|
||||
}
|
||||
|
||||
return validateReadsb(await res.json());
|
||||
},
|
||||
PROXY_TIMEOUT_MS,
|
||||
signal,
|
||||
);
|
||||
}
|
||||
|
||||
// ── Tier 3: OpenSky direct ─────────────────────────────────────────────
|
||||
|
||||
async function fetchFromOpenSkyPoint(
|
||||
lat: number,
|
||||
lon: number,
|
||||
radiusDeg: number,
|
||||
signal?: AbortSignal,
|
||||
): Promise<FlightState[]> {
|
||||
const [lamin, lamax, lomin, lomax] = bboxFromCenter(lon, lat, radiusDeg);
|
||||
const result = await fetchFlightsByBbox(lamin, lamax, lomin, lomax, signal);
|
||||
if (result.rateLimited) throw new Error("OpenSky rate limited (429)");
|
||||
return result.flights;
|
||||
}
|
||||
|
||||
// ── Fallback Engine ────────────────────────────────────────────────────
|
||||
|
||||
interface NamedTier {
|
||||
id: string;
|
||||
fn: () => Promise<FlightState[]>;
|
||||
}
|
||||
|
||||
async function runFallbackChain(
|
||||
tiers: NamedTier[],
|
||||
signal?: AbortSignal,
|
||||
): Promise<FlightApiFetchResult> {
|
||||
let lastError: Error | null = null;
|
||||
let allSkipped = true;
|
||||
let lastTriedId: string | undefined;
|
||||
|
||||
for (const { id, fn } of tiers) {
|
||||
if (shouldSkipTier(id)) continue;
|
||||
allSkipped = false;
|
||||
lastTriedId = id;
|
||||
|
||||
try {
|
||||
const flights = await fn();
|
||||
recordSuccess(id);
|
||||
return { flights, rateLimited: false, source: id };
|
||||
} catch (err) {
|
||||
if (signal?.aborted) throw err;
|
||||
if (err instanceof DOMException && err.name === "AbortError") throw err;
|
||||
|
||||
if (!isNonCircuitError(err)) recordFailure(id);
|
||||
|
||||
lastError = err instanceof Error ? err : new Error(String(err));
|
||||
}
|
||||
}
|
||||
|
||||
if (allSkipped) {
|
||||
return { flights: [], rateLimited: false, source: "none" };
|
||||
}
|
||||
|
||||
const msg = lastError?.message?.toLowerCase() ?? "";
|
||||
if (msg.includes("429") || msg.includes("rate limit")) {
|
||||
return { flights: [], rateLimited: true, source: lastTriedId };
|
||||
}
|
||||
|
||||
throw lastError ?? new Error("All flight providers failed");
|
||||
}
|
||||
|
||||
// ── Public API ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Fetch flights within a radius of a geographic point.
|
||||
* Uses the fallback chain: adsb.lol → OpenSky.
|
||||
*/
|
||||
export async function fetchFlightsByPoint(
|
||||
lat: number,
|
||||
lon: number,
|
||||
radiusDeg: number,
|
||||
signal?: AbortSignal,
|
||||
options?: ParseOptions,
|
||||
): Promise<FlightApiFetchResult> {
|
||||
if (!Number.isFinite(lat) || !Number.isFinite(lon)) {
|
||||
return { flights: [], rateLimited: false };
|
||||
}
|
||||
|
||||
const radiusNm = degreesToNm(radiusDeg);
|
||||
const cLat = Math.max(-90, Math.min(90, lat));
|
||||
const cLon = Math.max(-180, Math.min(180, lon));
|
||||
const readsbPath = `/point/${cLat.toFixed(4)}/${cLon.toFixed(4)}/${radiusNm}`;
|
||||
|
||||
const override = getProviderOverride();
|
||||
const tiers: NamedTier[] = [];
|
||||
|
||||
// Skip direct airplanes.live in the browser — CORS blocks it.
|
||||
// Only attempt when explicitly overridden via ?provider=airplanes.
|
||||
if (override === "airplanes") {
|
||||
tiers.push({
|
||||
id: "airplanes",
|
||||
fn: async () => {
|
||||
const resp = await fetchDirectAirplanesLive(readsbPath, signal);
|
||||
return parseAircraftList(resp.ac, options);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (override === "auto" || override === "adsb") {
|
||||
tiers.push({
|
||||
id: "adsb",
|
||||
fn: async () => {
|
||||
const resp = await fetchViaProxy(readsbPath, signal);
|
||||
return parseAircraftList(resp.ac, options);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (override === "auto" || override === "opensky") {
|
||||
tiers.push({
|
||||
id: "opensky",
|
||||
fn: () => fetchFromOpenSkyPoint(cLat, cLon, radiusDeg, signal),
|
||||
});
|
||||
}
|
||||
|
||||
return runFallbackChain(tiers, signal);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a single aircraft by ICAO24 hex address.
|
||||
* Uses the fallback chain: adsb.lol → OpenSky.
|
||||
*/
|
||||
export async function fetchFlightByHex(
|
||||
icao24: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<{ flight: FlightState | null }> {
|
||||
const normalized = icao24.trim().toLowerCase();
|
||||
if (!/^[0-9a-f]{6}$/i.test(normalized)) {
|
||||
return { flight: null };
|
||||
}
|
||||
|
||||
const parseOpts: ParseOptions = {
|
||||
includeGround: true,
|
||||
requireBaroAltitude: false,
|
||||
};
|
||||
const readsbPath = `/hex/${encodeURIComponent(normalized)}`;
|
||||
const override = getProviderOverride();
|
||||
const tiers: NamedTier[] = [];
|
||||
|
||||
if (override === "airplanes") {
|
||||
tiers.push({
|
||||
id: "airplanes",
|
||||
fn: async () => {
|
||||
const resp = await fetchDirectAirplanesLive(readsbPath, signal);
|
||||
return parseAircraftList(resp.ac, parseOpts);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (override === "auto" || override === "adsb") {
|
||||
tiers.push({
|
||||
id: "adsb",
|
||||
fn: async () => {
|
||||
const resp = await fetchViaProxy(readsbPath, signal);
|
||||
return parseAircraftList(resp.ac, parseOpts);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (override === "auto" || override === "opensky") {
|
||||
tiers.push({
|
||||
id: "opensky",
|
||||
fn: async () => {
|
||||
const result = await openskyFetchByIcao24(normalized, signal);
|
||||
return result.flight ? [result.flight] : [];
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await runFallbackChain(tiers, signal);
|
||||
return { flight: result.flights[0] ?? null };
|
||||
} catch {
|
||||
return { flight: null };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch flights matching a callsign.
|
||||
* Uses: adsb.lol only (OpenSky callsign search costs 4 credits).
|
||||
*/
|
||||
export async function fetchFlightByCallsign(
|
||||
callsign: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<{ flight: FlightState | null }> {
|
||||
const normalized = callsign.trim().toUpperCase();
|
||||
if (!normalized) return { flight: null };
|
||||
|
||||
const parseOpts: ParseOptions = {
|
||||
includeGround: true,
|
||||
requireBaroAltitude: false,
|
||||
};
|
||||
const readsbPath = `/callsign/${encodeURIComponent(normalized)}`;
|
||||
const override = getProviderOverride();
|
||||
const tiers: NamedTier[] = [];
|
||||
|
||||
if (override === "airplanes") {
|
||||
tiers.push({
|
||||
id: "airplanes",
|
||||
fn: async () => {
|
||||
const resp = await fetchDirectAirplanesLive(readsbPath, signal);
|
||||
return parseAircraftList(resp.ac, parseOpts);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (override === "auto" || override === "adsb") {
|
||||
tiers.push({
|
||||
id: "adsb",
|
||||
fn: async () => {
|
||||
const resp = await fetchViaProxy(readsbPath, signal);
|
||||
return parseAircraftList(resp.ac, parseOpts);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// No OpenSky tier: callsign search queries all aircraft (4-credit global fetch)
|
||||
|
||||
try {
|
||||
const result = await runFallbackChain(tiers, signal);
|
||||
return { flight: result.flights[0] ?? null };
|
||||
} catch {
|
||||
return { flight: null };
|
||||
}
|
||||
}
|
||||
253
src/lib/flight-api-parsing.ts
Normal file
253
src/lib/flight-api-parsing.ts
Normal file
@ -0,0 +1,253 @@
|
||||
// ── readsb Parser ────────────────────────────────────────────────────
|
||||
//
|
||||
// Converts raw readsb JSON (RawAircraft[]) → FlightState[].
|
||||
// Handles unit conversions, edge cases, and stale-position filtering.
|
||||
// Works identically for Airplanes.live and adsb.lol responses.
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
|
||||
import type { FlightState } from "./opensky-types";
|
||||
import type { RawAircraft } from "./flight-api-types";
|
||||
import { MAX_POSITION_AGE_S } from "./flight-api-types";
|
||||
|
||||
// ── Unit Conversion Constants ──────────────────────────────────────────
|
||||
|
||||
/** Feet → Meters */
|
||||
const FT_TO_M = 0.3048;
|
||||
|
||||
/** Knots → Meters per second */
|
||||
const KT_TO_MS = 0.514444;
|
||||
|
||||
/** Feet per minute → Meters per second */
|
||||
const FTPM_TO_MS = 0.00508;
|
||||
|
||||
// ── Registration → Country Lookup ──────────────────────────────────────
|
||||
//
|
||||
// readsb doesn't provide originCountry. We derive it from the
|
||||
// registration prefix. Sorted by prefix length descending so longer
|
||||
// prefixes match first (e.g. "EC-" before "E").
|
||||
|
||||
const REG_PREFIX_TO_COUNTRY: readonly [string, string][] = [
|
||||
// 3-char prefixes
|
||||
["EC-", "Spain"],
|
||||
["HB-", "Switzerland"],
|
||||
["OE-", "Austria"],
|
||||
["PH-", "Netherlands"],
|
||||
["SE-", "Sweden"],
|
||||
["OY-", "Denmark"],
|
||||
["OH-", "Finland"],
|
||||
["LN-", "Norway"],
|
||||
["9V-", "Singapore"],
|
||||
["9M-", "Malaysia"],
|
||||
["HS-", "Thailand"],
|
||||
["PK-", "Indonesia"],
|
||||
["VH-", "Australia"],
|
||||
["ZK-", "New Zealand"],
|
||||
["PP-", "Brazil"],
|
||||
["PT-", "Brazil"],
|
||||
["XA-", "Mexico"],
|
||||
["LV-", "Argentina"],
|
||||
["A6-", "UAE"],
|
||||
["A7-", "Qatar"],
|
||||
["HZ-", "Saudi Arabia"],
|
||||
["4X-", "Israel"],
|
||||
["TC-", "Turkey"],
|
||||
["SU-", "Egypt"],
|
||||
["5N-", "Nigeria"],
|
||||
["ZS-", "South Africa"],
|
||||
["AP-", "Pakistan"],
|
||||
["EI-", "Ireland"],
|
||||
["OO-", "Belgium"],
|
||||
["CS-", "Portugal"],
|
||||
["SX-", "Greece"],
|
||||
["SP-", "Poland"],
|
||||
["OK-", "Czech Republic"],
|
||||
["HA-", "Hungary"],
|
||||
["YR-", "Romania"],
|
||||
["UR-", "Ukraine"],
|
||||
["RA-", "Russia"],
|
||||
["VP-", "Bermuda"],
|
||||
// 2-char prefixes
|
||||
["C-", "Canada"],
|
||||
["G-", "United Kingdom"],
|
||||
["D-", "Germany"],
|
||||
["F-", "France"],
|
||||
["I-", "Italy"],
|
||||
["B-", "China"],
|
||||
// 2-char prefixes (no hyphen)
|
||||
["JA", "Japan"],
|
||||
["HL", "South Korea"],
|
||||
["VT", "India"],
|
||||
// 1-char prefix
|
||||
["N", "United States"],
|
||||
];
|
||||
|
||||
// Pre-build Maps by prefix length for O(1) lookup instead of O(42) linear scan
|
||||
const REG_BY_3 = new Map<string, string>();
|
||||
const REG_BY_2 = new Map<string, string>();
|
||||
const REG_BY_1 = new Map<string, string>();
|
||||
for (const [prefix, country] of REG_PREFIX_TO_COUNTRY) {
|
||||
if (prefix.length >= 3) REG_BY_3.set(prefix, country);
|
||||
else if (prefix.length === 2) REG_BY_2.set(prefix, country);
|
||||
else REG_BY_1.set(prefix, country);
|
||||
}
|
||||
|
||||
function countryFromRegistration(reg: string | undefined): string {
|
||||
if (!reg) return "Unknown";
|
||||
const upper = reg.toUpperCase();
|
||||
return (
|
||||
REG_BY_3.get(upper.slice(0, 3)) ??
|
||||
REG_BY_2.get(upper.slice(0, 2)) ??
|
||||
REG_BY_1.get(upper[0]) ??
|
||||
"Unknown"
|
||||
);
|
||||
}
|
||||
|
||||
// ── Category Conversion ────────────────────────────────────────────────
|
||||
//
|
||||
// Converts readsb category string ("A0"–"D7") to the numeric encoding
|
||||
// used by OpenSky (DO-260B spec). A-set: A0→0, A1→2(light)…A7→8(rotorcraft).
|
||||
// B-set: B0→0, B1→9(glider)…B7→15(space). C-set: surface vehicles. D: reserved.
|
||||
|
||||
function readsbCategoryToNumber(cat: string | undefined): number | null {
|
||||
if (!cat || cat.length !== 2) return null;
|
||||
|
||||
const set = cat.charAt(0).toUpperCase();
|
||||
const idx = Number.parseInt(cat.charAt(1), 10);
|
||||
if (!Number.isFinite(idx) || idx < 0 || idx > 7) return null;
|
||||
|
||||
switch (set) {
|
||||
case "A":
|
||||
return idx === 0 ? 0 : idx + 1;
|
||||
case "B":
|
||||
return idx === 0 ? 0 : idx + 8;
|
||||
case "C":
|
||||
return idx === 0 ? 0 : idx + 15;
|
||||
case "D":
|
||||
return 0;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Position Source Mapping ─────────────────────────────────────────────
|
||||
|
||||
/** Maps readsb `type` field to OpenSky positionSource: 0=ADS-B, 1=MLAT, 2=TIS-B */
|
||||
|
||||
function readsbTypeToPositionSource(type: string | undefined): number {
|
||||
if (!type) return 0;
|
||||
if (type === "mlat") return 1;
|
||||
if (type.startsWith("tisb")) return 2;
|
||||
return 0;
|
||||
}
|
||||
|
||||
// ── Altitude Parser ────────────────────────────────────────────────────
|
||||
|
||||
function parseAltBaro(value: number | "ground" | undefined): {
|
||||
altitude: number | null;
|
||||
onGround: boolean;
|
||||
} {
|
||||
if (value === "ground") return { altitude: 0, onGround: true };
|
||||
if (typeof value === "number" && Number.isFinite(value))
|
||||
return { altitude: value * FT_TO_M, onGround: false };
|
||||
return { altitude: null, onGround: false };
|
||||
}
|
||||
|
||||
// ── ICAO Hex Validation ────────────────────────────────────────────────
|
||||
|
||||
const ICAO_HEX_RE = /^[0-9a-f]{6}$/i;
|
||||
|
||||
function isValidIcaoHex(hex: string): boolean {
|
||||
// Filter out '~'-prefixed non-ICAO addresses and invalid formats
|
||||
return !hex.startsWith("~") && ICAO_HEX_RE.test(hex);
|
||||
}
|
||||
|
||||
// ── Single Aircraft Parser ─────────────────────────────────────────────
|
||||
|
||||
function parseRawAircraft(raw: RawAircraft): FlightState | null {
|
||||
// Reject non-ICAO addresses (TIS-B, etc.)
|
||||
if (!isValidIcaoHex(raw.hex)) return null;
|
||||
|
||||
// Require a valid position within geographic bounds
|
||||
if (typeof raw.lat !== "number" || typeof raw.lon !== "number") return null;
|
||||
if (!Number.isFinite(raw.lat) || !Number.isFinite(raw.lon)) return null;
|
||||
if (raw.lat < -90 || raw.lat > 90 || raw.lon < -180 || raw.lon > 180)
|
||||
return null;
|
||||
|
||||
// Filter stale positions (>60s old)
|
||||
if (typeof raw.seen_pos === "number" && raw.seen_pos > MAX_POSITION_AGE_S)
|
||||
return null;
|
||||
|
||||
const { altitude, onGround } = parseAltBaro(raw.alt_baro);
|
||||
|
||||
return {
|
||||
icao24: raw.hex.toLowerCase(),
|
||||
callsign: raw.flight?.trim() || null,
|
||||
originCountry: countryFromRegistration(raw.r),
|
||||
longitude: raw.lon,
|
||||
latitude: raw.lat,
|
||||
baroAltitude: altitude,
|
||||
onGround,
|
||||
velocity:
|
||||
typeof raw.gs === "number" && Number.isFinite(raw.gs)
|
||||
? raw.gs * KT_TO_MS
|
||||
: null,
|
||||
trueTrack:
|
||||
typeof raw.track === "number" && Number.isFinite(raw.track)
|
||||
? raw.track
|
||||
: null,
|
||||
verticalRate:
|
||||
typeof raw.baro_rate === "number" && Number.isFinite(raw.baro_rate)
|
||||
? raw.baro_rate * FTPM_TO_MS
|
||||
: null,
|
||||
geoAltitude:
|
||||
typeof raw.alt_geom === "number" && Number.isFinite(raw.alt_geom)
|
||||
? raw.alt_geom * FT_TO_M
|
||||
: null,
|
||||
squawk: raw.squawk ?? null,
|
||||
spiFlag: raw.spi === 1,
|
||||
positionSource: readsbTypeToPositionSource(raw.type),
|
||||
category: readsbCategoryToNumber(raw.category),
|
||||
typeCode: raw.t?.trim() || null,
|
||||
registration: raw.r?.trim() || null,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Batch Parser ───────────────────────────────────────────────────────
|
||||
|
||||
export interface ParseOptions {
|
||||
/** Include aircraft on the ground. Default: false. */
|
||||
includeGround?: boolean;
|
||||
/** Require barometric altitude. Default: true. */
|
||||
requireBaroAltitude?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses an array of raw readsb aircraft entries into FlightState[].
|
||||
* Handles unit conversions, filters stale/invalid positions, and
|
||||
* converts category strings to numeric codes for backward compatibility.
|
||||
*/
|
||||
export function parseAircraftList(
|
||||
rawList: RawAircraft[],
|
||||
options?: ParseOptions,
|
||||
): FlightState[] {
|
||||
const includeGround = options?.includeGround ?? false;
|
||||
const requireBaroAltitude = options?.requireBaroAltitude ?? true;
|
||||
|
||||
const results: FlightState[] = [];
|
||||
|
||||
for (const raw of rawList) {
|
||||
if (!raw || typeof raw !== "object") continue;
|
||||
const state = parseRawAircraft(raw);
|
||||
if (!state) continue;
|
||||
|
||||
// Filter ground aircraft unless specifically requested
|
||||
if (!includeGround && state.onGround) continue;
|
||||
|
||||
// Filter aircraft without barometric altitude if required
|
||||
if (requireBaroAltitude && state.baroAltitude === null) continue;
|
||||
|
||||
results.push(state);
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
174
src/lib/flight-api-types.ts
Normal file
174
src/lib/flight-api-types.ts
Normal file
@ -0,0 +1,174 @@
|
||||
// ── readsb API Types ─────────────────────────────────────────────────
|
||||
//
|
||||
// Shared format used by both Airplanes.live and adsb.lol.
|
||||
// Verified against official docs:
|
||||
// https://airplanes.live/rest-api-adsb-data-field-descriptions/
|
||||
// https://api.adsb.lol/api/openapi.json
|
||||
// https://github.com/wiedehopf/readsb/blob/dev/README-json.md
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// ── Provider Configuration ─────────────────────────────────────────────
|
||||
|
||||
export interface FlightApiProvider {
|
||||
name: string;
|
||||
/** No trailing slash, e.g. "https://api.airplanes.live/v2" */
|
||||
baseUrl: string;
|
||||
rateMs: number;
|
||||
}
|
||||
|
||||
export const PROVIDER_AIRPLANES_LIVE: FlightApiProvider = {
|
||||
name: "Airplanes.live",
|
||||
baseUrl: "https://api.airplanes.live/v2",
|
||||
rateMs: 1_000, // Documented: 1 req/s
|
||||
};
|
||||
|
||||
export const PROVIDER_ADSB_LOL: FlightApiProvider = {
|
||||
name: "adsb.lol",
|
||||
baseUrl: "https://api.adsb.lol/v2",
|
||||
rateMs: 500, // Self-imposed: 2 req/s
|
||||
};
|
||||
|
||||
export const PROVIDERS: readonly FlightApiProvider[] = [
|
||||
PROVIDER_AIRPLANES_LIVE,
|
||||
PROVIDER_ADSB_LOL,
|
||||
] as const;
|
||||
|
||||
// ── API Constants ──────────────────────────────────────────────────────
|
||||
|
||||
export const READSB_FETCH_TIMEOUT_MS = 10_000;
|
||||
export const ICAO24_REGEX = /^[0-9a-f]{6}$/i;
|
||||
export const MAX_RADIUS_NM = 250;
|
||||
|
||||
/**
|
||||
* From the docs: "when the regular lat and lon are older than 60 seconds
|
||||
* they are no longer considered valid."
|
||||
*/
|
||||
export const MAX_POSITION_AGE_S = 60;
|
||||
|
||||
export const NM_PER_DEG_LAT = 60;
|
||||
|
||||
// ── Raw API Response Types ─────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* A single aircraft entry from the readsb JSON response.
|
||||
* Keys are omitted by the API if data is not available.
|
||||
* @see https://airplanes.live/rest-api-adsb-data-field-descriptions/
|
||||
*/
|
||||
export interface RawAircraft {
|
||||
/** 24-bit ICAO hex address (6 chars). Starts with '~' for non-ICAO. */
|
||||
hex: string;
|
||||
/** Type of underlying message source (adsb_icao, mlat, tisb_icao, etc.) */
|
||||
type: string;
|
||||
/** Callsign, 8-char padded with trailing spaces. */
|
||||
flight?: string;
|
||||
/** Aircraft registration from database. */
|
||||
r?: string;
|
||||
/** Aircraft ICAO type code from database (e.g. "A320", "B738"). */
|
||||
t?: string;
|
||||
/** Aircraft type description. Airplanes.live only. */
|
||||
desc?: string;
|
||||
/** Database flags bitmask: military=1, interesting=2, PIA=4, LADD=8. */
|
||||
dbFlags?: number;
|
||||
|
||||
// ── Position ───────────────────────────────────────────────────────
|
||||
lat?: number;
|
||||
lon?: number;
|
||||
seen_pos?: number;
|
||||
|
||||
// ── Altitude ───────────────────────────────────────────────────────
|
||||
/** In feet, or "ground" when on ground. */
|
||||
alt_baro?: number | "ground";
|
||||
/** Geometric (GNSS) altitude in feet. */
|
||||
alt_geom?: number;
|
||||
|
||||
// ── Speed & Track ──────────────────────────────────────────────────
|
||||
gs?: number;
|
||||
/** Degrees (0–359). */
|
||||
track?: number;
|
||||
baro_rate?: number;
|
||||
geom_rate?: number;
|
||||
|
||||
// ── ADS-B Category ─────────────────────────────────────────────────
|
||||
/** "A0"–"A7", "B0"–"B7", "C1"–"C3", "D0"–"D7". */
|
||||
category?: string;
|
||||
|
||||
// ── Transponder ────────────────────────────────────────────────────
|
||||
/** 4 octal digits. */
|
||||
squawk?: string;
|
||||
/** "none", "general", "lifeguard", "minfuel", "nordo", "unlawful", "downed". */
|
||||
emergency?: string;
|
||||
spi?: number;
|
||||
alert?: number;
|
||||
|
||||
// ── Additional speed data ──────────────────────────────────────────
|
||||
ias?: number;
|
||||
tas?: number;
|
||||
mach?: number;
|
||||
|
||||
// ── Heading ────────────────────────────────────────────────────────
|
||||
mag_heading?: number;
|
||||
true_heading?: number;
|
||||
roll?: number;
|
||||
track_rate?: number;
|
||||
|
||||
// ── Navigation ─────────────────────────────────────────────────────
|
||||
nav_qnh?: number;
|
||||
nav_altitude_mcp?: number;
|
||||
nav_altitude_fms?: number;
|
||||
nav_heading?: number;
|
||||
nav_modes?: string[];
|
||||
|
||||
// ── Wind & Temperature ─────────────────────────────────────────────
|
||||
wd?: number;
|
||||
ws?: number;
|
||||
/** Outer air temperature (°C). */
|
||||
oat?: number;
|
||||
/** Total air temperature (°C). */
|
||||
tat?: number;
|
||||
|
||||
// ── Integrity / Accuracy ───────────────────────────────────────────
|
||||
nic?: number;
|
||||
rc?: number;
|
||||
nic_baro?: number;
|
||||
nac_p?: number;
|
||||
nac_v?: number;
|
||||
sil?: number;
|
||||
sil_type?: string;
|
||||
gva?: number;
|
||||
sda?: number;
|
||||
version?: number;
|
||||
|
||||
// ── Message stats ──────────────────────────────────────────────────
|
||||
messages: number;
|
||||
seen: number;
|
||||
/** dBFS (always negative). */
|
||||
rssi: number;
|
||||
mlat: string[];
|
||||
tisb: string[];
|
||||
|
||||
// ── Fallback position (stale) ──────────────────────────────────────
|
||||
lastPosition?: {
|
||||
lat: number;
|
||||
lon: number;
|
||||
nic: number;
|
||||
rc: number;
|
||||
seen_pos: number;
|
||||
};
|
||||
|
||||
// ── Rough estimated position ───────────────────────────────────────
|
||||
rr_lat?: number;
|
||||
rr_lon?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Top-level response from any readsb endpoint.
|
||||
* @see https://api.adsb.lol/api/openapi.json — V2Response_Model
|
||||
*/
|
||||
export interface ReadsbApiResponse {
|
||||
ac: RawAircraft[];
|
||||
msg: string;
|
||||
now: number;
|
||||
total: number;
|
||||
ctime: number;
|
||||
ptime: number;
|
||||
}
|
||||
39
src/lib/flight-api.ts
Normal file
39
src/lib/flight-api.ts
Normal file
@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Flight API client — barrel re-export.
|
||||
*
|
||||
* Default 2-tier fallback chain:
|
||||
* Tier 1: adsb.lol (via proxy, no CORS)
|
||||
* Tier 2: OpenSky (direct, CORS OK, limited credits)
|
||||
*
|
||||
* airplanes.live is available via explicit override only (CORS blocks
|
||||
* direct browser requests).
|
||||
*
|
||||
* Override: add ?provider=airplanes|adsb|opensky to the URL.
|
||||
*
|
||||
* @see https://airplanes.live/api-guide/
|
||||
* @see https://api.adsb.lol/docs
|
||||
* @see https://openskynetwork.github.io/opensky-api/rest.html
|
||||
*/
|
||||
|
||||
// ── Types ──────────────────────────────────────────────────────────────
|
||||
export type { RawAircraft, ReadsbApiResponse } from "./flight-api-types";
|
||||
|
||||
export type { FlightApiFetchResult, ProviderName } from "./flight-api-client";
|
||||
|
||||
// ── Constants ──────────────────────────────────────────────────────────
|
||||
export { MAX_RADIUS_NM, NM_PER_DEG_LAT } from "./flight-api-types";
|
||||
|
||||
// ── Client ─────────────────────────────────────────────────────────────
|
||||
export {
|
||||
fetchFlightsByPoint,
|
||||
fetchFlightByHex,
|
||||
fetchFlightByCallsign,
|
||||
getProviderOverride,
|
||||
getCircuitState,
|
||||
resetAllCircuits,
|
||||
} from "./flight-api-client";
|
||||
|
||||
export type { CircuitState } from "./flight-api-client";
|
||||
|
||||
// ── Parser ─────────────────────────────────────────────────────────────
|
||||
export { parseAircraftList } from "./flight-api-parsing";
|
||||
@ -212,7 +212,7 @@ export function splitAtAntimeridian(
|
||||
const currLat = path[i][1];
|
||||
|
||||
// Normalize longitudes for interpolation
|
||||
let norm1 = prevLng;
|
||||
const norm1 = prevLng;
|
||||
let norm2 = currLng;
|
||||
if (norm2 - norm1 > 180) norm2 -= 360;
|
||||
else if (norm1 - norm2 > 180) norm2 += 360;
|
||||
|
||||
438
src/lib/gpu-memory-monitor.ts
Normal file
438
src/lib/gpu-memory-monitor.ts
Normal file
@ -0,0 +1,438 @@
|
||||
/**
|
||||
* GPU & Native Memory Monitor
|
||||
*
|
||||
* Instruments WebGL calls to track GPU-side memory (textures, buffers,
|
||||
* framebuffers) that are invisible to V8 heap snapshots.
|
||||
*
|
||||
* Usage:
|
||||
* import { installGpuMemoryMonitor, getGpuMemoryReport } from '@/lib/gpu-memory-monitor';
|
||||
*
|
||||
* // Call once after map + deck.gl are initialized
|
||||
* installGpuMemoryMonitor();
|
||||
*
|
||||
* // Query anytime
|
||||
* const report = getGpuMemoryReport();
|
||||
* console.table(report.summary);
|
||||
*
|
||||
* How it works:
|
||||
* Wraps the WebGL2RenderingContext prototype methods that allocate/free
|
||||
* GPU resources. Tracks estimated byte sizes for:
|
||||
* - Textures (texImage2D, compressedTexImage2D, texStorage2D)
|
||||
* - Buffers (bufferData)
|
||||
* - Renderbuffers (renderbufferStorage)
|
||||
* - Framebuffers (create/delete tracking only)
|
||||
*
|
||||
* Limitations:
|
||||
* - Estimates are approximate (actual GPU memory may differ due to
|
||||
* alignment, mipmaps, driver overhead)
|
||||
* - Only tracks calls made AFTER installation
|
||||
* - WebGL1 fallback is not implemented (MapLibre uses WebGL2)
|
||||
*/
|
||||
|
||||
// ── Types ──────────────────────────────────────────────────────────────
|
||||
|
||||
interface ResourceEntry {
|
||||
id: number;
|
||||
type: "texture" | "buffer" | "renderbuffer" | "framebuffer";
|
||||
bytes: number;
|
||||
createdAt: number;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
interface GpuMemoryReport {
|
||||
totalEstimatedBytes: number;
|
||||
totalEstimatedMB: number;
|
||||
summary: {
|
||||
type: string;
|
||||
count: number;
|
||||
bytes: number;
|
||||
mb: string;
|
||||
}[];
|
||||
topResources: {
|
||||
type: string;
|
||||
bytes: number;
|
||||
mb: string;
|
||||
age: string;
|
||||
label?: string;
|
||||
}[];
|
||||
jsHeap: {
|
||||
used: number;
|
||||
total: number;
|
||||
limit: number;
|
||||
} | null;
|
||||
}
|
||||
|
||||
// ── State ──────────────────────────────────────────────────────────────
|
||||
|
||||
let installed = false;
|
||||
let nextId = 1;
|
||||
const resources = new Map<
|
||||
WebGLTexture | WebGLBuffer | WebGLRenderbuffer | WebGLFramebuffer,
|
||||
ResourceEntry
|
||||
>();
|
||||
|
||||
// ── Byte-size helpers ──────────────────────────────────────────────────
|
||||
|
||||
function bytesPerPixel(internalFormat: number): number {
|
||||
// WebGL2 sized internal formats → bytes per pixel
|
||||
// Reference: OpenGL ES 3.0 spec, Table 3.2
|
||||
switch (internalFormat) {
|
||||
// 1 byte
|
||||
case 0x1903: // RED (unsized)
|
||||
case 0x8229: // R8
|
||||
case 0x8d48: // STENCIL_INDEX8
|
||||
return 1;
|
||||
// 2 bytes
|
||||
case 0x8227: // RG (unsized)
|
||||
case 0x822b: // RG8
|
||||
case 0x822d: // R16F
|
||||
case 0x8d62: // RGB565
|
||||
case 0x8056: // RGBA4
|
||||
case 0x8057: // RGB5_A1
|
||||
case 0x81a5: // DEPTH_COMPONENT16
|
||||
case 0x1902: // DEPTH_COMPONENT (unsized, assume 16-bit)
|
||||
return 2;
|
||||
// 3 bytes
|
||||
case 0x1907: // RGB (unsized)
|
||||
case 0x8051: // RGB8
|
||||
return 3;
|
||||
// 4 bytes
|
||||
case 0x1908: // RGBA (unsized)
|
||||
case 0x8058: // RGBA8
|
||||
case 0x822e: // R32F
|
||||
case 0x822f: // RG16F
|
||||
case 0x8d7c: // RGBA8UI
|
||||
case 0x81a6: // DEPTH_COMPONENT24
|
||||
case 0x88f0: // DEPTH24_STENCIL8
|
||||
return 4;
|
||||
// 5 bytes
|
||||
case 0x8cad: // DEPTH32F_STENCIL8
|
||||
return 5;
|
||||
// 8 bytes
|
||||
case 0x8230: // RG32F
|
||||
case 0x881a: // RGBA16F
|
||||
return 8;
|
||||
// 16 bytes
|
||||
case 0x8814: // RGBA32F
|
||||
return 16;
|
||||
default:
|
||||
return 4; // reasonable default
|
||||
}
|
||||
}
|
||||
|
||||
// ── Installation ───────────────────────────────────────────────────────
|
||||
|
||||
export function installGpuMemoryMonitor(): void {
|
||||
if (installed) return;
|
||||
if (typeof WebGL2RenderingContext === "undefined") {
|
||||
console.warn("[gpu-monitor] WebGL2 not available");
|
||||
return;
|
||||
}
|
||||
installed = true;
|
||||
|
||||
const proto = WebGL2RenderingContext.prototype;
|
||||
|
||||
// ── Texture tracking ───────────────────────────────────────────────
|
||||
|
||||
const origTexImage2D = proto.texImage2D;
|
||||
proto.texImage2D = function (
|
||||
this: WebGL2RenderingContext,
|
||||
...args: unknown[]
|
||||
) {
|
||||
// texImage2D has multiple overloads; extract width/height/format
|
||||
// Overload: texImage2D(target, level, internalformat, width, height, border, format, type, source)
|
||||
const result = (origTexImage2D as Function).apply(this, args);
|
||||
|
||||
try {
|
||||
const boundTex = this.getParameter(
|
||||
this.TEXTURE_BINDING_2D,
|
||||
) as WebGLTexture | null;
|
||||
if (
|
||||
boundTex &&
|
||||
typeof args[3] === "number" &&
|
||||
typeof args[4] === "number"
|
||||
) {
|
||||
const internalFormat = args[2] as number;
|
||||
const width = args[3] as number;
|
||||
const height = args[4] as number;
|
||||
const bpp = bytesPerPixel(internalFormat);
|
||||
const bytes = width * height * bpp;
|
||||
|
||||
const existing = resources.get(boundTex);
|
||||
if (existing) {
|
||||
existing.bytes = Math.max(existing.bytes, bytes);
|
||||
} else {
|
||||
resources.set(boundTex, {
|
||||
id: nextId++,
|
||||
type: "texture",
|
||||
bytes,
|
||||
createdAt: performance.now(),
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* monitoring should never break the app */
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
const origTexStorage2D = proto.texStorage2D;
|
||||
proto.texStorage2D = function (
|
||||
this: WebGL2RenderingContext,
|
||||
target: number,
|
||||
levels: number,
|
||||
internalformat: number,
|
||||
width: number,
|
||||
height: number,
|
||||
) {
|
||||
const result = origTexStorage2D.call(
|
||||
this,
|
||||
target,
|
||||
levels,
|
||||
internalformat,
|
||||
width,
|
||||
height,
|
||||
);
|
||||
|
||||
try {
|
||||
const boundTex = this.getParameter(
|
||||
this.TEXTURE_BINDING_2D,
|
||||
) as WebGLTexture | null;
|
||||
if (boundTex) {
|
||||
const bpp = bytesPerPixel(internalformat);
|
||||
// Estimate mipmap chain: sum of mip levels ≈ 1.33× base
|
||||
let totalBytes = 0;
|
||||
for (let l = 0; l < levels; l++) {
|
||||
const mw = Math.max(1, width >> l);
|
||||
const mh = Math.max(1, height >> l);
|
||||
totalBytes += mw * mh * bpp;
|
||||
}
|
||||
resources.set(boundTex, {
|
||||
id: nextId++,
|
||||
type: "texture",
|
||||
bytes: totalBytes,
|
||||
createdAt: performance.now(),
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
/* safe */
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
const origDeleteTexture = proto.deleteTexture;
|
||||
proto.deleteTexture = function (
|
||||
this: WebGL2RenderingContext,
|
||||
texture: WebGLTexture | null,
|
||||
) {
|
||||
if (texture) resources.delete(texture);
|
||||
return origDeleteTexture.call(this, texture);
|
||||
};
|
||||
|
||||
// ── Buffer tracking ────────────────────────────────────────────────
|
||||
|
||||
const origBufferData = proto.bufferData;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(proto as any).bufferData = function (
|
||||
this: WebGL2RenderingContext,
|
||||
target: number,
|
||||
sizeOrData: number | ArrayBufferView | ArrayBuffer | null,
|
||||
usage: number,
|
||||
) {
|
||||
const result = (origBufferData as Function).apply(this, arguments);
|
||||
|
||||
try {
|
||||
const binding =
|
||||
target === this.ARRAY_BUFFER
|
||||
? this.ARRAY_BUFFER_BINDING
|
||||
: target === this.ELEMENT_ARRAY_BUFFER
|
||||
? this.ELEMENT_ARRAY_BUFFER_BINDING
|
||||
: null;
|
||||
|
||||
if (binding) {
|
||||
const boundBuf = this.getParameter(binding) as WebGLBuffer | null;
|
||||
if (boundBuf) {
|
||||
let bytes = 0;
|
||||
if (typeof sizeOrData === "number") {
|
||||
bytes = sizeOrData;
|
||||
} else if (sizeOrData && "byteLength" in sizeOrData) {
|
||||
bytes = (sizeOrData as ArrayBufferView).byteLength;
|
||||
}
|
||||
const existing = resources.get(boundBuf);
|
||||
if (existing) {
|
||||
existing.bytes = bytes;
|
||||
} else {
|
||||
resources.set(boundBuf, {
|
||||
id: nextId++,
|
||||
type: "buffer",
|
||||
bytes,
|
||||
createdAt: performance.now(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* safe */
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
const origDeleteBuffer = proto.deleteBuffer;
|
||||
proto.deleteBuffer = function (
|
||||
this: WebGL2RenderingContext,
|
||||
buffer: WebGLBuffer | null,
|
||||
) {
|
||||
if (buffer) resources.delete(buffer);
|
||||
return origDeleteBuffer.call(this, buffer);
|
||||
};
|
||||
|
||||
// ── Renderbuffer tracking ──────────────────────────────────────────
|
||||
|
||||
const origRenderbufferStorage = proto.renderbufferStorage;
|
||||
proto.renderbufferStorage = function (
|
||||
this: WebGL2RenderingContext,
|
||||
target: number,
|
||||
internalformat: number,
|
||||
width: number,
|
||||
height: number,
|
||||
) {
|
||||
const result = origRenderbufferStorage.call(
|
||||
this,
|
||||
target,
|
||||
internalformat,
|
||||
width,
|
||||
height,
|
||||
);
|
||||
|
||||
try {
|
||||
const boundRB = this.getParameter(
|
||||
this.RENDERBUFFER_BINDING,
|
||||
) as WebGLRenderbuffer | null;
|
||||
if (boundRB) {
|
||||
const bpp = bytesPerPixel(internalformat);
|
||||
resources.set(boundRB, {
|
||||
id: nextId++,
|
||||
type: "renderbuffer",
|
||||
bytes: width * height * bpp,
|
||||
createdAt: performance.now(),
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
/* safe */
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
const origDeleteRenderbuffer = proto.deleteRenderbuffer;
|
||||
proto.deleteRenderbuffer = function (
|
||||
this: WebGL2RenderingContext,
|
||||
rb: WebGLRenderbuffer | null,
|
||||
) {
|
||||
if (rb) resources.delete(rb);
|
||||
return origDeleteRenderbuffer.call(this, rb);
|
||||
};
|
||||
|
||||
console.log("[gpu-monitor] Installed — tracking WebGL resource allocations");
|
||||
}
|
||||
|
||||
// ── Reporting ──────────────────────────────────────────────────────────
|
||||
|
||||
export function getGpuMemoryReport(): GpuMemoryReport {
|
||||
const now = performance.now();
|
||||
const entries = Array.from(resources.values());
|
||||
|
||||
// Group by type
|
||||
const groups = new Map<string, { count: number; bytes: number }>();
|
||||
for (const e of entries) {
|
||||
const g = groups.get(e.type) ?? { count: 0, bytes: 0 };
|
||||
g.count += 1;
|
||||
g.bytes += e.bytes;
|
||||
groups.set(e.type, g);
|
||||
}
|
||||
|
||||
const totalBytes = entries.reduce((sum, e) => sum + e.bytes, 0);
|
||||
|
||||
const summary = Array.from(groups.entries())
|
||||
.sort((a, b) => b[1].bytes - a[1].bytes)
|
||||
.map(([type, g]) => ({
|
||||
type,
|
||||
count: g.count,
|
||||
bytes: g.bytes,
|
||||
mb: (g.bytes / 1048576).toFixed(1),
|
||||
}));
|
||||
|
||||
// Top 20 largest resources
|
||||
const topResources = entries
|
||||
.sort((a, b) => b.bytes - a.bytes)
|
||||
.slice(0, 20)
|
||||
.map((e) => ({
|
||||
type: e.type,
|
||||
bytes: e.bytes,
|
||||
mb: (e.bytes / 1048576).toFixed(2),
|
||||
age: `${((now - e.createdAt) / 1000).toFixed(0)}s`,
|
||||
label: e.label,
|
||||
}));
|
||||
|
||||
// JS heap (Chrome only)
|
||||
let jsHeap: GpuMemoryReport["jsHeap"] = null;
|
||||
const perfMemory = (
|
||||
performance as unknown as {
|
||||
memory?: {
|
||||
usedJSHeapSize: number;
|
||||
totalJSHeapSize: number;
|
||||
jsHeapSizeLimit: number;
|
||||
};
|
||||
}
|
||||
).memory;
|
||||
if (perfMemory) {
|
||||
jsHeap = {
|
||||
used: perfMemory.usedJSHeapSize,
|
||||
total: perfMemory.totalJSHeapSize,
|
||||
limit: perfMemory.jsHeapSizeLimit,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
totalEstimatedBytes: totalBytes,
|
||||
totalEstimatedMB: Math.round(totalBytes / 1048576),
|
||||
summary,
|
||||
topResources,
|
||||
jsHeap,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Log a formatted memory report to the console.
|
||||
* Call from browser DevTools: `getGpuMemoryReport()` or `logGpuMemory()`.
|
||||
*/
|
||||
export function logGpuMemory(): void {
|
||||
const report = getGpuMemoryReport();
|
||||
|
||||
console.group(`[gpu-monitor] GPU Memory: ~${report.totalEstimatedMB} MB`);
|
||||
console.table(report.summary);
|
||||
|
||||
if (report.jsHeap) {
|
||||
console.log(
|
||||
`JS Heap: ${(report.jsHeap.used / 1048576).toFixed(0)} MB used / ${(report.jsHeap.total / 1048576).toFixed(0)} MB total / ${(report.jsHeap.limit / 1048576).toFixed(0)} MB limit`,
|
||||
);
|
||||
}
|
||||
|
||||
if (report.topResources.length > 0) {
|
||||
console.log("Top 20 GPU resources:");
|
||||
console.table(report.topResources);
|
||||
}
|
||||
|
||||
console.groupEnd();
|
||||
}
|
||||
|
||||
// Expose to window for easy DevTools access
|
||||
if (typeof window !== "undefined") {
|
||||
(window as unknown as Record<string, unknown>).__gpuMemory = {
|
||||
report: getGpuMemoryReport,
|
||||
log: logGpuMemory,
|
||||
};
|
||||
}
|
||||
@ -1,5 +1,20 @@
|
||||
const MAX_LOADED_URLS = 1000;
|
||||
export const loadedAirlineLogoUrls = new Set<string>();
|
||||
|
||||
/**
|
||||
* Track a successfully loaded logo URL. Evicts oldest entry when the
|
||||
* Set exceeds MAX_LOADED_URLS to prevent unbounded growth.
|
||||
*/
|
||||
export function trackAirlineLogoLoaded(url: string): void {
|
||||
if (!url) return;
|
||||
loadedAirlineLogoUrls.add(url);
|
||||
if (loadedAirlineLogoUrls.size > MAX_LOADED_URLS) {
|
||||
// Set iterates in insertion order — first entry is oldest
|
||||
const oldest = loadedAirlineLogoUrls.values().next().value;
|
||||
if (oldest) loadedAirlineLogoUrls.delete(oldest);
|
||||
}
|
||||
}
|
||||
|
||||
const FAILED_TTL_MS = 10 * 60_000;
|
||||
const MAX_FAILED_ENTRIES = 500;
|
||||
const failedAirlineLogoTimestamps = new Map<string, number>();
|
||||
@ -20,22 +35,16 @@ export function markAirlineLogoFailed(url: string): void {
|
||||
const now = Date.now();
|
||||
failedAirlineLogoTimestamps.set(url, now);
|
||||
|
||||
// Opportunistically prune expired entries so the cache doesn't skew toward old URLs.
|
||||
// Prune expired entries
|
||||
for (const [key, ts] of failedAirlineLogoTimestamps) {
|
||||
if (now - ts > FAILED_TTL_MS) {
|
||||
failedAirlineLogoTimestamps.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
if (failedAirlineLogoTimestamps.size <= MAX_FAILED_ENTRIES) return;
|
||||
|
||||
let oldestUrl: string | null = null;
|
||||
let oldestTs = Number.POSITIVE_INFINITY;
|
||||
for (const [key, ts] of failedAirlineLogoTimestamps) {
|
||||
if (ts < oldestTs) {
|
||||
oldestTs = ts;
|
||||
oldestUrl = key;
|
||||
// Evict oldest if over limit — Map iterates in insertion order
|
||||
if (failedAirlineLogoTimestamps.size > MAX_FAILED_ENTRIES) {
|
||||
const oldest = failedAirlineLogoTimestamps.keys().next().value;
|
||||
if (oldest) failedAirlineLogoTimestamps.delete(oldest);
|
||||
}
|
||||
}
|
||||
if (oldestUrl) failedAirlineLogoTimestamps.delete(oldestUrl);
|
||||
}
|
||||
|
||||
@ -172,7 +172,10 @@ export type AttributionEntry = {
|
||||
};
|
||||
|
||||
/** Returns the proper attribution entries for a given map style. */
|
||||
export function getAttributions(styleId: string): AttributionEntry[] {
|
||||
export function getAttributions(
|
||||
styleId: string,
|
||||
options?: { showAirspace?: boolean },
|
||||
): AttributionEntry[] {
|
||||
const base: AttributionEntry[] = [];
|
||||
|
||||
switch (styleId) {
|
||||
@ -189,8 +192,8 @@ export function getAttributions(styleId: string): AttributionEntry[] {
|
||||
);
|
||||
if (styleId === "dark-terrain") {
|
||||
base.push({
|
||||
label: "MapLibre Terrain",
|
||||
url: "https://demotiles.maplibre.org/",
|
||||
label: "AWS/Mapzen Terrain",
|
||||
url: "https://registry.opendata.aws/terrain-tiles/",
|
||||
});
|
||||
}
|
||||
break;
|
||||
@ -215,5 +218,9 @@ export function getAttributions(styleId: string): AttributionEntry[] {
|
||||
|
||||
base.push({ label: "MapLibre", url: "https://maplibre.org/" });
|
||||
|
||||
if (options?.showAirspace) {
|
||||
base.push({ label: "OpenAIP", url: "https://www.openaip.net" });
|
||||
}
|
||||
|
||||
return base;
|
||||
}
|
||||
|
||||
@ -64,6 +64,12 @@ export async function fetchFlightsByBbox(
|
||||
};
|
||||
}
|
||||
|
||||
// Reject non-JSON responses (CloudFlare challenge pages)
|
||||
const ct = res.headers.get("content-type") ?? "";
|
||||
if (ct.includes("text/html") || ct.includes("text/xml")) {
|
||||
throw new Error("OpenSky returned non-JSON response");
|
||||
}
|
||||
|
||||
const payload = (await res.json()) as unknown;
|
||||
const data =
|
||||
typeof payload === "object" && payload !== null
|
||||
@ -146,6 +152,12 @@ export async function fetchFlightByIcao24(
|
||||
return { flight: null, creditsRemaining: rateLimitInfo.creditsRemaining };
|
||||
}
|
||||
|
||||
// Reject non-JSON responses (CloudFlare challenge pages)
|
||||
const ct = res.headers.get("content-type") ?? "";
|
||||
if (ct.includes("text/html") || ct.includes("text/xml")) {
|
||||
throw new Error("OpenSky returned non-JSON response");
|
||||
}
|
||||
|
||||
const payload = (await res.json()) as unknown;
|
||||
const data =
|
||||
typeof payload === "object" && payload !== null
|
||||
|
||||
@ -50,14 +50,18 @@ export function normalizeBounds(
|
||||
export function parseStateRow(
|
||||
rawState: (string | number | boolean | null)[],
|
||||
): FlightState | null {
|
||||
if (rawState.length < 17) return null;
|
||||
if (!Array.isArray(rawState) || rawState.length < 18) return null;
|
||||
|
||||
const icao24 =
|
||||
typeof rawState[0] === "string" ? rawState[0].toLowerCase() : "";
|
||||
if (!ICAO24_REGEX.test(icao24)) return null;
|
||||
|
||||
const longitude = isFiniteNumber(rawState[5]) ? rawState[5] : null;
|
||||
const latitude = isFiniteNumber(rawState[6]) ? rawState[6] : null;
|
||||
const rawLng = isFiniteNumber(rawState[5]) ? rawState[5] : null;
|
||||
const rawLat = isFiniteNumber(rawState[6]) ? rawState[6] : null;
|
||||
const longitude =
|
||||
rawLng !== null && rawLng >= -180 && rawLng <= 180 ? rawLng : null;
|
||||
const latitude =
|
||||
rawLat !== null && rawLat >= -90 && rawLat <= 90 ? rawLat : null;
|
||||
const baroAltitude = isFiniteNumber(rawState[7]) ? rawState[7] : null;
|
||||
|
||||
return {
|
||||
@ -103,7 +107,9 @@ export function parseStates(
|
||||
|
||||
// ── Callsign Normalization ─────────────────────────────────────────────
|
||||
|
||||
const WHITESPACE_RE = /\s+/g;
|
||||
|
||||
export function normalizeCallsign(value: string | null): string {
|
||||
if (!value) return "";
|
||||
return value.trim().toUpperCase().replace(/\s+/g, "");
|
||||
return value.trim().toUpperCase().replace(WHITESPACE_RE, "");
|
||||
}
|
||||
|
||||
@ -14,10 +14,14 @@ function parseTrackWaypoint(raw: unknown): TrackWaypoint | null {
|
||||
|
||||
const time =
|
||||
typeof raw[0] === "number" && Number.isFinite(raw[0]) ? raw[0] : null;
|
||||
const latitude =
|
||||
const rawLat =
|
||||
typeof raw[1] === "number" && Number.isFinite(raw[1]) ? raw[1] : null;
|
||||
const longitude =
|
||||
const rawLng =
|
||||
typeof raw[2] === "number" && Number.isFinite(raw[2]) ? raw[2] : null;
|
||||
const latitude =
|
||||
rawLat !== null && rawLat >= -90 && rawLat <= 90 ? rawLat : null;
|
||||
const longitude =
|
||||
rawLng !== null && rawLng >= -180 && rawLng <= 180 ? rawLng : null;
|
||||
const baroAltitude =
|
||||
typeof raw[3] === "number" && Number.isFinite(raw[3]) ? raw[3] : null;
|
||||
const trueTrack =
|
||||
|
||||
@ -1,14 +1,17 @@
|
||||
/** @see https://openskynetwork.github.io/opensky-api/rest.html */
|
||||
|
||||
import { ICAO24_REGEX } from "./flight-api-types";
|
||||
import { clamp } from "./utils";
|
||||
export { ICAO24_REGEX, clamp };
|
||||
|
||||
// ── API Constants ──────────────────────────────────────────────────────
|
||||
|
||||
export const OPENSKY_API = "https://opensky-network.org/api";
|
||||
export const FETCH_TIMEOUT_MS = 15_000;
|
||||
export const ICAO24_REGEX = /^[0-9a-f]{6}$/i;
|
||||
/** Callsign lookup scans global /states/all (4 credits); cache longer to reduce spikes. */
|
||||
export const CALLSIGN_CACHE_TTL_MS = 2 * 60_000;
|
||||
export const CALLSIGN_CACHE_MAX_ENTRIES = 200;
|
||||
/** Keep bbox queries inside OpenSky's 0–25 sq-deg (1 credit) tier. */
|
||||
/** Bbox is ±radius → side = 2×2.49 = 4.98° → area ≈ 24.8 sq-deg < 25 (1 credit tier). */
|
||||
export const MAX_1_CREDIT_RADIUS_DEG = 2.49;
|
||||
/** Delay between sequential segment fetches to avoid burst rate limits. */
|
||||
export const SEGMENT_DELAY_MS = 200;
|
||||
@ -31,6 +34,10 @@ export type FlightState = {
|
||||
spiFlag: boolean;
|
||||
positionSource: number;
|
||||
category: number | null;
|
||||
/** ICAO type designator (e.g. "A320", "B738") — available from readsb */
|
||||
typeCode?: string | null;
|
||||
/** Aircraft registration (e.g. "N12345", "G-KELS") — available from readsb */
|
||||
registration?: string | null;
|
||||
};
|
||||
|
||||
export type FetchResult = {
|
||||
@ -97,8 +104,3 @@ export type OpenSkyTrackResponse = {
|
||||
calllsign?: unknown;
|
||||
path?: unknown;
|
||||
};
|
||||
|
||||
// ── Shared Utilities ───────────────────────────────────────────────────
|
||||
|
||||
export const clamp = (value: number, min: number, max: number) =>
|
||||
Math.min(Math.max(value, min), max);
|
||||
|
||||
@ -48,19 +48,32 @@ export function adaptiveDownsample(
|
||||
return bestResult;
|
||||
}
|
||||
|
||||
/** Ramer-Douglas-Peucker simplification for 3D points. */
|
||||
/** Iterative Ramer-Douglas-Peucker simplification for 3D points.
|
||||
* Uses an explicit stack instead of recursion to avoid stack overflow
|
||||
* on trails with 5000+ points, and eliminates per-call .slice() allocations. */
|
||||
function rdpSimplify(
|
||||
points: ElevatedPoint[],
|
||||
epsilon: number,
|
||||
): ElevatedPoint[] {
|
||||
if (points.length <= 2) return points.slice();
|
||||
const n = points.length;
|
||||
if (n <= 2) return points.slice();
|
||||
|
||||
const first = points[0];
|
||||
const last = points[points.length - 1];
|
||||
const keep = new Uint8Array(n);
|
||||
keep[0] = 1;
|
||||
keep[n - 1] = 1;
|
||||
|
||||
// Explicit stack of [startIndex, endIndex] ranges to process
|
||||
const stack: [number, number][] = [[0, n - 1]];
|
||||
|
||||
while (stack.length > 0) {
|
||||
const [start, end] = stack.pop()!;
|
||||
let maxDist = 0;
|
||||
let maxIdx = 0;
|
||||
let maxIdx = start;
|
||||
|
||||
for (let i = 1; i < points.length - 1; i++) {
|
||||
const first = points[start];
|
||||
const last = points[end];
|
||||
|
||||
for (let i = start + 1; i < end; i++) {
|
||||
const d = perpendicularDistance(points[i], first, last);
|
||||
if (d > maxDist) {
|
||||
maxDist = d;
|
||||
@ -69,12 +82,17 @@ function rdpSimplify(
|
||||
}
|
||||
|
||||
if (maxDist > epsilon) {
|
||||
const left = rdpSimplify(points.slice(0, maxIdx + 1), epsilon);
|
||||
const right = rdpSimplify(points.slice(maxIdx), epsilon);
|
||||
return [...left.slice(0, -1), ...right];
|
||||
keep[maxIdx] = 1;
|
||||
if (maxIdx - start > 1) stack.push([start, maxIdx]);
|
||||
if (end - maxIdx > 1) stack.push([maxIdx, end]);
|
||||
}
|
||||
}
|
||||
|
||||
return [first, last];
|
||||
const result: ElevatedPoint[] = [];
|
||||
for (let i = 0; i < n; i++) {
|
||||
if (keep[i]) result.push(points[i]);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Perpendicular distance from a point to a line segment (2D, using lng/lat). */
|
||||
|
||||
@ -7,7 +7,7 @@
|
||||
* - trail-cleanup.ts: Downsampling, spike removal, corner rounding, loop removal
|
||||
*/
|
||||
|
||||
export { catmullRomSpline3D } from "./trail-spline";
|
||||
export { catmullRomSpline3D, catmullRomRespline3D } from "./trail-spline";
|
||||
export type { ElevatedPoint } from "./trail-spline";
|
||||
export { smoothAltitudeProfile, filterGroundSegments } from "./trail-altitude";
|
||||
export type { WaypointLike } from "./trail-altitude";
|
||||
|
||||
@ -281,3 +281,50 @@ function linearInterpolateSegment(
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-spline a window of points with C1 continuity at the boundaries.
|
||||
*
|
||||
* Unlike `catmullRomSpline3D`, which uses reflected virtual endpoints,
|
||||
* this function uses *actual* neighbouring points as tangent anchors.
|
||||
* This produces correct heading at the window boundaries, ideal for
|
||||
* smoothing a junction between two separately-generated paths.
|
||||
*
|
||||
* @param anchorBefore Point immediately before the window (tangent ref only)
|
||||
* @param windowPoints Points to re-spline (≥2, will be interpolated)
|
||||
* @param anchorAfter Point immediately after the window (tangent ref only)
|
||||
*/
|
||||
export function catmullRomRespline3D(
|
||||
anchorBefore: ElevatedPoint,
|
||||
windowPoints: ElevatedPoint[],
|
||||
anchorAfter: ElevatedPoint,
|
||||
minPtsPerSeg: number = 2,
|
||||
maxPtsPerSeg: number = 4,
|
||||
): ElevatedPoint[] {
|
||||
if (windowPoints.length < 2) return windowPoints.slice();
|
||||
|
||||
if (windowPoints.length === 2) {
|
||||
// With only 2 window points, build a 4-point extended array and
|
||||
// spline the single segment between them.
|
||||
const extended = [
|
||||
anchorBefore,
|
||||
windowPoints[0],
|
||||
windowPoints[1],
|
||||
anchorAfter,
|
||||
];
|
||||
return catmullRomSplineCore(extended, 1, 2, minPtsPerSeg, maxPtsPerSeg);
|
||||
}
|
||||
|
||||
// Build extended array: anchor + window + anchor.
|
||||
// catmullRomSplineCore uses extended[idx-1] and extended[idx+2] as
|
||||
// neighbouring control points, so anchors naturally provide the
|
||||
// correct tangent at the first and last window point.
|
||||
const extended = [anchorBefore, ...windowPoints, anchorAfter];
|
||||
return catmullRomSplineCore(
|
||||
extended,
|
||||
1,
|
||||
windowPoints.length,
|
||||
minPtsPerSeg,
|
||||
maxPtsPerSeg,
|
||||
);
|
||||
}
|
||||
|
||||
@ -7,9 +7,14 @@
|
||||
* thresholds are named constants.
|
||||
*/
|
||||
|
||||
import { snapLngToReference, unwrapLngPath } from "@/lib/geo";
|
||||
import {
|
||||
snapLngToReference,
|
||||
unwrapLngPath,
|
||||
greatCircleIntermediate,
|
||||
} from "@/lib/geo";
|
||||
import {
|
||||
catmullRomSpline3D,
|
||||
catmullRomRespline3D,
|
||||
filterGroundSegments,
|
||||
smoothAltitudeProfile,
|
||||
adaptiveDownsample,
|
||||
@ -26,7 +31,7 @@ import type { FlightState } from "@/lib/opensky";
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Number of recent live-trail points to append after the historical track. */
|
||||
const LIVE_TAIL_POINT_COUNT = 18;
|
||||
const LIVE_TAIL_POINT_COUNT = 24;
|
||||
|
||||
/** Maximum search depth (from end) when looking for overlap between the
|
||||
* historical track and the live tail. Increased to account for spline
|
||||
@ -82,57 +87,6 @@ const MAX_SPLINED_POINTS = 1800;
|
||||
* Spherical linear interpolation between two [lng, lat] points.
|
||||
* More accurate than linear interpolation for gaps > ~0.1°.
|
||||
*/
|
||||
function slerpBridge(
|
||||
aLng: number,
|
||||
aLat: number,
|
||||
bLng: number,
|
||||
bLat: number,
|
||||
t: number,
|
||||
): [number, number] {
|
||||
// For very small distances, linear interpolation is fine and avoids
|
||||
// numerical issues in the slerp formula.
|
||||
const dLng = bLng - aLng;
|
||||
const dLat = bLat - aLat;
|
||||
if (dLng * dLng + dLat * dLat < 0.01 * 0.01) {
|
||||
return [aLng + dLng * t, aLat + dLat * t];
|
||||
}
|
||||
|
||||
// Convert to radians.
|
||||
const toRad = Math.PI / 180;
|
||||
const la1 = aLat * toRad;
|
||||
const lo1 = aLng * toRad;
|
||||
const la2 = bLat * toRad;
|
||||
const lo2 = bLng * toRad;
|
||||
|
||||
// Great-circle angular distance.
|
||||
const dLat2 = la2 - la1;
|
||||
const dLon2 = lo2 - lo1;
|
||||
const a =
|
||||
Math.sin(dLat2 / 2) ** 2 +
|
||||
Math.cos(la1) * Math.cos(la2) * Math.sin(dLon2 / 2) ** 2;
|
||||
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||
|
||||
if (c < 1e-10) {
|
||||
return [aLng + dLng * t, aLat + dLat * t];
|
||||
}
|
||||
|
||||
const sinC = Math.sin(c);
|
||||
const A = Math.sin((1 - t) * c) / sinC;
|
||||
const B = Math.sin(t * c) / sinC;
|
||||
|
||||
const x =
|
||||
A * Math.cos(la1) * Math.cos(lo1) + B * Math.cos(la2) * Math.cos(lo2);
|
||||
const y =
|
||||
A * Math.cos(la1) * Math.sin(lo1) + B * Math.cos(la2) * Math.sin(lo2);
|
||||
const z = A * Math.sin(la1) + B * Math.sin(la2);
|
||||
|
||||
const toDeg = 180 / Math.PI;
|
||||
return [
|
||||
Math.atan2(y, x) * toDeg,
|
||||
Math.atan2(z, Math.sqrt(x * x + y * y)) * toDeg,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Cubic ease-in-out for altitude interpolation during bridge segments.
|
||||
* Produces a more natural transition than linear.
|
||||
@ -141,6 +95,31 @@ function cubicEaseInOut(t: number): number {
|
||||
return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Spline cache — avoids recomputing the expensive Steps 1-4 pipeline when
|
||||
// the historical track hasn't changed between poll cycles.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type SplinedTrack = {
|
||||
key: string;
|
||||
trackPositions: [number, number][];
|
||||
resultPath: [number, number][];
|
||||
resultAltitudes: Array<number | null>;
|
||||
lastWaypointTime: number | undefined;
|
||||
};
|
||||
|
||||
let splinedTrackCache: SplinedTrack | null = null;
|
||||
|
||||
export function clearSplinedTrackCache(): void {
|
||||
splinedTrackCache = null;
|
||||
}
|
||||
|
||||
function makeTrackCacheKey(track: FlightTrack): string {
|
||||
const first = track.path[0];
|
||||
const last = track.path[track.path.length - 1];
|
||||
return `${track.icao24}|${track.startTime}|${track.endTime}|${track.path.length}|${first?.latitude?.toFixed(4)}|${last?.longitude?.toFixed(4)}`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main stitch function
|
||||
// ---------------------------------------------------------------------------
|
||||
@ -170,6 +149,24 @@ export function stitchHistoricalTrail(
|
||||
flight: FlightState | null,
|
||||
fetchedAtMs: number,
|
||||
): StitchResult {
|
||||
// --- Steps 1-2 & 4: Use cached spline if the historical track is unchanged ---
|
||||
const cacheKey = makeTrackCacheKey(track);
|
||||
let trackPositions: [number, number][];
|
||||
let resultPath: [number, number][];
|
||||
let resultAltitudes: Array<number | null>;
|
||||
let lastWaypointTime: number | undefined;
|
||||
|
||||
if (splinedTrackCache && splinedTrackCache.key === cacheKey) {
|
||||
// Cache hit — reuse expensive spline result, clone since Steps 5-8 mutate.
|
||||
trackPositions = splinedTrackCache.trackPositions;
|
||||
resultPath = splinedTrackCache.resultPath.map(
|
||||
(p) => [...p] as [number, number],
|
||||
);
|
||||
resultAltitudes = [...splinedTrackCache.resultAltitudes];
|
||||
lastWaypointTime = splinedTrackCache.lastWaypointTime;
|
||||
} else {
|
||||
// Cache miss — run full Steps 1-2 & 4 pipeline.
|
||||
|
||||
// --- Step 1: Filter ground segments ---
|
||||
const airborneWaypoints = filterGroundSegments(track.path);
|
||||
const waypoints = airborneWaypoints ?? track.path;
|
||||
@ -189,10 +186,49 @@ export function stitchHistoricalTrail(
|
||||
}
|
||||
|
||||
// Unwrap longitudes to avoid dateline artifacts.
|
||||
const trackPositions = unwrapLngPath(rawPositions);
|
||||
const trackAltitudes = [...rawAltitudes];
|
||||
trackPositions = unwrapLngPath(rawPositions);
|
||||
|
||||
// --- Step 4: Apply Catmull-Rom spline smoothing ---
|
||||
const defaultAlt =
|
||||
flight?.baroAltitude ?? rawAltitudes.find((a) => a != null) ?? 0;
|
||||
const smoothedAlts = smoothAltitudeProfile([...rawAltitudes], defaultAlt);
|
||||
|
||||
const elevatedWaypoints: [number, number, number][] = trackPositions.map(
|
||||
(p, i) => [p[0], p[1], smoothedAlts[i] ?? defaultAlt],
|
||||
);
|
||||
|
||||
const roundedWaypoints = roundSharpCorners3D(elevatedWaypoints, 15);
|
||||
let splinedPath = catmullRomSpline3D(roundedWaypoints, 6, 28);
|
||||
splinedPath = removePathLoops(splinedPath);
|
||||
|
||||
if (splinedPath.length > MAX_SPLINED_POINTS) {
|
||||
splinedPath = adaptiveDownsample(splinedPath, MAX_SPLINED_POINTS);
|
||||
}
|
||||
|
||||
lastWaypointTime = waypoints[waypoints.length - 1]?.time;
|
||||
|
||||
// Store in cache for next poll cycle.
|
||||
const cachedPath = splinedPath.map<[number, number]>((p) => [p[0], p[1]]);
|
||||
const cachedAlts = splinedPath.map<number | null>((p) => p[2]);
|
||||
splinedTrackCache = {
|
||||
key: cacheKey,
|
||||
trackPositions,
|
||||
resultPath: cachedPath,
|
||||
resultAltitudes: cachedAlts,
|
||||
lastWaypointTime,
|
||||
};
|
||||
|
||||
// Clone for mutation in Steps 5-8.
|
||||
resultPath = cachedPath.map((p) => [...p] as [number, number]);
|
||||
resultAltitudes = [...cachedAlts];
|
||||
}
|
||||
|
||||
// --- Step 3: Validate track proximity to live position ---
|
||||
const lowAltitude =
|
||||
flight && Number.isFinite(flight.baroAltitude)
|
||||
? flight.baroAltitude! < LOW_ALTITUDE_THRESHOLD
|
||||
: false;
|
||||
|
||||
const livePosAdjusted: [number, number] | null =
|
||||
livePosition && trackPositions.length > 0
|
||||
? [
|
||||
@ -204,7 +240,6 @@ export function stitchHistoricalTrail(
|
||||
]
|
||||
: livePosition;
|
||||
|
||||
const lastWaypointTime = waypoints[waypoints.length - 1]?.time;
|
||||
const nowSec = fetchedAtMs > 0 ? Math.floor(fetchedAtMs / 1000) : 0;
|
||||
const lastWaypointAgeSec =
|
||||
typeof lastWaypointTime === "number" && Number.isFinite(lastWaypointTime)
|
||||
@ -232,10 +267,6 @@ export function stitchHistoricalTrail(
|
||||
if (d2 < bestDistSq) bestDistSq = d2;
|
||||
}
|
||||
|
||||
const lowAltitude =
|
||||
flight && Number.isFinite(flight.baroAltitude)
|
||||
? flight.baroAltitude! < LOW_ALTITUDE_THRESHOLD
|
||||
: false;
|
||||
const maxRejectDeg = lowAltitude
|
||||
? TRACK_REJECT_LOW_ALT_DEG
|
||||
: TRACK_REJECT_HIGH_ALT_DEG;
|
||||
@ -249,35 +280,8 @@ export function stitchHistoricalTrail(
|
||||
}
|
||||
}
|
||||
|
||||
// --- Step 4: Apply Catmull-Rom spline smoothing ---
|
||||
// Build elevated points for spline interpolation.
|
||||
const defaultAlt =
|
||||
flight?.baroAltitude ?? rawAltitudes.find((a) => a != null) ?? 0;
|
||||
const smoothedAlts = smoothAltitudeProfile(trackAltitudes, defaultAlt);
|
||||
|
||||
const elevatedWaypoints: [number, number, number][] = trackPositions.map(
|
||||
(p, i) => [p[0], p[1], smoothedAlts[i] ?? defaultAlt],
|
||||
);
|
||||
|
||||
// Pre-process: round sharp corners with Bézier arcs so the spline
|
||||
// doesn't overshoot into self-intersecting loops at sharp turns.
|
||||
const roundedWaypoints = roundSharpCorners3D(elevatedWaypoints, 20);
|
||||
|
||||
// Apply Catmull-Rom spline to produce a smooth path.
|
||||
let splinedPath = catmullRomSpline3D(roundedWaypoints, 6, 28);
|
||||
|
||||
// Safety net: detect and remove any self-intersecting loops the
|
||||
// spline may still have produced (e.g. from outlier waypoints).
|
||||
splinedPath = removePathLoops(splinedPath);
|
||||
|
||||
// Downsample if the splined path is very dense.
|
||||
if (splinedPath.length > MAX_SPLINED_POINTS) {
|
||||
splinedPath = adaptiveDownsample(splinedPath, MAX_SPLINED_POINTS);
|
||||
}
|
||||
|
||||
// Separate back into 2D path + altitudes for compatibility with TrailEntry.
|
||||
const resultPath: [number, number][] = splinedPath.map((p) => [p[0], p[1]]);
|
||||
const resultAltitudes: Array<number | null> = splinedPath.map((p) => p[2]);
|
||||
let junctionCoord: [number, number] | null = null;
|
||||
let tailMerged = false;
|
||||
|
||||
// --- Step 5: Merge live tail ---
|
||||
if (liveTail && liveTail.path.length >= 2) {
|
||||
@ -298,10 +302,6 @@ export function stitchHistoricalTrail(
|
||||
refLng = nextLng;
|
||||
}
|
||||
|
||||
const lowAltitude =
|
||||
flight && Number.isFinite(flight.baroAltitude)
|
||||
? flight.baroAltitude! < LOW_ALTITUDE_THRESHOLD
|
||||
: false;
|
||||
const maxConnectGapDeg = lowAltitude
|
||||
? MAX_GAP_LOW_ALT_DEG
|
||||
: MAX_GAP_HIGH_ALT_DEG;
|
||||
@ -370,7 +370,7 @@ export function stitchHistoricalTrail(
|
||||
|
||||
for (let s = 1; s < steps; s++) {
|
||||
const t = s / steps;
|
||||
const [lng, lat] = slerpBridge(
|
||||
const [lng, lat] = greatCircleIntermediate(
|
||||
last[0],
|
||||
last[1],
|
||||
firstTail[0],
|
||||
@ -397,6 +397,16 @@ export function stitchHistoricalTrail(
|
||||
}
|
||||
}
|
||||
|
||||
// Save the junction coordinate AFTER merge strategy selection but
|
||||
// BEFORE appending tail points. This captures the correct boundary
|
||||
// regardless of which strategy ran (snap, bridge, small-gap snap).
|
||||
if (tailPath.length > 0 && resultPath.length > 0) {
|
||||
junctionCoord = [
|
||||
resultPath[resultPath.length - 1][0],
|
||||
resultPath[resultPath.length - 1][1],
|
||||
];
|
||||
}
|
||||
|
||||
// Append remaining tail points (skip consecutive duplicates + near-duplicates).
|
||||
for (let i = 0; i < tailPath.length; i++) {
|
||||
const pos = tailPath[i];
|
||||
@ -410,6 +420,7 @@ export function stitchHistoricalTrail(
|
||||
}
|
||||
resultPath.push(pos);
|
||||
resultAltitudes.push(alt);
|
||||
tailMerged = true;
|
||||
}
|
||||
}
|
||||
|
||||
@ -440,16 +451,120 @@ export function stitchHistoricalTrail(
|
||||
return { path: [], altitudes: [], valid: false };
|
||||
}
|
||||
|
||||
// --- Step 8: Round sharp corners at the historical↔live junction ---
|
||||
// The splined historical path has gentle per-point heading changes (~3-10°)
|
||||
// so roundSharpCorners3D will ONLY add arcs where there's a significant
|
||||
// heading discontinuity — typically at the merge junction or in the tail.
|
||||
// --- Step 8: Smooth the historical↔live junction with localized Catmull-Rom ---
|
||||
// Instead of just rounding sharp corners (roundSharpCorners3D), apply a
|
||||
// full Catmull-Rom re-spline over a window around the junction. This
|
||||
// produces C1-continuous curvature at the merge point, eliminating the
|
||||
// visible heading kink between the smooth historical spline and the raw
|
||||
// GPS tail.
|
||||
const JUNCTION_WINDOW_BEFORE = 30;
|
||||
const JUNCTION_WINDOW_AFTER = 24;
|
||||
const MIN_JUNCTION_WINDOW = 6;
|
||||
|
||||
let junctionIdx = -1;
|
||||
if (tailMerged && junctionCoord) {
|
||||
// Find the junction coordinate in the post-spike-removal array.
|
||||
// Spike removal may have shifted indices, so search the full array.
|
||||
let bestDist = Number.POSITIVE_INFINITY;
|
||||
for (let i = 0; i < cleaned.path.length; i++) {
|
||||
const dx = cleaned.path[i][0] - junctionCoord[0];
|
||||
const dy = cleaned.path[i][1] - junctionCoord[1];
|
||||
const d = dx * dx + dy * dy;
|
||||
if (d < bestDist) {
|
||||
bestDist = d;
|
||||
junctionIdx = i;
|
||||
}
|
||||
}
|
||||
// Only accept if the match is within a reasonable distance.
|
||||
// 4× MERGE_SNAP_DEG² accounts for minor shifts from spike removal.
|
||||
if (bestDist > MERGE_SNAP_DEG * MERGE_SNAP_DEG * 4) {
|
||||
junctionIdx = -1;
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
junctionIdx >= 0 &&
|
||||
junctionIdx < cleaned.path.length - 1 &&
|
||||
cleaned.path.length >= MIN_JUNCTION_WINDOW
|
||||
) {
|
||||
const winStart = Math.max(0, junctionIdx - JUNCTION_WINDOW_BEFORE);
|
||||
const winEnd = Math.min(
|
||||
cleaned.path.length,
|
||||
junctionIdx + JUNCTION_WINDOW_AFTER + 1,
|
||||
);
|
||||
|
||||
if (winEnd - winStart >= MIN_JUNCTION_WINDOW) {
|
||||
// Extract window as 3D points.
|
||||
const windowPoints: [number, number, number][] = [];
|
||||
for (let i = winStart; i < winEnd; i++) {
|
||||
windowPoints.push([
|
||||
cleaned.path[i][0],
|
||||
cleaned.path[i][1],
|
||||
(cleaned.altitudes[i] as number) ?? 0,
|
||||
]);
|
||||
}
|
||||
|
||||
// Use real neighbouring points as tangent anchors for correct
|
||||
// heading at the window boundaries.
|
||||
const anchorBefore: [number, number, number] =
|
||||
winStart > 0
|
||||
? [
|
||||
cleaned.path[winStart - 1][0],
|
||||
cleaned.path[winStart - 1][1],
|
||||
(cleaned.altitudes[winStart - 1] as number) ?? 0,
|
||||
]
|
||||
: windowPoints[0]; // fallback: mirror first point
|
||||
const anchorAfter: [number, number, number] =
|
||||
winEnd < cleaned.path.length
|
||||
? [
|
||||
cleaned.path[winEnd][0],
|
||||
cleaned.path[winEnd][1],
|
||||
(cleaned.altitudes[winEnd] as number) ?? 0,
|
||||
]
|
||||
: windowPoints[windowPoints.length - 1]; // fallback: mirror last
|
||||
|
||||
const resplined = catmullRomRespline3D(
|
||||
anchorBefore,
|
||||
windowPoints,
|
||||
anchorAfter,
|
||||
3,
|
||||
6,
|
||||
);
|
||||
|
||||
// Reconstruct the full path: prefix + re-splined junction + suffix.
|
||||
const prefix3D: [number, number, number][] = [];
|
||||
for (let i = 0; i < winStart; i++) {
|
||||
prefix3D.push([
|
||||
cleaned.path[i][0],
|
||||
cleaned.path[i][1],
|
||||
(cleaned.altitudes[i] as number) ?? 0,
|
||||
]);
|
||||
}
|
||||
const suffix3D: [number, number, number][] = [];
|
||||
for (let i = winEnd; i < cleaned.path.length; i++) {
|
||||
suffix3D.push([
|
||||
cleaned.path[i][0],
|
||||
cleaned.path[i][1],
|
||||
(cleaned.altitudes[i] as number) ?? 0,
|
||||
]);
|
||||
}
|
||||
|
||||
const final3D = [...prefix3D, ...resplined, ...suffix3D];
|
||||
const finalPath = final3D.map<[number, number]>((p) => [p[0], p[1]]);
|
||||
const finalAlts = final3D.map<number | null>((p) => p[2]);
|
||||
|
||||
return { path: finalPath, altitudes: finalAlts, valid: true };
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback when no junction was found (no live tail, disconnect, etc.):
|
||||
// keep sharp-corner rounding for any remaining heading discontinuities.
|
||||
const merged3D: [number, number, number][] = cleaned.path.map((p, i) => [
|
||||
p[0],
|
||||
p[1],
|
||||
(cleaned.altitudes[i] as number) ?? 0,
|
||||
]);
|
||||
const rounded = roundSharpCorners3D(merged3D, 25);
|
||||
const rounded = roundSharpCorners3D(merged3D, 15);
|
||||
const finalPath = rounded.map<[number, number]>((p) => [p[0], p[1]]);
|
||||
const finalAlts = rounded.map<number | null>((p) => p[2]);
|
||||
|
||||
|
||||
@ -4,3 +4,7 @@ import { twMerge } from "tailwind-merge";
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
||||
/** Clamp a number to [min, max]. */
|
||||
export const clamp = (value: number, min: number, max: number) =>
|
||||
Math.min(Math.max(value, min), max);
|
||||
|
||||
Reference in New Issue
Block a user