Update city radius values and add Miami; refine map styles and improve OpenSky code readability

This commit is contained in:
Kewonit
2026-02-14 16:39:17 +05:30
parent bea74cc70f
commit 0f8012361f
13 changed files with 4647 additions and 196 deletions

1010
docs.txt Normal file

File diff suppressed because it is too large Load Diff

View File

@ -1,14 +1,10 @@
"use client"; "use client";
import { import { useState, useCallback, useSyncExternalStore } from "react";
useState,
useCallback,
useRef,
useEffect,
useSyncExternalStore,
} from "react";
import { ErrorBoundary } from "@/components/error-boundary"; import { ErrorBoundary } from "@/components/error-boundary";
import { Map, useMap } from "@/components/map/map"; import { Map } from "@/components/map/map";
import { CameraController } from "@/components/map/camera-controller";
import { AirportLayer } from "@/components/map/airport-layer";
import { FlightLayers } from "@/components/map/flight-layers"; import { FlightLayers } from "@/components/map/flight-layers";
import { FlightCard } from "@/components/ui/flight-card"; import { FlightCard } from "@/components/ui/flight-card";
import { ControlPanel } from "@/components/ui/control-panel"; import { ControlPanel } from "@/components/ui/control-panel";
@ -19,28 +15,47 @@ import { useFlights } from "@/hooks/use-flights";
import { useTrailHistory } from "@/hooks/use-trail-history"; import { useTrailHistory } from "@/hooks/use-trail-history";
import { MAP_STYLES, DEFAULT_STYLE, type MapStyle } from "@/lib/map-styles"; import { MAP_STYLES, DEFAULT_STYLE, type MapStyle } from "@/lib/map-styles";
import { CITIES, type City } from "@/lib/cities"; import { CITIES, type City } from "@/lib/cities";
import { findByIata, airportToCity } from "@/lib/airports";
import type { FlightState } from "@/lib/opensky"; import type { FlightState } from "@/lib/opensky";
import type { PickingInfo } from "@deck.gl/core"; import type { PickingInfo } from "@deck.gl/core";
const IDLE_TIMEOUT_MS = 5_000; const DEFAULT_CITY_ID = "mia";
const DEFAULT_CITY_ID = "sfo";
const STYLE_STORAGE_KEY = "aeris:mapStyle"; const STYLE_STORAGE_KEY = "aeris:mapStyle";
const DEFAULT_CITY = CITIES.find((c) => c.id === DEFAULT_CITY_ID) ?? CITIES[0]; const DEFAULT_CITY = CITIES.find((c) => c.id === DEFAULT_CITY_ID) ?? CITIES[0];
const subscribeNoop = () => () => {}; const subscribeNoop = () => () => {};
let _cachedInitialCity: City | null = null;
function resolveInitialCity(): City { function resolveInitialCity(): City {
if (_cachedInitialCity) return _cachedInitialCity;
try { try {
const params = new URLSearchParams(window.location.search); const params = new URLSearchParams(window.location.search);
const code = params.get("city")?.trim().toUpperCase(); const code = params.get("city")?.trim().toUpperCase();
if (!code) return DEFAULT_CITY; if (!code) {
return ( _cachedInitialCity = DEFAULT_CITY;
CITIES.find( return DEFAULT_CITY;
}
const preset = CITIES.find(
(c) => c.iata.toUpperCase() === code || c.id === code.toLowerCase(), (c) => c.iata.toUpperCase() === code || c.id === code.toLowerCase(),
) ?? DEFAULT_CITY
); );
if (preset) {
_cachedInitialCity = preset;
return preset;
}
const airport = findByIata(code);
if (airport) {
_cachedInitialCity = airportToCity(airport);
return _cachedInitialCity;
}
_cachedInitialCity = DEFAULT_CITY;
return DEFAULT_CITY;
} catch { } catch {
_cachedInitialCity = DEFAULT_CITY;
return DEFAULT_CITY; return DEFAULT_CITY;
} }
} }
@ -75,109 +90,6 @@ function saveMapStyle(style: MapStyle): void {
} }
} }
function CameraController({ city }: { city: City }) {
const { map, isLoaded } = useMap();
const { settings } = useSettings();
const prevCityRef = useRef<string | null>(null);
const idleTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const orbitFrameRef = useRef<number | null>(null);
const isInteractingRef = useRef(false);
useEffect(() => {
if (!map || !isLoaded || !city) return;
if (city.id === prevCityRef.current) return;
prevCityRef.current = city.id;
map.flyTo({
center: city.coordinates,
zoom: 9.2,
pitch: 49,
bearing: 27.4,
duration: 2800,
essential: true,
});
}, [map, isLoaded, city]);
useEffect(() => {
if (!map || !isLoaded || !city || !settings.autoOrbit) {
if (orbitFrameRef.current) cancelAnimationFrame(orbitFrameRef.current);
if (idleTimerRef.current) clearTimeout(idleTimerRef.current);
return;
}
const prefersReducedMotion =
window.matchMedia?.("(prefers-reduced-motion: reduce)").matches ?? false;
if (prefersReducedMotion) return;
const directionMultiplier =
settings.orbitDirection === "clockwise" ? 1 : -1;
const speed = settings.orbitSpeed * directionMultiplier;
function startOrbit() {
if (!map || isInteractingRef.current) return;
function tick() {
if (!map || isInteractingRef.current) return;
const bearing = map.getBearing() + speed;
map.setBearing(bearing % 360);
orbitFrameRef.current = requestAnimationFrame(tick);
}
orbitFrameRef.current = requestAnimationFrame(tick);
}
function stopOrbit() {
if (orbitFrameRef.current) {
cancelAnimationFrame(orbitFrameRef.current);
orbitFrameRef.current = null;
}
}
function resetIdleTimer() {
isInteractingRef.current = true;
stopOrbit();
if (idleTimerRef.current) clearTimeout(idleTimerRef.current);
idleTimerRef.current = setTimeout(() => {
isInteractingRef.current = false;
startOrbit();
}, IDLE_TIMEOUT_MS);
}
const events = ["mousedown", "wheel", "touchstart"] as const;
const container = map.getContainer();
events.forEach((e) =>
container.addEventListener(e, resetIdleTimer, { passive: true }),
);
const onMoveStart = () => {
if (isInteractingRef.current) stopOrbit();
};
map.on("movestart", onMoveStart);
idleTimerRef.current = setTimeout(() => {
isInteractingRef.current = false;
startOrbit();
}, IDLE_TIMEOUT_MS);
return () => {
stopOrbit();
if (idleTimerRef.current) clearTimeout(idleTimerRef.current);
events.forEach((e) => container.removeEventListener(e, resetIdleTimer));
map.off("movestart", onMoveStart);
};
}, [
map,
isLoaded,
city,
settings.autoOrbit,
settings.orbitSpeed,
settings.orbitDirection,
]);
return null;
}
function FlightTrackerInner() { function FlightTrackerInner() {
const hydratedCity = useSyncExternalStore( const hydratedCity = useSyncExternalStore(
subscribeNoop, subscribeNoop,
@ -228,8 +140,13 @@ function FlightTrackerInner() {
return ( return (
<main className="relative h-screen w-screen overflow-hidden bg-black"> <main className="relative h-screen w-screen overflow-hidden bg-black">
<Map mapStyle={mapStyle.style}> <Map mapStyle={mapStyle.style} isDark={mapStyle.dark}>
<CameraController city={activeCity} /> <CameraController city={activeCity} />
<AirportLayer
activeCity={activeCity}
onSelectAirport={setActiveCity}
isDark={mapStyle.dark}
/>
<FlightLayers <FlightLayers
flights={flights} flights={flights}
trails={trails} trails={trails}

View File

@ -0,0 +1,199 @@
"use client";
import { useEffect, useRef } from "react";
import maplibregl from "maplibre-gl";
import { useMap } from "./map";
import { AIRPORTS, airportToCity } from "@/lib/airports";
import { CITIES, type City } from "@/lib/cities";
const SOURCE_ID = "airport-markers";
const DOTS_LAYER = "airport-dots";
type AirportLayerProps = {
activeCity: City;
onSelectAirport: (city: City) => void;
isDark: boolean;
};
const airportGeoJson: GeoJSON.FeatureCollection = {
type: "FeatureCollection",
features: AIRPORTS.map((a) => ({
type: "Feature" as const,
geometry: { type: "Point" as const, coordinates: [a.lng, a.lat] },
properties: {
iata: a.iata,
name: a.name,
city: a.city,
country: a.country,
},
})),
};
const LAYER_CSS = `
.airport-beacon{position:relative;width:20px;height:20px;pointer-events:none}
.airport-beacon-core{position:absolute;inset:7px;border-radius:50%;background:rgba(255,255,255,0.3);box-shadow:0 0 6px rgba(255,255,255,0.1)}
.airport-beacon-ring{position:absolute;inset:2px;border-radius:50%;border:1px solid rgba(255,255,255,0.12);animation:ab-pulse 6s ease-out infinite}
.airport-beacon-ring:nth-child(2){animation-delay:2s}
.airport-beacon-ring:nth-child(3){animation-delay:4s}
@keyframes ab-pulse{0%{transform:scale(1);opacity:0.3}100%{transform:scale(2.5);opacity:0}}
.airport-popup .maplibregl-popup-content{background:rgba(12,12,14,0.9);color:rgba(255,255,255,0.8);font:500 11px/1.4 system-ui,sans-serif;padding:5px 10px;border-radius:8px;border:1px solid rgba(255,255,255,0.06);backdrop-filter:blur(12px);box-shadow:0 4px 20px rgba(0,0,0,0.4)}
.airport-popup .maplibregl-popup-tip{border-top-color:rgba(12,12,14,0.9)}
`;
let _cssInjected = false;
function injectCSS() {
if (_cssInjected) return;
const el = document.createElement("style");
el.textContent = LAYER_CSS;
document.head.appendChild(el);
_cssInjected = true;
}
function resolveCity(iata: string): City {
const preset = CITIES.find((c) => c.iata === iata);
if (preset) return preset;
const airport = AIRPORTS.find((a) => a.iata === iata);
if (airport) return airportToCity(airport);
return CITIES[0];
}
export function AirportLayer({
activeCity,
onSelectAirport,
isDark,
}: AirportLayerProps) {
const { map, isLoaded } = useMap();
const markerRef = useRef<maplibregl.Marker | null>(null);
const popupRef = useRef<maplibregl.Popup | null>(null);
const callbackRef = useRef(onSelectAirport);
useEffect(() => {
callbackRef.current = onSelectAirport;
});
useEffect(() => {
if (!map || !isLoaded) return;
injectCSS();
const m = map;
const dotColor = isDark ? "rgba(74,222,128,0.6)" : "rgba(22,163,74,0.55)";
const strokeColor = isDark ? "rgba(74,222,128,0.8)" : "rgba(22,163,74,0.7)";
function addSourceAndLayers() {
if (m.getSource(SOURCE_ID)) return;
m.addSource(SOURCE_ID, { type: "geojson", data: airportGeoJson });
m.addLayer({
id: DOTS_LAYER,
type: "circle",
source: SOURCE_ID,
paint: {
"circle-radius": [
"interpolate",
["linear"],
["zoom"],
3,
2,
6,
3,
10,
4.5,
14,
7,
],
"circle-color": dotColor,
"circle-stroke-width": 1,
"circle-stroke-color": strokeColor,
},
});
}
addSourceAndLayers();
m.on("style.load", addSourceAndLayers);
const popup = new maplibregl.Popup({
closeButton: false,
closeOnClick: false,
className: "airport-popup",
offset: 10,
});
popupRef.current = popup;
function onMouseEnter(
e: maplibregl.MapMouseEvent & {
features?: maplibregl.MapGeoJSONFeature[];
},
) {
m.getCanvas().style.cursor = "pointer";
const f = e.features?.[0];
if (f?.properties) {
popup
.setLngLat(e.lngLat)
.setHTML(
`<strong>${f.properties.iata}</strong> · ${f.properties.city}`,
)
.addTo(m);
}
}
function onMouseLeave() {
m.getCanvas().style.cursor = "";
popup.remove();
}
function onClick(
e: maplibregl.MapMouseEvent & {
features?: maplibregl.MapGeoJSONFeature[];
},
) {
const f = e.features?.[0];
if (f?.properties?.iata) {
const city = resolveCity(f.properties.iata as string);
callbackRef.current(city);
}
}
m.on("mouseenter", DOTS_LAYER, onMouseEnter);
m.on("mouseleave", DOTS_LAYER, onMouseLeave);
m.on("click", DOTS_LAYER, onClick);
return () => {
m.off("style.load", addSourceAndLayers);
m.off("mouseenter", DOTS_LAYER, onMouseEnter);
m.off("mouseleave", DOTS_LAYER, onMouseLeave);
m.off("click", DOTS_LAYER, onClick);
popup.remove();
try {
if (m.getLayer(DOTS_LAYER)) m.removeLayer(DOTS_LAYER);
if (m.getSource(SOURCE_ID)) m.removeSource(SOURCE_ID);
} catch {
/* already cleaned up */
}
};
}, [map, isLoaded, isDark]);
useEffect(() => {
if (!map || !isLoaded) return;
injectCSS();
const el = document.createElement("div");
el.className = "airport-beacon";
el.innerHTML =
'<div class="airport-beacon-ring"></div>' +
'<div class="airport-beacon-ring"></div>' +
'<div class="airport-beacon-ring"></div>' +
'<div class="airport-beacon-core"></div>';
const marker = new maplibregl.Marker({ element: el })
.setLngLat(activeCity.coordinates)
.addTo(map);
markerRef.current = marker;
return () => {
marker.remove();
markerRef.current = null;
};
}, [map, isLoaded, activeCity]);
return null;
}

View File

@ -0,0 +1,111 @@
"use client";
import { useEffect, useRef } from "react";
import { useMap } from "./map";
import { useSettings } from "@/hooks/use-settings";
import type { City } from "@/lib/cities";
const IDLE_TIMEOUT_MS = 5_000;
export function CameraController({ city }: { city: City }) {
const { map, isLoaded } = useMap();
const { settings } = useSettings();
const prevCityRef = useRef<string | null>(null);
const idleTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const orbitFrameRef = useRef<number | null>(null);
const isInteractingRef = useRef(false);
useEffect(() => {
if (!map || !isLoaded || !city) return;
if (city.id === prevCityRef.current) return;
prevCityRef.current = city.id;
map.flyTo({
center: city.coordinates,
zoom: 9.2,
pitch: 49,
bearing: 27.4,
duration: 2800,
essential: true,
});
}, [map, isLoaded, city]);
useEffect(() => {
if (!map || !isLoaded || !city || !settings.autoOrbit) {
if (orbitFrameRef.current) cancelAnimationFrame(orbitFrameRef.current);
if (idleTimerRef.current) clearTimeout(idleTimerRef.current);
return;
}
const prefersReducedMotion =
window.matchMedia?.("(prefers-reduced-motion: reduce)").matches ?? false;
if (prefersReducedMotion) return;
const directionMultiplier =
settings.orbitDirection === "clockwise" ? 1 : -1;
const speed = settings.orbitSpeed * directionMultiplier;
function startOrbit() {
if (!map || isInteractingRef.current) return;
function tick() {
if (!map || isInteractingRef.current) return;
const bearing = map.getBearing() + speed;
map.setBearing(bearing % 360);
orbitFrameRef.current = requestAnimationFrame(tick);
}
orbitFrameRef.current = requestAnimationFrame(tick);
}
function stopOrbit() {
if (orbitFrameRef.current) {
cancelAnimationFrame(orbitFrameRef.current);
orbitFrameRef.current = null;
}
}
function resetIdleTimer() {
isInteractingRef.current = true;
stopOrbit();
if (idleTimerRef.current) clearTimeout(idleTimerRef.current);
idleTimerRef.current = setTimeout(() => {
isInteractingRef.current = false;
startOrbit();
}, IDLE_TIMEOUT_MS);
}
const events = ["mousedown", "wheel", "touchstart"] as const;
const container = map.getContainer();
events.forEach((e) =>
container.addEventListener(e, resetIdleTimer, { passive: true }),
);
const onMoveStart = () => {
if (isInteractingRef.current) stopOrbit();
};
map.on("movestart", onMoveStart);
idleTimerRef.current = setTimeout(() => {
isInteractingRef.current = false;
startOrbit();
}, IDLE_TIMEOUT_MS);
return () => {
stopOrbit();
if (idleTimerRef.current) clearTimeout(idleTimerRef.current);
events.forEach((e) => container.removeEventListener(e, resetIdleTimer));
map.off("movestart", onMoveStart);
};
}, [
map,
isLoaded,
city,
settings.autoOrbit,
settings.orbitSpeed,
settings.orbitDirection,
]);
return null;
}

View File

@ -318,16 +318,10 @@ export function FlightLayers({
const ax = animFlight.longitude; const ax = animFlight.longitude;
const ay = animFlight.latitude; const ay = animFlight.latitude;
const curr = currSnapshotsRef.current.get(d.icao24); const heading = ((animFlight.trueTrack ?? 0) * Math.PI) / 180;
const prev = prevSnapshotsRef.current.get(d.icao24); const fdx = Math.sin(heading);
const fdy = Math.cos(heading);
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--) { for (let i = basePath.length - 1; i >= 0; i--) {
const vx = basePath[i][0] - ax; const vx = basePath[i][0] - ax;
const vy = basePath[i][1] - ay; const vy = basePath[i][1] - ay;
@ -337,7 +331,6 @@ export function FlightLayers({
break; break;
} }
} }
}
basePath[basePath.length - 1] = [ax, ay, alt]; basePath[basePath.length - 1] = [ax, ay, alt];
} }
return basePath; return basePath;

View File

@ -34,6 +34,7 @@ type MapProps = {
children?: ReactNode; children?: ReactNode;
className?: string; className?: string;
mapStyle?: MapStyleSpec; mapStyle?: MapStyleSpec;
isDark?: boolean;
center?: [number, number]; center?: [number, number];
zoom?: number; zoom?: number;
pitch?: number; pitch?: number;
@ -49,6 +50,7 @@ export const Map = forwardRef<MapRef, MapProps>(function Map(
children, children,
className, className,
mapStyle = DEFAULT_STYLE.style, mapStyle = DEFAULT_STYLE.style,
isDark = true,
center = [0, 20], center = [0, 20],
zoom = 2.5, zoom = 2.5,
pitch = 49, pitch = 49,
@ -92,11 +94,14 @@ export const Map = forwardRef<MapRef, MapProps>(function Map(
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, []); }, []);
const isDarkRef = useRef(isDark);
isDarkRef.current = isDark;
useEffect(() => { useEffect(() => {
if (!mapInstance || !isLoaded) return; if (!mapInstance || !isLoaded) return;
mapInstance.setStyle(mapStyle as maplibregl.StyleSpecification | string); mapInstance.setStyle(mapStyle as maplibregl.StyleSpecification | string);
const applyTerrain = () => { const onStyleLoad = () => {
if (typeof mapStyle === "object" && "terrain" in mapStyle) { if (typeof mapStyle === "object" && "terrain" in mapStyle) {
const spec = mapStyle as Record<string, unknown>; const spec = mapStyle as Record<string, unknown>;
try { try {
@ -113,11 +118,13 @@ export const Map = forwardRef<MapRef, MapProps>(function Map(
/* no terrain to remove */ /* no terrain to remove */
} }
} }
addAerowayLayers(mapInstance, isDarkRef.current);
}; };
mapInstance.once("style.load", applyTerrain); mapInstance.once("style.load", onStyleLoad);
return () => { return () => {
mapInstance.off("style.load", applyTerrain); mapInstance.off("style.load", onStyleLoad);
}; };
}, [mapInstance, isLoaded, mapStyle]); }, [mapInstance, isLoaded, mapStyle]);
@ -139,3 +146,83 @@ export const Map = forwardRef<MapRef, MapProps>(function Map(
}); });
Map.displayName = "Map"; Map.displayName = "Map";
function findVectorSource(map: maplibregl.Map): string | null {
const style = map.getStyle();
if (!style?.sources) return null;
for (const [name, source] of Object.entries(style.sources)) {
if (
source &&
typeof source === "object" &&
"type" in source &&
source.type === "vector"
) {
return name;
}
}
return null;
}
function addAerowayLayers(map: maplibregl.Map, dark: boolean): void {
const source = findVectorSource(map);
if (!source) return;
const runwayColor = dark ? "rgba(255,255,255,0.12)" : "rgba(0,0,0,0.1)";
const taxiwayColor = dark ? "rgba(255,255,255,0.06)" : "rgba(0,0,0,0.06)";
try {
if (!map.getLayer("aeroway-runway")) {
map.addLayer({
id: "aeroway-runway",
type: "line",
source,
"source-layer": "aeroway",
filter: ["==", "class", "runway"],
minzoom: 10,
layout: { "line-cap": "round" },
paint: {
"line-color": runwayColor,
"line-width": [
"interpolate",
["exponential", 1.5],
["zoom"],
10,
1,
14,
30,
18,
100,
],
},
});
}
if (!map.getLayer("aeroway-taxiway")) {
map.addLayer({
id: "aeroway-taxiway",
type: "line",
source,
"source-layer": "aeroway",
filter: ["==", "class", "taxiway"],
minzoom: 12,
layout: { "line-cap": "round" },
paint: {
"line-color": taxiwayColor,
"line-width": [
"interpolate",
["exponential", 1.5],
["zoom"],
12,
0.5,
14,
6,
18,
20,
],
},
});
}
} catch {
/* aeroway source-layer may not exist in this tileset */
}
}

View File

@ -19,6 +19,7 @@ import {
Github, Github,
} from "lucide-react"; } from "lucide-react";
import { CITIES, type City } from "@/lib/cities"; import { CITIES, type City } from "@/lib/cities";
import { searchAirports, airportToCity } from "@/lib/airports";
import { MAP_STYLES, type MapStyle } from "@/lib/map-styles"; import { MAP_STYLES, type MapStyle } from "@/lib/map-styles";
import { useSettings, type OrbitDirection } from "@/hooks/use-settings"; import { useSettings, type OrbitDirection } from "@/hooks/use-settings";
import { ScrollArea } from "@/components/ui/scroll-area"; import { ScrollArea } from "@/components/ui/scroll-area";
@ -375,16 +376,32 @@ function SearchContent({
requestAnimationFrame(() => inputRef.current?.focus()); requestAnimationFrame(() => inputRef.current?.focus());
}, []); }, []);
const filtered = useMemo(() => { const { featured, airports } = useMemo(() => {
const q = query.toLowerCase(); const q = query.trim().toLowerCase();
return CITIES.filter(
if (!q)
return {
featured: CITIES,
airports: [] as ReturnType<typeof searchAirports>,
};
const featured = CITIES.filter(
(c) => (c) =>
c.name.toLowerCase().includes(q) || c.name.toLowerCase().includes(q) ||
c.iata.toLowerCase().includes(q) || c.iata.toLowerCase().includes(q) ||
c.country.toLowerCase().includes(q), c.country.toLowerCase().includes(q),
); );
const featuredIatas = new Set(CITIES.map((c) => c.iata));
const airports = searchAirports(q).filter(
(a) => !featuredIatas.has(a.iata),
);
return { featured, airports };
}, [query]); }, [query]);
const hasResults = featured.length > 0 || airports.length > 0;
return ( return (
<div className="flex h-full flex-col"> <div className="flex h-full flex-col">
<div className="flex items-center gap-2.5 border-b border-white/6 mx-5 pb-3"> <div className="flex items-center gap-2.5 border-b border-white/6 mx-5 pb-3">
@ -393,45 +410,108 @@ function SearchContent({
ref={inputRef} ref={inputRef}
value={query} value={query}
onChange={(e) => setQuery(e.target.value)} onChange={(e) => setQuery(e.target.value)}
placeholder="Search airspace..." placeholder="Search airports..."
aria-label="Search cities by name, IATA code, or country" aria-label="Search airports by name, IATA code, city, or country"
className="flex-1 bg-transparent text-[14px] font-medium text-white/90 placeholder:text-white/20 outline-none" className="flex-1 bg-transparent text-[14px] font-medium text-white/90 placeholder:text-white/20 outline-none"
/> />
{query && (
<button
onClick={() => setQuery("")}
className="shrink-0 text-white/20 hover:text-white/40 transition-colors"
aria-label="Clear search"
>
<X className="h-3.5 w-3.5" />
</button>
)}
</div> </div>
<ScrollArea className="flex-1"> <ScrollArea className="flex-1">
<div className="p-2"> <div className="p-2">
{filtered.length === 0 && ( {!hasResults && (
<p className="py-8 text-center text-[12px] text-white/25"> <p className="py-8 text-center text-[12px] text-white/25">
No cities found No airports found
</p> </p>
)} )}
{filtered.map((city) => (
<button {featured.length > 0 && (
<>
{query && (
<p className="px-3 pt-2 pb-1.5 text-[10px] font-semibold uppercase tracking-widest text-white/15">
Featured
</p>
)}
{featured.map((city) => (
<LocationRow
key={city.id} key={city.id}
name={city.name}
detail={`${city.iata} \u00b7 ${city.country}`}
isActive={activeCity?.id === city.id}
onClick={() => onSelect(city)} onClick={() => onSelect(city)}
aria-current={activeCity?.id === city.id ? "true" : undefined} />
))}
</>
)}
{airports.length > 0 && (
<>
<p
className={`px-3 pb-1.5 text-[10px] font-semibold uppercase tracking-widest text-white/15 ${
featured.length > 0 ? "pt-3" : "pt-2"
}`}
>
Airports
</p>
{airports.map((airport) => (
<LocationRow
key={airport.iata}
name={airport.name}
detail={`${airport.iata} \u00b7 ${airport.city}, ${airport.country}`}
isActive={activeCity?.iata === airport.iata}
onClick={() => onSelect(airportToCity(airport))}
/>
))}
</>
)}
{!query && (
<p className="px-3 pt-3 pb-1 text-center text-[10px] font-medium text-white/10">
Search 400+ airports worldwide
</p>
)}
</div>
</ScrollArea>
</div>
);
}
function LocationRow({
name,
detail,
isActive,
onClick,
}: {
name: string;
detail: string;
isActive: boolean;
onClick: () => void;
}) {
return (
<button
onClick={onClick}
aria-current={isActive ? "true" : undefined}
className={`group flex w-full items-center gap-2.5 rounded-xl px-3 py-2.5 text-left transition-colors hover:bg-white/4 ${ className={`group flex w-full items-center gap-2.5 rounded-xl px-3 py-2.5 text-left transition-colors hover:bg-white/4 ${
activeCity?.id === city.id ? "bg-white/6" : "" isActive ? "bg-white/6" : ""
}`} }`}
> >
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-white/4"> <div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-white/4">
<MapPin className="h-3.5 w-3.5 text-white/40" /> <MapPin className="h-3.5 w-3.5 text-white/40" />
</div> </div>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<p className="truncate text-[14px] font-medium text-white/80"> <p className="truncate text-[14px] font-medium text-white/80">{name}</p>
{city.name} <p className="text-[11px] font-medium text-white/25">{detail}</p>
</p>
<p className="text-[11px] font-medium text-white/25">
{city.iata} \u00b7 {city.country}
</p>
</div> </div>
<ChevronRight className="h-3.5 w-3.5 shrink-0 text-white/12 transition-colors group-hover:text-white/25" /> <ChevronRight className="h-3.5 w-3.5 shrink-0 text-white/12 transition-colors group-hover:text-white/25" />
</button> </button>
))}
</div>
</ScrollArea>
</div>
); );
} }
@ -457,8 +537,8 @@ function StyleContent({
</div> </div>
<div className="border-t border-white/4 px-5 py-3"> <div className="border-t border-white/4 px-5 py-3">
<p className="text-[11px] font-medium text-white/12"> <p className="text-[11px] font-medium text-white/12">
Satellite \u00a9 Esri \u00b7 Terrain \u00a9 OpenTopoMap \u00b7 Base maps \u00a9 Satellite \u00a9 Esri \u00b7 Terrain \u00a9 OpenTopoMap \u00b7 Base
CARTO maps \u00a9 CARTO
</p> </p>
</div> </div>
</ScrollArea> </ScrollArea>

View File

@ -34,6 +34,7 @@ export function useFlights(city: City | null) {
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [rateLimited, setRateLimited] = useState(false); const [rateLimited, setRateLimited] = useState(false);
const [retryIn, setRetryIn] = useState(0); const [retryIn, setRetryIn] = useState(0);
const [creditsRemaining, setCreditsRemaining] = useState<number | null>(null);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null); const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const countdownRef = useRef<ReturnType<typeof setInterval> | null>(null); const countdownRef = useRef<ReturnType<typeof setInterval> | null>(null);
@ -111,6 +112,7 @@ export function useFlights(city: City | null) {
if (result.creditsRemaining !== null) { if (result.creditsRemaining !== null) {
creditsRef.current = result.creditsRemaining; creditsRef.current = result.creditsRemaining;
setCreditsRemaining(result.creditsRemaining);
} }
const nextInterval = adaptiveInterval(creditsRef.current); const nextInterval = adaptiveInterval(creditsRef.current);
@ -169,14 +171,16 @@ export function useFlights(city: City | null) {
setRateLimited(false); setRateLimited(false);
clearCountdown(); clearCountdown();
fetchData(city);
const deferred = setTimeout(() => fetchData(city), 0);
return () => { return () => {
clearTimeout(deferred);
clearSchedule(); clearSchedule();
abortRef.current?.abort(); abortRef.current?.abort();
clearCountdown(); clearCountdown();
}; };
}, [city, fetchData, clearCountdown, clearSchedule]); }, [city, fetchData, clearCountdown, clearSchedule]);
return { flights, loading, error, rateLimited, retryIn }; return { flights, loading, error, rateLimited, retryIn, creditsRemaining };
} }

View File

@ -1,6 +1,6 @@
"use client"; "use client";
import { useRef, useMemo } from "react"; import { useState, useMemo } from "react";
import type { FlightState } from "@/lib/opensky"; import type { FlightState } from "@/lib/opensky";
type Position = [lng: number, lat: number]; type Position = [lng: number, lat: number];
@ -141,7 +141,6 @@ class TrailStore {
} }
export function useTrailHistory(flights: FlightState[]): TrailEntry[] { export function useTrailHistory(flights: FlightState[]): TrailEntry[] {
const storeRef = useRef<TrailStore>(null); const [store] = useState(() => new TrailStore());
if (!storeRef.current) storeRef.current = new TrailStore(); return useMemo(() => store.update(flights), [flights, store]);
return useMemo(() => storeRef.current!.update(flights), [flights]);
} }

3048
src/lib/airports.ts Normal file

File diff suppressed because it is too large Load Diff

View File

@ -14,7 +14,7 @@ export const CITIES: City[] = [
country: "US", country: "US",
iata: "JFK", iata: "JFK",
coordinates: [-73.7781, 40.6413], coordinates: [-73.7781, 40.6413],
radius: 1.5, radius: 2.5,
}, },
{ {
id: "lax", id: "lax",
@ -22,7 +22,7 @@ export const CITIES: City[] = [
country: "US", country: "US",
iata: "LAX", iata: "LAX",
coordinates: [-118.4085, 33.9416], coordinates: [-118.4085, 33.9416],
radius: 1.5, radius: 2.5,
}, },
{ {
id: "lhr", id: "lhr",
@ -30,7 +30,7 @@ export const CITIES: City[] = [
country: "GB", country: "GB",
iata: "LHR", iata: "LHR",
coordinates: [-0.4614, 51.47], coordinates: [-0.4614, 51.47],
radius: 1.5, radius: 2.5,
}, },
{ {
id: "dxb", id: "dxb",
@ -38,7 +38,7 @@ export const CITIES: City[] = [
country: "AE", country: "AE",
iata: "DXB", iata: "DXB",
coordinates: [55.3644, 25.2532], coordinates: [55.3644, 25.2532],
radius: 1.5, radius: 2.5,
}, },
{ {
id: "nrt", id: "nrt",
@ -46,7 +46,7 @@ export const CITIES: City[] = [
country: "JP", country: "JP",
iata: "NRT", iata: "NRT",
coordinates: [140.3929, 35.772], coordinates: [140.3929, 35.772],
radius: 1.5, radius: 2.5,
}, },
{ {
id: "sin", id: "sin",
@ -54,7 +54,7 @@ export const CITIES: City[] = [
country: "SG", country: "SG",
iata: "SIN", iata: "SIN",
coordinates: [103.9915, 1.3644], coordinates: [103.9915, 1.3644],
radius: 1.5, radius: 2.5,
}, },
{ {
id: "cdg", id: "cdg",
@ -62,7 +62,7 @@ export const CITIES: City[] = [
country: "FR", country: "FR",
iata: "CDG", iata: "CDG",
coordinates: [2.5479, 49.0097], coordinates: [2.5479, 49.0097],
radius: 1.5, radius: 2.5,
}, },
{ {
id: "sfo", id: "sfo",
@ -70,7 +70,7 @@ export const CITIES: City[] = [
country: "US", country: "US",
iata: "SFO", iata: "SFO",
coordinates: [-122.379, 37.6213], coordinates: [-122.379, 37.6213],
radius: 1.5, radius: 2.5,
}, },
{ {
id: "ord", id: "ord",
@ -78,7 +78,7 @@ export const CITIES: City[] = [
country: "US", country: "US",
iata: "ORD", iata: "ORD",
coordinates: [-87.9073, 41.9742], coordinates: [-87.9073, 41.9742],
radius: 1.5, radius: 2.5,
}, },
{ {
id: "fra", id: "fra",
@ -86,7 +86,7 @@ export const CITIES: City[] = [
country: "DE", country: "DE",
iata: "FRA", iata: "FRA",
coordinates: [8.5622, 50.0379], coordinates: [8.5622, 50.0379],
radius: 1.5, radius: 2.5,
}, },
{ {
id: "bom", id: "bom",
@ -94,6 +94,14 @@ export const CITIES: City[] = [
country: "IN", country: "IN",
iata: "BOM", iata: "BOM",
coordinates: [72.8679, 19.0896], coordinates: [72.8679, 19.0896],
radius: 1.5, radius: 2.5,
},
{
id: "mia",
name: "Miami",
country: "US",
iata: "MIA",
coordinates: [-80.2906, 25.7959],
radius: 2.5,
}, },
]; ];

View File

@ -88,9 +88,7 @@ const SHADED_RELIEF_STYLE: Record<string, unknown> = {
"sky-horizon-blend": 0.5, "sky-horizon-blend": 0.5,
"horizon-fog-blend": 0.1, "horizon-fog-blend": 0.1,
}, },
layers: [ layers: [{ id: "satellite-base", type: "raster", source: "esri-satellite" }],
{ id: "satellite-base", type: "raster", source: "esri-satellite" },
],
}; };
export const MAP_STYLES: MapStyle[] = [ export const MAP_STYLES: MapStyle[] = [

View File

@ -60,7 +60,8 @@ export type FetchResult = {
creditsRemaining: number | null; creditsRemaining: number | null;
}; };
const clamp = (v: number, lo: number, hi: number) => Math.min(Math.max(v, lo), hi); const clamp = (v: number, lo: number, hi: number) =>
Math.min(Math.max(v, lo), hi);
export async function fetchFlightsByBbox( export async function fetchFlightsByBbox(
lamin: number, lamin: number,
@ -88,24 +89,20 @@ export async function fetchFlightsByBbox(
}); });
if (res.status === 429) { if (res.status === 429) {
console.warn("[aeris] OpenSky rate limit hit (429), backing off");
return { flights: [], rateLimited: true, creditsRemaining: null }; return { flights: [], rateLimited: true, creditsRemaining: null };
} }
if (!res.ok) { if (!res.ok) {
console.warn(`[aeris] OpenSky returned ${res.status}`);
return { flights: [], rateLimited: false, creditsRemaining: null }; return { flights: [], rateLimited: false, creditsRemaining: null };
} }
const data: OpenSkyResponse = await res.json(); const data: OpenSkyResponse = await res.json();
const creditsRaw = res.headers.get("x-rate-limit-remaining"); const creditsRaw = res.headers.get("x-rate-limit-remaining");
const creditsRemaining = const creditsRemaining =
creditsRaw !== null ? parseInt(creditsRaw, 10) : null; creditsRaw !== null ? parseInt(creditsRaw, 10) : null;
const flights = parseStates(data);
return { return {
flights, flights: parseStates(data),
rateLimited: false, rateLimited: false,
creditsRemaining: Number.isNaN(creditsRemaining) creditsRemaining: Number.isNaN(creditsRemaining)
? null ? null