feat: update OpenSky API integration and improve flight tracking

- Increased max duration for flight data requests from 10 to 30 seconds.
- Adjusted fetch timeouts and cache TTL to enhance performance.
- Implemented snapping of bounding box coordinates to a grid for better cache sharing.
- Enhanced flight data fetching logic to adapt polling intervals based on remaining API credits.
- Introduced a new adaptive polling mechanism with different tiers based on credit usage.
- Updated flight layers animation duration for smoother transitions.
- Added a new slider component for orbit speed control in the UI.
- Refactored flight card positioning logic to ensure it remains within viewport bounds.
- Improved control panel layout for better mobile usability.
- Adjusted default orbit speed settings for a more user-friendly experience.
This commit is contained in:
Kewonit
2026-02-14 14:13:20 +05:30
parent 08be8e1267
commit 4431c84ace
13 changed files with 550 additions and 77 deletions

View File

@ -1,17 +1,16 @@
import { NextRequest, NextResponse } from "next/server";
export const maxDuration = 10;
export const maxDuration = 30;
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 TOKEN_TIMEOUT_MS = 5_000;
const FETCH_TIMEOUT_MS = 20_000;
const CACHE_TTL_MS = 25_000;
const MAX_REQUESTS_PER_MINUTE = 20;
const MAX_BBOX_SPAN = 20;
// --- OAuth2 token cache ---
const CACHE_GRID_STEP = 0.5;
let cachedToken: string | null = null;
let tokenExpiresAt = 0;
@ -58,8 +57,6 @@ async function getAccessToken(): Promise<string | null> {
}
}
// --- Auth ---
type AuthMode = "oauth2" | "basic" | "anonymous";
let authDisabled = false;
let authLoggedOnce = false;
@ -99,8 +96,6 @@ function logAuthOnce() {
console.info(`[aeris] Auth mode: ${detectAuthMode()}`);
}
// --- Per-IP rate limiter ---
const requestLog = new Map<string, number[]>();
function isRateLimited(ip: string): boolean {
@ -120,8 +115,6 @@ function isRateLimited(ip: string): boolean {
return recent.length > MAX_REQUESTS_PER_MINUTE;
}
// --- Response cache ---
let responseCache: {
key: string;
data: unknown;
@ -143,8 +136,6 @@ 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,
@ -163,8 +154,6 @@ async function fetchOpenSky(
}
}
// --- Utilities ---
function clamp(val: number, min: number, max: number) {
return Math.max(min, Math.min(max, val));
}
@ -180,8 +169,6 @@ function json(
});
}
// --- Route handler ---
export async function GET(request: NextRequest) {
const ip =
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ??
@ -228,8 +215,18 @@ export async function GET(request: NextRequest) {
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}`;
// Snap bbox to grid so nearby viewports share cache entries
const snap = (v: number) =>
Math.round(v / CACHE_GRID_STEP) * CACHE_GRID_STEP;
const snapped = {
lamin: snap(coords.lamin),
lamax: snap(coords.lamax),
lomin: snap(coords.lomin),
lomax: snap(coords.lomax),
};
const url = `${OPENSKY_BASE}/states/all?lamin=${snapped.lamin}&lamax=${snapped.lamax}&lomin=${snapped.lomin}&lomax=${snapped.lomax}`;
const cacheKey = `${snapped.lamin},${snapped.lamax},${snapped.lomin},${snapped.lomax}`;
const cached = getCached(cacheKey);
if (cached) {
@ -273,6 +270,10 @@ export async function GET(request: NextRequest) {
);
}
const creditsRaw = res.headers.get("X-Rate-Limit-Remaining");
const creditsRemaining =
creditsRaw !== null ? parseInt(creditsRaw, 10) : null;
let data;
try {
data = await res.json();
@ -281,6 +282,10 @@ export async function GET(request: NextRequest) {
return json({ error: "Upstream returned invalid response" }, 502);
}
if (creditsRemaining !== null && !Number.isNaN(creditsRemaining)) {
data.creditsRemaining = creditsRemaining;
}
setCache(cacheKey, data);
return json(data, 200, { "X-Cache": "MISS" });
} catch (err) {

View File

@ -90,7 +90,7 @@ function CameraController({ city }: { city: City }) {
prevCityRef.current = city.id;
map.flyTo({
center: city.coordinates,
zoom: 11,
zoom: 9.2,
pitch: 49,
bearing: 27.4,
duration: 2800,

View File

@ -6,13 +6,10 @@ import { IconLayer, PathLayer } from "@deck.gl/layers";
import { useMap } from "./map";
import { altitudeToColor, altitudeToElevation } from "@/lib/flight-utils";
import type { FlightState } from "@/lib/opensky";
import {
SAMPLES_PER_SEGMENT,
type TrailEntry,
} from "@/hooks/use-trail-history";
import { type TrailEntry } from "@/hooks/use-trail-history";
import type { PickingInfo } from "@deck.gl/core";
const ANIM_DURATION_MS = 15_000;
const ANIM_DURATION_MS = 30_000;
const TELEPORT_THRESHOLD = 0.3; // degrees
type Snapshot = { lng: number; lat: number; alt: number; track: number };
@ -312,7 +309,6 @@ export function FlightLayers({
const basePath = d.path.map(
(p) => [p[0], p[1], alt] as [number, number, number],
);
// Reveal spline points progressively to match the animated position
if (
animFlight &&
animFlight.longitude != null &&
@ -321,15 +317,26 @@ export function FlightLayers({
) {
const ax = animFlight.longitude;
const ay = animFlight.latitude;
const segLen = Math.min(
SAMPLES_PER_SEGMENT,
basePath.length - 1,
);
const reveal = Math.floor(tPos * segLen);
const collapseFrom = basePath.length - segLen + reveal;
for (let i = collapseFrom; i < basePath.length; i++) {
basePath[i] = [ax, ay, alt];
const curr = currSnapshotsRef.current.get(d.icao24);
const prev = prevSnapshotsRef.current.get(d.icao24);
if (curr && prev) {
// Direction from prev → curr
const fdx = curr.lng - prev.lng;
const fdy = curr.lat - prev.lat;
// Walk backward; collapse points that are ahead of the
// animated position (positive projection along flight dir)
for (let i = basePath.length - 1; i >= 0; i--) {
const vx = basePath[i][0] - ax;
const vy = basePath[i][1] - ay;
if (vx * fdx + vy * fdy > 0) {
basePath[i] = [ax, ay, alt];
} else {
break;
}
}
}
basePath[basePath.length - 1] = [ax, ay, alt];
}

View File

@ -15,7 +15,6 @@ import {
Route,
Layers,
Palette,
Gauge,
ArrowLeftRight,
Github,
} from "lucide-react";
@ -23,6 +22,7 @@ import { CITIES, type City } from "@/lib/cities";
import { MAP_STYLES, type MapStyle } from "@/lib/map-styles";
import { useSettings, type OrbitDirection } from "@/hooks/use-settings";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Slider } from "@/components/ui/slider";
type TabId = "search" | "style" | "settings";
@ -52,7 +52,6 @@ export function ControlPanel({
return (
<>
{/* Trigger buttons */}
{TABS.map(({ id, icon: Icon, label }) => (
<motion.button
key={id}
@ -72,7 +71,6 @@ export function ControlPanel({
</motion.button>
))}
{/* Dialog */}
<AnimatePresence>
{openTab && (
<PanelDialog
@ -158,7 +156,6 @@ function PanelDialog({
return (
<>
{/* Backdrop */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
@ -168,7 +165,6 @@ function PanelDialog({
onClick={onClose}
/>
{/* Panel */}
<motion.div
ref={dialogRef}
initial={{ opacity: 0, scale: 0.94, y: 16 }}
@ -180,14 +176,14 @@ function PanelDialog({
damping: 30,
mass: 0.8,
}}
className="fixed left-1/2 top-1/2 z-90 w-full max-w-180 -translate-x-1/2 -translate-y-1/2 px-4"
className="fixed inset-x-3 bottom-3 top-auto z-90 sm:inset-auto sm:left-1/2 sm:top-1/2 sm:w-full sm:max-w-180 sm:-translate-x-1/2 sm:-translate-y-1/2 sm:px-4"
role="dialog"
aria-modal="true"
aria-labelledby="panel-dialog-title"
>
<div className="flex overflow-hidden rounded-3xl border border-white/8 bg-[#0c0c0e]/92 shadow-[0_40px_100px_rgba(0,0,0,0.8),0_0_0_1px_rgba(255,255,255,0.04)_inset] backdrop-blur-3xl backdrop-saturate-[1.8]">
{/* Sidebar */}
<div className="flex w-52 shrink-0 flex-col border-r border-white/6 py-5 px-3">
<div className="flex flex-col sm:flex-row overflow-hidden rounded-2xl sm:rounded-3xl border border-white/8 bg-[#0c0c0e]/92 shadow-[0_40px_100px_rgba(0,0,0,0.8),0_0_0_1px_rgba(255,255,255,0.04)_inset] backdrop-blur-3xl backdrop-saturate-[1.8] h-[75vh] sm:h-auto sm:max-h-[85vh]">
{/* Desktop sidebar (hidden on mobile) */}
<div className="hidden sm:flex w-52 shrink-0 flex-col border-r border-white/6 py-5 px-3">
<p className="mb-3 px-2 text-[11px] font-semibold uppercase tracking-widest text-white/20">
Controls
</p>
@ -246,9 +242,18 @@ function PanelDialog({
</div>
</div>
{/* Content */}
<div className="flex flex-1 flex-col h-120">
<div className="flex items-center justify-between px-5 pt-5 pb-2">
<div className="flex flex-1 flex-col min-h-0 sm:h-120">
{/* Mobile header */}
<div className="flex sm:hidden items-center justify-between px-4 pt-4 pb-2">
<h2
id="panel-dialog-title"
className="text-[14px] font-semibold tracking-tight text-white/90"
>
{TABS.find((t) => t.id === activeTab)?.label}
</h2>
</div>
{/* Desktop header */}
<div className="hidden sm:flex items-center justify-between px-5 pt-5 pb-2">
<h2
id="panel-dialog-title"
className="text-[15px] font-semibold tracking-tight text-white/90"
@ -266,7 +271,6 @@ function PanelDialog({
</motion.button>
</div>
{/* Content */}
<div className="relative flex-1 overflow-hidden">
<AnimatePresence mode="wait" initial={false}>
{activeTab === "search" && (
@ -293,6 +297,50 @@ function PanelDialog({
</AnimatePresence>
</div>
</div>
{/* Mobile tab bar — at bottom for thumb reach */}
<div className="flex sm:hidden items-center gap-1 border-t border-white/6 px-3 pt-2 pb-3">
<nav className="flex flex-1 gap-1">
{TABS.map(({ id, icon: Icon, label }) => {
const active = id === activeTab;
return (
<button
key={id}
onClick={() => onTabChange(id)}
className={`relative flex flex-1 items-center justify-center gap-1.5 rounded-lg px-2 py-2 text-center transition-colors ${
active
? "text-white/90"
: "text-white/35 active:bg-white/6"
}`}
>
{active && (
<motion.div
layoutId="panel-tab-bg-mobile"
className="absolute inset-0 rounded-lg bg-white/8"
transition={{
type: "spring",
stiffness: 400,
damping: 30,
}}
/>
)}
<Icon className="relative h-3.5 w-3.5 shrink-0" />
<span className="relative text-[12px] font-semibold">
{label}
</span>
</button>
);
})}
</nav>
<motion.button
onClick={onClose}
className="ml-1 flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-white/6 transition-colors active:bg-white/12"
whileTap={{ scale: 0.9 }}
aria-label="Close"
>
<X className="h-3.5 w-3.5 text-white/40" />
</motion.button>
</div>
</div>
</motion.div>
</>
@ -396,7 +444,7 @@ function StyleContent({
}) {
return (
<ScrollArea className="h-full">
<div className="grid grid-cols-2 gap-3 p-5 pt-2">
<div className="grid grid-cols-2 sm:grid-cols-2 gap-2.5 sm:gap-3 p-4 sm:p-5 pt-2">
{MAP_STYLES.map((style, i) => (
<StyleTile
key={style.id}
@ -501,12 +549,16 @@ function StyleTile({
);
}
const ORBIT_SPEEDS = [
const ORBIT_SPEED_PRESETS = [
{ label: "Slow", value: 0.06 },
{ label: "Normal", value: 0.15 },
{ label: "Fast", value: 0.35 },
];
const ORBIT_SPEED_MIN = 0.02;
const ORBIT_SPEED_MAX = 0.5;
const ORBIT_SNAP_THRESHOLD = 0.025;
const ORBIT_DIRECTIONS: { label: string; value: OrbitDirection }[] = [
{ label: "Clockwise", value: "clockwise" },
{ label: "Counter", value: "counter-clockwise" },
@ -528,10 +580,7 @@ function SettingsContent() {
{settings.autoOrbit && (
<>
<SegmentRow
icon={<Gauge className="h-4 w-4" />}
title="Orbit speed"
options={ORBIT_SPEEDS}
<OrbitSpeedSlider
value={settings.orbitSpeed}
onChange={(v) => update("orbitSpeed", v)}
/>
@ -573,6 +622,75 @@ function SettingsContent() {
);
}
function OrbitSpeedSlider({
value,
onChange,
}: {
value: number;
onChange: (v: number) => void;
}) {
const activeLabel =
ORBIT_SPEED_PRESETS.find(
(p) => Math.abs(p.value - value) < ORBIT_SNAP_THRESHOLD,
)?.label ?? `${value.toFixed(2)}×`;
function handleChange(vals: number[]) {
let raw = vals[0];
for (const preset of ORBIT_SPEED_PRESETS) {
if (Math.abs(raw - preset.value) < ORBIT_SNAP_THRESHOLD) {
raw = preset.value;
break;
}
}
onChange(raw);
}
return (
<div className="flex w-full items-center gap-3.5 rounded-xl px-3 py-2.5 text-left">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-white/5 text-white/35 ring-1 ring-white/6">
<RotateCw className="h-4 w-4" />
</div>
<div className="flex flex-1 min-w-0 flex-col gap-2">
<div className="flex items-center justify-between">
<p className="text-[13px] font-medium text-white/80">Orbit speed</p>
<span className="text-[11px] font-semibold text-white/40 tabular-nums">
{activeLabel}
</span>
</div>
<div className="relative">
<Slider
min={ORBIT_SPEED_MIN}
max={ORBIT_SPEED_MAX}
step={0.01}
value={[value]}
onValueChange={handleChange}
aria-label="Orbit speed"
/>
<div className="pointer-events-none absolute inset-x-0 top-1/2 -translate-y-1/2 flex justify-between px-[2px]">
{ORBIT_SPEED_PRESETS.map((preset) => {
const pct =
((preset.value - ORBIT_SPEED_MIN) /
(ORBIT_SPEED_MAX - ORBIT_SPEED_MIN)) *
100;
const isActive =
Math.abs(preset.value - value) < ORBIT_SNAP_THRESHOLD;
return (
<span
key={preset.label}
className={`absolute h-1.5 w-1.5 rounded-full -translate-x-1/2 -translate-y-1/2 transition-colors ${
isActive ? "bg-white/50" : "bg-white/15"
}`}
style={{ left: `${pct}%` }}
/>
);
})}
</div>
</div>
</div>
</div>
);
}
function SettingRow({
icon,
title,

View File

@ -30,12 +30,12 @@ export function FlightCard({ flight, x, y }: FlightCardProps) {
damping: 28,
mass: 0.8,
}}
className="pointer-events-none fixed z-50 w-72"
className="pointer-events-none fixed z-50 w-64 sm:w-72"
role="status"
aria-live="polite"
style={{
left: `min(${x + 16}px, calc(100vw - 304px))`,
top: `min(${y - 8}px, calc(100vh - 280px))`,
left: `clamp(8px, ${x + 16}px, calc(100vw - 272px))`,
top: `clamp(8px, ${y - 8}px, calc(100vh - 280px))`,
}}
>
<div className="rounded-2xl border border-white/8 bg-black/60 p-4 shadow-2xl shadow-black/40 backdrop-blur-2xl">

View File

@ -0,0 +1,25 @@
"use client";
import * as React from "react";
import * as SliderPrimitive from "@radix-ui/react-slider";
type SliderProps = React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root>;
const Slider = React.forwardRef<
React.ComponentRef<typeof SliderPrimitive.Root>,
SliderProps
>(({ className = "", ...props }, ref) => (
<SliderPrimitive.Root
ref={ref}
className={`relative flex w-full touch-none select-none items-center ${className}`}
{...props}
>
<SliderPrimitive.Track className="relative h-1 w-full grow overflow-hidden rounded-full bg-white/8">
<SliderPrimitive.Range className="absolute h-full bg-white/30" />
</SliderPrimitive.Track>
<SliderPrimitive.Thumb className="block h-3.5 w-3.5 rounded-full bg-white shadow-sm shadow-black/40 ring-1 ring-white/20 transition-colors hover:bg-white/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/40" />
</SliderPrimitive.Root>
));
Slider.displayName = "Slider";
export { Slider };

View File

@ -8,8 +8,27 @@ import {
} from "@/lib/opensky";
import type { City } from "@/lib/cities";
const POLL_INTERVAL_MS = 15_000;
const BASE_POLL_MS = 30_000;
const CONSERVATIVE_POLL_MS = 60_000;
const CAUTIOUS_POLL_MS = 120_000;
const EMERGENCY_POLL_MS = 300_000;
// Credit thresholds (out of 4 000 daily for authenticated users)
const CREDIT_TIER_CONSERVATIVE = 2_000; // < 50 % remaining
const CREDIT_TIER_CAUTIOUS = 800; // < 20 %
const CREDIT_TIER_EMERGENCY = 200; // < 5 %
const RATE_LIMIT_BACKOFF_MS = 30_000;
const VISIBILITY_RESUME_STALE_MS = 60_000;
/** Choose a poll interval based on how many API credits remain today. */
function adaptiveInterval(creditsRemaining: number | null): number {
if (creditsRemaining === null) return BASE_POLL_MS; // unknown → default
if (creditsRemaining < CREDIT_TIER_EMERGENCY) return EMERGENCY_POLL_MS;
if (creditsRemaining < CREDIT_TIER_CAUTIOUS) return CAUTIOUS_POLL_MS;
if (creditsRemaining < CREDIT_TIER_CONSERVATIVE) return CONSERVATIVE_POLL_MS;
return BASE_POLL_MS;
}
export function useFlights(city: City | null) {
const [flights, setFlights] = useState<FlightState[]>([]);
@ -17,10 +36,14 @@ export function useFlights(city: City | null) {
const [error, setError] = useState<string | null>(null);
const [rateLimited, setRateLimited] = useState(false);
const [retryIn, setRetryIn] = useState(0);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const countdownRef = useRef<ReturnType<typeof setInterval> | null>(null);
const abortRef = useRef<AbortController | null>(null);
const creditsRef = useRef<number | null>(null);
const lastFetchRef = useRef(0);
const clearCountdown = useCallback(() => {
if (countdownRef.current) {
clearInterval(countdownRef.current);
@ -43,9 +66,16 @@ export function useFlights(city: City | null) {
[clearCountdown],
);
const clearSchedule = useCallback(() => {
if (timerRef.current) {
clearTimeout(timerRef.current);
timerRef.current = null;
}
}, []);
const scheduleNext = useCallback(
(target: City, delayMs: number) => {
if (timerRef.current) clearTimeout(timerRef.current);
clearSchedule();
timerRef.current = setTimeout(() => fetchData(target), delayMs);
},
// eslint-disable-next-line react-hooks/exhaustive-deps
@ -61,6 +91,7 @@ export function useFlights(city: City | null) {
try {
setLoading(true);
setError(null);
const bbox = bboxFromCenter(
target.coordinates[0],
target.coordinates[1],
@ -78,11 +109,19 @@ export function useFlights(city: City | null) {
setRateLimited(false);
clearCountdown();
setFlights(result.flights);
scheduleNext(target, POLL_INTERVAL_MS);
lastFetchRef.current = Date.now();
if (result.creditsRemaining !== null) {
creditsRef.current = result.creditsRemaining;
}
const nextInterval = adaptiveInterval(creditsRef.current);
scheduleNext(target, nextInterval);
} catch (err) {
if (err instanceof DOMException && err.name === "AbortError") return;
setError(err instanceof Error ? err.message : "Unknown error");
setFlights([]);
// After an error, back off longer to avoid hammering a sick upstream
scheduleNext(target, RATE_LIMIT_BACKOFF_MS);
} finally {
setLoading(false);
@ -92,11 +131,41 @@ export function useFlights(city: City | null) {
);
useEffect(() => {
if (timerRef.current) {
clearTimeout(timerRef.current);
timerRef.current = null;
if (!city) return;
const activeCity = city;
function onVisibilityChange() {
if (document.visibilityState === "visible") {
// Tab just became visible — decide whether to fetch now or schedule
const elapsed = Date.now() - lastFetchRef.current;
if (elapsed >= VISIBILITY_RESUME_STALE_MS) {
// Data is stale after being hidden for a while; fetch immediately
clearSchedule();
fetchData(activeCity);
} else {
// Data is still fresh — schedule for the remaining time
const interval = adaptiveInterval(creditsRef.current);
const remaining = Math.max(1_000, interval - elapsed);
clearSchedule();
scheduleNext(activeCity, remaining);
}
} else {
// Tab hidden — cancel scheduled poll to save credits
clearSchedule();
}
}
document.addEventListener("visibilitychange", onVisibilityChange);
return () => {
document.removeEventListener("visibilitychange", onVisibilityChange);
};
}, [city, fetchData, scheduleNext, clearSchedule]);
useEffect(() => {
clearSchedule();
if (!city) {
setFlights([]);
setRateLimited(false);
@ -109,11 +178,11 @@ export function useFlights(city: City | null) {
fetchData(city);
return () => {
if (timerRef.current) clearTimeout(timerRef.current);
clearSchedule();
abortRef.current?.abort();
clearCountdown();
};
}, [city, fetchData, clearCountdown]);
}, [city, fetchData, clearCountdown, clearSchedule]);
return { flights, loading, error, rateLimited, retryIn };
}

View File

@ -24,7 +24,7 @@ export type Settings = {
const DEFAULT_SETTINGS: Settings = {
autoOrbit: true,
orbitSpeed: 0.15,
orbitSpeed: 0.06,
orbitDirection: "clockwise",
showTrails: true,
showShadows: true,

View File

@ -19,6 +19,7 @@ export type OpenSkyResponse = {
time: number;
states: (string | number | boolean | null)[][] | null;
rateLimited?: boolean;
creditsRemaining?: number | null;
};
function parseStates(raw: OpenSkyResponse): FlightState[] {
@ -53,6 +54,7 @@ function parseStates(raw: OpenSkyResponse): FlightState[] {
export type FetchResult = {
flights: FlightState[];
rateLimited: boolean;
creditsRemaining: number | null;
};
/** Fetch flights via the server-side proxy. */
@ -70,18 +72,22 @@ export async function fetchFlightsByBbox(
if (!res.ok) {
// Don't throw — let the hook retry gracefully
console.warn(`[aeris] Flight API returned ${res.status}`);
return { flights: [], rateLimited: false };
return { flights: [], rateLimited: false, creditsRemaining: null };
}
const data: OpenSkyResponse = await res.json();
if (data.rateLimited) {
console.warn("[aeris] OpenSky rate limit hit, backing off");
return { flights: [], rateLimited: true };
return { flights: [], rateLimited: true, creditsRemaining: null };
}
const flights = parseStates(data);
return { flights, rateLimited: false };
return {
flights,
rateLimited: false,
creditsRemaining: data.creditsRemaining ?? null,
};
}
export function bboxFromCenter(