diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..573433e --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,65 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Commands + +```bash +npm run dev # Start dev server (Turbopack) +npm run build # Production build +npm run start # Start production server +npm run lint # ESLint (flat config, ESLint 9) +``` + +No test framework is configured. + +## Architecture + +Aeris is a **Next.js 16 App Router** application for real-time 3D flight tracking, built with React 19, TypeScript (strict), and Tailwind CSS v4. Deployed on Vercel. + +### Rendering stack + +- **MapLibre GL JS** renders the base map (CARTO Dark Matter tiles) +- **Deck.gl 9** overlays WebGL layers on top: IconLayer (aircraft), PathLayer (trails), ScenegraphLayer (14 3D GLB models served from Cloudinary CDN) +- Altitude drives both z-displacement (`Math.max(altitude * 5, 200)` meters) and color (11-stop RGB gradient) +- **Motion** (Framer Motion v12) handles UI animations with spring physics + +### Data flow + +Flight data uses a **3-tier fallback with circuit breaker**: +1. **adsb.lol** — proxied through `/api/flights` (primary) +2. **OpenSky Network** — direct browser fetch (CORS-enabled) +3. **Airplanes.live** — override only + +Polling is adaptive: 10s normally, backs off to 5 min on rate limits. Pauses when tab is hidden (Page Visibility API). + +### Key source layout + +- `src/app/api/` — 8 proxy routes (flights, ATC audio, weather/METAR, aircraft photos, airspace tiles, radar tiles). All external API calls go through these proxies for CORS and SSRF protection. +- `src/components/flight-tracker.tsx` — Main orchestrator: manages state, camera modes, layers, and UI composition. +- `src/components/map/` — MapLibre context, Deck.gl layer builders, camera controllers (orbit/FPV/globe), animation helpers with Catmull-Rom spline smoothing. +- `src/components/ui/` — UI panels (control panel, flight card, ATC panel, FPV HUD, etc.). Uses Radix UI primitives, cmdk for search, Sonner for toasts. +- `src/hooks/` — Data fetching hooks (`use-flights`, `use-flight-track`, `use-trail-history`), settings context with localStorage persistence, ATC stream management. +- `src/lib/` — API clients, trail processing (cleanup, stitching, spline interpolation), large static datasets (airports 1.3MB, ATC feeds 200KB, airline logos), GPU memory monitoring. + +### State management + +React hooks + context only. Settings persist to localStorage via `use-settings.tsx`. URL params enable deep linking (`?city=IATA`, `?fpv=ICAO24`). SSR hydration uses `useSyncExternalStore`. + +### Trail system + +Per-aircraft trail history with 100-point accumulation buffer, Catmull-Rom spline smoothing, and grid-snapped deduplication. Configurable distance (12–100 points) and thickness (0.5–8 px). + +## Style conventions + +- Conventional commits: `feat:`, `fix:`, `refactor:`, etc. +- Path alias: `@/*` maps to `./src/*` +- OKLCH color system via CSS custom properties in `globals.css` +- Dark-first design with `next-themes` +- No server-side secrets required; env vars are optional (`NEXT_PUBLIC_GA_ID`) + +## Build notes + +- `next.config.ts` transpiles Deck.gl and Luma.gl packages +- CSP headers and aggressive caching on `/models` (immutable, 1-year max-age) +- License: AGPL-3.0 diff --git a/src/components/flight-tracker.tsx b/src/components/flight-tracker.tsx index 46a5725..08a0b04 100644 --- a/src/components/flight-tracker.tsx +++ b/src/components/flight-tracker.tsx @@ -30,6 +30,7 @@ const ControlPanel = dynamic(() => import("@/components/ui/control-panel").then((mod) => mod.ControlPanel), ); import { AltitudeLegend } from "@/components/ui/altitude-legend"; +import { AltitudeFilter } from "@/components/ui/altitude-filter"; import { CameraControls } from "@/components/ui/camera-controls"; import { StatusBar } from "@/components/ui/status-bar"; import { MapAttribution } from "@/components/ui/map-attribution"; @@ -149,8 +150,27 @@ function FlightTrackerInner() { fpvSeedCenter, ); - const displayFlights = flights; - const displayTrails = useTrailHistory(displayFlights); + // Trail history is built from ALL flights so filtered-out planes keep their trails + const allTrails = useTrailHistory(flights); + + const altMaxFt = settings.altitudeFilterMax; + const altMaxM = altMaxFt * 0.3048; // baroAltitude is stored in meters + const displayFlights = useMemo( + () => + altMaxFt >= 50_000 + ? flights + : flights.filter( + (f) => f.baroAltitude == null || f.baroAltitude <= altMaxM, + ), + [flights, altMaxFt, altMaxM], + ); + + // Filter trails by the flight's current altitude, not the trail points + const displayTrails = useMemo(() => { + if (altMaxFt >= 50_000) return allTrails; + const visible = new Set(displayFlights.map((f) => f.icao24)); + return allTrails.filter((t) => visible.has(t.icao24)); + }, [allTrails, displayFlights, altMaxFt]); // Single Map for O(1) flight lookups — replaces 4× O(n) find() calls per poll const displayFlightMap = useMemo(() => { @@ -550,6 +570,9 @@ function FlightTrackerInner() {
+
+ +
diff --git a/src/components/map/camera-controller.tsx b/src/components/map/camera-controller.tsx index 2e710e7..e3726d9 100644 --- a/src/components/map/camera-controller.tsx +++ b/src/components/map/camera-controller.tsx @@ -215,6 +215,61 @@ export function CameraController({ }; }, [map, isLoaded, city]); + // Lock-north-up: disable native dragRotate (which couples bearing + pitch) + // and replace with a custom right-click drag handler for pitch-only. + useEffect(() => { + if (!map || !isLoaded || !settings.lockNorthUp) return; + + map.dragRotate.disable(); + map.setBearing(0); + + // Enforce bearing 0 for programmatic camera moves (flyTo, easeTo, orbit) + const onRotate = () => { + if (Math.abs(map.getBearing()) > 0.01) map.setBearing(0); + }; + map.on("rotate", onRotate); + + // Custom right-click drag → pitch only + const container = map.getContainer(); + let dragging = false; + let lastY = 0; + + const onContextMenu = (e: MouseEvent) => e.preventDefault(); + + const onMouseDown = (e: MouseEvent) => { + if (e.button !== 2) return; // right-click only + dragging = true; + lastY = e.clientY; + e.preventDefault(); + }; + + const onMouseMove = (e: MouseEvent) => { + if (!dragging) return; + const dy = e.clientY - lastY; + lastY = e.clientY; + const pitch = map.getPitch() - dy * 0.3; + map.setPitch(Math.max(0, Math.min(pitch, 80))); + }; + + const onMouseUp = () => { + dragging = false; + }; + + container.addEventListener("contextmenu", onContextMenu); + container.addEventListener("mousedown", onMouseDown); + window.addEventListener("mousemove", onMouseMove); + window.addEventListener("mouseup", onMouseUp); + + return () => { + map.off("rotate", onRotate); + container.removeEventListener("contextmenu", onContextMenu); + container.removeEventListener("mousedown", onMouseDown); + window.removeEventListener("mousemove", onMouseMove); + window.removeEventListener("mouseup", onMouseUp); + map.dragRotate.enable(); + }; + }, [map, isLoaded, settings.lockNorthUp]); + // Keyboard camera hook useKeyboardCamera( map, diff --git a/src/components/ui/altitude-filter.tsx b/src/components/ui/altitude-filter.tsx new file mode 100644 index 0000000..2152adf --- /dev/null +++ b/src/components/ui/altitude-filter.tsx @@ -0,0 +1,74 @@ +"use client"; + +import { motion } from "motion/react"; +import { + useSettings, + ALTITUDE_FILTER_LEVELS, +} from "@/hooks/use-settings"; + +function formatAlt(ft: number): string { + if (ft === 0) return "0"; + if (ft >= 1_000) return `${ft / 1_000}k`; + return `${ft}`; +} + +export function AltitudeFilter() { + const { settings, update } = useSettings(); + const max = settings.altitudeFilterMax; + + function handleTap(level: number) { + if (level === max) { + const idx = ALTITUDE_FILTER_LEVELS.indexOf( + level as (typeof ALTITUDE_FILTER_LEVELS)[number], + ); + const next = + idx > 0 ? ALTITUDE_FILTER_LEVELS[idx - 1] : ALTITUDE_FILTER_LEVELS[0]; + update("altitudeFilterMax", next); + } else { + update("altitudeFilterMax", level); + } + } + + return ( + +

