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";
|
||||
|
||||
import {
|
||||
useState,
|
||||
useCallback,
|
||||
useRef,
|
||||
useEffect,
|
||||
useSyncExternalStore,
|
||||
} from "react";
|
||||
import { useState, useCallback, useSyncExternalStore } from "react";
|
||||
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 { FlightCard } from "@/components/ui/flight-card";
|
||||
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 { MAP_STYLES, DEFAULT_STYLE, type MapStyle } from "@/lib/map-styles";
|
||||
import { CITIES, type City } from "@/lib/cities";
|
||||
import { findByIata, airportToCity } from "@/lib/airports";
|
||||
import type { FlightState } from "@/lib/opensky";
|
||||
import type { PickingInfo } from "@deck.gl/core";
|
||||
|
||||
const IDLE_TIMEOUT_MS = 5_000;
|
||||
const DEFAULT_CITY_ID = "sfo";
|
||||
const DEFAULT_CITY_ID = "mia";
|
||||
const STYLE_STORAGE_KEY = "aeris:mapStyle";
|
||||
|
||||
const DEFAULT_CITY = CITIES.find((c) => c.id === DEFAULT_CITY_ID) ?? CITIES[0];
|
||||
|
||||
const subscribeNoop = () => () => {};
|
||||
|
||||
let _cachedInitialCity: City | null = null;
|
||||
|
||||
function resolveInitialCity(): City {
|
||||
if (_cachedInitialCity) return _cachedInitialCity;
|
||||
try {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const code = params.get("city")?.trim().toUpperCase();
|
||||
if (!code) return DEFAULT_CITY;
|
||||
return (
|
||||
CITIES.find(
|
||||
if (!code) {
|
||||
_cachedInitialCity = DEFAULT_CITY;
|
||||
return DEFAULT_CITY;
|
||||
}
|
||||
|
||||
const preset = CITIES.find(
|
||||
(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 {
|
||||
_cachedInitialCity = 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() {
|
||||
const hydratedCity = useSyncExternalStore(
|
||||
subscribeNoop,
|
||||
@ -228,8 +140,13 @@ function FlightTrackerInner() {
|
||||
|
||||
return (
|
||||
<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} />
|
||||
<AirportLayer
|
||||
activeCity={activeCity}
|
||||
onSelectAirport={setActiveCity}
|
||||
isDark={mapStyle.dark}
|
||||
/>
|
||||
<FlightLayers
|
||||
flights={flights}
|
||||
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,16 +318,10 @@ export function FlightLayers({
|
||||
const ax = animFlight.longitude;
|
||||
const ay = animFlight.latitude;
|
||||
|
||||
const curr = currSnapshotsRef.current.get(d.icao24);
|
||||
const prev = prevSnapshotsRef.current.get(d.icao24);
|
||||
const heading = ((animFlight.trueTrack ?? 0) * Math.PI) / 180;
|
||||
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--) {
|
||||
const vx = basePath[i][0] - ax;
|
||||
const vy = basePath[i][1] - ay;
|
||||
@ -337,7 +331,6 @@ export function FlightLayers({
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
basePath[basePath.length - 1] = [ax, ay, alt];
|
||||
}
|
||||
return basePath;
|
||||
|
||||
@ -34,6 +34,7 @@ type MapProps = {
|
||||
children?: ReactNode;
|
||||
className?: string;
|
||||
mapStyle?: MapStyleSpec;
|
||||
isDark?: boolean;
|
||||
center?: [number, number];
|
||||
zoom?: number;
|
||||
pitch?: number;
|
||||
@ -49,6 +50,7 @@ export const Map = forwardRef<MapRef, MapProps>(function Map(
|
||||
children,
|
||||
className,
|
||||
mapStyle = DEFAULT_STYLE.style,
|
||||
isDark = true,
|
||||
center = [0, 20],
|
||||
zoom = 2.5,
|
||||
pitch = 49,
|
||||
@ -92,11 +94,14 @@ export const Map = forwardRef<MapRef, MapProps>(function Map(
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const isDarkRef = useRef(isDark);
|
||||
isDarkRef.current = isDark;
|
||||
|
||||
useEffect(() => {
|
||||
if (!mapInstance || !isLoaded) return;
|
||||
mapInstance.setStyle(mapStyle as maplibregl.StyleSpecification | string);
|
||||
|
||||
const applyTerrain = () => {
|
||||
const onStyleLoad = () => {
|
||||
if (typeof mapStyle === "object" && "terrain" in mapStyle) {
|
||||
const spec = mapStyle as Record<string, unknown>;
|
||||
try {
|
||||
@ -113,11 +118,13 @@ export const Map = forwardRef<MapRef, MapProps>(function Map(
|
||||
/* no terrain to remove */
|
||||
}
|
||||
}
|
||||
|
||||
addAerowayLayers(mapInstance, isDarkRef.current);
|
||||
};
|
||||
mapInstance.once("style.load", applyTerrain);
|
||||
mapInstance.once("style.load", onStyleLoad);
|
||||
|
||||
return () => {
|
||||
mapInstance.off("style.load", applyTerrain);
|
||||
mapInstance.off("style.load", onStyleLoad);
|
||||
};
|
||||
}, [mapInstance, isLoaded, mapStyle]);
|
||||
|
||||
@ -139,3 +146,83 @@ export const Map = forwardRef<MapRef, MapProps>(function 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,
|
||||
} from "lucide-react";
|
||||
import { CITIES, type City } from "@/lib/cities";
|
||||
import { searchAirports, airportToCity } from "@/lib/airports";
|
||||
import { MAP_STYLES, type MapStyle } from "@/lib/map-styles";
|
||||
import { useSettings, type OrbitDirection } from "@/hooks/use-settings";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
@ -375,16 +376,32 @@ function SearchContent({
|
||||
requestAnimationFrame(() => inputRef.current?.focus());
|
||||
}, []);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = query.toLowerCase();
|
||||
return CITIES.filter(
|
||||
const { featured, airports } = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
|
||||
if (!q)
|
||||
return {
|
||||
featured: CITIES,
|
||||
airports: [] as ReturnType<typeof searchAirports>,
|
||||
};
|
||||
|
||||
const featured = CITIES.filter(
|
||||
(c) =>
|
||||
c.name.toLowerCase().includes(q) ||
|
||||
c.iata.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]);
|
||||
|
||||
const hasResults = featured.length > 0 || airports.length > 0;
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<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}
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Search airspace..."
|
||||
aria-label="Search cities by name, IATA code, or country"
|
||||
placeholder="Search airports..."
|
||||
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"
|
||||
/>
|
||||
{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>
|
||||
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="p-2">
|
||||
{filtered.length === 0 && (
|
||||
{!hasResults && (
|
||||
<p className="py-8 text-center text-[12px] text-white/25">
|
||||
No cities found
|
||||
No airports found
|
||||
</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}
|
||||
name={city.name}
|
||||
detail={`${city.iata} \u00b7 ${city.country}`}
|
||||
isActive={activeCity?.id === city.id}
|
||||
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 ${
|
||||
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">
|
||||
<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 className="text-[11px] font-medium text-white/25">
|
||||
{city.iata} \u00b7 {city.country}
|
||||
</p>
|
||||
<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>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -457,8 +537,8 @@ function StyleContent({
|
||||
</div>
|
||||
<div className="border-t border-white/4 px-5 py-3">
|
||||
<p className="text-[11px] font-medium text-white/12">
|
||||
Satellite \u00a9 Esri \u00b7 Terrain \u00a9 OpenTopoMap \u00b7 Base maps \u00a9
|
||||
CARTO
|
||||
Satellite \u00a9 Esri \u00b7 Terrain \u00a9 OpenTopoMap \u00b7 Base
|
||||
maps \u00a9 CARTO
|
||||
</p>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
@ -34,6 +34,7 @@ export function useFlights(city: City | null) {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [rateLimited, setRateLimited] = useState(false);
|
||||
const [retryIn, setRetryIn] = useState(0);
|
||||
const [creditsRemaining, setCreditsRemaining] = useState<number | null>(null);
|
||||
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const countdownRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
@ -111,6 +112,7 @@ export function useFlights(city: City | null) {
|
||||
|
||||
if (result.creditsRemaining !== null) {
|
||||
creditsRef.current = result.creditsRemaining;
|
||||
setCreditsRemaining(result.creditsRemaining);
|
||||
}
|
||||
|
||||
const nextInterval = adaptiveInterval(creditsRef.current);
|
||||
@ -169,14 +171,16 @@ export function useFlights(city: City | null) {
|
||||
|
||||
setRateLimited(false);
|
||||
clearCountdown();
|
||||
fetchData(city);
|
||||
|
||||
const deferred = setTimeout(() => fetchData(city), 0);
|
||||
|
||||
return () => {
|
||||
clearTimeout(deferred);
|
||||
clearSchedule();
|
||||
abortRef.current?.abort();
|
||||
clearCountdown();
|
||||
};
|
||||
}, [city, fetchData, clearCountdown, clearSchedule]);
|
||||
|
||||
return { flights, loading, error, rateLimited, retryIn };
|
||||
return { flights, loading, error, rateLimited, retryIn, creditsRemaining };
|
||||
}
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useMemo } from "react";
|
||||
import { useState, useMemo } from "react";
|
||||
import type { FlightState } from "@/lib/opensky";
|
||||
|
||||
type Position = [lng: number, lat: number];
|
||||
@ -141,7 +141,6 @@ class TrailStore {
|
||||
}
|
||||
|
||||
export function useTrailHistory(flights: FlightState[]): TrailEntry[] {
|
||||
const storeRef = useRef<TrailStore>(null);
|
||||
if (!storeRef.current) storeRef.current = new TrailStore();
|
||||
return useMemo(() => storeRef.current!.update(flights), [flights]);
|
||||
const [store] = useState(() => new TrailStore());
|
||||
return useMemo(() => store.update(flights), [flights, store]);
|
||||
}
|
||||
|
||||
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",
|
||||
iata: "JFK",
|
||||
coordinates: [-73.7781, 40.6413],
|
||||
radius: 1.5,
|
||||
radius: 2.5,
|
||||
},
|
||||
{
|
||||
id: "lax",
|
||||
@ -22,7 +22,7 @@ export const CITIES: City[] = [
|
||||
country: "US",
|
||||
iata: "LAX",
|
||||
coordinates: [-118.4085, 33.9416],
|
||||
radius: 1.5,
|
||||
radius: 2.5,
|
||||
},
|
||||
{
|
||||
id: "lhr",
|
||||
@ -30,7 +30,7 @@ export const CITIES: City[] = [
|
||||
country: "GB",
|
||||
iata: "LHR",
|
||||
coordinates: [-0.4614, 51.47],
|
||||
radius: 1.5,
|
||||
radius: 2.5,
|
||||
},
|
||||
{
|
||||
id: "dxb",
|
||||
@ -38,7 +38,7 @@ export const CITIES: City[] = [
|
||||
country: "AE",
|
||||
iata: "DXB",
|
||||
coordinates: [55.3644, 25.2532],
|
||||
radius: 1.5,
|
||||
radius: 2.5,
|
||||
},
|
||||
{
|
||||
id: "nrt",
|
||||
@ -46,7 +46,7 @@ export const CITIES: City[] = [
|
||||
country: "JP",
|
||||
iata: "NRT",
|
||||
coordinates: [140.3929, 35.772],
|
||||
radius: 1.5,
|
||||
radius: 2.5,
|
||||
},
|
||||
{
|
||||
id: "sin",
|
||||
@ -54,7 +54,7 @@ export const CITIES: City[] = [
|
||||
country: "SG",
|
||||
iata: "SIN",
|
||||
coordinates: [103.9915, 1.3644],
|
||||
radius: 1.5,
|
||||
radius: 2.5,
|
||||
},
|
||||
{
|
||||
id: "cdg",
|
||||
@ -62,7 +62,7 @@ export const CITIES: City[] = [
|
||||
country: "FR",
|
||||
iata: "CDG",
|
||||
coordinates: [2.5479, 49.0097],
|
||||
radius: 1.5,
|
||||
radius: 2.5,
|
||||
},
|
||||
{
|
||||
id: "sfo",
|
||||
@ -70,7 +70,7 @@ export const CITIES: City[] = [
|
||||
country: "US",
|
||||
iata: "SFO",
|
||||
coordinates: [-122.379, 37.6213],
|
||||
radius: 1.5,
|
||||
radius: 2.5,
|
||||
},
|
||||
{
|
||||
id: "ord",
|
||||
@ -78,7 +78,7 @@ export const CITIES: City[] = [
|
||||
country: "US",
|
||||
iata: "ORD",
|
||||
coordinates: [-87.9073, 41.9742],
|
||||
radius: 1.5,
|
||||
radius: 2.5,
|
||||
},
|
||||
{
|
||||
id: "fra",
|
||||
@ -86,7 +86,7 @@ export const CITIES: City[] = [
|
||||
country: "DE",
|
||||
iata: "FRA",
|
||||
coordinates: [8.5622, 50.0379],
|
||||
radius: 1.5,
|
||||
radius: 2.5,
|
||||
},
|
||||
{
|
||||
id: "bom",
|
||||
@ -94,6 +94,14 @@ export const CITIES: City[] = [
|
||||
country: "IN",
|
||||
iata: "BOM",
|
||||
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,
|
||||
"horizon-fog-blend": 0.1,
|
||||
},
|
||||
layers: [
|
||||
{ id: "satellite-base", type: "raster", source: "esri-satellite" },
|
||||
],
|
||||
layers: [{ id: "satellite-base", type: "raster", source: "esri-satellite" }],
|
||||
};
|
||||
|
||||
export const MAP_STYLES: MapStyle[] = [
|
||||
|
||||
@ -60,7 +60,8 @@ export type FetchResult = {
|
||||
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(
|
||||
lamin: number,
|
||||
@ -88,24 +89,20 @@ export async function fetchFlightsByBbox(
|
||||
});
|
||||
|
||||
if (res.status === 429) {
|
||||
console.warn("[aeris] OpenSky rate limit hit (429), backing off");
|
||||
return { flights: [], rateLimited: true, creditsRemaining: null };
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
console.warn(`[aeris] OpenSky returned ${res.status}`);
|
||||
return { flights: [], rateLimited: false, creditsRemaining: null };
|
||||
}
|
||||
|
||||
const data: OpenSkyResponse = await res.json();
|
||||
|
||||
const creditsRaw = res.headers.get("x-rate-limit-remaining");
|
||||
const creditsRemaining =
|
||||
creditsRaw !== null ? parseInt(creditsRaw, 10) : null;
|
||||
|
||||
const flights = parseStates(data);
|
||||
return {
|
||||
flights,
|
||||
flights: parseStates(data),
|
||||
rateLimited: false,
|
||||
creditsRemaining: Number.isNaN(creditsRemaining)
|
||||
? null
|
||||
|
||||
Reference in New Issue
Block a user