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"; 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,69 +93,94 @@ 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 =
@ -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,
); );
} }
} }

View File

@ -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>

View File

@ -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 />
</>
);
} }

View File

@ -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++) {

View File

@ -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(

View File

@ -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>

View File

@ -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: "&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[] = [ 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];