+ FL Filter +

+
+ {[...ALTITUDE_FILTER_LEVELS].reverse().map((level) => { + const active = level <= max; + return ( + + ); + })} +
+
+ ); +} diff --git a/src/components/ui/camera-controls.tsx b/src/components/ui/camera-controls.tsx index 95d4870..cb3508a 100644 --- a/src/components/ui/camera-controls.tsx +++ b/src/components/ui/camera-controls.tsx @@ -9,7 +9,9 @@ import { ChevronsDown, RotateCw, RotateCcw, + Navigation, } from "lucide-react"; +import { useSettings } from "@/hooks/use-settings"; type CameraActionType = "zoom" | "pitch" | "bearing"; @@ -94,6 +96,9 @@ function Divider() { } export function CameraControls() { + const { settings, update } = useSettings(); + const locked = settings.lockNorthUp; + return ( + +
+ + update("lockNorthUp", !locked)} + > + + ); } diff --git a/src/hooks/use-settings.tsx b/src/hooks/use-settings.tsx index f4b0e63..60c9f83 100644 --- a/src/hooks/use-settings.tsx +++ b/src/hooks/use-settings.tsx @@ -32,6 +32,8 @@ export type Settings = { showAtcPanel: boolean; showWeatherRadar: boolean; weatherRadarOpacity: number; + altitudeFilterMax: number; + lockNorthUp: boolean; }; const TRAIL_THICKNESS_MIN = 0.5; @@ -45,6 +47,10 @@ export const AIRSPACE_OPACITY_MAX = 1.0; export const WEATHER_RADAR_OPACITY_MIN = 0.15; export const WEATHER_RADAR_OPACITY_MAX = 0.9; +export const ALTITUDE_FILTER_LEVELS = [ + 0, 500, 1_000, 2_000, 5_000, 10_000, 20_000, 30_000, 40_000, 50_000, +] as const; + function normalizeSettings(input: Settings): Settings { return { ...input, @@ -92,6 +98,8 @@ const DEFAULT_SETTINGS: Settings = { showAtcPanel: false, showWeatherRadar: false, weatherRadarOpacity: 0.5, + altitudeFilterMax: 50_000, + lockNorthUp: false, }; const STORAGE_KEY = "aeris:settings"; @@ -138,7 +146,11 @@ function isValidSettings(obj: unknown): obj is Settings { typeof s.weatherRadarOpacity === "number" && Number.isFinite(s.weatherRadarOpacity) && s.weatherRadarOpacity >= WEATHER_RADAR_OPACITY_MIN && - s.weatherRadarOpacity <= WEATHER_RADAR_OPACITY_MAX + s.weatherRadarOpacity <= WEATHER_RADAR_OPACITY_MAX && + typeof s.altitudeFilterMax === "number" && + Number.isFinite(s.altitudeFilterMax) && + ALTITUDE_FILTER_LEVELS.includes(s.altitudeFilterMax as (typeof ALTITUDE_FILTER_LEVELS)[number]) && + typeof s.lockNorthUp === "boolean" ); }