diff --git a/.gitignore b/.gitignore
index f09a999..41a245a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -46,5 +46,18 @@ next-env.d.ts
docs.txt
ROADMAP.local.md
-# heap analysis
-scripts/
\ No newline at end of file
+# 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/
\ No newline at end of file
diff --git a/README.md b/README.md
index b71e505..349d2fb 100644
--- a/README.md
+++ b/README.md
@@ -6,33 +6,28 @@ Aeris renders live air traffic over the world's busiest airspaces on a premium d
[Live Demo](https://aeris.edbn.me)
-
-
-
-
## 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) |
-| Animation | Motion (Framer Motion) |
-| Data | OpenSky Network API |
-| Hosting | Vercel |
+| Layer | Technology |
+| --------- | ---------------------------------------------------------------- |
+| Framework | Next.js 16 (App Router, Turbopack) |
+| Language | TypeScript |
+| Styling | Tailwind CSS v4 |
+| Map | MapLibre GL JS |
+| WebGL | Deck.gl 9 (ScenegraphLayer, IconLayer, PathLayer, MapboxOverlay) |
+| Animation | Motion (Framer Motion) |
+| Data | Airplanes.live / adsb.lol / OpenSky (3-tier fallback) |
+| Hosting | Vercel |
## Getting Started
```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
-│ └── 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
@@ -84,15 +109,11 @@ 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 |
+| Variable | Required | Description |
+| ------------------- | -------- | ------------------------------- |
+| `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
diff --git a/docs/3D-MODELS.md b/docs/3D-MODELS.md
new file mode 100644
index 0000000..c633890
--- /dev/null
+++ b/docs/3D-MODELS.md
@@ -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 `` 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.
diff --git a/next.config.ts b/next.config.ts
index b78dabf..395e952 100644
--- a/next.config.ts
+++ b/next.config.ts
@@ -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';
diff --git a/package.json b/package.json
index b2e637d..a94d4bd 100644
--- a/package.json
+++ b/package.json
@@ -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"
}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index e4cebd0..7320ccc 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -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
diff --git a/public/models/aircraft/NOTICE.md b/public/models/aircraft/NOTICE.md
new file mode 100644
index 0000000..34b299d
--- /dev/null
+++ b/public/models/aircraft/NOTICE.md
@@ -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
diff --git a/public/models/aircraft/b737.glb b/public/models/aircraft/b737.glb
new file mode 100644
index 0000000..b5d4679
Binary files /dev/null and b/public/models/aircraft/b737.glb differ
diff --git a/public/models/aircraft/bizjet.glb b/public/models/aircraft/bizjet.glb
new file mode 100644
index 0000000..79d6b22
Binary files /dev/null and b/public/models/aircraft/bizjet.glb differ
diff --git a/public/models/aircraft/drone.glb b/public/models/aircraft/drone.glb
new file mode 100644
index 0000000..2c7ded4
Binary files /dev/null and b/public/models/aircraft/drone.glb differ
diff --git a/public/models/aircraft/fighter.glb b/public/models/aircraft/fighter.glb
new file mode 100644
index 0000000..7db31fd
Binary files /dev/null and b/public/models/aircraft/fighter.glb differ
diff --git a/public/models/aircraft/generic.glb b/public/models/aircraft/generic.glb
new file mode 100644
index 0000000..e20daa8
Binary files /dev/null and b/public/models/aircraft/generic.glb differ
diff --git a/public/models/aircraft/glider.glb b/public/models/aircraft/glider.glb
new file mode 100644
index 0000000..e55c36a
Binary files /dev/null and b/public/models/aircraft/glider.glb differ
diff --git a/public/models/aircraft/helicopter.glb b/public/models/aircraft/helicopter.glb
new file mode 100644
index 0000000..820250f
Binary files /dev/null and b/public/models/aircraft/helicopter.glb differ
diff --git a/public/models/aircraft/light-prop.glb b/public/models/aircraft/light-prop.glb
new file mode 100644
index 0000000..2c7ded4
Binary files /dev/null and b/public/models/aircraft/light-prop.glb differ
diff --git a/public/models/aircraft/narrowbody.glb b/public/models/aircraft/narrowbody.glb
new file mode 100644
index 0000000..e20daa8
Binary files /dev/null and b/public/models/aircraft/narrowbody.glb differ
diff --git a/public/models/aircraft/regional-jet.glb b/public/models/aircraft/regional-jet.glb
new file mode 100644
index 0000000..d8cfa91
Binary files /dev/null and b/public/models/aircraft/regional-jet.glb differ
diff --git a/public/models/aircraft/turboprop.glb b/public/models/aircraft/turboprop.glb
new file mode 100644
index 0000000..1ee6931
Binary files /dev/null and b/public/models/aircraft/turboprop.glb differ
diff --git a/public/models/aircraft/widebody-2eng.glb b/public/models/aircraft/widebody-2eng.glb
new file mode 100644
index 0000000..30f2746
Binary files /dev/null and b/public/models/aircraft/widebody-2eng.glb differ
diff --git a/public/models/aircraft/widebody-4eng.glb b/public/models/aircraft/widebody-4eng.glb
new file mode 100644
index 0000000..e3e6315
Binary files /dev/null and b/public/models/aircraft/widebody-4eng.glb differ
diff --git a/src/app/api/aircraft-photos/route.ts b/src/app/api/aircraft-photos/route.ts
index 8bf611e..2e22b8f 100644
--- a/src/app/api/aircraft-photos/route.ts
+++ b/src/app/api/aircraft-photos/route.ts
@@ -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 {
+ 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 {
+ 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();
+
+ 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 {
+ 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;
+ 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();
+
+ 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 {
+ 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();
+
+ 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 {
- 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();
+ const photos: NormalizedPhoto[] = [];
- if (!upstream.ok) {
- return NextResponse.json(
- { error: "Upstream error" },
- {
- status: upstream.status >= 500 ? 502 : upstream.status,
- headers: { "Cache-Control": "no-store" },
- },
- );
- }
-
- const data: unknown = await upstream.json();
-
- return NextResponse.json(data, {
- 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" } },
- );
+ function addPhoto(raw: NormalizedPhoto) {
+ if (seenUrls.has(raw.url)) return;
+ const safe = sanitizePhoto(raw);
+ if (!safe) return;
+ seenUrls.add(safe.url);
+ photos.push(safe);
}
+
+ 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);
+
+ 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",
+ },
+ });
}
diff --git a/src/app/api/airspace-tiles/route.ts b/src/app/api/airspace-tiles/route.ts
new file mode 100644
index 0000000..d53899a
--- /dev/null
+++ b/src/app/api/airspace-tiles/route.ts
@@ -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();
+
+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>();
+
+// ── 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 {
+ if (activeCount < MAX_CONCURRENT) {
+ activeCount++;
+ } else {
+ // Wait for a slot — releaseSlot will increment activeCount before resolving.
+ await new Promise((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((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 {
+ 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((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 {
+ 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": "*",
+ },
+ });
+}
diff --git a/src/app/api/atc/feeds/route.ts b/src/app/api/atc/feeds/route.ts
new file mode 100644
index 0000000..8494605
--- /dev/null
+++ b/src/app/api/atc/feeds/route.ts
@@ -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,
+ });
+}
diff --git a/src/app/api/atc/stream/route.ts b/src/app/api/atc/stream/route.ts
new file mode 100644
index 0000000..18beef0
--- /dev/null
+++ b/src/app/api/atc/stream/route.ts
@@ -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