feat: add flight tracking components and hooks

- Introduced FlightCard component for displaying flight information with animations.
- Added ScrollArea component for custom scroll behavior.
- Implemented StatusBar component to show flight count and loading status.
- Created useFlights hook for fetching and managing flight data based on city selection.
- Developed useSettings hook for managing user settings with local storage persistence.
- Added useTrailHistory hook for managing flight trail data.
- Defined City type and CITIES constant for city data management.
- Implemented flight utility functions for altitude and speed conversions.
- Created map styles for different visual representations.
- Added OpenSky API integration for fetching flight data.
- Implemented utility functions for class name merging.
- Configured TypeScript settings for the project.
This commit is contained in:
Kewonit
2026-02-14 12:26:44 +05:30
commit b3f20b7659
37 changed files with 9356 additions and 0 deletions

View File

@ -0,0 +1,270 @@
import { NextRequest, NextResponse } from "next/server";
const OPENSKY_BASE = "https://opensky-network.org/api";
const OPENSKY_TOKEN_URL =
"https://auth.opensky-network.org/auth/realms/opensky-network/protocol/openid-connect/token";
// OAuth2 token cache
let cachedToken: string | null = null;
let tokenExpiresAt = 0; // epoch ms
async function getAccessToken(): Promise<string | null> {
const clientId = process.env.OPENSKY_CLIENT_ID;
const clientSecret = process.env.OPENSKY_CLIENT_SECRET;
if (!clientId || !clientSecret) return null;
// Reuse token if still valid (with 60s margin)
if (cachedToken && Date.now() < tokenExpiresAt - 60_000) {
return cachedToken;
}
try {
const res = await fetch(OPENSKY_TOKEN_URL, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "client_credentials",
client_id: clientId,
client_secret: clientSecret,
}),
cache: "no-store",
});
if (!res.ok) {
console.error(
`[aeris] OAuth2 token request failed: ${res.status} ${res.statusText}`,
);
cachedToken = null;
return null;
}
const data = await res.json();
cachedToken = data.access_token;
tokenExpiresAt = Date.now() + (data.expires_in ?? 1800) * 1000;
if (process.env.NODE_ENV === "development") {
console.info(
`[aeris] OAuth2 token acquired, expires in ${data.expires_in}s`,
);
}
return cachedToken;
} catch (err) {
console.error("[aeris] OAuth2 token error:", err);
cachedToken = null;
return null;
}
}
type AuthMode = "oauth2" | "basic" | "anonymous";
let authDisabled = false;
let authLoggedOnce = false;
function detectAuthMode(): AuthMode {
if (authDisabled) return "anonymous";
if (process.env.OPENSKY_CLIENT_ID && process.env.OPENSKY_CLIENT_SECRET)
return "oauth2";
if (process.env.OPENSKY_USERNAME && process.env.OPENSKY_PASSWORD)
return "basic";
return "anonymous";
}
async function buildAuthHeaders(): Promise<HeadersInit> {
const mode = detectAuthMode();
if (mode === "oauth2") {
const token = await getAccessToken();
if (token) return { Authorization: `Bearer ${token}` };
return {}; // token fetch failed — fall through
}
if (mode === "basic") {
const user = process.env.OPENSKY_USERNAME!;
const pass = process.env.OPENSKY_PASSWORD!;
return {
Authorization: `Basic ${Buffer.from(`${user}:${pass}`).toString("base64")}`,
};
}
return {};
}
function logAuthStatus() {
if (authLoggedOnce) return;
authLoggedOnce = true;
const mode = detectAuthMode();
const isDev = process.env.NODE_ENV === "development";
if (isDev) {
console.info("┌───────────────────────────────────────────────────┐");
if (mode === "oauth2") {
console.info("│ ✓ OpenSky: OAuth2 client credentials │");
console.info(
`│ Client: ${(process.env.OPENSKY_CLIENT_ID ?? "").slice(0, 37).padEnd(39)}`,
);
} else if (mode === "basic") {
console.info("│ ✓ OpenSky: Basic auth (legacy) │");
console.info(
`│ User: ${(process.env.OPENSKY_USERNAME ?? "").slice(0, 38).padEnd(40)}`,
);
} else {
console.info("│ ✗ OpenSky: Anonymous mode (rate-limited) │");
console.info("│ Set OPENSKY_CLIENT_ID & OPENSKY_CLIENT_SECRET │");
console.info("│ in .env.local for authenticated access │");
}
console.info("└───────────────────────────────────────────────────┘");
} else {
console.info(`[aeris] Proxy: ${mode} mode`);
}
}
// Per-IP rate limiter
const requestLog = new Map<string, number[]>();
const MAX_REQUESTS_PER_MINUTE = 20;
function isRateLimited(ip: string): boolean {
const now = Date.now();
const windowMs = 60_000;
const timestamps = requestLog.get(ip) ?? [];
const recent = timestamps.filter((t) => now - t < windowMs);
recent.push(now);
requestLog.set(ip, recent);
// Clean up stale entries periodically
if (requestLog.size > 500) {
for (const [key, val] of requestLog) {
if (val.every((t) => now - t > windowMs)) requestLog.delete(key);
}
}
return recent.length > MAX_REQUESTS_PER_MINUTE;
}
function clamp(val: number, min: number, max: number): number {
return Math.max(min, Math.min(max, val));
}
async function fetchFromOpenSky(
url: string,
useAuth: boolean,
): Promise<Response> {
const headers = useAuth ? await buildAuthHeaders() : {};
return fetch(url, { headers, cache: "no-store" });
}
export async function GET(request: NextRequest) {
const ip =
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ??
request.headers.get("x-real-ip") ??
"unknown";
if (isRateLimited(ip)) {
return NextResponse.json(
{ time: 0, states: null, rateLimited: true },
{ status: 200, headers: { "Cache-Control": "no-store" } },
);
}
const { searchParams } = request.nextUrl;
const lamin = searchParams.get("lamin");
const lamax = searchParams.get("lamax");
const lomin = searchParams.get("lomin");
const lomax = searchParams.get("lomax");
if (!lamin || !lamax || !lomin || !lomax) {
return NextResponse.json(
{ error: "Missing required bbox parameters" },
{ status: 400 },
);
}
const raw = { lamin: +lamin, lamax: +lamax, lomin: +lomin, lomax: +lomax };
for (const [key, val] of Object.entries(raw)) {
if (Number.isNaN(val)) {
return NextResponse.json(
{ error: `Invalid parameter: ${key}` },
{ status: 400 },
);
}
}
// Clamp to valid geographic ranges and limit bbox size
const coords = {
lamin: clamp(raw.lamin, -90, 90),
lamax: clamp(raw.lamax, -90, 90),
lomin: clamp(raw.lomin, -180, 180),
lomax: clamp(raw.lomax, -180, 180),
};
const latSpan = Math.abs(coords.lamax - coords.lamin);
const lonSpan = Math.abs(coords.lomax - coords.lomin);
if (latSpan > 20 || lonSpan > 20) {
return NextResponse.json(
{ error: "Bounding box too large (max 20° per axis)" },
{ status: 400 },
);
}
if (!authLoggedOnce) logAuthStatus();
const url = `${OPENSKY_BASE}/states/all?lamin=${coords.lamin}&lamax=${coords.lamax}&lomin=${coords.lomin}&lomax=${coords.lomax}`;
const useAuth = detectAuthMode() !== "anonymous";
try {
let res = await fetchFromOpenSky(url, useAuth);
// On 401, invalidate token/auth and retry anonymously
if (res.status === 401 && useAuth) {
cachedToken = null;
tokenExpiresAt = 0;
authDisabled = true;
console.warn(
"[aeris] Auth rejected (401). Falling back to anonymous. Check credentials in .env.local",
);
res = await fetchFromOpenSky(url, false);
}
if (res.status === 429) {
const retryAfter = res.headers.get("X-Rate-Limit-Retry-After-Seconds");
return NextResponse.json(
{
time: 0,
states: null,
rateLimited: true,
retryAfter: retryAfter ? parseInt(retryAfter, 10) : null,
},
{ status: 200, headers: { "Cache-Control": "no-store" } },
);
}
if (!res.ok) {
console.error(`[aeris] OpenSky error: ${res.status} ${res.statusText}`);
return NextResponse.json(
{ error: "Upstream data source error" },
{ status: 502 },
);
}
const data = await res.json();
// Log remaining credits in dev
if (process.env.NODE_ENV === "development") {
const remaining = res.headers.get("X-Rate-Limit-Remaining");
if (remaining) {
console.info(`[aeris] API credits remaining: ${remaining}`);
}
}
return NextResponse.json(data, {
headers: { "Cache-Control": "no-store" },
});
} catch (err) {
console.error("[aeris] OpenSky proxy error:", err);
return NextResponse.json(
{ error: "Failed to fetch flight data" },
{ status: 502 },
);
}
}

BIN
src/app/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

81
src/app/globals.css Normal file
View File

@ -0,0 +1,81 @@
@import "tailwindcss";
@custom-variant dark (&:is(.dark *));
:root {
--background: 0 0% 0%;
--foreground: 0 0% 100%;
--muted: 0 0% 12%;
--muted-foreground: 0 0% 60%;
--border: 0 0% 14%;
--ring: 0 0% 30%;
--radius: 0.625rem;
}
@theme inline {
--color-background: hsl(var(--background));
--color-foreground: hsl(var(--foreground));
--color-muted: hsl(var(--muted));
--color-muted-foreground: hsl(var(--muted-foreground));
--color-border: hsl(var(--border));
--color-ring: hsl(var(--ring));
--radius: var(--radius);
--font-sans: "Inter", ui-sans-serif, system-ui, -apple-system, sans-serif;
--font-mono: "SF Mono", ui-monospace, monospace;
}
* {
border-color: hsl(var(--border));
}
:focus-visible {
outline: 2px solid hsl(var(--ring));
outline-offset: 2px;
}
html,
body {
height: 100%;
margin: 0;
padding: 0;
overflow: hidden;
background: hsl(var(--background));
color: hsl(var(--foreground));
}
.maplibregl-ctrl-attrib {
display: none !important;
}
.maplibregl-ctrl-logo {
display: none !important;
}
[data-map-theme="dark"] {
--ui-fg: 255 255 255;
--ui-bg: 0 0 0;
}
[data-map-theme="light"] {
--ui-fg: 0 0 0;
--ui-bg: 255 255 255;
}
.scrollbar-none::-webkit-scrollbar {
display: none;
}
.scrollbar-none {
-ms-overflow-style: none;
scrollbar-width: none;
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}

29
src/app/layout.tsx Normal file
View File

@ -0,0 +1,29 @@
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import "./globals.css";
const inter = Inter({
variable: "--font-inter",
subsets: ["latin"],
display: "swap",
});
export const metadata: Metadata = {
title: "Aeris — Real-Time 3D Flight Tracking",
description:
"Altitude-aware, visually stunning flight tracking over the world's busiest airspaces.",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en" className="dark">
<body className={`${inter.variable} font-sans antialiased`}>
{children}
</body>
</html>
);
}

