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) - Screenshot 2026-02-15 112222 - image - - ## 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