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("@/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>
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
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,
|
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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user