5
src/app/page.tsx Normal file
View File

@ -0,0 +1,5 @@
import { FlightTracker } from "@/components/flight-tracker";
export default function Home() {
return <FlightTracker />;
}

View File

@ -0,0 +1,41 @@
"use client";
import { Component, type ReactNode } from "react";
type Props = { children: ReactNode };
type State = { error: Error | null };
export class ErrorBoundary extends Component<Props, State> {
state: State = { error: null };
static getDerivedStateFromError(error: Error) {
return { error };
}
componentDidCatch(error: Error, info: React.ErrorInfo) {
console.error("[aeris] Uncaught error:", error, info.componentStack);
}
render() {
if (this.state.error) {
return (
<div
role="alert"
className="flex h-screen w-screen flex-col items-center justify-center gap-4 bg-black text-white"
>
<p className="text-lg font-semibold">Something went wrong</p>
<p className="max-w-md text-center text-sm text-white/50">
{this.state.error.message}
</p>
<button
onClick={() => this.setState({ error: null })}
className="rounded-lg bg-white/10 px-4 py-2 text-sm font-medium transition-colors hover:bg-white/20"
>
Try again
</button>
</div>
);
}
return this.props.children;
}
}

View File

@ -0,0 +1,299 @@
"use client";
import {
useState,
useCallback,
useRef,
useEffect,
useSyncExternalStore,
} from "react";
import { ErrorBoundary } from "@/components/error-boundary";
import { Map, useMap } from "@/components/map/map";
import { FlightLayers } from "@/components/map/flight-layers";
import { FlightCard } from "@/components/ui/flight-card";
import { ControlPanel } from "@/components/ui/control-panel";
import { AltitudeLegend } from "@/components/ui/altitude-legend";
import { StatusBar } from "@/components/ui/status-bar";
import { SettingsProvider, useSettings } from "@/hooks/use-settings";
import { useFlights } from "@/hooks/use-flights";
import { useTrailHistory } from "@/hooks/use-trail-history";
import { MAP_STYLES, DEFAULT_STYLE, type MapStyle } from "@/lib/map-styles";
import { CITIES, type City } from "@/lib/cities";
import type { FlightState } from "@/lib/opensky";
import type { PickingInfo } from "@deck.gl/core";
const IDLE_TIMEOUT_MS = 5_000;
const DEFAULT_CITY_ID = "sfo";
const STYLE_STORAGE_KEY = "aeris:mapStyle";
const DEFAULT_CITY = CITIES.find((c) => c.id === DEFAULT_CITY_ID) ?? CITIES[0];
const subscribeNoop = () => () => {};
function resolveInitialCity(): City {
try {
const params = new URLSearchParams(window.location.search);
const code = params.get("city")?.trim().toUpperCase();
if (!code) return DEFAULT_CITY;
return (
CITIES.find(
(c) => c.iata.toUpperCase() === code || c.id === code.toLowerCase(),
) ?? DEFAULT_CITY
);
} catch {
return DEFAULT_CITY;
}
}
function syncCityToUrl(city: City): void {
if (typeof window === "undefined") return;
try {
const url = new URL(window.location.href);
url.searchParams.set("city", city.iata);
window.history.replaceState(null, "", url.toString());
} catch {
/* ignore */
}
}
function loadMapStyle(): MapStyle {
try {
const id = localStorage.getItem(STYLE_STORAGE_KEY);
if (!id) return DEFAULT_STYLE;
return MAP_STYLES.find((s) => s.id === id) ?? DEFAULT_STYLE;
} catch {
return DEFAULT_STYLE;
}
}
function saveMapStyle(style: MapStyle): void {
if (typeof window === "undefined") return;
try {
localStorage.setItem(STYLE_STORAGE_KEY, style.id);
} catch {
/* blocked */
}
}
function CameraController({ city }: { city: City }) {
const { map, isLoaded } = useMap();
const { settings } = useSettings();
const prevCityRef = useRef<string | null>(null);
const idleTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const orbitFrameRef = useRef<number | null>(null);
const isInteractingRef = useRef(false);
useEffect(() => {
if (!map || !isLoaded || !city) return;
if (city.id === prevCityRef.current) return;
prevCityRef.current = city.id;
map.flyTo({
center: city.coordinates,
zoom: 11,
pitch: 49,
bearing: 27.4,
duration: 2800,
essential: true,
});
}, [map, isLoaded, city]);
useEffect(() => {
if (!map || !isLoaded || !city || !settings.autoOrbit) {
if (orbitFrameRef.current) cancelAnimationFrame(orbitFrameRef.current);
if (idleTimerRef.current) clearTimeout(idleTimerRef.current);
return;
}
const prefersReducedMotion =
window.matchMedia?.("(prefers-reduced-motion: reduce)").matches ?? false;
if (prefersReducedMotion) return;
const directionMultiplier =
settings.orbitDirection === "clockwise" ? 1 : -1;
const speed = settings.orbitSpeed * directionMultiplier;
function startOrbit() {
if (!map || isInteractingRef.current) return;
function tick() {
if (!map || isInteractingRef.current) return;
const bearing = map.getBearing() + speed;
map.setBearing(bearing % 360);
orbitFrameRef.current = requestAnimationFrame(tick);
}
orbitFrameRef.current = requestAnimationFrame(tick);
}
function stopOrbit() {
if (orbitFrameRef.current) {
cancelAnimationFrame(orbitFrameRef.current);
orbitFrameRef.current = null;
}
}
function resetIdleTimer() {
isInteractingRef.current = true;
stopOrbit();
if (idleTimerRef.current) clearTimeout(idleTimerRef.current);
idleTimerRef.current = setTimeout(() => {
isInteractingRef.current = false;
startOrbit();
}, IDLE_TIMEOUT_MS);
}
const events = ["mousedown", "wheel", "touchstart"] as const;
const container = map.getContainer();
events.forEach((e) =>
container.addEventListener(e, resetIdleTimer, { passive: true }),
);
map.on("movestart", () => {
if (isInteractingRef.current) stopOrbit();
});
idleTimerRef.current = setTimeout(() => {
isInteractingRef.current = false;
startOrbit();
}, IDLE_TIMEOUT_MS);
return () => {
stopOrbit();
if (idleTimerRef.current) clearTimeout(idleTimerRef.current);
events.forEach((e) => container.removeEventListener(e, resetIdleTimer));
};
}, [
map,
isLoaded,
city,
settings.autoOrbit,
settings.orbitSpeed,
settings.orbitDirection,
]);
return null;
}
function FlightTrackerInner() {
const hydratedCity = useSyncExternalStore(
subscribeNoop,
resolveInitialCity,
() => DEFAULT_CITY,
);
const hydratedStyle = useSyncExternalStore(
subscribeNoop,
loadMapStyle,
() => DEFAULT_STYLE,
);
const [cityOverride, setCityOverride] = useState<City | undefined>();
const [styleOverride, setStyleOverride] = useState<MapStyle | undefined>();
const activeCity = cityOverride ?? hydratedCity;
const mapStyle = styleOverride ?? hydratedStyle;
const { settings } = useSettings();
const setActiveCity = useCallback((city: City) => {
setCityOverride(city);
syncCityToUrl(city);
}, []);
const setMapStyle = useCallback((style: MapStyle) => {
setStyleOverride(style);
saveMapStyle(style);
}, []);
const { flights, loading, rateLimited, retryIn } = useFlights(activeCity);
const trails = useTrailHistory(flights);
const [hoveredFlight, setHoveredFlight] = useState<FlightState | null>(null);
const [cursorPos, setCursorPos] = useState({ x: 0, y: 0 });
const handleHover = useCallback((info: PickingInfo<FlightState> | null) => {
if (info?.object) {
setHoveredFlight(info.object);
setCursorPos({ x: info.x ?? 0, y: info.y ?? 0 });
} else {
setHoveredFlight(null);
}
}, []);
const handleClick = useCallback((info: PickingInfo<FlightState> | null) => {
if (info?.object) {
setHoveredFlight(info.object);
setCursorPos({ x: info.x ?? 0, y: info.y ?? 0 });
}
}, []);
return (
<main className="relative h-screen w-screen overflow-hidden bg-black">
<Map mapStyle={mapStyle.style}>
<CameraController city={activeCity} />
<FlightLayers
flights={flights}
trails={trails}
onHover={handleHover}
onClick={handleClick}
showTrails={settings.showTrails}
showShadows={settings.showShadows}
showAltitudeColors={settings.showAltitudeColors}
/>
</Map>
<div
data-map-theme={mapStyle.dark ? "dark" : "light"}
className="pointer-events-none absolute inset-0 z-10"
>
<div className="pointer-events-auto absolute left-4 top-4 flex items-center gap-3">
<Brand isDark={mapStyle.dark} />
</div>
<div className="pointer-events-auto absolute right-4 top-4 flex items-center gap-2">
<ControlPanel
activeCity={activeCity}
onSelectCity={setActiveCity}
activeStyle={mapStyle}
onSelectStyle={setMapStyle}
/>
</div>
<div className="pointer-events-auto absolute bottom-4 left-4">
<StatusBar
flightCount={flights.length}
cityName={activeCity.name}
loading={loading}
rateLimited={rateLimited}
retryIn={retryIn}
/>
</div>
<div className="pointer-events-auto absolute bottom-4 right-4">
<AltitudeLegend />
</div>
</div>
<FlightCard flight={hoveredFlight} x={cursorPos.x} y={cursorPos.y} />
</main>
);
}
export function FlightTracker() {
return (
<ErrorBoundary>
<SettingsProvider>
<FlightTrackerInner />
</SettingsProvider>
</ErrorBoundary>
);
}
function Brand({ isDark }: { isDark: boolean }) {
return (
<span
className={`text-sm font-semibold tracking-wide ${
isDark ? "text-white/70" : "text-black/70"
}`}
>
aeris
</span>
);
}

View File

