Zlr3-NFJ@NFaJlhyfcw{k^vvtGl`N9xSo**rDW4S}i
zM9{fMPWo%4wYDG~BZ18BD+}h|GQKc-g^{++3MY>}W_uq7jGHx{mwE9fZiPCoxN$+7
zrODGGJrOkcPQUB(FD5aoS4g~7#6NR^ma7-!>mHuJfY5kTe6PpNNKC9GGRiu^L31uG
z$7v`*JknQHsYB!Tm_W{a32TM099djW%5e+j0Ve_ct}IM>XLF1Ap+YvcrLV=|CKo6S
zb+9Nl3_YdKP6%Cxy@6TxZ>;4&nTneadr
z_ES90ydCev)LV!dN=#(*f}|ZORFdvkYBni^aLbUk>BajeWIOcmHP#8S)*2U~QKI%S
zyrLmtPqb&TphJ;>yAxri#;{uyk`JJqODDw%(Z=2`1uc}br^V%>j!gS)D*q*f_-qf8&D;W1dJgQMlaH5er
zN2U<%Smb7==vE}dDI8K7cKz!vs^73o9f>2sgiTzWcwY|BMYHH5%Vn7#kiw&eItCqa
zIkR2~Q}>X=Ar8W|^Ms41Fm8o6IB2_j60eOeBB1Br!boW7JnoeX6Gs)?7rW0^5psc-
zjS16yb>dFn>KPOF;imD}e!enuIniFzv}n$m2#gCCv4jM#ArwlzZ$7@9&XkFxZ4n!V
zj3dyiwW4Ki2QG{@i>yuZXQizw_OkZI^-3otXC{!(lUpJF33gI60ak;Uqitp74|B6I
zgg{b=Iz}WkhCGj1M=hu4#Aw173YxIVbISaoc
z-nLZC*6Tgivd5V`K%GxhBsp@SUU60-rfc$=wb>zdJzXS&-5(NRRodFk;Kxk!S(O(a0e7oY=E(
zAyS;Ow?6Q&XA+cnkCb{28_1N8H#?J!*$MmIwLq^*T_9-z^&UE@A(z9oGYtFy6EZef
LrJugUA?W`A8`#=m
literal 0
HcmV?d00001
diff --git a/src/app/globals.css b/src/app/globals.css
new file mode 100644
index 0000000..aadf696
--- /dev/null
+++ b/src/app/globals.css
@@ -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;
+ }
+}
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
new file mode 100644
index 0000000..d9cc2dc
--- /dev/null
+++ b/src/app/layout.tsx
@@ -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 (
+
+
+ {children}
+
+
+ );
+}
diff --git a/src/app/page.tsx b/src/app/page.tsx
new file mode 100644
index 0000000..26f0f92
--- /dev/null
+++ b/src/app/page.tsx
@@ -0,0 +1,5 @@
+import { FlightTracker } from "@/components/flight-tracker";
+
+export default function Home() {
+ return ;
+}
diff --git a/src/components/error-boundary.tsx b/src/components/error-boundary.tsx
new file mode 100644
index 0000000..c79f1e4
--- /dev/null
+++ b/src/components/error-boundary.tsx
@@ -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 {
+ 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 (
+
+ Something went wrong
+
+ {this.state.error.message}
+
+
+
+ );
+ }
+ return this.props.children;
+ }
+}
diff --git a/src/components/flight-tracker.tsx b/src/components/flight-tracker.tsx
new file mode 100644
index 0000000..ad43497
--- /dev/null
+++ b/src/components/flight-tracker.tsx
@@ -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(null);
+ const idleTimerRef = useRef | null>(null);
+ const orbitFrameRef = useRef(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();
+ const [styleOverride, setStyleOverride] = useState();
+ 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(null);
+ const [cursorPos, setCursorPos] = useState({ x: 0, y: 0 });
+
+ const handleHover = useCallback((info: PickingInfo | null) => {
+ if (info?.object) {
+ setHoveredFlight(info.object);
+ setCursorPos({ x: info.x ?? 0, y: info.y ?? 0 });
+ } else {
+ setHoveredFlight(null);
+ }
+ }, []);
+
+ const handleClick = useCallback((info: PickingInfo | null) => {
+ if (info?.object) {
+ setHoveredFlight(info.object);
+ setCursorPos({ x: info.x ?? 0, y: info.y ?? 0 });
+ }
+ }, []);
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+export function FlightTracker() {
+ return (
+
+
+
+
+
+ );
+}
+
+function Brand({ isDark }: { isDark: boolean }) {
+ return (
+
+ aeris
+
+ );
+}
diff --git a/src/components/map/flight-layers.tsx b/src/components/map/flight-layers.tsx
new file mode 100644
index 0000000..25ff768
--- /dev/null
+++ b/src/components/map/flight-layers.tsx
@@ -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 | null) => void;
+ onClick: (info: PickingInfo | 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(null);
+ const atlasUrl = getAircraftAtlasUrl();
+
+ const prevSnapshotsRef = useRef |