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";
|
||||
|
||||
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,69 +93,94 @@ 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 =
|
||||
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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++) {
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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: "© 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[] = [
|
||||
{
|
||||
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];
|
||||
|
||||
Reference in New Issue
Block a user