@ -0,0 +1,398 @@
"use client";
import { useEffect, useRef, useCallback } from "react";
import { MapboxOverlay } from "@deck.gl/mapbox";
import { IconLayer, PathLayer } from "@deck.gl/layers";
import { useMap } from "./map";
import { altitudeToColor, altitudeToElevation } from "@/lib/flight-utils";
import type { FlightState } from "@/lib/opensky";
import {
SAMPLES_PER_SEGMENT,
type TrailEntry,
} from "@/hooks/use-trail-history";
import type { PickingInfo } from "@deck.gl/core";
const ANIM_DURATION_MS = 15_000;
const TELEPORT_THRESHOLD = 0.3; // degrees
type Snapshot = { lng: number; lat: number; alt: number; track: number };
function lerpAngle(a: number, b: number, t: number): number {
const delta = ((b - a + 540) % 360) - 180;
return a + delta * t;
}
function easeOut(t: number): number {
return 1 - (1 - t) * (1 - t);
}
function createAircraftAtlas(): HTMLCanvasElement {
const size = 128;
const canvas = document.createElement("canvas");
canvas.width = size;
canvas.height = size;
const ctx = canvas.getContext("2d")!;
ctx.fillStyle = "#ffffff";
ctx.beginPath();
ctx.moveTo(64, 12);
ctx.lineTo(72, 48);
ctx.lineTo(108, 72);
ctx.lineTo(104, 78);
ctx.lineTo(72, 66);
ctx.lineTo(70, 96);
ctx.lineTo(88, 108);
ctx.lineTo(86, 114);
ctx.lineTo(64, 104);
ctx.lineTo(42, 114);
ctx.lineTo(40, 108);
ctx.lineTo(58, 96);
ctx.lineTo(56, 66);
ctx.lineTo(24, 78);
ctx.lineTo(20, 72);
ctx.lineTo(56, 48);
ctx.closePath();
ctx.fill();
return canvas;
}
const AIRCRAFT_ICON_MAPPING = {
aircraft: { x: 0, y: 0, width: 128, height: 128, mask: true },
};
let _atlasCache: string | undefined;
function getAircraftAtlasUrl(): string {
if (typeof document === "undefined") return "";
if (!_atlasCache) _atlasCache = createAircraftAtlas().toDataURL();
return _atlasCache;
}
type FlightLayerProps = {
flights: FlightState[];
trails: TrailEntry[];
onHover: (info: PickingInfo<FlightState> | null) => void;
onClick: (info: PickingInfo<FlightState> | null) => void;
showTrails: boolean;
showShadows: boolean;
showAltitudeColors: boolean;
};
export function FlightLayers({
flights,
trails,
onHover,
onClick,
showTrails,
showShadows,
showAltitudeColors,
}: FlightLayerProps) {
const { map, isLoaded } = useMap();
const overlayRef = useRef<MapboxOverlay | null>(null);
const atlasUrl = getAircraftAtlasUrl();
const prevSnapshotsRef = useRef<Map<string, Snapshot>>(new Map());
const currSnapshotsRef = useRef<Map<string, Snapshot>>(new Map());
const dataTimestampRef = useRef(0);
const animFrameRef = useRef(0);
const flightsRef = useRef(flights);
const trailsRef = useRef(trails);
const showTrailsRef = useRef(showTrails);
const showShadowsRef = useRef(showShadows);
const showAltColorsRef = useRef(showAltitudeColors);
useEffect(() => {
flightsRef.current = flights;
trailsRef.current = trails;
showTrailsRef.current = showTrails;
showShadowsRef.current = showShadows;
showAltColorsRef.current = showAltitudeColors;
});
// Capture current animated position as new "prev" on each data update
useEffect(() => {
const elapsed = performance.now() - dataTimestampRef.current;
const oldT = easeOut(Math.min(elapsed / ANIM_DURATION_MS, 1));
const newPrev = new Map<string, Snapshot>();
for (const f of flights) {
if (f.longitude == null || f.latitude == null) continue;
const id = f.icao24;
const oldPrev = prevSnapshotsRef.current.get(id);
const oldCurr = currSnapshotsRef.current.get(id);
if (oldPrev && oldCurr) {
const dx = oldCurr.lng - oldPrev.lng;
const dy = oldCurr.lat - oldPrev.lat;
if (dx * dx + dy * dy <= TELEPORT_THRESHOLD * TELEPORT_THRESHOLD) {
newPrev.set(id, {
lng: oldPrev.lng + dx * oldT,
lat: oldPrev.lat + dy * oldT,
alt: oldPrev.alt + (oldCurr.alt - oldPrev.alt) * oldT,
track: lerpAngle(oldPrev.track, oldCurr.track, oldT),
});
} else {
newPrev.set(id, oldCurr);
}
} else if (oldCurr) {
newPrev.set(id, oldCurr);
}
}
prevSnapshotsRef.current = newPrev;
const next = new Map<string, Snapshot>();
for (const f of flights) {
if (f.longitude != null && f.latitude != null) {
next.set(f.icao24, {
lng: f.longitude,
lat: f.latitude,
alt: f.baroAltitude ?? 0,
track: f.trueTrack ?? 0,
});
}
}
currSnapshotsRef.current = next;
dataTimestampRef.current = performance.now();
}, [flights]);
const handleHover = useCallback(
(info: PickingInfo<FlightState>) => {
onHover(info.object ? info : null);
},
[onHover],
);
const handleClick = useCallback(
(info: PickingInfo<FlightState>) => {
if (info.object) onClick(info);
},
[onClick],
);
useEffect(() => {
if (!map || !isLoaded) return;
if (!overlayRef.current) {
overlayRef.current = new MapboxOverlay({
interleaved: false,
layers: [],
});
map.addControl(overlayRef.current as unknown as maplibregl.IControl);
}
return () => {
if (overlayRef.current) {
try {
map.removeControl(
overlayRef.current as unknown as maplibregl.IControl,
);
} catch {
/* unmounted */
}
overlayRef.current = null;
}
};
}, [map, isLoaded]);
useEffect(() => {
if (!atlasUrl) return;
function buildAndPushLayers() {
animFrameRef.current = requestAnimationFrame(buildAndPushLayers);
const overlay = overlayRef.current;
if (!overlay) return;
try {
const elapsed = performance.now() - dataTimestampRef.current;
const rawT = elapsed / ANIM_DURATION_MS;
const t = easeOut(Math.min(rawT, 1));
const currentFlights = flightsRef.current;
const currentTrails = trailsRef.current;
const altColors = showAltColorsRef.current;
const defaultColor: [number, number, number, number] = [
180, 220, 255, 200,
];
const interpolated: FlightState[] = currentFlights.map((f) => {
if (f.longitude == null || f.latitude == null) return f;
const curr = currSnapshotsRef.current.get(f.icao24);
if (!curr) return f;
// Synthesize a virtual "prev" for new flights so they slide in
let prev = prevSnapshotsRef.current.get(f.icao24);
if (!prev) {
const rad = (curr.track * Math.PI) / 180;
const spd = f.velocity ?? 200;
const step = Math.min(
(spd * (ANIM_DURATION_MS / 1000)) / 111_320,
0.015,
);
prev = {
lng: curr.lng - Math.sin(rad) * step,
lat: curr.lat - Math.cos(rad) * step,
alt: curr.alt,
track: curr.track,
};
}
const dx = curr.lng - prev.lng;
const dy = curr.lat - prev.lat;
if (dx * dx + dy * dy > TELEPORT_THRESHOLD * TELEPORT_THRESHOLD) {
return f; // teleport — skip interpolation
}
if (rawT <= 1) {
return {
...f,
longitude: prev.lng + dx * t,
latitude: prev.lat + dy * t,
baroAltitude: prev.alt + (curr.alt - prev.alt) * t,
trueTrack: lerpAngle(prev.track, curr.track, t),
};
}
// Extrapolate when the next poll is delayed
const heading = (curr.track * Math.PI) / 180;
const speed = f.velocity ?? 200;
const extraSec = ((rawT - 1) * ANIM_DURATION_MS) / 1000;
const extraDeg = Math.min((speed * extraSec) / 111_320, 0.03);
return {
...f,
longitude: curr.lng + Math.sin(heading) * extraDeg,
latitude: curr.lat + Math.cos(heading) * extraDeg,
baroAltitude: curr.alt,
trueTrack: curr.track,
};
});
const interpolatedMap = new Map<string, FlightState>();
for (const f of interpolated) {
interpolatedMap.set(f.icao24, f);
}
const layers = [];
if (showShadowsRef.current) {
layers.push(
new IconLayer<FlightState>({
id: "flight-shadows",
data: interpolated,
getPosition: (d) => [d.longitude!, d.latitude!, 0],
getIcon: () => "aircraft",
getSize: 18,
getColor: [0, 0, 0, 60],
getAngle: (d) => 360 - (d.trueTrack ?? 0),
iconAtlas: atlasUrl,
iconMapping: AIRCRAFT_ICON_MAPPING,
billboard: false,
sizeUnits: "pixels",
sizeScale: 1,
}),
);
}
if (showTrailsRef.current) {
layers.push(
new PathLayer<TrailEntry>({
id: "flight-trails",
data: currentTrails,
updateTriggers: { getPath: elapsed },
getPath: (d) => {
const animFlight = interpolatedMap.get(d.icao24);
const alt = altitudeToElevation(
animFlight?.baroAltitude ?? d.baroAltitude,
);
const basePath = d.path.map(
(p) => [p[0], p[1], alt] as [number, number, number],
);
// Reveal spline points progressively to match the animated position
if (
animFlight &&
animFlight.longitude != null &&
animFlight.latitude != null &&
basePath.length > 1
) {
const ax = animFlight.longitude;
const ay = animFlight.latitude;
const segLen = Math.min(
SAMPLES_PER_SEGMENT,
basePath.length - 1,
);
const reveal = Math.floor(t * segLen);
const collapseFrom = basePath.length - segLen + reveal;
for (let i = collapseFrom; i < basePath.length; i++) {
basePath[i] = [ax, ay, alt];
}
basePath[basePath.length - 1] = [ax, ay, alt];
}
return basePath;
},
getColor: (d) => {
const len = d.path.length;
const base = altColors
? altitudeToColor(d.baroAltitude)
: defaultColor;
return Array.from({ length: len }, (_, i) => {
const tVal = len > 1 ? i / (len - 1) : 1;
return [
base[0],
base[1],
base[2],
Math.round(tVal * tVal * 100),
];
}) as [number, number, number, number][];
},
getWidth: 2,
widthUnits: "pixels",
widthMinPixels: 1,
widthMaxPixels: 4,
capRounded: true,
jointRounded: true,
}),
);
}
layers.push(
new IconLayer<FlightState>({
id: "flight-aircraft",
data: interpolated,
getPosition: (d) => [
d.longitude!,
d.latitude!,
altitudeToElevation(d.baroAltitude),
],
getIcon: () => "aircraft",
getSize: 22,
getColor: (d) =>
altColors ? altitudeToColor(d.baroAltitude) : defaultColor,
getAngle: (d) => 360 - (d.trueTrack ?? 0),
iconAtlas: atlasUrl,
iconMapping: AIRCRAFT_ICON_MAPPING,
billboard: false,
sizeUnits: "pixels",
sizeScale: 1,
pickable: true,
onHover: handleHover,
onClick: handleClick,
autoHighlight: true,
highlightColor: [255, 255, 255, 80],
}),
);
overlay.setProps({ layers });
} catch (err) {
console.error("[aeris] FlightLayers render error:", err);
}
}
buildAndPushLayers();
return () => cancelAnimationFrame(animFrameRef.current);
}, [atlasUrl, handleHover, handleClick]);
return null;
}

115
src/components/map/map.tsx Normal file
View File

