feat: update OpenSky API integration and improve flight tracking
- Increased max duration for flight data requests from 10 to 30 seconds. - Adjusted fetch timeouts and cache TTL to enhance performance. - Implemented snapping of bounding box coordinates to a grid for better cache sharing. - Enhanced flight data fetching logic to adapt polling intervals based on remaining API credits. - Introduced a new adaptive polling mechanism with different tiers based on credit usage. - Updated flight layers animation duration for smoother transitions. - Added a new slider component for orbit speed control in the UI. - Refactored flight card positioning logic to ensure it remains within viewport bounds. - Improved control panel layout for better mobile usability. - Adjusted default orbit speed settings for a more user-friendly experience.
This commit is contained in:
@ -13,3 +13,6 @@ OPENSKY_CLIENT_SECRET=
|
|||||||
# Deprecated — will be removed. Only works for accounts created before March 2025.
|
# Deprecated — will be removed. Only works for accounts created before March 2025.
|
||||||
# OPENSKY_USERNAME=
|
# OPENSKY_USERNAME=
|
||||||
# OPENSKY_PASSWORD=
|
# OPENSKY_PASSWORD=
|
||||||
|
|
||||||
|
# ─── Analytics (optional) ─────────────────────────────────────────────────────
|
||||||
|
# NEXT_PUBLIC_GA_ID=G-XXXXXXXXXX
|
||||||
11
README.md
11
README.md
@ -4,6 +4,8 @@ Real-time 3D flight tracking — altitude-aware, visually stunning.
|
|||||||
|
|
||||||
Aeris renders live air traffic over the world's busiest airspaces on a premium dark-mode map. Flights are separated by altitude in true 3D: low altitudes glow cyan, high altitudes shift to gold. Select a city, and the camera glides to that airspace with spring-eased animation.
|
Aeris renders live air traffic over the world's busiest airspaces on a premium dark-mode map. Flights are separated by altitude in true 3D: low altitudes glow cyan, high altitudes shift to gold. Select a city, and the camera glides to that airspace with spring-eased animation.
|
||||||
|
|
||||||
|
**[Live Demo](https://aeris.edbn.me)**
|
||||||
|
|
||||||
## Stack
|
## Stack
|
||||||
|
|
||||||
| Layer | Technology |
|
| Layer | Technology |
|
||||||
@ -15,6 +17,7 @@ Aeris renders live air traffic over the world's busiest airspaces on a premium d
|
|||||||
| WebGL | Deck.gl 9 (IconLayer, PathLayer, MapboxOverlay) |
|
| WebGL | Deck.gl 9 (IconLayer, PathLayer, MapboxOverlay) |
|
||||||
| Animation | Motion (Framer Motion) |
|
| Animation | Motion (Framer Motion) |
|
||||||
| Data | OpenSky Network API |
|
| Data | OpenSky Network API |
|
||||||
|
| Hosting | Vercel |
|
||||||
|
|
||||||
## Getting Started
|
## Getting Started
|
||||||
|
|
||||||
@ -46,9 +49,10 @@ src/
|
|||||||
│ ├── control-panel.tsx Tabbed dialog — search, map style, settings
|
│ ├── control-panel.tsx Tabbed dialog — search, map style, settings
|
||||||
│ ├── flight-card.tsx Hover card with flight details
|
│ ├── flight-card.tsx Hover card with flight details
|
||||||
│ ├── scroll-area.tsx Custom scrollbar
|
│ ├── scroll-area.tsx Custom scrollbar
|
||||||
|
│ ├── slider.tsx Orbit speed slider (Radix)
|
||||||
│ └── status-bar.tsx Live status indicator
|
│ └── status-bar.tsx Live status indicator
|
||||||
├── hooks/
|
├── hooks/
|
||||||
│ ├── use-flights.ts Polling hook for OpenSky API
|
│ ├── use-flights.ts Adaptive polling hook with credit-aware throttling
|
||||||
│ ├── use-settings.tsx Settings context with localStorage persistence
|
│ ├── use-settings.tsx Settings context with localStorage persistence
|
||||||
│ └── use-trail-history.ts Trail accumulation + Catmull-Rom smoothing
|
│ └── use-trail-history.ts Trail accumulation + Catmull-Rom smoothing
|
||||||
└── lib/
|
└── lib/
|
||||||
@ -66,16 +70,19 @@ src/
|
|||||||
- **Smooth animation**: Catmull-Rom spline trails, per-frame interpolation between polls
|
- **Smooth animation**: Catmull-Rom spline trails, per-frame interpolation between polls
|
||||||
- **Glassmorphism**: `backdrop-blur-2xl`, `bg-black/60`, `border-white/[0.08]`
|
- **Glassmorphism**: `backdrop-blur-2xl`, `bg-black/60`, `border-white/[0.08]`
|
||||||
- **Spring physics**: All UI transitions use spring easing
|
- **Spring physics**: All UI transitions use spring easing
|
||||||
|
- **Responsive**: Desktop sidebar dialog, mobile bottom-sheet with thumb-zone tab bar
|
||||||
|
- **API efficiency**: Adaptive polling (30 s → 5 min) based on remaining credits, Page Visibility pause, grid-snapped cache
|
||||||
- **Persistence**: Settings + map style in localStorage, `?city=IATA` URL deep links
|
- **Persistence**: Settings + map style in localStorage, `?city=IATA` URL deep links
|
||||||
|
|
||||||
## Environment Variables
|
## Environment Variables
|
||||||
|
|
||||||
| Variable | Required | Description |
|
| Variable | Required | Description |
|
||||||
| ----------------------- | -------- | ------------------------------ |
|
| ----------------------- | -------- | ------------------------------- |
|
||||||
| `OPENSKY_CLIENT_ID` | No | OAuth2 client ID (recommended) |
|
| `OPENSKY_CLIENT_ID` | No | OAuth2 client ID (recommended) |
|
||||||
| `OPENSKY_CLIENT_SECRET` | No | OAuth2 client secret |
|
| `OPENSKY_CLIENT_SECRET` | No | OAuth2 client secret |
|
||||||
| `OPENSKY_USERNAME` | No | Basic auth username (legacy) |
|
| `OPENSKY_USERNAME` | No | Basic auth username (legacy) |
|
||||||
| `OPENSKY_PASSWORD` | No | Basic auth password (legacy) |
|
| `OPENSKY_PASSWORD` | No | Basic auth password (legacy) |
|
||||||
|
| `NEXT_PUBLIC_GA_ID` | No | Google Analytics measurement ID |
|
||||||
|
|
||||||
Without credentials, anonymous access is used (~10 requests/minute).
|
Without credentials, anonymous access is used (~10 requests/minute).
|
||||||
|
|
||||||
|
|||||||
@ -22,6 +22,7 @@
|
|||||||
"@loaders.gl/gltf": "^4.3.4",
|
"@loaders.gl/gltf": "^4.3.4",
|
||||||
"@luma.gl/core": "^9.2.6",
|
"@luma.gl/core": "^9.2.6",
|
||||||
"@luma.gl/webgl": "^9.2.6",
|
"@luma.gl/webgl": "^9.2.6",
|
||||||
|
"@radix-ui/react-slider": "^1.3.6",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"lucide-react": "^0.564.0",
|
"lucide-react": "^0.564.0",
|
||||||
"maplibre-gl": "^5.18.0",
|
"maplibre-gl": "^5.18.0",
|
||||||
|
|||||||
232
pnpm-lock.yaml
generated
232
pnpm-lock.yaml
generated
@ -38,6 +38,9 @@ importers:
|
|||||||
'@luma.gl/webgl':
|
'@luma.gl/webgl':
|
||||||
specifier: ^9.2.6
|
specifier: ^9.2.6
|
||||||
version: 9.2.6(@luma.gl/core@9.2.6)
|
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)
|
||||||
clsx:
|
clsx:
|
||||||
specifier: ^2.1.1
|
specifier: ^2.1.1
|
||||||
version: 2.1.1
|
version: 2.1.1
|
||||||
@ -732,6 +735,132 @@ packages:
|
|||||||
'@probe.gl/stats@4.1.0':
|
'@probe.gl/stats@4.1.0':
|
||||||
resolution: {integrity: sha512-EI413MkWKBDVNIfLdqbeNSJTs7ToBz/KVGkwi3D+dQrSIkRI2IYbWGAU3xX+D6+CI4ls8ehxMhNpUVMaZggDvQ==}
|
resolution: {integrity: sha512-EI413MkWKBDVNIfLdqbeNSJTs7ToBz/KVGkwi3D+dQrSIkRI2IYbWGAU3xX+D6+CI4ls8ehxMhNpUVMaZggDvQ==}
|
||||||
|
|
||||||
|
'@radix-ui/number@1.1.1':
|
||||||
|
resolution: {integrity: sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==}
|
||||||
|
|
||||||
|
'@radix-ui/primitive@1.1.3':
|
||||||
|
resolution: {integrity: sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==}
|
||||||
|
|
||||||
|
'@radix-ui/react-collection@1.1.7':
|
||||||
|
resolution: {integrity: sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==}
|
||||||
|
peerDependencies:
|
||||||
|
'@types/react': '*'
|
||||||
|
'@types/react-dom': '*'
|
||||||
|
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||||
|
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@types/react':
|
||||||
|
optional: true
|
||||||
|
'@types/react-dom':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@radix-ui/react-compose-refs@1.1.2':
|
||||||
|
resolution: {integrity: sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==}
|
||||||
|
peerDependencies:
|
||||||
|
'@types/react': '*'
|
||||||
|
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@types/react':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@radix-ui/react-context@1.1.2':
|
||||||
|
resolution: {integrity: sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==}
|
||||||
|
peerDependencies:
|
||||||
|
'@types/react': '*'
|
||||||
|
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@types/react':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@radix-ui/react-direction@1.1.1':
|
||||||
|
resolution: {integrity: sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==}
|
||||||
|
peerDependencies:
|
||||||
|
'@types/react': '*'
|
||||||
|
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@types/react':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@radix-ui/react-primitive@2.1.3':
|
||||||
|
resolution: {integrity: sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==}
|
||||||
|
peerDependencies:
|
||||||
|
'@types/react': '*'
|
||||||
|
'@types/react-dom': '*'
|
||||||
|
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||||
|
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@types/react':
|
||||||
|
optional: true
|
||||||
|
'@types/react-dom':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@radix-ui/react-slider@1.3.6':
|
||||||
|
resolution: {integrity: sha512-JPYb1GuM1bxfjMRlNLE+BcmBC8onfCi60Blk7OBqi2MLTFdS+8401U4uFjnwkOr49BLmXxLC6JHkvAsx5OJvHw==}
|
||||||
|
peerDependencies:
|
||||||
|
'@types/react': '*'
|
||||||
|
'@types/react-dom': '*'
|
||||||
|
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||||
|
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@types/react':
|
||||||
|
optional: true
|
||||||
|
'@types/react-dom':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@radix-ui/react-slot@1.2.3':
|
||||||
|
resolution: {integrity: sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==}
|
||||||
|
peerDependencies:
|
||||||
|
'@types/react': '*'
|
||||||
|
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@types/react':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@radix-ui/react-use-controllable-state@1.2.2':
|
||||||
|
resolution: {integrity: sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==}
|
||||||
|
peerDependencies:
|
||||||
|
'@types/react': '*'
|
||||||
|
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@types/react':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@radix-ui/react-use-effect-event@0.0.2':
|
||||||
|
resolution: {integrity: sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==}
|
||||||
|
peerDependencies:
|
||||||
|
'@types/react': '*'
|
||||||
|
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@types/react':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@radix-ui/react-use-layout-effect@1.1.1':
|
||||||
|
resolution: {integrity: sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==}
|
||||||
|
peerDependencies:
|
||||||
|
'@types/react': '*'
|
||||||
|
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@types/react':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@radix-ui/react-use-previous@1.1.1':
|
||||||
|
resolution: {integrity: sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==}
|
||||||
|
peerDependencies:
|
||||||
|
'@types/react': '*'
|
||||||
|
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@types/react':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@radix-ui/react-use-size@1.1.1':
|
||||||
|
resolution: {integrity: sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==}
|
||||||
|
peerDependencies:
|
||||||
|
'@types/react': '*'
|
||||||
|
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@types/react':
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@rtsao/scc@1.1.0':
|
'@rtsao/scc@1.1.0':
|
||||||
resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==}
|
resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==}
|
||||||
|
|
||||||
@ -3284,6 +3413,109 @@ snapshots:
|
|||||||
|
|
||||||
'@probe.gl/stats@4.1.0': {}
|
'@probe.gl/stats@4.1.0': {}
|
||||||
|
|
||||||
|
'@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)':
|
||||||
|
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)
|
||||||
|
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)':
|
||||||
|
dependencies:
|
||||||
|
react: 19.2.3
|
||||||
|
optionalDependencies:
|
||||||
|
'@types/react': 19.2.14
|
||||||
|
|
||||||
|
'@radix-ui/react-context@1.1.2(@types/react@19.2.14)(react@19.2.3)':
|
||||||
|
dependencies:
|
||||||
|
react: 19.2.3
|
||||||
|
optionalDependencies:
|
||||||
|
'@types/react': 19.2.14
|
||||||
|
|
||||||
|
'@radix-ui/react-direction@1.1.1(@types/react@19.2.14)(react@19.2.3)':
|
||||||
|
dependencies:
|
||||||
|
react: 19.2.3
|
||||||
|
optionalDependencies:
|
||||||
|
'@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)':
|
||||||
|
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)
|
||||||
|
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)':
|
||||||
|
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)
|
||||||
|
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)':
|
||||||
|
dependencies:
|
||||||
|
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.3)
|
||||||
|
react: 19.2.3
|
||||||
|
optionalDependencies:
|
||||||
|
'@types/react': 19.2.14
|
||||||
|
|
||||||
|
'@radix-ui/react-use-controllable-state@1.2.2(@types/react@19.2.14)(react@19.2.3)':
|
||||||
|
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
|
||||||
|
optionalDependencies:
|
||||||
|
'@types/react': 19.2.14
|
||||||
|
|
||||||
|
'@radix-ui/react-use-effect-event@0.0.2(@types/react@19.2.14)(react@19.2.3)':
|
||||||
|
dependencies:
|
||||||
|
'@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.3)
|
||||||
|
react: 19.2.3
|
||||||
|
optionalDependencies:
|
||||||
|
'@types/react': 19.2.14
|
||||||
|
|
||||||
|
'@radix-ui/react-use-layout-effect@1.1.1(@types/react@19.2.14)(react@19.2.3)':
|
||||||
|
dependencies:
|
||||||
|
react: 19.2.3
|
||||||
|
optionalDependencies:
|
||||||
|
'@types/react': 19.2.14
|
||||||
|
|
||||||
|
'@radix-ui/react-use-previous@1.1.1(@types/react@19.2.14)(react@19.2.3)':
|
||||||
|
dependencies:
|
||||||
|
react: 19.2.3
|
||||||
|
optionalDependencies:
|
||||||
|
'@types/react': 19.2.14
|
||||||
|
|
||||||
|
'@radix-ui/react-use-size@1.1.1(@types/react@19.2.14)(react@19.2.3)':
|
||||||
|
dependencies:
|
||||||
|
'@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.3)
|
||||||
|
react: 19.2.3
|
||||||
|
optionalDependencies:
|
||||||
|
'@types/react': 19.2.14
|
||||||
|
|
||||||
'@rtsao/scc@1.1.0': {}
|
'@rtsao/scc@1.1.0': {}
|
||||||
|
|
||||||
'@swc/helpers@0.5.15':
|
'@swc/helpers@0.5.15':
|
||||||
|
|||||||
@ -1,17 +1,16 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
|
||||||
export const maxDuration = 10;
|
export const maxDuration = 30;
|
||||||
|
|
||||||
const OPENSKY_BASE = "https://opensky-network.org/api";
|
const OPENSKY_BASE = "https://opensky-network.org/api";
|
||||||
const OPENSKY_TOKEN_URL =
|
const OPENSKY_TOKEN_URL =
|
||||||
"https://auth.opensky-network.org/auth/realms/opensky-network/protocol/openid-connect/token";
|
"https://auth.opensky-network.org/auth/realms/opensky-network/protocol/openid-connect/token";
|
||||||
const TOKEN_TIMEOUT_MS = 3_000;
|
const TOKEN_TIMEOUT_MS = 5_000;
|
||||||
const FETCH_TIMEOUT_MS = 5_000;
|
const FETCH_TIMEOUT_MS = 20_000;
|
||||||
const CACHE_TTL_MS = 10_000;
|
const CACHE_TTL_MS = 25_000;
|
||||||
const MAX_REQUESTS_PER_MINUTE = 20;
|
const MAX_REQUESTS_PER_MINUTE = 20;
|
||||||
const MAX_BBOX_SPAN = 20;
|
const MAX_BBOX_SPAN = 20;
|
||||||
|
const CACHE_GRID_STEP = 0.5;
|
||||||
// --- OAuth2 token cache ---
|
|
||||||
|
|
||||||
let cachedToken: string | null = null;
|
let cachedToken: string | null = null;
|
||||||
let tokenExpiresAt = 0;
|
let tokenExpiresAt = 0;
|
||||||
@ -58,8 +57,6 @@ async function getAccessToken(): Promise<string | null> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Auth ---
|
|
||||||
|
|
||||||
type AuthMode = "oauth2" | "basic" | "anonymous";
|
type AuthMode = "oauth2" | "basic" | "anonymous";
|
||||||
let authDisabled = false;
|
let authDisabled = false;
|
||||||
let authLoggedOnce = false;
|
let authLoggedOnce = false;
|
||||||
@ -99,8 +96,6 @@ function logAuthOnce() {
|
|||||||
console.info(`[aeris] Auth mode: ${detectAuthMode()}`);
|
console.info(`[aeris] Auth mode: ${detectAuthMode()}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Per-IP rate limiter ---
|
|
||||||
|
|
||||||
const requestLog = new Map<string, number[]>();
|
const requestLog = new Map<string, number[]>();
|
||||||
|
|
||||||
function isRateLimited(ip: string): boolean {
|
function isRateLimited(ip: string): boolean {
|
||||||
@ -120,8 +115,6 @@ function isRateLimited(ip: string): boolean {
|
|||||||
return recent.length > MAX_REQUESTS_PER_MINUTE;
|
return recent.length > MAX_REQUESTS_PER_MINUTE;
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Response cache ---
|
|
||||||
|
|
||||||
let responseCache: {
|
let responseCache: {
|
||||||
key: string;
|
key: string;
|
||||||
data: unknown;
|
data: unknown;
|
||||||
@ -143,8 +136,6 @@ function setCache(key: string, data: unknown): void {
|
|||||||
responseCache = { key, data, expiresAt: Date.now() + CACHE_TTL_MS };
|
responseCache = { key, data, expiresAt: Date.now() + CACHE_TTL_MS };
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Fetch with timeout ---
|
|
||||||
|
|
||||||
async function fetchOpenSky(
|
async function fetchOpenSky(
|
||||||
url: string,
|
url: string,
|
||||||
useAuth: boolean,
|
useAuth: boolean,
|
||||||
@ -163,8 +154,6 @@ async function fetchOpenSky(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Utilities ---
|
|
||||||
|
|
||||||
function clamp(val: number, min: number, max: number) {
|
function clamp(val: number, min: number, max: number) {
|
||||||
return Math.max(min, Math.min(max, val));
|
return Math.max(min, Math.min(max, val));
|
||||||
}
|
}
|
||||||
@ -180,8 +169,6 @@ function json(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Route handler ---
|
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
const ip =
|
const ip =
|
||||||
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ??
|
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ??
|
||||||
@ -228,8 +215,18 @@ export async function GET(request: NextRequest) {
|
|||||||
|
|
||||||
logAuthOnce();
|
logAuthOnce();
|
||||||
|
|
||||||
const url = `${OPENSKY_BASE}/states/all?lamin=${coords.lamin}&lamax=${coords.lamax}&lomin=${coords.lomin}&lomax=${coords.lomax}`;
|
// Snap bbox to grid so nearby viewports share cache entries
|
||||||
const cacheKey = `${coords.lamin},${coords.lamax},${coords.lomin},${coords.lomax}`;
|
const snap = (v: number) =>
|
||||||
|
Math.round(v / CACHE_GRID_STEP) * CACHE_GRID_STEP;
|
||||||
|
const snapped = {
|
||||||
|
lamin: snap(coords.lamin),
|
||||||
|
lamax: snap(coords.lamax),
|
||||||
|
lomin: snap(coords.lomin),
|
||||||
|
lomax: snap(coords.lomax),
|
||||||
|
};
|
||||||
|
|
||||||
|
const url = `${OPENSKY_BASE}/states/all?lamin=${snapped.lamin}&lamax=${snapped.lamax}&lomin=${snapped.lomin}&lomax=${snapped.lomax}`;
|
||||||
|
const cacheKey = `${snapped.lamin},${snapped.lamax},${snapped.lomin},${snapped.lomax}`;
|
||||||
|
|
||||||
const cached = getCached(cacheKey);
|
const cached = getCached(cacheKey);
|
||||||
if (cached) {
|
if (cached) {
|
||||||
@ -273,6 +270,10 @@ export async function GET(request: NextRequest) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const creditsRaw = res.headers.get("X-Rate-Limit-Remaining");
|
||||||
|
const creditsRemaining =
|
||||||
|
creditsRaw !== null ? parseInt(creditsRaw, 10) : null;
|
||||||
|
|
||||||
let data;
|
let data;
|
||||||
try {
|
try {
|
||||||
data = await res.json();
|
data = await res.json();
|
||||||
@ -281,6 +282,10 @@ export async function GET(request: NextRequest) {
|
|||||||
return json({ error: "Upstream returned invalid response" }, 502);
|
return json({ error: "Upstream returned invalid response" }, 502);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (creditsRemaining !== null && !Number.isNaN(creditsRemaining)) {
|
||||||
|
data.creditsRemaining = creditsRemaining;
|
||||||
|
}
|
||||||
|
|
||||||
setCache(cacheKey, data);
|
setCache(cacheKey, data);
|
||||||
return json(data, 200, { "X-Cache": "MISS" });
|
return json(data, 200, { "X-Cache": "MISS" });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@ -90,7 +90,7 @@ function CameraController({ city }: { city: City }) {
|
|||||||
prevCityRef.current = city.id;
|
prevCityRef.current = city.id;
|
||||||
map.flyTo({
|
map.flyTo({
|
||||||
center: city.coordinates,
|
center: city.coordinates,
|
||||||
zoom: 11,
|
zoom: 9.2,
|
||||||
pitch: 49,
|
pitch: 49,
|
||||||
bearing: 27.4,
|
bearing: 27.4,
|
||||||
duration: 2800,
|
duration: 2800,
|
||||||
|
|||||||
@ -6,13 +6,10 @@ import { IconLayer, PathLayer } from "@deck.gl/layers";
|
|||||||
import { useMap } from "./map";
|
import { useMap } from "./map";
|
||||||
import { altitudeToColor, altitudeToElevation } from "@/lib/flight-utils";
|
import { altitudeToColor, altitudeToElevation } from "@/lib/flight-utils";
|
||||||
import type { FlightState } from "@/lib/opensky";
|
import type { FlightState } from "@/lib/opensky";
|
||||||
import {
|
import { type TrailEntry } from "@/hooks/use-trail-history";
|
||||||
SAMPLES_PER_SEGMENT,
|
|
||||||
type TrailEntry,
|
|
||||||
} from "@/hooks/use-trail-history";
|
|
||||||
import type { PickingInfo } from "@deck.gl/core";
|
import type { PickingInfo } from "@deck.gl/core";
|
||||||
|
|
||||||
const ANIM_DURATION_MS = 15_000;
|
const ANIM_DURATION_MS = 30_000;
|
||||||
const TELEPORT_THRESHOLD = 0.3; // degrees
|
const TELEPORT_THRESHOLD = 0.3; // degrees
|
||||||
|
|
||||||
type Snapshot = { lng: number; lat: number; alt: number; track: number };
|
type Snapshot = { lng: number; lat: number; alt: number; track: number };
|
||||||
@ -312,7 +309,6 @@ export function FlightLayers({
|
|||||||
const basePath = d.path.map(
|
const basePath = d.path.map(
|
||||||
(p) => [p[0], p[1], alt] as [number, number, number],
|
(p) => [p[0], p[1], alt] as [number, number, number],
|
||||||
);
|
);
|
||||||
// Reveal spline points progressively to match the animated position
|
|
||||||
if (
|
if (
|
||||||
animFlight &&
|
animFlight &&
|
||||||
animFlight.longitude != null &&
|
animFlight.longitude != null &&
|
||||||
@ -321,15 +317,26 @@ export function FlightLayers({
|
|||||||
) {
|
) {
|
||||||
const ax = animFlight.longitude;
|
const ax = animFlight.longitude;
|
||||||
const ay = animFlight.latitude;
|
const ay = animFlight.latitude;
|
||||||
const segLen = Math.min(
|
|
||||||
SAMPLES_PER_SEGMENT,
|
|
||||||
basePath.length - 1,
|
|
||||||
);
|
|
||||||
const reveal = Math.floor(tPos * segLen);
|
|
||||||
const collapseFrom = basePath.length - segLen + reveal;
|
|
||||||
|
|
||||||
for (let i = collapseFrom; i < basePath.length; i++) {
|
const curr = currSnapshotsRef.current.get(d.icao24);
|
||||||
|
const prev = prevSnapshotsRef.current.get(d.icao24);
|
||||||
|
|
||||||
|
if (curr && prev) {
|
||||||
|
// Direction from prev → curr
|
||||||
|
const fdx = curr.lng - prev.lng;
|
||||||
|
const fdy = curr.lat - prev.lat;
|
||||||
|
|
||||||
|
// Walk backward; collapse points that are ahead of the
|
||||||
|
// animated position (positive projection along flight dir)
|
||||||
|
for (let i = basePath.length - 1; i >= 0; i--) {
|
||||||
|
const vx = basePath[i][0] - ax;
|
||||||
|
const vy = basePath[i][1] - ay;
|
||||||
|
if (vx * fdx + vy * fdy > 0) {
|
||||||
basePath[i] = [ax, ay, alt];
|
basePath[i] = [ax, ay, alt];
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
basePath[basePath.length - 1] = [ax, ay, alt];
|
basePath[basePath.length - 1] = [ax, ay, alt];
|
||||||
}
|
}
|
||||||
|
|||||||
@ -15,7 +15,6 @@ import {
|
|||||||
Route,
|
Route,
|
||||||
Layers,
|
Layers,
|
||||||
Palette,
|
Palette,
|
||||||
Gauge,
|
|
||||||
ArrowLeftRight,
|
ArrowLeftRight,
|
||||||
Github,
|
Github,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
@ -23,6 +22,7 @@ import { CITIES, type City } from "@/lib/cities";
|
|||||||
import { MAP_STYLES, type MapStyle } from "@/lib/map-styles";
|
import { MAP_STYLES, type MapStyle } from "@/lib/map-styles";
|
||||||
import { useSettings, type OrbitDirection } from "@/hooks/use-settings";
|
import { useSettings, type OrbitDirection } from "@/hooks/use-settings";
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
|
import { Slider } from "@/components/ui/slider";
|
||||||
|
|
||||||
type TabId = "search" | "style" | "settings";
|
type TabId = "search" | "style" | "settings";
|
||||||
|
|
||||||
@ -52,7 +52,6 @@ export function ControlPanel({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Trigger buttons */}
|
|
||||||
{TABS.map(({ id, icon: Icon, label }) => (
|
{TABS.map(({ id, icon: Icon, label }) => (
|
||||||
<motion.button
|
<motion.button
|
||||||
key={id}
|
key={id}
|
||||||
@ -72,7 +71,6 @@ export function ControlPanel({
|
|||||||
</motion.button>
|
</motion.button>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{/* Dialog */}
|
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{openTab && (
|
{openTab && (
|
||||||
<PanelDialog
|
<PanelDialog
|
||||||
@ -158,7 +156,6 @@ function PanelDialog({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Backdrop */}
|
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0 }}
|
initial={{ opacity: 0 }}
|
||||||
animate={{ opacity: 1 }}
|
animate={{ opacity: 1 }}
|
||||||
@ -168,7 +165,6 @@ function PanelDialog({
|
|||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Panel */}
|
|
||||||
<motion.div
|
<motion.div
|
||||||
ref={dialogRef}
|
ref={dialogRef}
|
||||||
initial={{ opacity: 0, scale: 0.94, y: 16 }}
|
initial={{ opacity: 0, scale: 0.94, y: 16 }}
|
||||||
@ -180,14 +176,14 @@ function PanelDialog({
|
|||||||
damping: 30,
|
damping: 30,
|
||||||
mass: 0.8,
|
mass: 0.8,
|
||||||
}}
|
}}
|
||||||
className="fixed left-1/2 top-1/2 z-90 w-full max-w-180 -translate-x-1/2 -translate-y-1/2 px-4"
|
className="fixed inset-x-3 bottom-3 top-auto z-90 sm:inset-auto sm:left-1/2 sm:top-1/2 sm:w-full sm:max-w-180 sm:-translate-x-1/2 sm:-translate-y-1/2 sm:px-4"
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
aria-labelledby="panel-dialog-title"
|
aria-labelledby="panel-dialog-title"
|
||||||
>
|
>
|
||||||
<div className="flex overflow-hidden rounded-3xl border border-white/8 bg-[#0c0c0e]/92 shadow-[0_40px_100px_rgba(0,0,0,0.8),0_0_0_1px_rgba(255,255,255,0.04)_inset] backdrop-blur-3xl backdrop-saturate-[1.8]">
|
<div className="flex flex-col sm:flex-row overflow-hidden rounded-2xl sm:rounded-3xl border border-white/8 bg-[#0c0c0e]/92 shadow-[0_40px_100px_rgba(0,0,0,0.8),0_0_0_1px_rgba(255,255,255,0.04)_inset] backdrop-blur-3xl backdrop-saturate-[1.8] h-[75vh] sm:h-auto sm:max-h-[85vh]">
|
||||||
{/* Sidebar */}
|
{/* Desktop sidebar (hidden on mobile) */}
|
||||||
<div className="flex w-52 shrink-0 flex-col border-r border-white/6 py-5 px-3">
|
<div className="hidden sm:flex w-52 shrink-0 flex-col border-r border-white/6 py-5 px-3">
|
||||||
<p className="mb-3 px-2 text-[11px] font-semibold uppercase tracking-widest text-white/20">
|
<p className="mb-3 px-2 text-[11px] font-semibold uppercase tracking-widest text-white/20">
|
||||||
Controls
|
Controls
|
||||||
</p>
|
</p>
|
||||||
@ -246,9 +242,18 @@ function PanelDialog({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content */}
|
<div className="flex flex-1 flex-col min-h-0 sm:h-120">
|
||||||
<div className="flex flex-1 flex-col h-120">
|
{/* Mobile header */}
|
||||||
<div className="flex items-center justify-between px-5 pt-5 pb-2">
|
<div className="flex sm:hidden items-center justify-between px-4 pt-4 pb-2">
|
||||||
|
<h2
|
||||||
|
id="panel-dialog-title"
|
||||||
|
className="text-[14px] font-semibold tracking-tight text-white/90"
|
||||||
|
>
|
||||||
|
{TABS.find((t) => t.id === activeTab)?.label}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
{/* Desktop header */}
|
||||||
|
<div className="hidden sm:flex items-center justify-between px-5 pt-5 pb-2">
|
||||||
<h2
|
<h2
|
||||||
id="panel-dialog-title"
|
id="panel-dialog-title"
|
||||||
className="text-[15px] font-semibold tracking-tight text-white/90"
|
className="text-[15px] font-semibold tracking-tight text-white/90"
|
||||||
@ -266,7 +271,6 @@ function PanelDialog({
|
|||||||
</motion.button>
|
</motion.button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content */}
|
|
||||||
<div className="relative flex-1 overflow-hidden">
|
<div className="relative flex-1 overflow-hidden">
|
||||||
<AnimatePresence mode="wait" initial={false}>
|
<AnimatePresence mode="wait" initial={false}>
|
||||||
{activeTab === "search" && (
|
{activeTab === "search" && (
|
||||||
@ -293,6 +297,50 @@ function PanelDialog({
|
|||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile tab bar — at bottom for thumb reach */}
|
||||||
|
<div className="flex sm:hidden items-center gap-1 border-t border-white/6 px-3 pt-2 pb-3">
|
||||||
|
<nav className="flex flex-1 gap-1">
|
||||||
|
{TABS.map(({ id, icon: Icon, label }) => {
|
||||||
|
const active = id === activeTab;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={id}
|
||||||
|
onClick={() => onTabChange(id)}
|
||||||
|
className={`relative flex flex-1 items-center justify-center gap-1.5 rounded-lg px-2 py-2 text-center transition-colors ${
|
||||||
|
active
|
||||||
|
? "text-white/90"
|
||||||
|
: "text-white/35 active:bg-white/6"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{active && (
|
||||||
|
<motion.div
|
||||||
|
layoutId="panel-tab-bg-mobile"
|
||||||
|
className="absolute inset-0 rounded-lg bg-white/8"
|
||||||
|
transition={{
|
||||||
|
type: "spring",
|
||||||
|
stiffness: 400,
|
||||||
|
damping: 30,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Icon className="relative h-3.5 w-3.5 shrink-0" />
|
||||||
|
<span className="relative text-[12px] font-semibold">
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
<motion.button
|
||||||
|
onClick={onClose}
|
||||||
|
className="ml-1 flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-white/6 transition-colors active:bg-white/12"
|
||||||
|
whileTap={{ scale: 0.9 }}
|
||||||
|
aria-label="Close"
|
||||||
|
>
|
||||||
|
<X className="h-3.5 w-3.5 text-white/40" />
|
||||||
|
</motion.button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</>
|
</>
|
||||||
@ -396,7 +444,7 @@ function StyleContent({
|
|||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<ScrollArea className="h-full">
|
<ScrollArea className="h-full">
|
||||||
<div className="grid grid-cols-2 gap-3 p-5 pt-2">
|
<div className="grid grid-cols-2 sm:grid-cols-2 gap-2.5 sm:gap-3 p-4 sm:p-5 pt-2">
|
||||||
{MAP_STYLES.map((style, i) => (
|
{MAP_STYLES.map((style, i) => (
|
||||||
<StyleTile
|
<StyleTile
|
||||||
key={style.id}
|
key={style.id}
|
||||||
@ -501,12 +549,16 @@ function StyleTile({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const ORBIT_SPEEDS = [
|
const ORBIT_SPEED_PRESETS = [
|
||||||
{ label: "Slow", value: 0.06 },
|
{ label: "Slow", value: 0.06 },
|
||||||
{ label: "Normal", value: 0.15 },
|
{ label: "Normal", value: 0.15 },
|
||||||
{ label: "Fast", value: 0.35 },
|
{ label: "Fast", value: 0.35 },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const ORBIT_SPEED_MIN = 0.02;
|
||||||
|
const ORBIT_SPEED_MAX = 0.5;
|
||||||
|
const ORBIT_SNAP_THRESHOLD = 0.025;
|
||||||
|
|
||||||
const ORBIT_DIRECTIONS: { label: string; value: OrbitDirection }[] = [
|
const ORBIT_DIRECTIONS: { label: string; value: OrbitDirection }[] = [
|
||||||
{ label: "Clockwise", value: "clockwise" },
|
{ label: "Clockwise", value: "clockwise" },
|
||||||
{ label: "Counter", value: "counter-clockwise" },
|
{ label: "Counter", value: "counter-clockwise" },
|
||||||
@ -528,10 +580,7 @@ function SettingsContent() {
|
|||||||
|
|
||||||
{settings.autoOrbit && (
|
{settings.autoOrbit && (
|
||||||
<>
|
<>
|
||||||
<SegmentRow
|
<OrbitSpeedSlider
|
||||||
icon={<Gauge className="h-4 w-4" />}
|
|
||||||
title="Orbit speed"
|
|
||||||
options={ORBIT_SPEEDS}
|
|
||||||
value={settings.orbitSpeed}
|
value={settings.orbitSpeed}
|
||||||
onChange={(v) => update("orbitSpeed", v)}
|
onChange={(v) => update("orbitSpeed", v)}
|
||||||
/>
|
/>
|
||||||
@ -573,6 +622,75 @@ function SettingsContent() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function OrbitSpeedSlider({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
value: number;
|
||||||
|
onChange: (v: number) => void;
|
||||||
|
}) {
|
||||||
|
const activeLabel =
|
||||||
|
ORBIT_SPEED_PRESETS.find(
|
||||||
|
(p) => Math.abs(p.value - value) < ORBIT_SNAP_THRESHOLD,
|
||||||
|
)?.label ?? `${value.toFixed(2)}×`;
|
||||||
|
|
||||||
|
function handleChange(vals: number[]) {
|
||||||
|
let raw = vals[0];
|
||||||
|
for (const preset of ORBIT_SPEED_PRESETS) {
|
||||||
|
if (Math.abs(raw - preset.value) < ORBIT_SNAP_THRESHOLD) {
|
||||||
|
raw = preset.value;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onChange(raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex w-full items-center gap-3.5 rounded-xl px-3 py-2.5 text-left">
|
||||||
|
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-white/5 text-white/35 ring-1 ring-white/6">
|
||||||
|
<RotateCw className="h-4 w-4" />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-1 min-w-0 flex-col gap-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="text-[13px] font-medium text-white/80">Orbit speed</p>
|
||||||
|
<span className="text-[11px] font-semibold text-white/40 tabular-nums">
|
||||||
|
{activeLabel}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="relative">
|
||||||
|
<Slider
|
||||||
|
min={ORBIT_SPEED_MIN}
|
||||||
|
max={ORBIT_SPEED_MAX}
|
||||||
|
step={0.01}
|
||||||
|
value={[value]}
|
||||||
|
onValueChange={handleChange}
|
||||||
|
aria-label="Orbit speed"
|
||||||
|
/>
|
||||||
|
<div className="pointer-events-none absolute inset-x-0 top-1/2 -translate-y-1/2 flex justify-between px-[2px]">
|
||||||
|
{ORBIT_SPEED_PRESETS.map((preset) => {
|
||||||
|
const pct =
|
||||||
|
((preset.value - ORBIT_SPEED_MIN) /
|
||||||
|
(ORBIT_SPEED_MAX - ORBIT_SPEED_MIN)) *
|
||||||
|
100;
|
||||||
|
const isActive =
|
||||||
|
Math.abs(preset.value - value) < ORBIT_SNAP_THRESHOLD;
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
key={preset.label}
|
||||||
|
className={`absolute h-1.5 w-1.5 rounded-full -translate-x-1/2 -translate-y-1/2 transition-colors ${
|
||||||
|
isActive ? "bg-white/50" : "bg-white/15"
|
||||||
|
}`}
|
||||||
|
style={{ left: `${pct}%` }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function SettingRow({
|
function SettingRow({
|
||||||
icon,
|
icon,
|
||||||
title,
|
title,
|
||||||
|
|||||||
@ -30,12 +30,12 @@ export function FlightCard({ flight, x, y }: FlightCardProps) {
|
|||||||
damping: 28,
|
damping: 28,
|
||||||
mass: 0.8,
|
mass: 0.8,
|
||||||
}}
|
}}
|
||||||
className="pointer-events-none fixed z-50 w-72"
|
className="pointer-events-none fixed z-50 w-64 sm:w-72"
|
||||||
role="status"
|
role="status"
|
||||||
aria-live="polite"
|
aria-live="polite"
|
||||||
style={{
|
style={{
|
||||||
left: `min(${x + 16}px, calc(100vw - 304px))`,
|
left: `clamp(8px, ${x + 16}px, calc(100vw - 272px))`,
|
||||||
top: `min(${y - 8}px, calc(100vh - 280px))`,
|
top: `clamp(8px, ${y - 8}px, calc(100vh - 280px))`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="rounded-2xl border border-white/8 bg-black/60 p-4 shadow-2xl shadow-black/40 backdrop-blur-2xl">
|
<div className="rounded-2xl border border-white/8 bg-black/60 p-4 shadow-2xl shadow-black/40 backdrop-blur-2xl">
|
||||||
|
|||||||
25
src/components/ui/slider.tsx
Normal file
25
src/components/ui/slider.tsx
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import * as SliderPrimitive from "@radix-ui/react-slider";
|
||||||
|
|
||||||
|
type SliderProps = React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root>;
|
||||||
|
|
||||||
|
const Slider = React.forwardRef<
|
||||||
|
React.ComponentRef<typeof SliderPrimitive.Root>,
|
||||||
|
SliderProps
|
||||||
|
>(({ className = "", ...props }, ref) => (
|
||||||
|
<SliderPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
className={`relative flex w-full touch-none select-none items-center ${className}`}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<SliderPrimitive.Track className="relative h-1 w-full grow overflow-hidden rounded-full bg-white/8">
|
||||||
|
<SliderPrimitive.Range className="absolute h-full bg-white/30" />
|
||||||
|
</SliderPrimitive.Track>
|
||||||
|
<SliderPrimitive.Thumb className="block h-3.5 w-3.5 rounded-full bg-white shadow-sm shadow-black/40 ring-1 ring-white/20 transition-colors hover:bg-white/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/40" />
|
||||||
|
</SliderPrimitive.Root>
|
||||||
|
));
|
||||||
|
Slider.displayName = "Slider";
|
||||||
|
|
||||||
|
export { Slider };
|
||||||
@ -8,8 +8,27 @@ import {
|
|||||||
} from "@/lib/opensky";
|
} from "@/lib/opensky";
|
||||||
import type { City } from "@/lib/cities";
|
import type { City } from "@/lib/cities";
|
||||||
|
|
||||||
const POLL_INTERVAL_MS = 15_000;
|
const BASE_POLL_MS = 30_000;
|
||||||
|
const CONSERVATIVE_POLL_MS = 60_000;
|
||||||
|
const CAUTIOUS_POLL_MS = 120_000;
|
||||||
|
const EMERGENCY_POLL_MS = 300_000;
|
||||||
|
|
||||||
|
// Credit thresholds (out of 4 000 daily for authenticated users)
|
||||||
|
const CREDIT_TIER_CONSERVATIVE = 2_000; // < 50 % remaining
|
||||||
|
const CREDIT_TIER_CAUTIOUS = 800; // < 20 %
|
||||||
|
const CREDIT_TIER_EMERGENCY = 200; // < 5 %
|
||||||
|
|
||||||
const RATE_LIMIT_BACKOFF_MS = 30_000;
|
const RATE_LIMIT_BACKOFF_MS = 30_000;
|
||||||
|
const VISIBILITY_RESUME_STALE_MS = 60_000;
|
||||||
|
|
||||||
|
/** Choose a poll interval based on how many API credits remain today. */
|
||||||
|
function adaptiveInterval(creditsRemaining: number | null): number {
|
||||||
|
if (creditsRemaining === null) return BASE_POLL_MS; // unknown → default
|
||||||
|
if (creditsRemaining < CREDIT_TIER_EMERGENCY) return EMERGENCY_POLL_MS;
|
||||||
|
if (creditsRemaining < CREDIT_TIER_CAUTIOUS) return CAUTIOUS_POLL_MS;
|
||||||
|
if (creditsRemaining < CREDIT_TIER_CONSERVATIVE) return CONSERVATIVE_POLL_MS;
|
||||||
|
return BASE_POLL_MS;
|
||||||
|
}
|
||||||
|
|
||||||
export function useFlights(city: City | null) {
|
export function useFlights(city: City | null) {
|
||||||
const [flights, setFlights] = useState<FlightState[]>([]);
|
const [flights, setFlights] = useState<FlightState[]>([]);
|
||||||
@ -17,10 +36,14 @@ export function useFlights(city: City | null) {
|
|||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [rateLimited, setRateLimited] = useState(false);
|
const [rateLimited, setRateLimited] = useState(false);
|
||||||
const [retryIn, setRetryIn] = useState(0);
|
const [retryIn, setRetryIn] = useState(0);
|
||||||
|
|
||||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const countdownRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
const countdownRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
const abortRef = useRef<AbortController | null>(null);
|
const abortRef = useRef<AbortController | null>(null);
|
||||||
|
|
||||||
|
const creditsRef = useRef<number | null>(null);
|
||||||
|
const lastFetchRef = useRef(0);
|
||||||
|
|
||||||
const clearCountdown = useCallback(() => {
|
const clearCountdown = useCallback(() => {
|
||||||
if (countdownRef.current) {
|
if (countdownRef.current) {
|
||||||
clearInterval(countdownRef.current);
|
clearInterval(countdownRef.current);
|
||||||
@ -43,9 +66,16 @@ export function useFlights(city: City | null) {
|
|||||||
[clearCountdown],
|
[clearCountdown],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const clearSchedule = useCallback(() => {
|
||||||
|
if (timerRef.current) {
|
||||||
|
clearTimeout(timerRef.current);
|
||||||
|
timerRef.current = null;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
const scheduleNext = useCallback(
|
const scheduleNext = useCallback(
|
||||||
(target: City, delayMs: number) => {
|
(target: City, delayMs: number) => {
|
||||||
if (timerRef.current) clearTimeout(timerRef.current);
|
clearSchedule();
|
||||||
timerRef.current = setTimeout(() => fetchData(target), delayMs);
|
timerRef.current = setTimeout(() => fetchData(target), delayMs);
|
||||||
},
|
},
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
@ -61,6 +91,7 @@ export function useFlights(city: City | null) {
|
|||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
const bbox = bboxFromCenter(
|
const bbox = bboxFromCenter(
|
||||||
target.coordinates[0],
|
target.coordinates[0],
|
||||||
target.coordinates[1],
|
target.coordinates[1],
|
||||||
@ -78,11 +109,19 @@ export function useFlights(city: City | null) {
|
|||||||
setRateLimited(false);
|
setRateLimited(false);
|
||||||
clearCountdown();
|
clearCountdown();
|
||||||
setFlights(result.flights);
|
setFlights(result.flights);
|
||||||
scheduleNext(target, POLL_INTERVAL_MS);
|
lastFetchRef.current = Date.now();
|
||||||
|
|
||||||
|
if (result.creditsRemaining !== null) {
|
||||||
|
creditsRef.current = result.creditsRemaining;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextInterval = adaptiveInterval(creditsRef.current);
|
||||||
|
scheduleNext(target, nextInterval);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof DOMException && err.name === "AbortError") return;
|
if (err instanceof DOMException && err.name === "AbortError") return;
|
||||||
setError(err instanceof Error ? err.message : "Unknown error");
|
setError(err instanceof Error ? err.message : "Unknown error");
|
||||||
setFlights([]);
|
setFlights([]);
|
||||||
|
// After an error, back off longer to avoid hammering a sick upstream
|
||||||
scheduleNext(target, RATE_LIMIT_BACKOFF_MS);
|
scheduleNext(target, RATE_LIMIT_BACKOFF_MS);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@ -92,10 +131,40 @@ export function useFlights(city: City | null) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (timerRef.current) {
|
if (!city) return;
|
||||||
clearTimeout(timerRef.current);
|
|
||||||
timerRef.current = null;
|
const activeCity = city;
|
||||||
|
|
||||||
|
function onVisibilityChange() {
|
||||||
|
if (document.visibilityState === "visible") {
|
||||||
|
// Tab just became visible — decide whether to fetch now or schedule
|
||||||
|
const elapsed = Date.now() - lastFetchRef.current;
|
||||||
|
|
||||||
|
if (elapsed >= VISIBILITY_RESUME_STALE_MS) {
|
||||||
|
// Data is stale after being hidden for a while; fetch immediately
|
||||||
|
clearSchedule();
|
||||||
|
fetchData(activeCity);
|
||||||
|
} else {
|
||||||
|
// Data is still fresh — schedule for the remaining time
|
||||||
|
const interval = adaptiveInterval(creditsRef.current);
|
||||||
|
const remaining = Math.max(1_000, interval - elapsed);
|
||||||
|
clearSchedule();
|
||||||
|
scheduleNext(activeCity, remaining);
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
// Tab hidden — cancel scheduled poll to save credits
|
||||||
|
clearSchedule();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener("visibilitychange", onVisibilityChange);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("visibilitychange", onVisibilityChange);
|
||||||
|
};
|
||||||
|
}, [city, fetchData, scheduleNext, clearSchedule]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
clearSchedule();
|
||||||
|
|
||||||
if (!city) {
|
if (!city) {
|
||||||
setFlights([]);
|
setFlights([]);
|
||||||
@ -109,11 +178,11 @@ export function useFlights(city: City | null) {
|
|||||||
fetchData(city);
|
fetchData(city);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
if (timerRef.current) clearTimeout(timerRef.current);
|
clearSchedule();
|
||||||
abortRef.current?.abort();
|
abortRef.current?.abort();
|
||||||
clearCountdown();
|
clearCountdown();
|
||||||
};
|
};
|
||||||
}, [city, fetchData, clearCountdown]);
|
}, [city, fetchData, clearCountdown, clearSchedule]);
|
||||||
|
|
||||||
return { flights, loading, error, rateLimited, retryIn };
|
return { flights, loading, error, rateLimited, retryIn };
|
||||||
}
|
}
|
||||||
|
|||||||
@ -24,7 +24,7 @@ export type Settings = {
|
|||||||
|
|
||||||
const DEFAULT_SETTINGS: Settings = {
|
const DEFAULT_SETTINGS: Settings = {
|
||||||
autoOrbit: true,
|
autoOrbit: true,
|
||||||
orbitSpeed: 0.15,
|
orbitSpeed: 0.06,
|
||||||
orbitDirection: "clockwise",
|
orbitDirection: "clockwise",
|
||||||
showTrails: true,
|
showTrails: true,
|
||||||
showShadows: true,
|
showShadows: true,
|
||||||
|
|||||||
@ -19,6 +19,7 @@ export type OpenSkyResponse = {
|
|||||||
time: number;
|
time: number;
|
||||||
states: (string | number | boolean | null)[][] | null;
|
states: (string | number | boolean | null)[][] | null;
|
||||||
rateLimited?: boolean;
|
rateLimited?: boolean;
|
||||||
|
creditsRemaining?: number | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
function parseStates(raw: OpenSkyResponse): FlightState[] {
|
function parseStates(raw: OpenSkyResponse): FlightState[] {
|
||||||
@ -53,6 +54,7 @@ function parseStates(raw: OpenSkyResponse): FlightState[] {
|
|||||||
export type FetchResult = {
|
export type FetchResult = {
|
||||||
flights: FlightState[];
|
flights: FlightState[];
|
||||||
rateLimited: boolean;
|
rateLimited: boolean;
|
||||||
|
creditsRemaining: number | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Fetch flights via the server-side proxy. */
|
/** Fetch flights via the server-side proxy. */
|
||||||
@ -70,18 +72,22 @@ export async function fetchFlightsByBbox(
|
|||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
// Don't throw — let the hook retry gracefully
|
// Don't throw — let the hook retry gracefully
|
||||||
console.warn(`[aeris] Flight API returned ${res.status}`);
|
console.warn(`[aeris] Flight API returned ${res.status}`);
|
||||||
return { flights: [], rateLimited: false };
|
return { flights: [], rateLimited: false, creditsRemaining: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
const data: OpenSkyResponse = await res.json();
|
const data: OpenSkyResponse = await res.json();
|
||||||
|
|
||||||
if (data.rateLimited) {
|
if (data.rateLimited) {
|
||||||
console.warn("[aeris] OpenSky rate limit hit, backing off");
|
console.warn("[aeris] OpenSky rate limit hit, backing off");
|
||||||
return { flights: [], rateLimited: true };
|
return { flights: [], rateLimited: true, creditsRemaining: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
const flights = parseStates(data);
|
const flights = parseStates(data);
|
||||||
return { flights, rateLimited: false };
|
return {
|
||||||
|
flights,
|
||||||
|
rateLimited: false,
|
||||||
|
creditsRemaining: data.creditsRemaining ?? null,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function bboxFromCenter(
|
export function bboxFromCenter(
|
||||||
|
|||||||
Reference in New Issue
Block a user