From 4431c84acefd6a3d10e1de14d52f1437d3162843 Mon Sep 17 00:00:00 2001 From: Kewonit <108450560+kewonit@users.noreply.github.com> Date: Sat, 14 Feb 2026 14:13:20 +0530 Subject: [PATCH] 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. --- .env.example | 5 +- README.md | 21 ++- package.json | 1 + pnpm-lock.yaml | 232 +++++++++++++++++++++++++++ src/app/api/flights/route.ts | 45 +++--- src/components/flight-tracker.tsx | 2 +- src/components/map/flight-layers.tsx | 35 ++-- src/components/ui/control-panel.tsx | 156 +++++++++++++++--- src/components/ui/flight-card.tsx | 6 +- src/components/ui/slider.tsx | 25 +++ src/hooks/use-flights.ts | 85 +++++++++- src/hooks/use-settings.tsx | 2 +- src/lib/opensky.ts | 12 +- 13 files changed, 550 insertions(+), 77 deletions(-) create mode 100644 src/components/ui/slider.tsx diff --git a/.env.example b/.env.example index ddae9cb..6c0be13 100644 --- a/.env.example +++ b/.env.example @@ -12,4 +12,7 @@ OPENSKY_CLIENT_SECRET= # OPTION 2: Basic Auth (Legacy accounts only) # Deprecated — will be removed. Only works for accounts created before March 2025. # OPENSKY_USERNAME= -# OPENSKY_PASSWORD= \ No newline at end of file +# OPENSKY_PASSWORD= + +# ─── Analytics (optional) ───────────────────────────────────────────────────── +# NEXT_PUBLIC_GA_ID=G-XXXXXXXXXX \ No newline at end of file diff --git a/README.md b/README.md index 6e122e0..c0d4592 100644 --- a/README.md +++ b/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. +**[Live Demo](https://aeris.edbn.me)** + ## Stack | 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) | | Animation | Motion (Framer Motion) | | Data | OpenSky Network API | +| Hosting | Vercel | ## Getting Started @@ -46,9 +49,10 @@ src/ │ ├── control-panel.tsx Tabbed dialog — search, map style, settings │ ├── flight-card.tsx Hover card with flight details │ ├── scroll-area.tsx Custom scrollbar +│ ├── slider.tsx Orbit speed slider (Radix) │ └── status-bar.tsx Live status indicator ├── 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-trail-history.ts Trail accumulation + Catmull-Rom smoothing └── lib/ @@ -66,16 +70,19 @@ src/ - **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 +- **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 ## 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) | +| Variable | Required | Description | +| ----------------------- | -------- | ------------------------------- | +| `OPENSKY_CLIENT_ID` | No | OAuth2 client ID (recommended) | +| `OPENSKY_CLIENT_SECRET` | No | OAuth2 client secret | +| `OPENSKY_USERNAME` | No | Basic auth username (legacy) | +| `OPENSKY_PASSWORD` | No | Basic auth password (legacy) | +| `NEXT_PUBLIC_GA_ID` | No | Google Analytics measurement ID | Without credentials, anonymous access is used (~10 requests/minute). diff --git a/package.json b/package.json index 21b1aa9..aa4fba4 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "@loaders.gl/gltf": "^4.3.4", "@luma.gl/core": "^9.2.6", "@luma.gl/webgl": "^9.2.6", + "@radix-ui/react-slider": "^1.3.6", "clsx": "^2.1.1", "lucide-react": "^0.564.0", "maplibre-gl": "^5.18.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5864952..8e3fdbd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -38,6 +38,9 @@ importers: '@luma.gl/webgl': specifier: ^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: specifier: ^2.1.1 version: 2.1.1 @@ -732,6 +735,132 @@ packages: '@probe.gl/stats@4.1.0': 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': resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==} @@ -3284,6 +3413,109 @@ snapshots: '@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': {} '@swc/helpers@0.5.15': diff --git a/src/app/api/flights/route.ts b/src/app/api/flights/route.ts index 4d36d1a..af92896 100644 --- a/src/app/api/flights/route.ts +++ b/src/app/api/flights/route.ts @@ -1,17 +1,16 @@ import { NextRequest, NextResponse } from "next/server"; -export const maxDuration = 10; +export const maxDuration = 30; const OPENSKY_BASE = "https://opensky-network.org/api"; const OPENSKY_TOKEN_URL = "https://auth.opensky-network.org/auth/realms/opensky-network/protocol/openid-connect/token"; -const TOKEN_TIMEOUT_MS = 3_000; -const FETCH_TIMEOUT_MS = 5_000; -const CACHE_TTL_MS = 10_000; +const TOKEN_TIMEOUT_MS = 5_000; +const FETCH_TIMEOUT_MS = 20_000; +const CACHE_TTL_MS = 25_000; const MAX_REQUESTS_PER_MINUTE = 20; const MAX_BBOX_SPAN = 20; - -// --- OAuth2 token cache --- +const CACHE_GRID_STEP = 0.5; let cachedToken: string | null = null; let tokenExpiresAt = 0; @@ -58,8 +57,6 @@ async function getAccessToken(): Promise { } } -// --- Auth --- - type AuthMode = "oauth2" | "basic" | "anonymous"; let authDisabled = false; let authLoggedOnce = false; @@ -99,8 +96,6 @@ function logAuthOnce() { console.info(`[aeris] Auth mode: ${detectAuthMode()}`); } -// --- Per-IP rate limiter --- - const requestLog = new Map(); function isRateLimited(ip: string): boolean { @@ -120,8 +115,6 @@ function isRateLimited(ip: string): boolean { return recent.length > MAX_REQUESTS_PER_MINUTE; } -// --- Response cache --- - let responseCache: { key: string; data: unknown; @@ -143,8 +136,6 @@ function setCache(key: string, data: unknown): void { responseCache = { key, data, expiresAt: Date.now() + CACHE_TTL_MS }; } -// --- Fetch with timeout --- - async function fetchOpenSky( url: string, useAuth: boolean, @@ -163,8 +154,6 @@ async function fetchOpenSky( } } -// --- Utilities --- - function clamp(val: number, min: number, max: number) { return Math.max(min, Math.min(max, val)); } @@ -180,8 +169,6 @@ function json( }); } -// --- Route handler --- - export async function GET(request: NextRequest) { const ip = request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? @@ -228,8 +215,18 @@ export async function GET(request: NextRequest) { logAuthOnce(); - const url = `${OPENSKY_BASE}/states/all?lamin=${coords.lamin}&lamax=${coords.lamax}&lomin=${coords.lomin}&lomax=${coords.lomax}`; - const cacheKey = `${coords.lamin},${coords.lamax},${coords.lomin},${coords.lomax}`; + // Snap bbox to grid so nearby viewports share cache entries + 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); 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; try { data = await res.json(); @@ -281,6 +282,10 @@ export async function GET(request: NextRequest) { return json({ error: "Upstream returned invalid response" }, 502); } + if (creditsRemaining !== null && !Number.isNaN(creditsRemaining)) { + data.creditsRemaining = creditsRemaining; + } + setCache(cacheKey, data); return json(data, 200, { "X-Cache": "MISS" }); } catch (err) { diff --git a/src/components/flight-tracker.tsx b/src/components/flight-tracker.tsx index ad43497..a825fc5 100644 --- a/src/components/flight-tracker.tsx +++ b/src/components/flight-tracker.tsx @@ -90,7 +90,7 @@ function CameraController({ city }: { city: City }) { prevCityRef.current = city.id; map.flyTo({ center: city.coordinates, - zoom: 11, + zoom: 9.2, pitch: 49, bearing: 27.4, duration: 2800, diff --git a/src/components/map/flight-layers.tsx b/src/components/map/flight-layers.tsx index 6ea1406..fd95c5c 100644 --- a/src/components/map/flight-layers.tsx +++ b/src/components/map/flight-layers.tsx @@ -6,13 +6,10 @@ import { IconLayer, PathLayer } from "@deck.gl/layers"; import { useMap } from "./map"; import { altitudeToColor, altitudeToElevation } from "@/lib/flight-utils"; import type { FlightState } from "@/lib/opensky"; -import { - SAMPLES_PER_SEGMENT, - type TrailEntry, -} from "@/hooks/use-trail-history"; +import { type TrailEntry } from "@/hooks/use-trail-history"; 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 type Snapshot = { lng: number; lat: number; alt: number; track: number }; @@ -312,7 +309,6 @@ export function FlightLayers({ const basePath = d.path.map( (p) => [p[0], p[1], alt] as [number, number, number], ); - // Reveal spline points progressively to match the animated position if ( animFlight && animFlight.longitude != null && @@ -321,15 +317,26 @@ export function FlightLayers({ ) { const ax = animFlight.longitude; 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++) { - basePath[i] = [ax, ay, alt]; + 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]; + } else { + break; + } + } } basePath[basePath.length - 1] = [ax, ay, alt]; } diff --git a/src/components/ui/control-panel.tsx b/src/components/ui/control-panel.tsx index f0b75a9..f0f762b 100644 --- a/src/components/ui/control-panel.tsx +++ b/src/components/ui/control-panel.tsx @@ -15,7 +15,6 @@ import { Route, Layers, Palette, - Gauge, ArrowLeftRight, Github, } from "lucide-react"; @@ -23,6 +22,7 @@ import { CITIES, type City } from "@/lib/cities"; import { MAP_STYLES, type MapStyle } from "@/lib/map-styles"; import { useSettings, type OrbitDirection } from "@/hooks/use-settings"; import { ScrollArea } from "@/components/ui/scroll-area"; +import { Slider } from "@/components/ui/slider"; type TabId = "search" | "style" | "settings"; @@ -52,7 +52,6 @@ export function ControlPanel({ return ( <> - {/* Trigger buttons */} {TABS.map(({ id, icon: Icon, label }) => ( ))} - {/* Dialog */} {openTab && ( - {/* Backdrop */} - {/* Panel */} -
- {/* Sidebar */} -
+
+ {/* Desktop sidebar (hidden on mobile) */} +