@ -0,0 +1,115 @@
"use client";
import maplibregl from "maplibre-gl";
import "maplibre-gl/dist/maplibre-gl.css";
import {
createContext,
forwardRef,
useContext,
useEffect,
useImperativeHandle,
useMemo,
useRef,
useState,
type ReactNode,
} from "react";
import { cn } from "@/lib/utils";
import { DEFAULT_STYLE, type MapStyleSpec } from "@/lib/map-styles";
type MapContextValue = {
map: maplibregl.Map | null;
isLoaded: boolean;
};
const MapContext = createContext<MapContextValue | null>(null);
export function useMap() {
const context = useContext(MapContext);
if (!context)
throw new Error("useMap must be used within a <Map /> provider");
return context;
}
type MapProps = {
children?: ReactNode;
className?: string;
mapStyle?: MapStyleSpec;
center?: [number, number];
zoom?: number;
pitch?: number;
bearing?: number;
minZoom?: number;
maxZoom?: number;
};
export type MapRef = maplibregl.Map;
export const Map = forwardRef<MapRef, MapProps>(function Map(
{
children,
className,
mapStyle = DEFAULT_STYLE.style,
center = [0, 20],
zoom = 2.5,
pitch = 49,
bearing = -20,
minZoom = 2,
maxZoom = 16,
},
ref,
) {
const containerRef = useRef<HTMLDivElement>(null);
const [mapInstance, setMapInstance] = useState<maplibregl.Map | null>(null);
const [isLoaded, setIsLoaded] = useState(false);
useImperativeHandle(ref, () => mapInstance as maplibregl.Map, [mapInstance]);
useEffect(() => {
if (!containerRef.current) return;
const map = new maplibregl.Map({
container: containerRef.current,
style: DEFAULT_STYLE.style as maplibregl.StyleSpecification | string,
center,
zoom,
pitch,
bearing,
minZoom,
maxZoom,
maxPitch: 85,
attributionControl: false,
renderWorldCopies: false,
});
map.on("load", () => setIsLoaded(true));
setMapInstance(map);
return () => {
map.remove();
setIsLoaded(false);
setMapInstance(null);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
if (!mapInstance || !isLoaded) return;
mapInstance.setStyle(mapStyle as maplibregl.StyleSpecification | string);
}, [mapInstance, isLoaded, mapStyle]);
const ctx = useMemo(
() => ({ map: mapInstance, isLoaded }),
[mapInstance, isLoaded],
);
return (
<MapContext.Provider value={ctx}>
<div
ref={containerRef}
className={cn("relative h-full w-full", className)}
>
{mapInstance && children}
</div>
</MapContext.Provider>
);
});

View File

@ -0,0 +1,80 @@
"use client";
import { motion } from "motion/react";
export function AltitudeLegend() {
return (
<motion.div
initial={{ opacity: 0, x: 12 }}
animate={{ opacity: 1, x: 0 }}
transition={{ type: "spring", stiffness: 300, damping: 24, delay: 0.6 }}
className="flex flex-col gap-2 rounded-xl border p-3 backdrop-blur-2xl"
style={{
borderColor: "rgb(var(--ui-fg) / 0.06)",
backgroundColor: "rgb(var(--ui-bg) / 0.5)",
}}
role="img"
aria-label="Altitude color scale from 0 feet (green) to 43,000 feet (blue)"
>
<p
className="text-[10px] font-semibold tracking-widest uppercase"
style={{ color: "rgb(var(--ui-fg) / 0.3)" }}
>
Altitude
</p>
<div className="flex items-center gap-2">
<div
className="h-32 w-1.5 rounded-full"
style={{
background:
"linear-gradient(to top, rgb(72,210,160), rgb(160,195,80), rgb(235,150,60), rgb(240,110,80), rgb(220,85,130), rgb(180,90,190), rgb(120,110,220), rgb(100,170,240))",
}}
/>
<div className="flex h-32 flex-col justify-between">
<span
className="text-[10px] font-medium"
style={{ color: "rgb(var(--ui-fg) / 0.5)" }}
>
43,000 ft
</span>
<span
className="text-[10px] font-medium"
style={{ color: "rgb(var(--ui-fg) / 0.5)" }}
>
20,000 ft
</span>
<span
className="text-[10px] font-medium"
style={{ color: "rgb(var(--ui-fg) / 0.5)" }}
>
10,000 ft
</span>
<span
className="text-[10px] font-medium"
style={{ color: "rgb(var(--ui-fg) / 0.5)" }}
>
5,000 ft
</span>
<span
className="text-[10px] font-medium"
style={{ color: "rgb(var(--ui-fg) / 0.5)" }}
>
2,000 ft
</span>
<span
className="text-[10px] font-medium"
style={{ color: "rgb(var(--ui-fg) / 0.5)" }}
>
500 ft
</span>
<span
className="text-[10px] font-medium"
style={{ color: "rgb(var(--ui-fg) / 0.5)" }}
>
0 ft
</span>
</div>
</div>
</motion.div>
);
}

View File

@ -0,0 +1,684 @@
"use client";
import { useState, useMemo, useRef, useEffect, type ReactNode } from "react";
import Image from "next/image";
import { motion, AnimatePresence } from "motion/react";
import {
Search,
Map as MapIcon,
Settings,
X,
Check,
MapPin,
ChevronRight,
RotateCw,
Route,
Layers,
Palette,
Gauge,
ArrowLeftRight,
Github,
} from "lucide-react";
import { CITIES, type City } from "@/lib/cities";
import { MAP_STYLES, type MapStyle } from "@/lib/map-styles";
import { useSettings, type OrbitDirection } from "@/hooks/use-settings";
import { ScrollArea } from "@/components/ui/scroll-area";
type TabId = "search" | "style" | "settings";
const TABS: { id: TabId; icon: typeof Search; label: string }[] = [
{ id: "search", icon: Search, label: "Search" },
{ id: "style", icon: MapIcon, label: "Map Style" },
{ id: "settings", icon: Settings, label: "Settings" },
];
type ControlPanelProps = {
activeCity: City;
onSelectCity: (city: City) => void;
activeStyle: MapStyle;
onSelectStyle: (style: MapStyle) => void;
};
export function ControlPanel({
activeCity,
onSelectCity,
activeStyle,
onSelectStyle,
}: ControlPanelProps) {
const [openTab, setOpenTab] = useState<TabId | null>(null);
const open = (tab: TabId) => setOpenTab(tab);
const close = () => setOpenTab(null);
return (
<>
{/* Trigger buttons */}
{TABS.map(({ id, icon: Icon, label }) => (
<motion.button
key={id}
onClick={() => open(id)}
className="flex h-9 w-9 items-center justify-center rounded-xl backdrop-blur-2xl transition-colors"
style={{
borderWidth: 1,
borderColor: "rgb(var(--ui-fg) / 0.06)",
backgroundColor: "rgb(var(--ui-fg) / 0.03)",
color: "rgb(var(--ui-fg) / 0.5)",
}}
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
aria-label={label}
>
<Icon className="h-4 w-4" />
</motion.button>
))}
{/* Dialog */}
<AnimatePresence>
{openTab && (
<PanelDialog
activeTab={openTab}
onTabChange={setOpenTab}
onClose={close}
activeCity={activeCity}
onSelectCity={(c) => {
onSelectCity(c);
close();
}}
activeStyle={activeStyle}
onSelectStyle={onSelectStyle}
/>
)}
</AnimatePresence>
</>
);
}
function PanelDialog({
activeTab,
onTabChange,
onClose,
activeCity,
onSelectCity,
activeStyle,
onSelectStyle,
}: {
activeTab: TabId;
onTabChange: (tab: TabId) => void;
onClose: () => void;
activeCity: City;
onSelectCity: (city: City) => void;
activeStyle: MapStyle;
onSelectStyle: (style: MapStyle) => void;
}) {
const dialogRef = useRef<HTMLDivElement>(null);
useEffect(() => {
function handleKey(e: KeyboardEvent) {
if (e.key === "Escape") onClose();
}
window.addEventListener("keydown", handleKey);
return () => window.removeEventListener("keydown", handleKey);
}, [onClose]);
useEffect(() => {
const dialog = dialogRef.current;
if (!dialog) return;
const focusable = dialog.querySelectorAll<HTMLElement>(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
);
if (focusable.length === 0) return;
const first = focusable[0];
first.focus();
function trapFocus(e: KeyboardEvent) {
if (e.key !== "Tab") return;
const elements = dialog!.querySelectorAll<HTMLElement>(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
);
const f = elements[0];
const l = elements[elements.length - 1];
if (e.shiftKey) {
if (document.activeElement === f) {
e.preventDefault();
l.focus();
}
} else {
if (document.activeElement === l) {
e.preventDefault();
f.focus();
}
}
}
dialog.addEventListener("keydown", trapFocus);
return () => dialog.removeEventListener("keydown", trapFocus);
}, [activeTab]);
return (
<>
{/* Backdrop */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
className="fixed inset-0 z-80 bg-black/60 backdrop-blur-xl"
onClick={onClose}
/>
{/* Panel */}
<motion.div
ref={dialogRef}
initial={{ opacity: 0, scale: 0.94, y: 16 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.94, y: 16 }}
transition={{
type: "spring",
stiffness: 400,
damping: 30,
mass: 0.8,
}}
className="fixed left-1/2 top-1/2 z-90 w-full max-w-180 -translate-x-1/2 -translate-y-1/2 px-4"
role="dialog"
aria-modal="true"
aria-labelledby="panel-dialog-title"
>
<div className="flex overflow-hidden rounded-3xl border border-white/8 bg-[#0c0c0e]/92 shadow-[0_40px_100px_rgba(0,0,0,0.8),0_0_0_1px_rgba(255,255,255,0.04)_inset] backdrop-blur-3xl backdrop-saturate-[1.8]">
{/* Sidebar */}
<div className="flex w-52 shrink-0 flex-col border-r border-white/6 py-5 px-3">
<p className="mb-3 px-2 text-[11px] font-semibold uppercase tracking-widest text-white/20">
Controls
</p>
<nav className="flex flex-col gap-0.5">
{TABS.map(({ id, icon: Icon, label }) => {
const active = id === activeTab;
return (
<button
key={id}
onClick={() => onTabChange(id)}
className={`group relative flex items-center gap-2.5 rounded-xl px-3 py-2.5 text-left transition-colors ${
active
? "text-white/90"
: "text-white/35 hover:text-white/55 hover:bg-white/4"
}`}
>
{active && (
<motion.div
layoutId="panel-tab-bg"
className="absolute inset-0 rounded-xl bg-white/8"
transition={{
type: "spring",
stiffness: 400,
damping: 30,
}}
/>
)}
<Icon className="relative h-4 w-4 shrink-0" />
<span className="relative text-[14px] font-medium">
{label}
</span>
</button>
);
})}
</nav>
<div className="mt-auto pt-4 px-1 flex flex-col gap-3">
<a
href="https://github.com/kewonit/aeris"
target="_blank"
rel="noopener noreferrer"
aria-label="GitHub (opens in new tab)"
className="group relative flex items-center gap-2.5 rounded-xl px-3 py-2.5 text-left transition-colors text-white/35 hover:text-white/55 hover:bg-white/4"
>
<Github
className="relative h-4 w-4 shrink-0"
aria-hidden="true"
/>
<span className="relative text-[14px] font-medium">GitHub</span>
</a>
<div className="border-t border-white/3 pt-2 px-2.5">
<p className="text-[10px] font-medium text-white/10 tracking-wide">
v0.1 · OpenSky Network
</p>
</div>
</div>
</div>
{/* Content */}
<div className="flex flex-1 flex-col h-120">
<div className="flex items-center justify-between px-5 pt-5 pb-2">
<h2
id="panel-dialog-title"
className="text-[15px] font-semibold tracking-tight text-white/90"
>
{TABS.find((t) => t.id === activeTab)?.label}
</h2>
<motion.button
onClick={onClose}
className="flex h-7 w-7 items-center justify-center rounded-full bg-white/6 transition-colors hover:bg-white/12"
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
aria-label="Close"
>
<X className="h-3.5 w-3.5 text-white/40" />
</motion.button>
</div>
{/* Content */}
<div className="relative flex-1 overflow-hidden">
<AnimatePresence mode="wait" initial={false}>
{activeTab === "search" && (
<TabContent key="search">
<SearchContent
activeCity={activeCity}
onSelect={onSelectCity}
/>
</TabContent>
)}
{activeTab === "style" && (
<TabContent key="style">
<StyleContent
activeStyle={activeStyle}
onSelect={onSelectStyle}
/>
</TabContent>
)}
{activeTab === "settings" && (
<TabContent key="settings">
<SettingsContent />
</TabContent>
)}
</AnimatePresence>
</div>
</div>
</div>
</motion.div>
</>
);
}
function TabContent({ children }: { children: ReactNode }) {
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
className="absolute inset-0"
>
{children}
</motion.div>
);
}
function SearchContent({
activeCity,
onSelect,
}: {
activeCity: City;
onSelect: (city: City) => void;
}) {
const [query, setQuery] = useState("");
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
requestAnimationFrame(() => inputRef.current?.focus());
}, []);
const filtered = useMemo(() => {
const q = query.toLowerCase();
return CITIES.filter(
(c) =>
c.name.toLowerCase().includes(q) ||
c.iata.toLowerCase().includes(q) ||
c.country.toLowerCase().includes(q),
);
}, [query]);
return (
<div className="flex h-full flex-col">
<div className="flex items-center gap-2.5 border-b border-white/6 mx-5 pb-3">
<Search className="h-3.5 w-3.5 shrink-0 text-white/25" />
<input
ref={inputRef}
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search airspace..."
aria-label="Search cities by name, IATA code, or country"
className="flex-1 bg-transparent text-[14px] font-medium text-white/90 placeholder:text-white/20 focus:outline-none focus-visible:ring-1 focus-visible:ring-white/40 focus-visible:rounded"
/>
</div>
<ScrollArea className="flex-1">
<div className="p-2">
{filtered.length === 0 && (
<p className="py-8 text-center text-[12px] text-white/25">
No cities found
</p>
)}
{filtered.map((city) => (
<button
key={city.id}
onClick={() => onSelect(city)}
aria-current={activeCity?.id === city.id ? "true" : undefined}
className={`group flex w-full items-center gap-2.5 rounded-xl px-3 py-2.5 text-left transition-colors hover:bg-white/4 ${
activeCity?.id === city.id ? "bg-white/6" : ""
}`}
>
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-white/4">
<MapPin className="h-3.5 w-3.5 text-white/40" />
</div>
<div className="flex-1 min-w-0">
<p className="truncate text-[14px] font-medium text-white/80">
{city.name}
</p>
<p className="text-[11px] font-medium text-white/25">
{city.iata} · {city.country}
</p>
</div>
<ChevronRight className="h-3.5 w-3.5 shrink-0 text-white/12 transition-colors group-hover:text-white/25" />
</button>
))}
</div>
</ScrollArea>
</div>
);
}
function StyleContent({
activeStyle,
onSelect,
}: {
activeStyle: MapStyle;
onSelect: (style: MapStyle) => void;
}) {
return (
<ScrollArea className="h-full">
<div className="grid grid-cols-2 gap-3 p-5 pt-2">
{MAP_STYLES.map((style, i) => (
<StyleTile
key={style.id}
style={style}
isActive={style.id === activeStyle.id}
index={i}
onSelect={() => onSelect(style)}
/>
))}
</div>
<div className="border-t border-white/4 px-5 py-3">
<p className="text-[11px] font-medium text-white/12">
Satellite &copy; Esri · Terrain &copy; OpenTopoMap · Base maps &copy;
CARTO
</p>
</div>
</ScrollArea>
);
}
function StyleTile({
style,
isActive,
index,
onSelect,
}: {
style: MapStyle;
isActive: boolean;
index: number;
onSelect: () => void;
}) {
const [imgLoaded, setImgLoaded] = useState(false);
return (
<motion.button
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.04 * index, duration: 0.25, ease: "easeOut" }}
onClick={onSelect}
aria-pressed={isActive}
aria-label={`${style.name} map style`}
className="group relative flex flex-col gap-2 text-left"
>
<div
className={`relative aspect-16/10 w-full overflow-hidden rounded-xl transition-all duration-200 ${
isActive
? "ring-2 ring-white/50 ring-offset-2 ring-offset-black/80 shadow-[0_0_20px_rgba(255,255,255,0.06)]"
: "ring-1 ring-white/8 group-hover:ring-white/18"
}`}
>
<div
className="absolute inset-0"
style={{ background: style.preview }}
/>
<Image
src={style.previewUrl}
alt={`${style.name} preview`}
fill
unoptimized
onLoad={() => setImgLoaded(true)}
className={`object-cover transition-all duration-500 group-hover:scale-105 ${
imgLoaded ? "opacity-100" : "opacity-0"
}`}
draggable={false}
/>
<div className="absolute inset-0 rounded-xl shadow-[inset_0_1px_0_rgba(255,255,255,0.06),inset_0_-16px_28px_-10px_rgba(0,0,0,0.4)]" />
<AnimatePresence>
{isActive && (
<motion.div
initial={{ scale: 0, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0, opacity: 0 }}
transition={{
type: "spring",
stiffness: 500,
damping: 28,
}}
className="absolute right-1.5 top-1.5 flex h-5 w-5 items-center justify-center rounded-full bg-white shadow-md shadow-black/30"
>
<Check className="h-3 w-3 text-black" strokeWidth={3} />
</motion.div>
)}
</AnimatePresence>
</div>
<div className="flex items-center gap-1.5 px-0.5">
<span
className={`text-[12px] font-semibold tracking-tight transition-colors ${
isActive
? "text-white/90"
: "text-white/40 group-hover:text-white/60"
}`}
>
{style.name}
</span>
{style.dark && (
<span className="h-0.5 w-0.5 rounded-full bg-white/20" />
)}
</div>
</motion.button>
);
}
const ORBIT_SPEEDS = [
{ label: "Slow", value: 0.06 },
{ label: "Normal", value: 0.15 },
{ label: "Fast", value: 0.35 },
];
const ORBIT_DIRECTIONS: { label: string; value: OrbitDirection }[] = [
{ label: "Clockwise", value: "clockwise" },
{ label: "Counter", value: "counter-clockwise" },
];
function SettingsContent() {
const { settings, update } = useSettings();
return (
<ScrollArea className="h-full">
<div className="space-y-0.5 p-3 pt-1">
<SettingRow
icon={<RotateCw className="h-4 w-4" />}
title="Auto-orbit"
description="Camera slowly rotates around the airport"
checked={settings.autoOrbit}
onChange={(v) => update("autoOrbit", v)}
/>
{settings.autoOrbit && (
<>
<SegmentRow
icon={<Gauge className="h-4 w-4" />}
title="Orbit speed"
options={ORBIT_SPEEDS}
value={settings.orbitSpeed}
onChange={(v) => update("orbitSpeed", v)}
/>
<SegmentRow
icon={<ArrowLeftRight className="h-4 w-4" />}
title="Direction"
options={ORBIT_DIRECTIONS}
value={settings.orbitDirection}
onChange={(v) => update("orbitDirection", v)}
/>
</>
)}
<div className="mx-3 my-2 h-px bg-white/4" />
<SettingRow
icon={<Route className="h-4 w-4" />}
title="Flight trails"
description="Altitude-colored trails behind aircraft"
checked={settings.showTrails}
onChange={(v) => update("showTrails", v)}
/>
<SettingRow
icon={<Layers className="h-4 w-4" />}
title="Ground shadows"
description="Shadow projections on the map surface"
checked={settings.showShadows}
onChange={(v) => update("showShadows", v)}
/>
<SettingRow
icon={<Palette className="h-4 w-4" />}
title="Altitude colors"
description="Color aircraft and trails by altitude"
checked={settings.showAltitudeColors}
onChange={(v) => update("showAltitudeColors", v)}
/>
</div>
</ScrollArea>
);
}
function SettingRow({
icon,
title,
description,
checked,
onChange,
}: {
icon: ReactNode;
title: string;
description: string;
checked: boolean;
onChange: (v: boolean) => void;
}) {
return (
<button
role="switch"
aria-checked={checked}
onClick={() => onChange(!checked)}
className="flex w-full items-center gap-3.5 rounded-xl px-3 py-3 text-left transition-colors hover:bg-white/4 active:bg-white/6"
>
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-white/5 text-white/35 ring-1 ring-white/6">
{icon}
</div>
<div className="flex-1 min-w-0">
<p className="text-[13px] font-medium text-white/80">{title}</p>
<p className="mt-0.5 text-[11px] font-medium leading-relaxed text-white/22">
{description}
</p>
</div>
<Toggle checked={checked} />
</button>
);
}
function SegmentRow<T extends string | number>({
icon,
title,
options,
value,
onChange,
}: {
icon: ReactNode;
title: string;
options: { label: string; value: T }[];
value: T;
onChange: (v: T) => void;
}) {
return (
<div className="flex w-full items-center gap-3.5 rounded-xl px-3 py-2.5 text-left">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-white/5 text-white/35 ring-1 ring-white/6">
{icon}
</div>
<p className="flex-1 min-w-0 text-[13px] font-medium text-white/80">
{title}
</p>
<div
role="radiogroup"
aria-label={title}
className="flex shrink-0 rounded-md bg-white/4 p-0.5 ring-1 ring-white/6"
>
{options.map((opt) => {
const isActive = opt.value === value;
return (
<button
key={String(opt.value)}
role="radio"
aria-checked={isActive}
onClick={() => onChange(opt.value)}
className={`relative rounded-md px-2 py-1 text-[11px] font-semibold transition-colors ${
isActive ? "text-white/90" : "text-white/30 hover:text-white/50"
}`}
>
{isActive && (
<motion.div
layoutId={`seg-${title}`}
className="absolute inset-0 rounded-md bg-white/10"
transition={{
type: "spring",
stiffness: 500,
damping: 35,
}}
/>
)}
<span className="relative">{opt.label}</span>
</button>
);
})}
</div>
</div>
);
}
function Toggle({ checked }: { checked: boolean }) {
return (
<div
className={`relative h-5 w-9 shrink-0 rounded-full transition-colors duration-200 ${
checked ? "bg-white/20" : "bg-white/6"
}`}
>
<motion.div
animate={{ x: checked ? 17 : 2 }}
transition={{ type: "spring", stiffness: 500, damping: 30 }}
className={`absolute top-0.75 h-3.5 w-3.5 rounded-full shadow-sm transition-colors duration-200 ${
checked ? "bg-white" : "bg-white/25"
}`}
/>
</div>
);
}

