Added basic height filtering.

This commit is contained in:
2026-03-31 16:03:17 -05:00
parent 498504b73b
commit a246765884
6 changed files with 262 additions and 3 deletions

65
CLAUDE.md Normal file
View File

@ -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 (12100 points) and thickness (0.58 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

View File

@ -30,6 +30,7 @@ const ControlPanel = dynamic(() =>
import("@/components/ui/control-panel").then((mod) => mod.ControlPanel), import("@/components/ui/control-panel").then((mod) => mod.ControlPanel),
); );
import { AltitudeLegend } from "@/components/ui/altitude-legend"; import { AltitudeLegend } from "@/components/ui/altitude-legend";
import { AltitudeFilter } from "@/components/ui/altitude-filter";
import { CameraControls } from "@/components/ui/camera-controls"; import { CameraControls } from "@/components/ui/camera-controls";
import { StatusBar } from "@/components/ui/status-bar"; import { StatusBar } from "@/components/ui/status-bar";
import { MapAttribution } from "@/components/ui/map-attribution"; import { MapAttribution } from "@/components/ui/map-attribution";
@ -149,8 +150,27 @@ function FlightTrackerInner() {
fpvSeedCenter, fpvSeedCenter,
); );
const displayFlights = flights; // Trail history is built from ALL flights so filtered-out planes keep their trails
const displayTrails = useTrailHistory(displayFlights); 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 // Single Map for O(1) flight lookups — replaces 4× O(n) find() calls per poll
const displayFlightMap = useMemo(() => { const displayFlightMap = useMemo(() => {
@ -550,6 +570,9 @@ function FlightTrackerInner() {
<div className="pointer-events-auto"> <div className="pointer-events-auto">
<CameraControls /> <CameraControls />
</div> </div>
<div className="pointer-events-auto">
<AltitudeFilter />
</div>
<div className="pointer-events-auto"> <div className="pointer-events-auto">
<AltitudeLegend /> <AltitudeLegend />
</div> </div>

View File

@ -215,6 +215,61 @@ export function CameraController({
}; };
}, [map, isLoaded, city]); }, [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 // Keyboard camera hook
useKeyboardCamera( useKeyboardCamera(
map, map,

View File

@ -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 (
<motion.div
initial={{ opacity: 0, x: 12 }}
animate={{ opacity: 1, x: 0 }}
transition={{ type: "spring", stiffness: 300, damping: 24, delay: 0.5 }}
className="flex flex-col items-end gap-1 rounded-xl border p-1.5 backdrop-blur-2xl sm:p-2"
style={{
borderColor: "rgb(var(--ui-fg) / 0.06)",
backgroundColor: "rgb(var(--ui-bg) / 0.5)",
}}
>
<p
className="w-full text-center text-[8px] font-semibold tracking-widest uppercase sm:text-[10px]"
style={{ color: "rgb(var(--ui-fg) / 0.3)" }}
>
FL Filter
</p>
<div className="grid grid-cols-2 gap-0.5 sm:grid-cols-1 sm:gap-0.5">
{[...ALTITUDE_FILTER_LEVELS].reverse().map((level) => {
const active = level <= max;
return (
<button
key={level}
type="button"
onClick={() => handleTap(level)}
className="min-h-[28px] min-w-[36px] rounded-md px-1.5 py-0.5 text-[10px] font-medium transition-colors active:scale-95 sm:min-h-[22px] sm:min-w-[44px] sm:text-[10px]"
style={{
backgroundColor: active
? "rgb(var(--ui-fg) / 0.1)"
: "transparent",
color: active
? "rgb(var(--ui-fg) / 0.7)"
: "rgb(var(--ui-fg) / 0.2)",
}}
>
{formatAlt(level)}
</button>
);
})}
</div>
</motion.div>
);
}

View File

@ -9,7 +9,9 @@ import {
ChevronsDown, ChevronsDown,
RotateCw, RotateCw,
RotateCcw, RotateCcw,
Navigation,
} from "lucide-react"; } from "lucide-react";
import { useSettings } from "@/hooks/use-settings";
type CameraActionType = "zoom" | "pitch" | "bearing"; type CameraActionType = "zoom" | "pitch" | "bearing";
@ -94,6 +96,9 @@ function Divider() {
} }
export function CameraControls() { export function CameraControls() {
const { settings, update } = useSettings();
const locked = settings.lockNorthUp;
return ( return (
<motion.div <motion.div
initial={{ opacity: 0, x: 12 }} initial={{ opacity: 0, x: 12 }}
@ -170,6 +175,31 @@ export function CameraControls() {
> >
<RotateCcw className="h-3.5 w-3.5" /> <RotateCcw className="h-3.5 w-3.5" />
</ControlButton> </ControlButton>
<div
className="mx-auto my-0.5 h-px w-6"
style={{ backgroundColor: "rgb(var(--ui-fg) / 0.10)" }}
/>
<motion.button
type="button"
className="flex h-8 w-8 items-center justify-center select-none"
style={{
color: locked
? "rgb(var(--ui-fg) / 0.85)"
: "rgb(var(--ui-fg) / 0.45)",
}}
whileHover={{ scale: 1.12 }}
whileTap={{ scale: 0.88 }}
aria-label="Lock north up"
title={locked ? "North locked — tap to unlock" : "Lock north up"}
onClick={() => update("lockNorthUp", !locked)}
>
<Navigation
className="h-3.5 w-3.5"
fill={locked ? "currentColor" : "none"}
/>
</motion.button>
</motion.div> </motion.div>
); );
} }

View File

@ -32,6 +32,8 @@ export type Settings = {
showAtcPanel: boolean; showAtcPanel: boolean;
showWeatherRadar: boolean; showWeatherRadar: boolean;
weatherRadarOpacity: number; weatherRadarOpacity: number;
altitudeFilterMax: number;
lockNorthUp: boolean;
}; };
const TRAIL_THICKNESS_MIN = 0.5; 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_MIN = 0.15;
export const WEATHER_RADAR_OPACITY_MAX = 0.9; 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 { function normalizeSettings(input: Settings): Settings {
return { return {
...input, ...input,
@ -92,6 +98,8 @@ const DEFAULT_SETTINGS: Settings = {
showAtcPanel: false, showAtcPanel: false,
showWeatherRadar: false, showWeatherRadar: false,
weatherRadarOpacity: 0.5, weatherRadarOpacity: 0.5,
altitudeFilterMax: 50_000,
lockNorthUp: false,
}; };
const STORAGE_KEY = "aeris:settings"; const STORAGE_KEY = "aeris:settings";
@ -138,7 +146,11 @@ function isValidSettings(obj: unknown): obj is Settings {
typeof s.weatherRadarOpacity === "number" && typeof s.weatherRadarOpacity === "number" &&
Number.isFinite(s.weatherRadarOpacity) && Number.isFinite(s.weatherRadarOpacity) &&
s.weatherRadarOpacity >= WEATHER_RADAR_OPACITY_MIN && 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"
); );
} }