Controls

@@ -246,9 +242,18 @@ function PanelDialog({
- {/* Content */} -
-
+
+ {/* Mobile header */} +
+

+ {TABS.find((t) => t.id === activeTab)?.label} +

+
+ {/* Desktop header */} +

- {/* Content */}
{activeTab === "search" && ( @@ -293,6 +297,50 @@ function PanelDialog({
+ + {/* Mobile tab bar — at bottom for thumb reach */} +
+ + + + +
@@ -396,7 +444,7 @@ function StyleContent({ }) { return ( -
+
{MAP_STYLES.map((style, i) => ( - } - title="Orbit speed" - options={ORBIT_SPEEDS} + 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 ( +
+
+ +
+
+
+

Orbit speed

+ + {activeLabel} + +
+
+ +
+ {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 ( + + ); + })} +
+
+
+
+ ); +} + function SettingRow({ icon, title, diff --git a/src/components/ui/flight-card.tsx b/src/components/ui/flight-card.tsx index 8fee577..66cc5f1 100644 --- a/src/components/ui/flight-card.tsx +++ b/src/components/ui/flight-card.tsx @@ -30,12 +30,12 @@ export function FlightCard({ flight, x, y }: FlightCardProps) { damping: 28, 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" aria-live="polite" style={{ - left: `min(${x + 16}px, calc(100vw - 304px))`, - top: `min(${y - 8}px, calc(100vh - 280px))`, + left: `clamp(8px, ${x + 16}px, calc(100vw - 272px))`, + top: `clamp(8px, ${y - 8}px, calc(100vh - 280px))`, }} >
diff --git a/src/components/ui/slider.tsx b/src/components/ui/slider.tsx new file mode 100644 index 0000000..27106f7 --- /dev/null +++ b/src/components/ui/slider.tsx @@ -0,0 +1,25 @@ +"use client"; + +import * as React from "react"; +import * as SliderPrimitive from "@radix-ui/react-slider"; + +type SliderProps = React.ComponentPropsWithoutRef; + +const Slider = React.forwardRef< + React.ComponentRef, + SliderProps +>(({ className = "", ...props }, ref) => ( + + + + + + +)); +Slider.displayName = "Slider"; + +export { Slider }; diff --git a/src/hooks/use-flights.ts b/src/hooks/use-flights.ts index 3f35a08..4703889 100644 --- a/src/hooks/use-flights.ts +++ b/src/hooks/use-flights.ts @@ -8,8 +8,27 @@ import { } from "@/lib/opensky"; 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 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) { const [flights, setFlights] = useState([]); @@ -17,10 +36,14 @@ export function useFlights(city: City | null) { const [error, setError] = useState(null); const [rateLimited, setRateLimited] = useState(false); const [retryIn, setRetryIn] = useState(0); + const timerRef = useRef | null>(null); const countdownRef = useRef | null>(null); const abortRef = useRef(null); + const creditsRef = useRef(null); + const lastFetchRef = useRef(0); + const clearCountdown = useCallback(() => { if (countdownRef.current) { clearInterval(countdownRef.current); @@ -43,9 +66,16 @@ export function useFlights(city: City | null) { [clearCountdown], ); + const clearSchedule = useCallback(() => { + if (timerRef.current) { + clearTimeout(timerRef.current); + timerRef.current = null; + } + }, []); + const scheduleNext = useCallback( (target: City, delayMs: number) => { - if (timerRef.current) clearTimeout(timerRef.current); + clearSchedule(); timerRef.current = setTimeout(() => fetchData(target), delayMs); }, // eslint-disable-next-line react-hooks/exhaustive-deps @@ -61,6 +91,7 @@ export function useFlights(city: City | null) { try { setLoading(true); setError(null); + const bbox = bboxFromCenter( target.coordinates[0], target.coordinates[1], @@ -78,11 +109,19 @@ export function useFlights(city: City | null) { setRateLimited(false); clearCountdown(); 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) { if (err instanceof DOMException && err.name === "AbortError") return; setError(err instanceof Error ? err.message : "Unknown error"); setFlights([]); + // After an error, back off longer to avoid hammering a sick upstream scheduleNext(target, RATE_LIMIT_BACKOFF_MS); } finally { setLoading(false); @@ -92,11 +131,41 @@ export function useFlights(city: City | null) { ); useEffect(() => { - if (timerRef.current) { - clearTimeout(timerRef.current); - timerRef.current = null; + if (!city) return; + + 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) { setFlights([]); setRateLimited(false); @@ -109,11 +178,11 @@ export function useFlights(city: City | null) { fetchData(city); return () => { - if (timerRef.current) clearTimeout(timerRef.current); + clearSchedule(); abortRef.current?.abort(); clearCountdown(); }; - }, [city, fetchData, clearCountdown]); + }, [city, fetchData, clearCountdown, clearSchedule]); return { flights, loading, error, rateLimited, retryIn }; } diff --git a/src/hooks/use-settings.tsx b/src/hooks/use-settings.tsx index b3726b3..3f24d50 100644 --- a/src/hooks/use-settings.tsx +++ b/src/hooks/use-settings.tsx @@ -24,7 +24,7 @@ export type Settings = { const DEFAULT_SETTINGS: Settings = { autoOrbit: true, - orbitSpeed: 0.15, + orbitSpeed: 0.06, orbitDirection: "clockwise", showTrails: true, showShadows: true, diff --git a/src/lib/opensky.ts b/src/lib/opensky.ts index dd3a081..e8dda99 100644 --- a/src/lib/opensky.ts +++ b/src/lib/opensky.ts @@ -19,6 +19,7 @@ export type OpenSkyResponse = { time: number; states: (string | number | boolean | null)[][] | null; rateLimited?: boolean; + creditsRemaining?: number | null; }; function parseStates(raw: OpenSkyResponse): FlightState[] { @@ -53,6 +54,7 @@ function parseStates(raw: OpenSkyResponse): FlightState[] { export type FetchResult = { flights: FlightState[]; rateLimited: boolean; + creditsRemaining: number | null; }; /** Fetch flights via the server-side proxy. */ @@ -70,18 +72,22 @@ export async function fetchFlightsByBbox( if (!res.ok) { // Don't throw — let the hook retry gracefully console.warn(`[aeris] Flight API returned ${res.status}`); - return { flights: [], rateLimited: false }; + return { flights: [], rateLimited: false, creditsRemaining: null }; } const data: OpenSkyResponse = await res.json(); if (data.rateLimited) { console.warn("[aeris] OpenSky rate limit hit, backing off"); - return { flights: [], rateLimited: true }; + return { flights: [], rateLimited: true, creditsRemaining: null }; } const flights = parseStates(data); - return { flights, rateLimited: false }; + return { + flights, + rateLimited: false, + creditsRemaining: data.creditsRemaining ?? null, + }; } export function bboxFromCenter(