View File

@ -0,0 +1,131 @@
"use client";
import { motion, AnimatePresence } from "motion/react";
import { Plane, ArrowUp, ArrowDown, Gauge, Compass, Globe } from "lucide-react";
import type { FlightState } from "@/lib/opensky";
import {
metersToFeet,
msToKnots,
formatCallsign,
headingToCardinal,
} from "@/lib/flight-utils";
type FlightCardProps = {
flight: FlightState | null;
x: number;
y: number;
};
export function FlightCard({ flight, x, y }: FlightCardProps) {
return (
<AnimatePresence>
{flight && (
<motion.div
initial={{ opacity: 0, scale: 0.92, y: 8 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.92, y: 8 }}
transition={{
type: "spring",
stiffness: 400,
damping: 28,
mass: 0.8,
}}
className="pointer-events-none fixed z-50 w-72"
role="status"
aria-live="polite"
style={{
left: `min(${x + 16}px, calc(100vw - 304px))`,
top: `min(${y - 8}px, calc(100vh - 280px))`,
}}
>
<div className="rounded-2xl border border-white/8 bg-black/60 p-4 shadow-2xl shadow-black/40 backdrop-blur-2xl">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2.5">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-white/6">
<Plane className="h-4 w-4 text-white/80" />
</div>
<div>
<p className="text-sm font-semibold tracking-wide text-white">
{formatCallsign(flight.callsign)}
</p>
<p className="text-[11px] font-medium tracking-wider text-white/40 uppercase">
{flight.icao24}
</p>
</div>
</div>
<span className="rounded-full bg-emerald-500/10 px-2.5 py-0.5 text-[10px] font-semibold tracking-wider text-emerald-400 uppercase">
Live
</span>
</div>
<div className="mt-4 h-px bg-linear-to-r from-transparent via-white/6 to-transparent" />
<div className="mt-3.5 grid grid-cols-2 gap-3">
<Metric
icon={<ArrowUp className="h-3 w-3" />}
label="Altitude"
value={metersToFeet(flight.baroAltitude)}
/>
<Metric
icon={<Gauge className="h-3 w-3" />}
label="Speed"
value={msToKnots(flight.velocity)}
/>
<Metric
icon={<Compass className="h-3 w-3" />}
label="Heading"
value={
flight.trueTrack !== null
? `${Math.round(flight.trueTrack)}° ${headingToCardinal(flight.trueTrack)}`
: "—"
}
/>
<Metric
icon={<ArrowDown className="h-3 w-3" />}
label="V/S"
value={
flight.verticalRate !== null
? `${flight.verticalRate > 0 ? "+" : ""}${Math.round(flight.verticalRate)} m/s`
: "—"
}
/>
</div>
<div className="mt-3.5 h-px bg-linear-to-r from-transparent via-white/6 to-transparent" />
<div className="mt-3 flex items-center gap-1.5">
<Globe className="h-3 w-3 text-white/30" />
<p className="text-[11px] font-medium tracking-wide text-white/40">
{flight.originCountry}
</p>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
);
}
function Metric({
icon,
label,
value,
}: {
icon: React.ReactNode;
label: string;
value: string;
}) {
return (
<div className="flex flex-col gap-1">
<div className="flex items-center gap-1.5 text-white/30">
{icon}
<span className="text-[10px] font-medium tracking-wider uppercase">
{label}
</span>
</div>
<p className="text-[13px] font-semibold tracking-tight text-white/90">
{value}
</p>
</div>
);
}

View File

@ -0,0 +1,95 @@
"use client";
import {
forwardRef,
useRef,
useState,
useEffect,
useCallback,
type HTMLAttributes,
} from "react";
import { cn } from "@/lib/utils";
type ScrollAreaProps = HTMLAttributes<HTMLDivElement>;
export const ScrollArea = forwardRef<HTMLDivElement, ScrollAreaProps>(
({ className, children, ...props }, ref) => {
const viewportRef = useRef<HTMLDivElement>(null);
const thumbRef = useRef<HTMLDivElement>(null);
const [thumbHeight, setThumbHeight] = useState(0);
const [thumbTop, setThumbTop] = useState(0);
const [visible, setVisible] = useState(false);
const hideTimer = useRef<ReturnType<typeof setTimeout>>(null);
const updateThumb = useCallback(() => {
const vp = viewportRef.current;
if (!vp) return;
const ratio = vp.clientHeight / vp.scrollHeight;
if (ratio >= 1) {
setVisible(false);
return;
}
setThumbHeight(Math.max(ratio * vp.clientHeight, 24));
setThumbTop(
(vp.scrollTop / (vp.scrollHeight - vp.clientHeight)) *
(vp.clientHeight - Math.max(ratio * vp.clientHeight, 24)),
);
setVisible(true);
if (hideTimer.current) clearTimeout(hideTimer.current);
hideTimer.current = setTimeout(() => setVisible(false), 1200);
}, []);
useEffect(() => {
const vp = viewportRef.current;
if (!vp) return;
const onScroll = () => updateThumb();
vp.addEventListener("scroll", onScroll, { passive: true });
const observer = new ResizeObserver(() => updateThumb());
observer.observe(vp);
return () => {
vp.removeEventListener("scroll", onScroll);
observer.disconnect();
};
}, [updateThumb]);
return (
<div
ref={ref}
className={cn("relative overflow-hidden", className)}
{...props}
>
<div
ref={viewportRef}
className="h-full w-full overflow-y-auto overflow-x-hidden scrollbar-none"
style={{ scrollbarWidth: "none" }}
onMouseEnter={updateThumb}
>
{children}
</div>
<div
className={cn(
"absolute right-0.5 top-0 bottom-0 w-1.5 transition-opacity duration-300",
visible ? "opacity-100" : "opacity-0",
)}
>
<div
ref={thumbRef}
className="absolute w-full rounded-full bg-white/15 transition-[background-color] duration-150 hover:bg-white/25"
style={{
height: thumbHeight,
transform: `translateY(${thumbTop}px)`,
}}
/>
</div>
</div>
);
},
);
ScrollArea.displayName = "ScrollArea";

View File

@ -0,0 +1,111 @@
"use client";
import { motion, AnimatePresence } from "motion/react";
import { Plane, Radio, ShieldAlert } from "lucide-react";
type StatusBarProps = {
flightCount: number;
cityName: string;
loading: boolean;
rateLimited?: boolean;
retryIn?: number;
};
export function StatusBar({
flightCount,
cityName,
loading,
rateLimited = false,
retryIn = 0,
}: StatusBarProps) {
return (
<div className="flex flex-col items-start gap-2">
<AnimatePresence>
{rateLimited && (
<motion.div
initial={{ opacity: 0, y: 8, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 8, scale: 0.95 }}
transition={{ type: "spring", stiffness: 400, damping: 28 }}
className="flex items-center gap-2.5 rounded-xl border border-amber-500/15 bg-amber-500/6 px-3.5 py-2 backdrop-blur-2xl"
role="alert"
>
<ShieldAlert className="h-3.5 w-3.5 text-amber-400/80" />
<span className="text-[11px] font-medium tracking-wide text-amber-300/70">
Rate limited
</span>
{retryIn > 0 && (
<>
<div className="h-3 w-px bg-amber-400/10" />
<span className="font-mono text-[11px] font-semibold tabular-nums text-amber-400/60">
{retryIn}s
</span>
</>
)}
</motion.div>
)}
</AnimatePresence>
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{
type: "spring",
stiffness: 300,
damping: 24,
delay: 0.4,
}}
className="flex items-center gap-3 rounded-xl border px-3.5 py-2 backdrop-blur-2xl"
style={{
borderColor: "rgb(var(--ui-fg) / 0.06)",
backgroundColor: "rgb(var(--ui-bg) / 0.5)",
}}
aria-live="polite"
aria-atomic="true"
>
<div className="flex items-center gap-2">
<div className="relative">
<Radio
className={`h-3 w-3 ${rateLimited ? "text-amber-400/80" : "text-emerald-400/80"}`}
/>
</div>
<span
className="text-[11px] font-medium tracking-wide"
style={{ color: "rgb(var(--ui-fg) / 0.4)" }}
>
{rateLimited ? "Paused" : loading ? "Scanning..." : "Live"}
</span>
</div>
<div
className="h-3 w-px"
style={{ backgroundColor: "rgb(var(--ui-fg) / 0.08)" }}
/>
<div className="flex items-center gap-1.5">
<Plane
className="h-3 w-3"
style={{ color: "rgb(var(--ui-fg) / 0.3)" }}
/>
<span
className="text-[11px] font-semibold tracking-wide"
style={{ color: "rgb(var(--ui-fg) / 0.6)" }}
>
{flightCount}
</span>
</div>
<div
className="h-3 w-px"
style={{ backgroundColor: "rgb(var(--ui-fg) / 0.08)" }}
/>
<span
className="text-[11px] font-medium tracking-wide"
style={{ color: "rgb(var(--ui-fg) / 0.4)" }}
>
{cityName}
</span>
</motion.div>
</div>
);
}

119
src/hooks/use-flights.ts Normal file
View File

@ -0,0 +1,119 @@
"use client";
import { useState, useEffect, useCallback, useRef } from "react";
import {
fetchFlightsByBbox,
bboxFromCenter,
type FlightState,
} from "@/lib/opensky";
import type { City } from "@/lib/cities";
const POLL_INTERVAL_MS = 15_000;
const RATE_LIMIT_BACKOFF_MS = 30_000;
export function useFlights(city: City | null) {
const [flights, setFlights] = useState<FlightState[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [rateLimited, setRateLimited] = useState(false);
const [retryIn, setRetryIn] = useState(0);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const countdownRef = useRef<ReturnType<typeof setInterval> | null>(null);
const abortRef = useRef<AbortController | null>(null);
const clearCountdown = useCallback(() => {
if (countdownRef.current) {
clearInterval(countdownRef.current);
countdownRef.current = null;
}
setRetryIn(0);
}, []);
const startCountdown = useCallback(
(ms: number) => {
clearCountdown();
const endTime = Date.now() + ms;
setRetryIn(Math.ceil(ms / 1000));
countdownRef.current = setInterval(() => {
const remaining = Math.max(0, Math.ceil((endTime - Date.now()) / 1000));
setRetryIn(remaining);
if (remaining <= 0) clearCountdown();
}, 1000);
},
[clearCountdown],
);
const scheduleNext = useCallback(
(target: City, delayMs: number) => {
if (timerRef.current) clearTimeout(timerRef.current);
timerRef.current = setTimeout(() => fetchData(target), delayMs);
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[],
);
const fetchData = useCallback(
async (target: City) => {
abortRef.current?.abort();
const controller = new AbortController();
abortRef.current = controller;
try {
setLoading(true);
setError(null);
const bbox = bboxFromCenter(
target.coordinates[0],
target.coordinates[1],
target.radius,
);
const result = await fetchFlightsByBbox(...bbox, controller.signal);
if (result.rateLimited) {
setRateLimited(true);
startCountdown(RATE_LIMIT_BACKOFF_MS);
scheduleNext(target, RATE_LIMIT_BACKOFF_MS);
return;
}
setRateLimited(false);
clearCountdown();
setFlights(result.flights);
scheduleNext(target, POLL_INTERVAL_MS);
} catch (err) {
if (err instanceof DOMException && err.name === "AbortError") return;
setError(err instanceof Error ? err.message : "Unknown error");
setFlights([]);
scheduleNext(target, RATE_LIMIT_BACKOFF_MS);
} finally {
setLoading(false);
}
},
[scheduleNext, startCountdown, clearCountdown],
);
useEffect(() => {
if (timerRef.current) {
clearTimeout(timerRef.current);
timerRef.current = null;
}
if (!city) {
setFlights([]);
setRateLimited(false);
clearCountdown();
return;
}
setRateLimited(false);
clearCountdown();
fetchData(city);
return () => {
if (timerRef.current) clearTimeout(timerRef.current);
abortRef.current?.abort();
clearCountdown();
};
}, [city, fetchData, clearCountdown]);
return { flights, loading, error, rateLimited, retryIn };
}

152
src/hooks/use-settings.tsx Normal file
View File

@ -0,0 +1,152 @@
"use client";
import {
createContext,
useContext,
useState,
useCallback,
useEffect,
useRef,
useSyncExternalStore,
type ReactNode,
} from "react";
export type OrbitDirection = "clockwise" | "counter-clockwise";
export type Settings = {
autoOrbit: boolean;
orbitSpeed: number;
orbitDirection: OrbitDirection;
showTrails: boolean;
showShadows: boolean;
showAltitudeColors: boolean;
};
const DEFAULT_SETTINGS: Settings = {
autoOrbit: true,
orbitSpeed: 0.15,
orbitDirection: "clockwise",
showTrails: true,
showShadows: true,
showAltitudeColors: true,
};
const STORAGE_KEY = "aeris:settings";
const STORAGE_VERSION = 1;
const WRITE_DEBOUNCE_MS = 300;
type StorageEnvelope = {
v: number;
data: Settings;
};
/** Validate that a parsed value matches the Settings shape. */
function isValidSettings(obj: unknown): obj is Settings {
if (typeof obj !== "object" || obj === null) return false;
const s = obj as Record<string, unknown>;
return (
typeof s.autoOrbit === "boolean" &&
typeof s.orbitSpeed === "number" &&
(s.orbitDirection === "clockwise" ||
s.orbitDirection === "counter-clockwise") &&
typeof s.showTrails === "boolean" &&
typeof s.showShadows === "boolean" &&
typeof s.showAltitudeColors === "boolean"
);
}
function loadSettings(): Settings {
if (typeof window === "undefined") return DEFAULT_SETTINGS;
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return DEFAULT_SETTINGS;
const envelope: StorageEnvelope = JSON.parse(raw);
if (envelope.v !== STORAGE_VERSION || !isValidSettings(envelope.data)) {
// Merge salvageable keys with defaults
const merged = { ...DEFAULT_SETTINGS };
if (typeof envelope.data === "object" && envelope.data !== null) {
const d = envelope.data as Record<string, unknown>;
for (const key of Object.keys(DEFAULT_SETTINGS) as (keyof Settings)[]) {
if (key in d && typeof d[key] === typeof DEFAULT_SETTINGS[key]) {
(merged as Record<string, unknown>)[key] = d[key];
}
}
}
return merged;
}
return { ...DEFAULT_SETTINGS, ...envelope.data };
} catch {
return DEFAULT_SETTINGS;
}
}
function saveSettings(settings: Settings): void {
if (typeof window === "undefined") return;
try {
const envelope: StorageEnvelope = { v: STORAGE_VERSION, data: settings };
localStorage.setItem(STORAGE_KEY, JSON.stringify(envelope));
} catch {
/* quota exceeded or blocked */
}
}
type SettingsContextValue = {
settings: Settings;
update: <K extends keyof Settings>(key: K, value: Settings[K]) => void;
};
const SettingsContext = createContext<SettingsContextValue | null>(null);
const subscribeNoop = () => () => {};
let settingsCache: Settings | undefined;
function getSettingsSnapshot(): Settings {
if (!settingsCache) settingsCache = loadSettings();
return settingsCache;
}
export function useSettings() {
const ctx = useContext(SettingsContext);
if (!ctx) throw new Error("useSettings must be used within SettingsProvider");
return ctx;
}
export function SettingsProvider({ children }: { children: ReactNode }) {
const hydrated = useSyncExternalStore(
subscribeNoop,
getSettingsSnapshot,
() => DEFAULT_SETTINGS,
);
const [override, setOverride] = useState<Settings | undefined>();
const settings = override ?? hydrated;
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
if (!override) return;
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(
() => saveSettings(override),
WRITE_DEBOUNCE_MS,
);
return () => {
if (debounceRef.current) clearTimeout(debounceRef.current);
};
}, [override]);
const update = useCallback(
<K extends keyof Settings>(key: K, value: Settings[K]) => {
setOverride((prev) => {
const base = prev ?? getSettingsSnapshot();
return { ...base, [key]: value };
});
},
[],
);
return (
<SettingsContext.Provider value={{ settings, update }}>
{children}
</SettingsContext.Provider>
);
}

