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"
);
}