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(
|
||||
(c) => c.iata.toUpperCase() === code || c.id === code.toLowerCase(),
|
||||
) ?? DEFAULT_CITY
|
||||
if (!code) {
|
||||
_cachedInitialCity = DEFAULT_CITY;
|
||||
return 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 {
|
||||
_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,24 +318,17 @@ 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;
|
||||
if (vx * fdx + vy * fdy > 0) {
|
||||
basePath[i] = [ax, ay, alt];
|
||||
} else {
|
||||
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];
|
||||
|
||||
@ -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,48 +410,111 @@ 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
|
||||
key={city.id}
|
||||
onClick={() => onSelect(city)}
|
||||
aria-current={activeCity?.id === city.id ? "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" : ""
|
||||
}`}
|
||||
>
|
||||
<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}
|
||||
|
||||
{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>
|
||||
<p className="text-[11px] font-medium text-white/25">
|
||||
{city.iata} \u00b7 {city.country}
|
||||
</p>
|
||||
</div>
|
||||
<ChevronRight className="h-3.5 w-3.5 shrink-0 text-white/12 transition-colors group-hover:text-white/25" />
|
||||
</button>
|
||||
))}
|
||||
)}
|
||||
{featured.map((city) => (
|
||||
<LocationRow
|
||||
key={city.id}
|
||||
name={city.name}
|
||||
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>
|
||||
</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 ${
|
||||
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({
|
||||
activeStyle,
|
||||
onSelect,
|
||||
@ -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>
|
||||
|
||||
Reference in New Issue
Block a user