View File

@ -0,0 +1,148 @@
"use client";
import { useState, useMemo } from "react";
import type { FlightState } from "@/lib/opensky";
type Position = [lng: number, lat: number];
export type TrailEntry = {
icao24: string;
path: Position[];
baroAltitude: number | null;
};
const MAX_POINTS = 40;
const SYNTHETIC_COUNT = 12;
const JUMP_THRESHOLD_DEG = 0.3;
export const SAMPLES_PER_SEGMENT = 8;
// Centripetal Catmull-Rom spline (Barry-Goldman algorithm, α = 0.5).
// Produces smooth C1 curves that pass through every control point.
function catmullRomSmooth(
points: Position[],
samplesPerSegment: number = SAMPLES_PER_SEGMENT,
): Position[] {
if (points.length < 3) return [...points];
const result: Position[] = [points[0]];
for (let i = 0; i < points.length - 1; i++) {
const p0 = points[Math.max(0, i - 1)];
const p1 = points[i];
const p2 = points[i + 1];
const p3 = points[Math.min(points.length - 1, i + 2)];
// Knot intervals
const d01 = Math.pow(Math.hypot(p1[0] - p0[0], p1[1] - p0[1]), 0.5) || 1e-6;
const d12 = Math.pow(Math.hypot(p2[0] - p1[0], p2[1] - p1[1]), 0.5) || 1e-6;
const d23 = Math.pow(Math.hypot(p3[0] - p2[0], p3[1] - p2[1]), 0.5) || 1e-6;
const t0 = 0;
const t1 = d01;
const t2 = t1 + d12;
const t3 = t2 + d23;
for (let s = 1; s <= samplesPerSegment; s++) {
const t = t1 + (t2 - t1) * (s / samplesPerSegment);
// Barry-Goldman interpolation
const a1x =
((t1 - t) / (t1 - t0)) * p0[0] + ((t - t0) / (t1 - t0)) * p1[0];
const a1y =
((t1 - t) / (t1 - t0)) * p0[1] + ((t - t0) / (t1 - t0)) * p1[1];
const a2x =
((t2 - t) / (t2 - t1)) * p1[0] + ((t - t1) / (t2 - t1)) * p2[0];
const a2y =
((t2 - t) / (t2 - t1)) * p1[1] + ((t - t1) / (t2 - t1)) * p2[1];
const a3x =
((t3 - t) / (t3 - t2)) * p2[0] + ((t - t2) / (t3 - t2)) * p3[0];
const a3y =
((t3 - t) / (t3 - t2)) * p2[1] + ((t - t2) / (t3 - t2)) * p3[1];
const b1x = ((t2 - t) / (t2 - t0)) * a1x + ((t - t0) / (t2 - t0)) * a2x;
const b1y = ((t2 - t) / (t2 - t0)) * a1y + ((t - t0) / (t2 - t0)) * a2y;
const b2x = ((t3 - t) / (t3 - t1)) * a2x + ((t - t1) / (t3 - t1)) * a3x;
const b2y = ((t3 - t) / (t3 - t1)) * a2y + ((t - t1) / (t3 - t1)) * a3y;
const cx = ((t2 - t) / (t2 - t1)) * b1x + ((t - t1) / (t2 - t1)) * b2x;
const cy = ((t2 - t) / (t2 - t1)) * b1y + ((t - t1) / (t2 - t1)) * b2y;
result.push([cx, cy]);
}
}
return result;
}
function synthesizeTail(f: FlightState): Position[] {
const lng = f.longitude!;
const lat = f.latitude!;
const heading = ((f.trueTrack ?? 0) * Math.PI) / 180;
const speed = f.velocity ?? 200;
const step = Math.min((speed * 10) / 111_320, 0.02);
const pts: Position[] = [];
for (let i = SYNTHETIC_COUNT; i >= 1; i--) {
const d = step * i;
pts.push([lng - Math.sin(heading) * d, lat - Math.cos(heading) * d]);
}
return pts;
}
class TrailStore {
private trails = new Map<string, Position[]>();
private seen = new Set<string>();
update(flights: FlightState[]): TrailEntry[] {
const current = new Set<string>();
for (const f of flights) {
if (f.longitude === null || f.latitude === null) continue;
const id = f.icao24;
current.add(id);
const pos: Position = [f.longitude, f.latitude];
let trail = this.trails.get(id);
if (!trail) {
trail = synthesizeTail(f);
this.trails.set(id, trail);
}
const last = trail[trail.length - 1];
const dx = pos[0] - last[0];
const dy = pos[1] - last[1];
if (dx * dx + dy * dy > JUMP_THRESHOLD_DEG * JUMP_THRESHOLD_DEG) {
trail.length = 0;
}
trail.push(pos);
if (trail.length > MAX_POINTS) {
trail.splice(0, trail.length - MAX_POINTS);
}
}
for (const id of this.seen) {
if (!current.has(id)) this.trails.delete(id);
}
this.seen = current;
const result: TrailEntry[] = [];
for (const f of flights) {
const trail = this.trails.get(f.icao24);
if (trail && trail.length >= 2) {
result.push({
icao24: f.icao24,
path: trail.length >= 3 ? catmullRomSmooth(trail) : [...trail],
baroAltitude: f.baroAltitude,
});
}
}
return result;
}
}
export function useTrailHistory(flights: FlightState[]): TrailEntry[] {
const [store] = useState(() => new TrailStore());
return useMemo(() => store.update(flights), [store, flights]);
}

