Update city radius values and add Miami; refine map styles and improve OpenSky code readability
This commit is contained in:
@ -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;
|
||||||
(c) => c.iata.toUpperCase() === code || c.id === code.toLowerCase(),
|
}
|
||||||
) ?? DEFAULT_CITY
|
|
||||||
|
const preset = CITIES.find(
|
||||||
|
(c) => c.iata.toUpperCase() === code || c.id === code.toLowerCase(),
|
||||||
);
|
);
|
||||||
|
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}
|
||||||
|
|||||||
199
src/components/map/airport-layer.tsx
Normal file
199
src/components/map/airport-layer.tsx
Normal 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;
|
||||||
|
}
|
||||||
111
src/components/map/camera-controller.tsx
Normal file
111
src/components/map/camera-controller.tsx
Normal 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;
|
||||||
|
}
|
||||||
@ -318,24 +318,17 @@ 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) {
|
for (let i = basePath.length - 1; i >= 0; i--) {
|
||||||
// Direction from prev → curr
|
const vx = basePath[i][0] - ax;
|
||||||
const fdx = curr.lng - prev.lng;
|
const vy = basePath[i][1] - ay;
|
||||||
const fdy = curr.lat - prev.lat;
|
if (vx * fdx + vy * fdy > 0) {
|
||||||
|
basePath[i] = [ax, ay, alt];
|
||||||
// Walk backward; collapse points that are ahead of the
|
} else {
|
||||||
// animated position (positive projection along flight dir)
|
break;
|
||||||
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];
|
basePath[basePath.length - 1] = [ax, ay, alt];
|
||||||
|
|||||||
@ -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 */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -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,48 +410,111 @@ 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 && (
|
||||||
key={city.id}
|
<>
|
||||||
onClick={() => onSelect(city)}
|
{query && (
|
||||||
aria-current={activeCity?.id === city.id ? "true" : undefined}
|
<p className="px-3 pt-2 pb-1.5 text-[10px] font-semibold uppercase tracking-widest text-white/15">
|
||||||
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 ${
|
Featured
|
||||||
activeCity?.id === city.id ? "bg-white/6" : ""
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<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" />
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<p className="truncate text-[14px] font-medium text-white/80">
|
|
||||||
{city.name}
|
|
||||||
</p>
|
</p>
|
||||||
<p className="text-[11px] font-medium text-white/25">
|
)}
|
||||||
{city.iata} \u00b7 {city.country}
|
{featured.map((city) => (
|
||||||
</p>
|
<LocationRow
|
||||||
</div>
|
key={city.id}
|
||||||
<ChevronRight className="h-3.5 w-3.5 shrink-0 text-white/12 transition-colors group-hover:text-white/25" />
|
name={city.name}
|
||||||
</button>
|
detail={`${city.iata} \u00b7 ${city.country}`}
|
||||||
))}
|
isActive={activeCity?.id === city.id}
|
||||||
|
onClick={() => onSelect(city)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{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>
|
</div>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
</div>
|
</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 ${
|
||||||
|
isActive ? "bg-white/6" : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<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" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="truncate text-[14px] font-medium text-white/80">{name}</p>
|
||||||
|
<p className="text-[11px] font-medium text-white/25">{detail}</p>
|
||||||
|
</div>
|
||||||
|
<ChevronRight className="h-3.5 w-3.5 shrink-0 text-white/12 transition-colors group-hover:text-white/25" />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function StyleContent({
|
function StyleContent({
|
||||||
activeStyle,
|
activeStyle,
|
||||||
onSelect,
|
onSelect,
|
||||||
@ -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>
|
||||||
|
|||||||
@ -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 };
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
3048
src/lib/airports.ts
Normal file
File diff suppressed because it is too large
Load Diff
@ -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,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@ -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[] = [
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user