feat: enhance flight tracking with improved API handling, metadata, and performance optimizations

This commit is contained in:
Kewonit
2026-02-14 13:14:43 +05:30
parent b3f20b7659
commit 08be8e1267
7 changed files with 338 additions and 132 deletions

View File

@ -1,25 +1,31 @@
import { NextRequest, NextResponse } from "next/server";
export const maxDuration = 10;
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";
const TOKEN_TIMEOUT_MS = 3_000;
const FETCH_TIMEOUT_MS = 5_000;
const CACHE_TTL_MS = 10_000;
const MAX_REQUESTS_PER_MINUTE = 20;
const MAX_BBOX_SPAN = 20;
// --- OAuth2 token cache ---
// OAuth2 token cache
let cachedToken: string | null = null;
let tokenExpiresAt = 0; // epoch ms
let tokenExpiresAt = 0;
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;
}
if (cachedToken && Date.now() < tokenExpiresAt - 60_000) return cachedToken;
try {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), TOKEN_TIMEOUT_MS);
const res = await fetch(OPENSKY_TOKEN_URL, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
@ -29,12 +35,11 @@ async function getAccessToken(): Promise<string | null> {
client_secret: clientSecret,
}),
cache: "no-store",
});
signal: controller.signal,
}).finally(() => clearTimeout(timer));
if (!res.ok) {
console.error(
`[aeris] OAuth2 token request failed: ${res.status} ${res.statusText}`,
);
console.error(`[aeris] Token request failed: ${res.status}`);
cachedToken = null;
return null;
}
@ -42,21 +47,19 @@ async function getAccessToken(): Promise<string | 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);
console.error(
"[aeris] Token error:",
err instanceof Error ? err.message : err,
);
cachedToken = null;
return null;
}
}
// --- Auth ---
type AuthMode = "oauth2" | "basic" | "anonymous";
let authDisabled = false;
let authLoggedOnce = false;
@ -76,7 +79,7 @@ async function buildAuthHeaders(): Promise<HeadersInit> {
if (mode === "oauth2") {
const token = await getAccessToken();
if (token) return { Authorization: `Bearer ${token}` };
return {}; // token fetch failed — fall through
return {};
}
if (mode === "basic") {
@ -90,70 +93,95 @@ async function buildAuthHeaders(): Promise<HeadersInit> {
return {};
}
function logAuthStatus() {
function logAuthOnce() {
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`);
}
console.info(`[aeris] Auth mode: ${detectAuthMode()}`);
}
// Per-IP rate limiter
// --- 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 window = 60_000;
const timestamps = requestLog.get(ip) ?? [];
const recent = timestamps.filter((t) => now - t < windowMs);
const recent = timestamps.filter((t) => now - t < window);
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);
if (val.every((t) => now - t > window)) 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));
// --- Response cache ---
let responseCache: {
key: string;
data: unknown;
expiresAt: number;
} | null = null;
function getCached(key: string): unknown | null {
if (
responseCache &&
responseCache.key === key &&
Date.now() < responseCache.expiresAt
) {
return responseCache.data;
}
return null;
}
async function fetchFromOpenSky(
function setCache(key: string, data: unknown): void {
responseCache = { key, data, expiresAt: Date.now() + CACHE_TTL_MS };
}
// --- Fetch with timeout ---
async function fetchOpenSky(
url: string,
useAuth: boolean,
): Promise<Response> {
const headers = useAuth ? await buildAuthHeaders() : {};
return fetch(url, { headers, cache: "no-store" });
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
try {
return await fetch(url, {
headers,
cache: "no-store",
signal: controller.signal,
});
} finally {
clearTimeout(timer);
}
}
// --- Utilities ---
function clamp(val: number, min: number, max: number) {
return Math.max(min, Math.min(max, val));
}
function json(
body: unknown,
status: number,
extra?: Record<string, string>,
) {
return NextResponse.json(body, {
status,
headers: { "Cache-Control": "no-store", ...extra },
});
}
// --- Route handler ---
export async function GET(request: NextRequest) {
const ip =
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ??
@ -161,10 +189,7 @@ export async function GET(request: NextRequest) {
"unknown";
if (isRateLimited(ip)) {
return NextResponse.json(
{ time: 0, states: null, rateLimited: true },
{ status: 200, headers: { "Cache-Control": "no-store" } },
);
return json({ time: 0, states: null, rateLimited: true }, 200);
}
const { searchParams } = request.nextUrl;
@ -174,23 +199,16 @@ export async function GET(request: NextRequest) {
const lomax = searchParams.get("lomax");
if (!lamin || !lamax || !lomin || !lomax) {
return NextResponse.json(
{ error: "Missing required bbox parameters" },
{ status: 400 },
);
return json({ error: "Missing required bbox parameters" }, 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 },
);
return json({ error: `Invalid parameter: ${key}` }, 400);
}
}
// Clamp to valid geographic ranges and limit bbox size
const coords = {
lamin: clamp(raw.lamin, -90, 90),
lamax: clamp(raw.lamax, -90, 90),
@ -198,73 +216,87 @@ export async function GET(request: NextRequest) {
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 (
Math.abs(coords.lamax - coords.lamin) > MAX_BBOX_SPAN ||
Math.abs(coords.lomax - coords.lomin) > MAX_BBOX_SPAN
) {
return json(
{ error: `Bounding box too large (max ${MAX_BBOX_SPAN}° per axis)` },
400,
);
}
if (!authLoggedOnce) logAuthStatus();
logAuthOnce();
const url = `${OPENSKY_BASE}/states/all?lamin=${coords.lamin}&lamax=${coords.lamax}&lomin=${coords.lomin}&lomax=${coords.lomax}`;
const cacheKey = `${coords.lamin},${coords.lamax},${coords.lomin},${coords.lomax}`;
const cached = getCached(cacheKey);
if (cached) {
return json(cached, 200, { "X-Cache": "HIT" });
}
const useAuth = detectAuthMode() !== "anonymous";
try {
let res = await fetchFromOpenSky(url, useAuth);
let res = await fetchOpenSky(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);
console.warn("[aeris] Auth rejected (401), falling back to anonymous");
res = await fetchOpenSky(url, false);
}
if (res.status === 429) {
const retryAfter = res.headers.get("X-Rate-Limit-Retry-After-Seconds");
return NextResponse.json(
const retryAfter = res.headers.get(
"X-Rate-Limit-Retry-After-Seconds",
);
return json(
{
time: 0,
states: null,
rateLimited: true,
retryAfter: retryAfter ? parseInt(retryAfter, 10) : null,
},
{ status: 200, headers: { "Cache-Control": "no-store" } },
200,
);
}
if (!res.ok) {
console.error(`[aeris] OpenSky error: ${res.status} ${res.statusText}`);
return NextResponse.json(
{ error: "Upstream data source error" },
{ status: 502 },
const body = await res.text().catch(() => "");
console.error(`[aeris] OpenSky ${res.status}: ${body.slice(0, 300)}`);
return json(
{ error: "Upstream data source error", status: res.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}`);
}
let data;
try {
data = await res.json();
} catch {
console.error("[aeris] OpenSky returned non-JSON response");
return json({ error: "Upstream returned invalid response" }, 502);
}
return NextResponse.json(data, {
headers: { "Cache-Control": "no-store" },
});
setCache(cacheKey, data);
return json(data, 200, { "X-Cache": "MISS" });
} catch (err) {
console.error("[aeris] OpenSky proxy error:", err);
return NextResponse.json(
{ error: "Failed to fetch flight data" },
{ status: 502 },
if (err instanceof DOMException && err.name === "AbortError") {
console.error(`[aeris] OpenSky timed out (${FETCH_TIMEOUT_MS}ms)`);
return json(
{ error: "Upstream request timed out", timeout: true },
504,
);
}
const msg = err instanceof Error ? err.message : String(err);
console.error(`[aeris] Proxy error: ${msg}`);
return json(
{ error: "Failed to fetch flight data", detail: msg },
502,
);
}
}

View File

@ -1,5 +1,6 @@
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import Script from "next/script";
import "./globals.css";
const inter = Inter({
@ -8,10 +9,48 @@ const inter = Inter({
display: "swap",
});
const GA_ID = process.env.NEXT_PUBLIC_GA_ID;
const title = "Aeris — Real-Time 3D Flight Tracking";
const description =
"Track live flights in 3D over the world's busiest airspaces. Altitude-aware, beautifully rendered, and completely free.";
const siteUrl = "https://aeris-flight.vercel.app";
export const metadata: Metadata = {
title: "Aeris — Real-Time 3D Flight Tracking",
description:
"Altitude-aware, visually stunning flight tracking over the world's busiest airspaces.",
title,
description,
metadataBase: new URL(siteUrl),
keywords: [
"flight tracker",
"live flights",
"3D flight tracking",
"real-time aviation",
"flight radar",
"aircraft tracking",
"aeris",
"opensky",
],
authors: [{ name: "kewonit", url: "https://github.com/kewonit" }],
creator: "kewonit",
openGraph: {
type: "website",
locale: "en_US",
url: siteUrl,
siteName: "Aeris",
title,
description,
},
twitter: {
card: "summary_large_image",
title,
description,
},
robots: {
index: true,
follow: true,
googleBot: { index: true, follow: true },
},
alternates: { canonical: siteUrl },
};
export default function RootLayout({
@ -21,6 +60,20 @@ export default function RootLayout({
}>) {
return (
<html lang="en" className="dark">
<head>
<link rel="canonical" href={siteUrl} />
{GA_ID && (
<>
<Script
src={`https://www.googletagmanager.com/gtag/js?id=${GA_ID}`}
strategy="afterInteractive"
/>
<Script id="gtag-init" strategy="afterInteractive">
{`window.dataLayer=window.dataLayer||[];function gtag(){dataLayer.push(arguments)}gtag('js',new Date());gtag('config','${GA_ID}');`}
</Script>
</>
)}
</head>
<body className={`${inter.variable} font-sans antialiased`}>
{children}
</body>

View File

@ -1,5 +1,26 @@
import { FlightTracker } from "@/components/flight-tracker";
const jsonLd = {
"@context": "https://schema.org",
"@type": "WebApplication",
name: "Aeris",
url: "https://aeris-flight.vercel.app",
description:
"Track live flights in 3D over the world's busiest airspaces. Altitude-aware, beautifully rendered, and completely free.",
applicationCategory: "TravelApplication",
operatingSystem: "Any",
offers: { "@type": "Offer", price: "0", priceCurrency: "USD" },
author: { "@type": "Person", name: "kewonit" },
};
export default function Home() {
return <FlightTracker />;
return (
<>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
<FlightTracker />
</>
);
}

View File

@ -22,8 +22,8 @@ function lerpAngle(a: number, b: number, t: number): number {
return a + delta * t;
}
function easeOut(t: number): number {
return 1 - (1 - t) * (1 - t);
function smoothStep(t: number): number {
return t * t * (3 - 2 * t);
}
function createAircraftAtlas(): HTMLCanvasElement {
@ -113,7 +113,8 @@ export function FlightLayers({
// 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 oldLinearT = Math.min(elapsed / ANIM_DURATION_MS, 1);
const oldAngleT = smoothStep(oldLinearT);
const newPrev = new Map<string, Snapshot>();
for (const f of flights) {
@ -127,10 +128,10 @@ export function FlightLayers({
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),
lng: oldPrev.lng + dx * oldLinearT,
lat: oldPrev.lat + dy * oldLinearT,
alt: oldPrev.alt + (oldCurr.alt - oldPrev.alt) * oldLinearT,
track: lerpAngle(oldPrev.track, oldCurr.track, oldAngleT),
});
} else {
newPrev.set(id, oldCurr);
@ -207,7 +208,8 @@ export function FlightLayers({
try {
const elapsed = performance.now() - dataTimestampRef.current;
const rawT = elapsed / ANIM_DURATION_MS;
const t = easeOut(Math.min(rawT, 1));
const tPos = Math.min(rawT, 1);
const tAngle = smoothStep(tPos);
const currentFlights = flightsRef.current;
const currentTrails = trailsRef.current;
@ -248,14 +250,15 @@ export function FlightLayers({
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),
longitude: prev.lng + dx * tPos,
latitude: prev.lat + dy * tPos,
baroAltitude: prev.alt + (curr.alt - prev.alt) * tPos,
trueTrack: lerpAngle(prev.track, curr.track, tAngle),
};
}
// Extrapolate when the next poll is delayed
// Extrapolate when the next poll is delayed (velocity-continuous
// with the linear interpolation above)
const heading = (curr.track * Math.PI) / 180;
const speed = f.velocity ?? 200;
const extraSec = ((rawT - 1) * ANIM_DURATION_MS) / 1000;
@ -322,7 +325,7 @@ export function FlightLayers({
SAMPLES_PER_SEGMENT,
basePath.length - 1,
);
const reveal = Math.floor(t * segLen);
const reveal = Math.floor(tPos * segLen);
const collapseFrom = basePath.length - segLen + reveal;
for (let i = collapseFrom; i < basePath.length; i++) {

View File

@ -95,6 +95,31 @@ export const Map = forwardRef<MapRef, MapProps>(function Map(
useEffect(() => {
if (!mapInstance || !isLoaded) return;
mapInstance.setStyle(mapStyle as maplibregl.StyleSpecification | string);
// Re-apply terrain/sky after style load (MapLibre can drop these on setStyle)
const applyTerrain = () => {
if (typeof mapStyle === "object" && "terrain" in mapStyle) {
const spec = mapStyle as Record<string, unknown>;
try {
mapInstance.setTerrain(
spec.terrain as maplibregl.TerrainSpecification,
);
} catch {
/* terrain source not yet loaded */
}
} else {
try {
mapInstance.setTerrain(null);
} catch {
/* no terrain to remove */
}
}
};
mapInstance.once("style.load", applyTerrain);
return () => {
mapInstance.off("style.load", applyTerrain);
};
}, [mapInstance, isLoaded, mapStyle]);
const ctx = useMemo(

View File

@ -347,7 +347,7 @@ function SearchContent({
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"
className="flex-1 bg-transparent text-[14px] font-medium text-white/90 placeholder:text-white/20 outline-none"
/>
</div>

View File

@ -39,6 +39,60 @@ const TERRAIN_STYLE: Record<string, unknown> = {
layers: [{ id: "terrain", type: "raster", source: "opentopomap" }],
};
const ESRI_TOPO_STYLE: Record<string, unknown> = {
version: 8,
sources: {
"esri-topo": {
type: "raster",
tiles: [
"https://server.arcgisonline.com/ArcGIS/rest/services/World_Topo_Map/MapServer/tile/{z}/{y}/{x}",
],
tileSize: 256,
maxzoom: 19,
attribution: "&copy; Esri",
},
},
layers: [{ id: "esri-topo", type: "raster", source: "esri-topo" }],
};
const SHADED_RELIEF_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",
},
"terrain-dem": {
type: "raster-dem",
tiles: [
"https://s3.amazonaws.com/elevation-tiles-prod/terrarium/{z}/{x}/{y}.png",
],
tileSize: 256,
maxzoom: 15,
encoding: "terrarium",
},
},
terrain: {
source: "terrain-dem",
exaggeration: 1.5,
},
sky: {
"sky-color": "#76a8d6",
"horizon-color": "#d4e4f0",
"fog-color": "#c8d8e8",
"sky-horizon-blend": 0.5,
"horizon-fog-blend": 0.1,
},
layers: [
{ id: "satellite-base", type: "raster", source: "esri-satellite" },
],
};
export const MAP_STYLES: MapStyle[] = [
{
id: "dark",
@ -67,15 +121,6 @@ export const MAP_STYLES: MapStyle[] = [
"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",
@ -93,6 +138,33 @@ export const MAP_STYLES: MapStyle[] = [
previewUrl: "https://tile.opentopomap.org/3/4/2.png",
dark: false,
},
{
id: "topo",
name: "Topo",
style: ESRI_TOPO_STYLE,
preview: "linear-gradient(135deg, #d4cbb3 0%, #c4b89c 50%, #e0d8c4 100%)",
previewUrl:
"https://server.arcgisonline.com/ArcGIS/rest/services/World_Topo_Map/MapServer/tile/3/2/4",
dark: false,
},
{
id: "relief",
name: "3D Terrain",
style: SHADED_RELIEF_STYLE,
preview: "linear-gradient(135deg, #1a3050 0%, #2a5040 50%, #1a3050 100%)",
previewUrl:
"https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/3/2/4",
dark: true,
},
{
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,
},
];
export const DEFAULT_STYLE = MAP_STYLES[0];