feat: enhance flight tracking with improved API handling, metadata, and performance optimizations
This commit is contained in:
@ -1,25 +1,31 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
|
||||||
|
export const maxDuration = 10;
|
||||||
|
|
||||||
const OPENSKY_BASE = "https://opensky-network.org/api";
|
const OPENSKY_BASE = "https://opensky-network.org/api";
|
||||||
const OPENSKY_TOKEN_URL =
|
const OPENSKY_TOKEN_URL =
|
||||||
"https://auth.opensky-network.org/auth/realms/opensky-network/protocol/openid-connect/token";
|
"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 cachedToken: string | null = null;
|
||||||
let tokenExpiresAt = 0; // epoch ms
|
let tokenExpiresAt = 0;
|
||||||
|
|
||||||
async function getAccessToken(): Promise<string | null> {
|
async function getAccessToken(): Promise<string | null> {
|
||||||
const clientId = process.env.OPENSKY_CLIENT_ID;
|
const clientId = process.env.OPENSKY_CLIENT_ID;
|
||||||
const clientSecret = process.env.OPENSKY_CLIENT_SECRET;
|
const clientSecret = process.env.OPENSKY_CLIENT_SECRET;
|
||||||
|
|
||||||
if (!clientId || !clientSecret) return null;
|
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 {
|
try {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timer = setTimeout(() => controller.abort(), TOKEN_TIMEOUT_MS);
|
||||||
const res = await fetch(OPENSKY_TOKEN_URL, {
|
const res = await fetch(OPENSKY_TOKEN_URL, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||||
@ -29,12 +35,11 @@ async function getAccessToken(): Promise<string | null> {
|
|||||||
client_secret: clientSecret,
|
client_secret: clientSecret,
|
||||||
}),
|
}),
|
||||||
cache: "no-store",
|
cache: "no-store",
|
||||||
});
|
signal: controller.signal,
|
||||||
|
}).finally(() => clearTimeout(timer));
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
console.error(
|
console.error(`[aeris] Token request failed: ${res.status}`);
|
||||||
`[aeris] OAuth2 token request failed: ${res.status} ${res.statusText}`,
|
|
||||||
);
|
|
||||||
cachedToken = null;
|
cachedToken = null;
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@ -42,21 +47,19 @@ async function getAccessToken(): Promise<string | null> {
|
|||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
cachedToken = data.access_token;
|
cachedToken = data.access_token;
|
||||||
tokenExpiresAt = Date.now() + (data.expires_in ?? 1800) * 1000;
|
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;
|
return cachedToken;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("[aeris] OAuth2 token error:", err);
|
console.error(
|
||||||
|
"[aeris] Token error:",
|
||||||
|
err instanceof Error ? err.message : err,
|
||||||
|
);
|
||||||
cachedToken = null;
|
cachedToken = null;
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Auth ---
|
||||||
|
|
||||||
type AuthMode = "oauth2" | "basic" | "anonymous";
|
type AuthMode = "oauth2" | "basic" | "anonymous";
|
||||||
let authDisabled = false;
|
let authDisabled = false;
|
||||||
let authLoggedOnce = false;
|
let authLoggedOnce = false;
|
||||||
@ -76,7 +79,7 @@ async function buildAuthHeaders(): Promise<HeadersInit> {
|
|||||||
if (mode === "oauth2") {
|
if (mode === "oauth2") {
|
||||||
const token = await getAccessToken();
|
const token = await getAccessToken();
|
||||||
if (token) return { Authorization: `Bearer ${token}` };
|
if (token) return { Authorization: `Bearer ${token}` };
|
||||||
return {}; // token fetch failed — fall through
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (mode === "basic") {
|
if (mode === "basic") {
|
||||||
@ -90,70 +93,95 @@ async function buildAuthHeaders(): Promise<HeadersInit> {
|
|||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
function logAuthStatus() {
|
function logAuthOnce() {
|
||||||
if (authLoggedOnce) return;
|
if (authLoggedOnce) return;
|
||||||
authLoggedOnce = true;
|
authLoggedOnce = true;
|
||||||
|
console.info(`[aeris] Auth mode: ${detectAuthMode()}`);
|
||||||
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
|
// --- Per-IP rate limiter ---
|
||||||
|
|
||||||
const requestLog = new Map<string, number[]>();
|
const requestLog = new Map<string, number[]>();
|
||||||
const MAX_REQUESTS_PER_MINUTE = 20;
|
|
||||||
|
|
||||||
function isRateLimited(ip: string): boolean {
|
function isRateLimited(ip: string): boolean {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const windowMs = 60_000;
|
const window = 60_000;
|
||||||
const timestamps = requestLog.get(ip) ?? [];
|
const timestamps = requestLog.get(ip) ?? [];
|
||||||
const recent = timestamps.filter((t) => now - t < windowMs);
|
const recent = timestamps.filter((t) => now - t < window);
|
||||||
recent.push(now);
|
recent.push(now);
|
||||||
requestLog.set(ip, recent);
|
requestLog.set(ip, recent);
|
||||||
|
|
||||||
// Clean up stale entries periodically
|
|
||||||
if (requestLog.size > 500) {
|
if (requestLog.size > 500) {
|
||||||
for (const [key, val] of requestLog) {
|
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;
|
return recent.length > MAX_REQUESTS_PER_MINUTE;
|
||||||
}
|
}
|
||||||
|
|
||||||
function clamp(val: number, min: number, max: number): number {
|
// --- Response cache ---
|
||||||
return Math.max(min, Math.min(max, val));
|
|
||||||
|
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,
|
url: string,
|
||||||
useAuth: boolean,
|
useAuth: boolean,
|
||||||
): Promise<Response> {
|
): Promise<Response> {
|
||||||
const headers = useAuth ? await buildAuthHeaders() : {};
|
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) {
|
export async function GET(request: NextRequest) {
|
||||||
const ip =
|
const ip =
|
||||||
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ??
|
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ??
|
||||||
@ -161,10 +189,7 @@ export async function GET(request: NextRequest) {
|
|||||||
"unknown";
|
"unknown";
|
||||||
|
|
||||||
if (isRateLimited(ip)) {
|
if (isRateLimited(ip)) {
|
||||||
return NextResponse.json(
|
return json({ time: 0, states: null, rateLimited: true }, 200);
|
||||||
{ time: 0, states: null, rateLimited: true },
|
|
||||||
{ status: 200, headers: { "Cache-Control": "no-store" } },
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const { searchParams } = request.nextUrl;
|
const { searchParams } = request.nextUrl;
|
||||||
@ -174,23 +199,16 @@ export async function GET(request: NextRequest) {
|
|||||||
const lomax = searchParams.get("lomax");
|
const lomax = searchParams.get("lomax");
|
||||||
|
|
||||||
if (!lamin || !lamax || !lomin || !lomax) {
|
if (!lamin || !lamax || !lomin || !lomax) {
|
||||||
return NextResponse.json(
|
return json({ error: "Missing required bbox parameters" }, 400);
|
||||||
{ error: "Missing required bbox parameters" },
|
|
||||||
{ status: 400 },
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const raw = { lamin: +lamin, lamax: +lamax, lomin: +lomin, lomax: +lomax };
|
const raw = { lamin: +lamin, lamax: +lamax, lomin: +lomin, lomax: +lomax };
|
||||||
for (const [key, val] of Object.entries(raw)) {
|
for (const [key, val] of Object.entries(raw)) {
|
||||||
if (Number.isNaN(val)) {
|
if (Number.isNaN(val)) {
|
||||||
return NextResponse.json(
|
return json({ error: `Invalid parameter: ${key}` }, 400);
|
||||||
{ error: `Invalid parameter: ${key}` },
|
|
||||||
{ status: 400 },
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clamp to valid geographic ranges and limit bbox size
|
|
||||||
const coords = {
|
const coords = {
|
||||||
lamin: clamp(raw.lamin, -90, 90),
|
lamin: clamp(raw.lamin, -90, 90),
|
||||||
lamax: clamp(raw.lamax, -90, 90),
|
lamax: clamp(raw.lamax, -90, 90),
|
||||||
@ -198,73 +216,87 @@ export async function GET(request: NextRequest) {
|
|||||||
lomax: clamp(raw.lomax, -180, 180),
|
lomax: clamp(raw.lomax, -180, 180),
|
||||||
};
|
};
|
||||||
|
|
||||||
const latSpan = Math.abs(coords.lamax - coords.lamin);
|
if (
|
||||||
const lonSpan = Math.abs(coords.lomax - coords.lomin);
|
Math.abs(coords.lamax - coords.lamin) > MAX_BBOX_SPAN ||
|
||||||
if (latSpan > 20 || lonSpan > 20) {
|
Math.abs(coords.lomax - coords.lomin) > MAX_BBOX_SPAN
|
||||||
return NextResponse.json(
|
) {
|
||||||
{ error: "Bounding box too large (max 20° per axis)" },
|
return json(
|
||||||
{ status: 400 },
|
{ 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 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";
|
const useAuth = detectAuthMode() !== "anonymous";
|
||||||
|
|
||||||
try {
|
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) {
|
if (res.status === 401 && useAuth) {
|
||||||
cachedToken = null;
|
cachedToken = null;
|
||||||
tokenExpiresAt = 0;
|
tokenExpiresAt = 0;
|
||||||
authDisabled = true;
|
authDisabled = true;
|
||||||
console.warn(
|
console.warn("[aeris] Auth rejected (401), falling back to anonymous");
|
||||||
"[aeris] Auth rejected (401). Falling back to anonymous. Check credentials in .env.local",
|
res = await fetchOpenSky(url, false);
|
||||||
);
|
|
||||||
res = await fetchFromOpenSky(url, false);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (res.status === 429) {
|
if (res.status === 429) {
|
||||||
const retryAfter = res.headers.get("X-Rate-Limit-Retry-After-Seconds");
|
const retryAfter = res.headers.get(
|
||||||
return NextResponse.json(
|
"X-Rate-Limit-Retry-After-Seconds",
|
||||||
|
);
|
||||||
|
return json(
|
||||||
{
|
{
|
||||||
time: 0,
|
time: 0,
|
||||||
states: null,
|
states: null,
|
||||||
rateLimited: true,
|
rateLimited: true,
|
||||||
retryAfter: retryAfter ? parseInt(retryAfter, 10) : null,
|
retryAfter: retryAfter ? parseInt(retryAfter, 10) : null,
|
||||||
},
|
},
|
||||||
{ status: 200, headers: { "Cache-Control": "no-store" } },
|
200,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
console.error(`[aeris] OpenSky error: ${res.status} ${res.statusText}`);
|
const body = await res.text().catch(() => "");
|
||||||
return NextResponse.json(
|
console.error(`[aeris] OpenSky ${res.status}: ${body.slice(0, 300)}`);
|
||||||
{ error: "Upstream data source error" },
|
return json(
|
||||||
{ status: 502 },
|
{ error: "Upstream data source error", status: res.status },
|
||||||
|
502,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await res.json();
|
let data;
|
||||||
|
try {
|
||||||
// Log remaining credits in dev
|
data = await res.json();
|
||||||
if (process.env.NODE_ENV === "development") {
|
} catch {
|
||||||
const remaining = res.headers.get("X-Rate-Limit-Remaining");
|
console.error("[aeris] OpenSky returned non-JSON response");
|
||||||
if (remaining) {
|
return json({ error: "Upstream returned invalid response" }, 502);
|
||||||
console.info(`[aeris] API credits remaining: ${remaining}`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json(data, {
|
setCache(cacheKey, data);
|
||||||
headers: { "Cache-Control": "no-store" },
|
return json(data, 200, { "X-Cache": "MISS" });
|
||||||
});
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("[aeris] OpenSky proxy error:", err);
|
if (err instanceof DOMException && err.name === "AbortError") {
|
||||||
return NextResponse.json(
|
console.error(`[aeris] OpenSky timed out (${FETCH_TIMEOUT_MS}ms)`);
|
||||||
{ error: "Failed to fetch flight data" },
|
return json(
|
||||||
{ status: 502 },
|
{ 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,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { Inter } from "next/font/google";
|
import { Inter } from "next/font/google";
|
||||||
|
import Script from "next/script";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
|
||||||
const inter = Inter({
|
const inter = Inter({
|
||||||
@ -8,10 +9,48 @@ const inter = Inter({
|
|||||||
display: "swap",
|
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 = {
|
export const metadata: Metadata = {
|
||||||
title: "Aeris — Real-Time 3D Flight Tracking",
|
title,
|
||||||
description:
|
description,
|
||||||
"Altitude-aware, visually stunning flight tracking over the world's busiest airspaces.",
|
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({
|
export default function RootLayout({
|
||||||
@ -21,6 +60,20 @@ export default function RootLayout({
|
|||||||
}>) {
|
}>) {
|
||||||
return (
|
return (
|
||||||
<html lang="en" className="dark">
|
<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`}>
|
<body className={`${inter.variable} font-sans antialiased`}>
|
||||||
{children}
|
{children}
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@ -1,5 +1,26 @@
|
|||||||
import { FlightTracker } from "@/components/flight-tracker";
|
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() {
|
export default function Home() {
|
||||||
return <FlightTracker />;
|
return (
|
||||||
|
<>
|
||||||
|
<script
|
||||||
|
type="application/ld+json"
|
||||||
|
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
|
||||||
|
/>
|
||||||
|
<FlightTracker />
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -22,8 +22,8 @@ function lerpAngle(a: number, b: number, t: number): number {
|
|||||||
return a + delta * t;
|
return a + delta * t;
|
||||||
}
|
}
|
||||||
|
|
||||||
function easeOut(t: number): number {
|
function smoothStep(t: number): number {
|
||||||
return 1 - (1 - t) * (1 - t);
|
return t * t * (3 - 2 * t);
|
||||||
}
|
}
|
||||||
|
|
||||||
function createAircraftAtlas(): HTMLCanvasElement {
|
function createAircraftAtlas(): HTMLCanvasElement {
|
||||||
@ -113,7 +113,8 @@ export function FlightLayers({
|
|||||||
// Capture current animated position as new "prev" on each data update
|
// Capture current animated position as new "prev" on each data update
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const elapsed = performance.now() - dataTimestampRef.current;
|
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>();
|
const newPrev = new Map<string, Snapshot>();
|
||||||
for (const f of flights) {
|
for (const f of flights) {
|
||||||
@ -127,10 +128,10 @@ export function FlightLayers({
|
|||||||
const dy = oldCurr.lat - oldPrev.lat;
|
const dy = oldCurr.lat - oldPrev.lat;
|
||||||
if (dx * dx + dy * dy <= TELEPORT_THRESHOLD * TELEPORT_THRESHOLD) {
|
if (dx * dx + dy * dy <= TELEPORT_THRESHOLD * TELEPORT_THRESHOLD) {
|
||||||
newPrev.set(id, {
|
newPrev.set(id, {
|
||||||
lng: oldPrev.lng + dx * oldT,
|
lng: oldPrev.lng + dx * oldLinearT,
|
||||||
lat: oldPrev.lat + dy * oldT,
|
lat: oldPrev.lat + dy * oldLinearT,
|
||||||
alt: oldPrev.alt + (oldCurr.alt - oldPrev.alt) * oldT,
|
alt: oldPrev.alt + (oldCurr.alt - oldPrev.alt) * oldLinearT,
|
||||||
track: lerpAngle(oldPrev.track, oldCurr.track, oldT),
|
track: lerpAngle(oldPrev.track, oldCurr.track, oldAngleT),
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
newPrev.set(id, oldCurr);
|
newPrev.set(id, oldCurr);
|
||||||
@ -207,7 +208,8 @@ export function FlightLayers({
|
|||||||
try {
|
try {
|
||||||
const elapsed = performance.now() - dataTimestampRef.current;
|
const elapsed = performance.now() - dataTimestampRef.current;
|
||||||
const rawT = elapsed / ANIM_DURATION_MS;
|
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 currentFlights = flightsRef.current;
|
||||||
const currentTrails = trailsRef.current;
|
const currentTrails = trailsRef.current;
|
||||||
@ -248,14 +250,15 @@ export function FlightLayers({
|
|||||||
if (rawT <= 1) {
|
if (rawT <= 1) {
|
||||||
return {
|
return {
|
||||||
...f,
|
...f,
|
||||||
longitude: prev.lng + dx * t,
|
longitude: prev.lng + dx * tPos,
|
||||||
latitude: prev.lat + dy * t,
|
latitude: prev.lat + dy * tPos,
|
||||||
baroAltitude: prev.alt + (curr.alt - prev.alt) * t,
|
baroAltitude: prev.alt + (curr.alt - prev.alt) * tPos,
|
||||||
trueTrack: lerpAngle(prev.track, curr.track, t),
|
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 heading = (curr.track * Math.PI) / 180;
|
||||||
const speed = f.velocity ?? 200;
|
const speed = f.velocity ?? 200;
|
||||||
const extraSec = ((rawT - 1) * ANIM_DURATION_MS) / 1000;
|
const extraSec = ((rawT - 1) * ANIM_DURATION_MS) / 1000;
|
||||||
@ -322,7 +325,7 @@ export function FlightLayers({
|
|||||||
SAMPLES_PER_SEGMENT,
|
SAMPLES_PER_SEGMENT,
|
||||||
basePath.length - 1,
|
basePath.length - 1,
|
||||||
);
|
);
|
||||||
const reveal = Math.floor(t * segLen);
|
const reveal = Math.floor(tPos * segLen);
|
||||||
const collapseFrom = basePath.length - segLen + reveal;
|
const collapseFrom = basePath.length - segLen + reveal;
|
||||||
|
|
||||||
for (let i = collapseFrom; i < basePath.length; i++) {
|
for (let i = collapseFrom; i < basePath.length; i++) {
|
||||||
|
|||||||
@ -95,6 +95,31 @@ export const Map = forwardRef<MapRef, MapProps>(function Map(
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!mapInstance || !isLoaded) return;
|
if (!mapInstance || !isLoaded) return;
|
||||||
mapInstance.setStyle(mapStyle as maplibregl.StyleSpecification | string);
|
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]);
|
}, [mapInstance, isLoaded, mapStyle]);
|
||||||
|
|
||||||
const ctx = useMemo(
|
const ctx = useMemo(
|
||||||
|
|||||||
@ -347,7 +347,7 @@ function SearchContent({
|
|||||||
onChange={(e) => setQuery(e.target.value)}
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
placeholder="Search airspace..."
|
placeholder="Search airspace..."
|
||||||
aria-label="Search cities by name, IATA code, or country"
|
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>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -39,6 +39,60 @@ const TERRAIN_STYLE: Record<string, unknown> = {
|
|||||||
layers: [{ id: "terrain", type: "raster", source: "opentopomap" }],
|
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: "© 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: "© 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[] = [
|
export const MAP_STYLES: MapStyle[] = [
|
||||||
{
|
{
|
||||||
id: "dark",
|
id: "dark",
|
||||||
@ -67,15 +121,6 @@ export const MAP_STYLES: MapStyle[] = [
|
|||||||
"https://a.basemaps.cartocdn.com/rastertiles/voyager_nolabels/3/4/2@2x.png",
|
"https://a.basemaps.cartocdn.com/rastertiles/voyager_nolabels/3/4/2@2x.png",
|
||||||
dark: false,
|
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",
|
id: "satellite",
|
||||||
name: "Satellite",
|
name: "Satellite",
|
||||||
@ -93,6 +138,33 @@ export const MAP_STYLES: MapStyle[] = [
|
|||||||
previewUrl: "https://tile.opentopomap.org/3/4/2.png",
|
previewUrl: "https://tile.opentopomap.org/3/4/2.png",
|
||||||
dark: false,
|
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];
|
export const DEFAULT_STYLE = MAP_STYLES[0];
|
||||||
|
|||||||
Reference in New Issue
Block a user