99
src/lib/cities.ts Normal file
View File

@ -0,0 +1,99 @@
export type City = {
id: string;
name: string;
country: string;
iata: string;
coordinates: [longitude: number, latitude: number];
radius: number;
};
export const CITIES: City[] = [
{
id: "nyc",
name: "New York",
country: "US",
iata: "JFK",
coordinates: [-73.7781, 40.6413],
radius: 1.5,
},
{
id: "lax",
name: "Los Angeles",
country: "US",
iata: "LAX",
coordinates: [-118.4085, 33.9416],
radius: 1.5,
},
{
id: "lhr",
name: "London",
country: "GB",
iata: "LHR",
coordinates: [-0.4614, 51.47],
radius: 1.5,
},
{
id: "dxb",
name: "Dubai",
country: "AE",
iata: "DXB",
coordinates: [55.3644, 25.2532],
radius: 1.5,
},
{
id: "nrt",
name: "Tokyo",
country: "JP",
iata: "NRT",
coordinates: [140.3929, 35.772],
radius: 1.5,
},
{
id: "sin",
name: "Singapore",
country: "SG",
iata: "SIN",
coordinates: [103.9915, 1.3644],
radius: 1.5,
},
{
id: "cdg",
name: "Paris",
country: "FR",
iata: "CDG",
coordinates: [2.5479, 49.0097],
radius: 1.5,
},
{
id: "sfo",
name: "San Francisco",
country: "US",
iata: "SFO",
coordinates: [-122.379, 37.6213],
radius: 1.5,
},
{
id: "ord",
name: "Chicago",
country: "US",
iata: "ORD",
coordinates: [-87.9073, 41.9742],
radius: 1.5,
},
{
id: "fra",
name: "Frankfurt",
country: "DE",
iata: "FRA",
coordinates: [8.5622, 50.0379],
radius: 1.5,
},
{
id: "bom",
name: "Mumbai",
country: "IN",
iata: "BOM",
coordinates: [72.8679, 19.0896],
radius: 1.5,
},
];

