Added basic height filtering.
This commit is contained in:
65
CLAUDE.md
Normal file
65
CLAUDE.md
Normal 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 (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
|
||||
@ -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() {
|
||||
<div className="pointer-events-auto">
|
||||
<CameraControls />
|
||||
</div>
|
||||
<div className="pointer-events-auto">
|
||||
<AltitudeFilter />
|
||||
</div>
|
||||
<div className="pointer-events-auto">
|
||||
<AltitudeLegend />
|
||||
</div>
|
||||
|
||||
@ -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,
|
||||
|
||||
74
src/components/ui/altitude-filter.tsx
Normal file
74
src/components/ui/altitude-filter.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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 (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: 12 }}
|
||||
@ -170,6 +175,31 @@ export function CameraControls() {
|
||||
>
|
||||
<RotateCcw className="h-3.5 w-3.5" />
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user