73
src/lib/flight-utils.ts Normal file
View File

@ -0,0 +1,73 @@
const MAX_ALTITUDE_METERS = 13000;
type RGB = [number, number, number];
const ALTITUDE_STOPS: { t: number; color: RGB }[] = [
{ t: 0.0, color: [72, 210, 160] },
{ t: 0.1, color: [100, 200, 120] },
{ t: 0.2, color: [160, 195, 80] },
{ t: 0.3, color: [210, 180, 60] },
{ t: 0.4, color: [235, 150, 60] },
{ t: 0.52, color: [240, 110, 80] },
{ t: 0.64, color: [220, 85, 130] },
{ t: 0.76, color: [180, 90, 190] },
{ t: 0.88, color: [120, 110, 220] },
{ t: 1.0, color: [100, 170, 240] },
];
function lerpColor(a: RGB, b: RGB, t: number): RGB {
return [
Math.round(a[0] + (b[0] - a[0]) * t),
Math.round(a[1] + (b[1] - a[1]) * t),
Math.round(a[2] + (b[2] - a[2]) * t),
];
}
export function altitudeToColor(
altitude: number | null,
): [number, number, number, number] {
if (altitude === null) return [100, 100, 100, 200];
const normalized = Math.min(Math.max(altitude / MAX_ALTITUDE_METERS, 0), 1);
const t = Math.pow(normalized, 0.4);
for (let i = 0; i < ALTITUDE_STOPS.length - 1; i++) {
const a = ALTITUDE_STOPS[i];
const b = ALTITUDE_STOPS[i + 1];
if (t >= a.t && t <= b.t) {
const segT = (t - a.t) / (b.t - a.t);
const [r, g, bl] = lerpColor(a.color, b.color, segT);
return [r, g, bl, 210];
}
}
const last = ALTITUDE_STOPS[ALTITUDE_STOPS.length - 1];
return [last.color[0], last.color[1], last.color[2], 210];
}
export function altitudeToElevation(altitude: number | null): number {
if (altitude === null) return 0;
return Math.max(altitude * 5, 200);
}
export function metersToFeet(meters: number | null): string {
if (meters === null) return "—";
return `${Math.round(meters * 3.28084).toLocaleString()} ft`;
}
export function msToKnots(ms: number | null): string {
if (ms === null) return "—";
return `${Math.round(ms * 1.94384)} kts`;
}
export function formatCallsign(callsign: string | null): string {
if (!callsign) return "N/A";
return callsign.trim().toUpperCase();
}
export function headingToCardinal(degrees: number | null): string {
if (degrees === null) return "—";
const directions = ["N", "NE", "E", "SE", "S", "SW", "W", "NW"];
const index = Math.round(degrees / 45) % 8;
return directions[index];
}

98
src/lib/map-styles.ts Normal file
View File

@ -0,0 +1,98 @@
export type MapStyleSpec = string | Record<string, unknown>;
export type MapStyle = {
id: string;
name: string;
style: MapStyleSpec;
preview: string;
previewUrl: string;
dark: boolean;
};
const SATELLITE_STYLE: Record<string, unknown> = {
version: 8,
sources: {
"esri-satellite": {
type: "raster",
tiles: [
"https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}",
],
tileSize: 256,
maxzoom: 18,
attribution: "&copy; Esri",
},
},
layers: [{ id: "satellite", type: "raster", source: "esri-satellite" }],
};
const TERRAIN_STYLE: Record<string, unknown> = {
version: 8,
sources: {
opentopomap: {
type: "raster",
tiles: ["https://tile.opentopomap.org/{z}/{x}/{y}.png"],
tileSize: 256,
maxzoom: 17,
attribution: "&copy; OpenTopoMap",
},
},
layers: [{ id: "terrain", type: "raster", source: "opentopomap" }],
};
export const MAP_STYLES: MapStyle[] = [
{
id: "dark",
name: "Dark",
style:
"https://basemaps.cartocdn.com/gl/dark-matter-nolabels-gl-style/style.json",
preview: "linear-gradient(135deg, #191a1a 0%, #2d2d2d 50%, #191a1a 100%)",
previewUrl: "https://a.basemaps.cartocdn.com/dark_nolabels/3/4/2@2x.png",
dark: true,
},
{
id: "dark-labels",
name: "Annotated",
style: "https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json",
preview: "linear-gradient(135deg, #1a1c1e 0%, #33363a 50%, #1a1c1e 100%)",
previewUrl: "https://a.basemaps.cartocdn.com/dark_all/3/4/2@2x.png",
dark: true,
},
{
id: "voyager",
name: "Voyager",
style:
"https://basemaps.cartocdn.com/gl/voyager-nolabels-gl-style/style.json",
preview: "linear-gradient(135deg, #f2efe9 0%, #d4cfc4 50%, #f2efe9 100%)",
previewUrl:
"https://a.basemaps.cartocdn.com/rastertiles/voyager_nolabels/3/4/2@2x.png",
dark: false,
},
{
id: "positron",
name: "Light",
style:
"https://basemaps.cartocdn.com/gl/positron-nolabels-gl-style/style.json",
preview: "linear-gradient(135deg, #e8e8e8 0%, #fafafa 50%, #e8e8e8 100%)",
previewUrl: "https://a.basemaps.cartocdn.com/light_nolabels/3/4/2@2x.png",
dark: false,
},
{
id: "satellite",
name: "Satellite",
style: SATELLITE_STYLE,
preview: "linear-gradient(135deg, #0a1628 0%, #1a3050 50%, #0a1628 100%)",
previewUrl:
"https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/3/2/4",
dark: true,
},
{
id: "terrain",
name: "Terrain",
style: TERRAIN_STYLE,
preview: "linear-gradient(135deg, #c8d8c0 0%, #a8c098 50%, #d0d8c0 100%)",
previewUrl: "https://tile.opentopomap.org/3/4/2.png",
dark: false,
},
];
export const DEFAULT_STYLE = MAP_STYLES[0];

93
src/lib/opensky.ts Normal file
View File

@ -0,0 +1,93 @@
export type FlightState = {
icao24: string;
callsign: string | null;
originCountry: string;
longitude: number | null;
latitude: number | null;
baroAltitude: number | null;
onGround: boolean;
velocity: number | null;
trueTrack: number | null;
verticalRate: number | null;
geoAltitude: number | null;
squawk: string | null;
spiFlag: boolean;
positionSource: number;
};
export type OpenSkyResponse = {
time: number;
states: (string | number | boolean | null)[][] | null;
rateLimited?: boolean;
};
function parseStates(raw: OpenSkyResponse): FlightState[] {
if (!raw.states) return [];
return raw.states
.map((s) => ({
icao24: s[0] as string,
callsign: (s[1] as string)?.trim() || null,
originCountry: s[2] as string,
longitude: s[5] as number | null,
latitude: s[6] as number | null,
baroAltitude: s[7] as number | null,
onGround: s[8] as boolean,
velocity: s[9] as number | null,
trueTrack: s[10] as number | null,
verticalRate: s[11] as number | null,
geoAltitude: s[13] as number | null,
squawk: s[14] as string | null,
spiFlag: s[15] as boolean,
positionSource: s[16] as number,
}))
.filter(
(f) =>
f.longitude !== null &&
f.latitude !== null &&
!f.onGround &&
f.baroAltitude !== null,
);
}
export type FetchResult = {
flights: FlightState[];
rateLimited: boolean;
};
/** Fetch flights via the server-side proxy. */
export async function fetchFlightsByBbox(
lamin: number,
lamax: number,
lomin: number,
lomax: number,
signal?: AbortSignal,
): Promise<FetchResult> {
const url = `/api/flights?lamin=${lamin}&lamax=${lamax}&lomin=${lomin}&lomax=${lomax}`;
const res = await fetch(url, { cache: "no-store", signal });
if (!res.ok) {
// Don't throw — let the hook retry gracefully
console.warn(`[aeris] Flight API returned ${res.status}`);
return { flights: [], rateLimited: false };
}
const data: OpenSkyResponse = await res.json();
if (data.rateLimited) {
console.warn("[aeris] OpenSky rate limit hit, backing off");
return { flights: [], rateLimited: true };
}
const flights = parseStates(data);
return { flights, rateLimited: false };
}
export function bboxFromCenter(
lng: number,
lat: number,
radiusDeg: number,
): [lamin: number, lamax: number, lomin: number, lomax: number] {
return [lat - radiusDeg, lat + radiusDeg, lng - radiusDeg, lng + radiusDeg];
}

6
src/lib/utils.ts Normal file
View File

@ -0,0 +1,6 @@
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}