Feat/airline logos opensky refresh (#6)
* feat: keyboard shortcuts, click-to-select, pulse/glow, smooth orbit resume * feat: add camera controls and enhance keyboard shortcuts help; improve flight card accessibility * feat: enhance flight layers and keyboard shortcuts; improve airline data structure * feat: expand airline logos and refresh flight/OpenSky mapping * feat: import expanded airport dataset * perf: reduce trail recomputation and soften airport dots * perf: speed up 9k airport search with index + cache * docs: add community standards and contribution templates * docs: enforce issue templates via config * chore: ignore only .github/agents * fix: improve airport visibility and stable map marker
This commit is contained in:
@ -8,6 +8,10 @@ import { CITIES, type City } from "@/lib/cities";
|
||||
|
||||
const SOURCE_ID = "airport-markers";
|
||||
const DOTS_LAYER = "airport-dots";
|
||||
const HIT_LAYER = "airport-hit";
|
||||
const ACTIVE_SOURCE_ID = "active-airport-marker";
|
||||
const ACTIVE_RING_LAYER = "active-airport-ring";
|
||||
const ACTIVE_CORE_LAYER = "active-airport-core";
|
||||
|
||||
type AirportLayerProps = {
|
||||
activeCity: City;
|
||||
@ -79,7 +83,6 @@ export function AirportLayer({
|
||||
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(() => {
|
||||
@ -92,45 +95,122 @@ export function AirportLayer({
|
||||
const m = map;
|
||||
|
||||
const dotColor = isDark
|
||||
? "rgba(167,243,208,0.28)"
|
||||
: "rgba(15,118,110,0.22)";
|
||||
? "rgba(188,248,221,0.68)"
|
||||
: "rgba(15,118,110,0.62)";
|
||||
|
||||
function addSourceAndLayers() {
|
||||
if (m.getSource(SOURCE_ID)) return;
|
||||
|
||||
m.addSource(SOURCE_ID, { type: "geojson", data: airportGeoJson });
|
||||
|
||||
m.addLayer({
|
||||
id: HIT_LAYER,
|
||||
type: "circle",
|
||||
source: SOURCE_ID,
|
||||
paint: {
|
||||
"circle-radius": ["step", ["zoom"], 8, 6, 10, 10, 12, 14, 15],
|
||||
"circle-color": "rgba(255,255,255,0.01)",
|
||||
"circle-opacity": 0.01,
|
||||
"circle-pitch-alignment": "map",
|
||||
"circle-pitch-scale": "map",
|
||||
},
|
||||
});
|
||||
|
||||
m.addLayer({
|
||||
id: DOTS_LAYER,
|
||||
type: "circle",
|
||||
source: SOURCE_ID,
|
||||
paint: {
|
||||
"circle-radius": [
|
||||
"step",
|
||||
["zoom"],
|
||||
0.55,
|
||||
6,
|
||||
0.8,
|
||||
10,
|
||||
1.05,
|
||||
14,
|
||||
1.35,
|
||||
],
|
||||
"circle-radius": ["step", ["zoom"], 1.3, 6, 1.8, 10, 2.4, 14, 3],
|
||||
"circle-color": dotColor,
|
||||
"circle-opacity": [
|
||||
"interpolate",
|
||||
["linear"],
|
||||
["zoom"],
|
||||
2,
|
||||
0.14,
|
||||
0.44,
|
||||
8,
|
||||
0.22,
|
||||
0.56,
|
||||
14,
|
||||
0.34,
|
||||
0.68,
|
||||
],
|
||||
"circle-stroke-width": 0,
|
||||
"circle-stroke-color": "rgba(255,255,255,0.18)",
|
||||
"circle-stroke-width": [
|
||||
"interpolate",
|
||||
["linear"],
|
||||
["zoom"],
|
||||
2,
|
||||
0.15,
|
||||
10,
|
||||
0.3,
|
||||
14,
|
||||
0.5,
|
||||
],
|
||||
"circle-pitch-alignment": "map",
|
||||
"circle-pitch-scale": "map",
|
||||
},
|
||||
});
|
||||
|
||||
if (!m.getSource(ACTIVE_SOURCE_ID)) {
|
||||
m.addSource(ACTIVE_SOURCE_ID, {
|
||||
type: "geojson",
|
||||
data: {
|
||||
type: "FeatureCollection",
|
||||
features: [],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (!m.getLayer(ACTIVE_RING_LAYER)) {
|
||||
m.addLayer({
|
||||
id: ACTIVE_RING_LAYER,
|
||||
type: "circle",
|
||||
source: ACTIVE_SOURCE_ID,
|
||||
paint: {
|
||||
"circle-radius": [
|
||||
"interpolate",
|
||||
["linear"],
|
||||
["zoom"],
|
||||
2,
|
||||
4,
|
||||
8,
|
||||
6,
|
||||
14,
|
||||
9,
|
||||
],
|
||||
"circle-color": "rgba(255,255,255,0)",
|
||||
"circle-stroke-color": "rgba(255,255,255,0.26)",
|
||||
"circle-stroke-width": 1,
|
||||
"circle-pitch-alignment": "map",
|
||||
"circle-pitch-scale": "map",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (!m.getLayer(ACTIVE_CORE_LAYER)) {
|
||||
m.addLayer({
|
||||
id: ACTIVE_CORE_LAYER,
|
||||
type: "circle",
|
||||
source: ACTIVE_SOURCE_ID,
|
||||
paint: {
|
||||
"circle-radius": [
|
||||
"interpolate",
|
||||
["linear"],
|
||||
["zoom"],
|
||||
2,
|
||||
1.6,
|
||||
8,
|
||||
2.2,
|
||||
14,
|
||||
2.8,
|
||||
],
|
||||
"circle-color": "rgba(255,255,255,0.62)",
|
||||
"circle-opacity": 0.95,
|
||||
"circle-pitch-alignment": "map",
|
||||
"circle-pitch-scale": "map",
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
addSourceAndLayers();
|
||||
@ -180,18 +260,22 @@ export function AirportLayer({
|
||||
}
|
||||
}
|
||||
|
||||
m.on("mouseenter", DOTS_LAYER, onMouseEnter);
|
||||
m.on("mouseleave", DOTS_LAYER, onMouseLeave);
|
||||
m.on("click", DOTS_LAYER, onClick);
|
||||
m.on("mouseenter", HIT_LAYER, onMouseEnter);
|
||||
m.on("mouseleave", HIT_LAYER, onMouseLeave);
|
||||
m.on("click", HIT_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);
|
||||
m.off("mouseenter", HIT_LAYER, onMouseEnter);
|
||||
m.off("mouseleave", HIT_LAYER, onMouseLeave);
|
||||
m.off("click", HIT_LAYER, onClick);
|
||||
popup.remove();
|
||||
try {
|
||||
if (m.getLayer(ACTIVE_CORE_LAYER)) m.removeLayer(ACTIVE_CORE_LAYER);
|
||||
if (m.getLayer(ACTIVE_RING_LAYER)) m.removeLayer(ACTIVE_RING_LAYER);
|
||||
if (m.getSource(ACTIVE_SOURCE_ID)) m.removeSource(ACTIVE_SOURCE_ID);
|
||||
if (m.getLayer(DOTS_LAYER)) m.removeLayer(DOTS_LAYER);
|
||||
if (m.getLayer(HIT_LAYER)) m.removeLayer(HIT_LAYER);
|
||||
if (m.getSource(SOURCE_ID)) m.removeSource(SOURCE_ID);
|
||||
} catch {
|
||||
/* already cleaned up */
|
||||
@ -201,26 +285,26 @@ export function AirportLayer({
|
||||
|
||||
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>';
|
||||
if (!isValidCoordinates(activeCity.coordinates)) return;
|
||||
|
||||
const marker = new maplibregl.Marker({ element: el })
|
||||
.setLngLat(activeCity.coordinates)
|
||||
.addTo(map);
|
||||
markerRef.current = marker;
|
||||
const src = map.getSource(ACTIVE_SOURCE_ID) as
|
||||
| maplibregl.GeoJSONSource
|
||||
| undefined;
|
||||
if (!src) return;
|
||||
|
||||
return () => {
|
||||
marker.remove();
|
||||
markerRef.current = null;
|
||||
};
|
||||
src.setData({
|
||||
type: "FeatureCollection",
|
||||
features: [
|
||||
{
|
||||
type: "Feature",
|
||||
geometry: {
|
||||
type: "Point",
|
||||
coordinates: activeCity.coordinates,
|
||||
},
|
||||
properties: {},
|
||||
},
|
||||
],
|
||||
});
|
||||
}, [map, isLoaded, activeCity]);
|
||||
|
||||
return null;
|
||||
|
||||
@ -31,6 +31,66 @@ const TRACK_DAMPING = 0.18;
|
||||
const TRAIL_SMOOTHING_ITERATIONS = 3;
|
||||
const AIRCRAFT_SCENEGRAPH_URL = "/models/airplane.glb";
|
||||
const AIRCRAFT_PX_PER_UNIT = 0.3;
|
||||
const BASE_AIRCRAFT_SIZE = 25;
|
||||
const AIRCRAFT_PICK_RADIUS_PX = 14;
|
||||
|
||||
const CATEGORY_TINT: Record<number, [number, number, number]> = {
|
||||
2: [100, 235, 180],
|
||||
3: [120, 225, 235],
|
||||
4: [255, 210, 120],
|
||||
5: [255, 185, 110],
|
||||
6: [255, 160, 120],
|
||||
7: [255, 120, 200],
|
||||
8: [140, 220, 160],
|
||||
9: [170, 210, 255],
|
||||
10: [220, 170, 255],
|
||||
11: [255, 150, 180],
|
||||
12: [180, 230, 160],
|
||||
14: [195, 165, 255],
|
||||
};
|
||||
|
||||
function categorySizeMultiplier(category: number | null): number {
|
||||
switch (category) {
|
||||
case 2:
|
||||
return 0.88;
|
||||
case 3:
|
||||
return 0.96;
|
||||
case 4:
|
||||
return 1.08;
|
||||
case 5:
|
||||
return 1.18;
|
||||
case 6:
|
||||
return 1.28;
|
||||
case 7:
|
||||
return 1.04;
|
||||
case 8:
|
||||
return 0.86;
|
||||
case 9:
|
||||
case 12:
|
||||
return 0.8;
|
||||
case 10:
|
||||
return 1.15;
|
||||
case 14:
|
||||
return 0.72;
|
||||
default:
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
function tintAircraftColor(
|
||||
base: [number, number, number, number],
|
||||
category: number | null,
|
||||
): [number, number, number, number] {
|
||||
const tint = category !== null ? CATEGORY_TINT[category] : undefined;
|
||||
if (!tint) return base;
|
||||
|
||||
return [
|
||||
Math.round(base[0] * 0.58 + tint[0] * 0.42),
|
||||
Math.round(base[1] * 0.58 + tint[1] * 0.42),
|
||||
Math.round(base[2] * 0.58 + tint[2] * 0.42),
|
||||
base[3],
|
||||
];
|
||||
}
|
||||
|
||||
const PULSE_PERIOD_MS = 7000;
|
||||
const RING_PERIOD_MS = 5500;
|
||||
@ -543,7 +603,7 @@ export function FlightLayers({
|
||||
const picked = (overlay as unknown as DeckGLOverlay).pickObject?.({
|
||||
x: e.point.x,
|
||||
y: e.point.y,
|
||||
radius: 10,
|
||||
radius: AIRCRAFT_PICK_RADIUS_PX,
|
||||
});
|
||||
if (!picked?.object) {
|
||||
onClick(null);
|
||||
@ -562,6 +622,7 @@ export function FlightLayers({
|
||||
if (!overlayRef.current) {
|
||||
overlayRef.current = new MapboxOverlay({
|
||||
interleaved: false,
|
||||
pickingRadius: AIRCRAFT_PICK_RADIUS_PX,
|
||||
layers: [],
|
||||
});
|
||||
map.addControl(overlayRef.current as unknown as maplibregl.IControl);
|
||||
@ -731,7 +792,7 @@ export function FlightLayers({
|
||||
data: interpolated,
|
||||
getPosition: (d) => [d.longitude!, d.latitude!, 0],
|
||||
getIcon: () => "aircraft",
|
||||
getSize: 20,
|
||||
getSize: (d) => 20 * categorySizeMultiplier(d.category),
|
||||
getColor: [0, 0, 0, 60],
|
||||
getAngle: (d) => 360 - (d.trueTrack ?? 0),
|
||||
iconAtlas: atlasUrl,
|
||||
@ -747,6 +808,9 @@ export function FlightLayers({
|
||||
const trailMap = new Map(currentTrails.map((t) => [t.icao24, t]));
|
||||
const handledIds = new Set<string>();
|
||||
const trailData: TrailEntry[] = [];
|
||||
const denseSubdivisions = interpolated.length > 140 ? 1 : 2;
|
||||
const smoothingIterations =
|
||||
interpolated.length > 220 ? 1 : TRAIL_SMOOTHING_ITERATIONS;
|
||||
|
||||
const buildVisibleTrailPoints = (
|
||||
trail: TrailEntry,
|
||||
@ -777,7 +841,10 @@ export function FlightLayers({
|
||||
p[1],
|
||||
Math.max(0, altitudeMeters[i] ?? trail.baroAltitude ?? 0),
|
||||
]) as ElevatedPoint[];
|
||||
const denseBasePath = densifyElevatedPath(basePath, 2);
|
||||
const denseBasePath = densifyElevatedPath(
|
||||
basePath,
|
||||
denseSubdivisions,
|
||||
);
|
||||
|
||||
if (
|
||||
animFlight &&
|
||||
@ -792,7 +859,9 @@ export function FlightLayers({
|
||||
]);
|
||||
|
||||
const smoothed =
|
||||
clipped.length < 4 ? clipped : smoothElevatedPath(clipped);
|
||||
clipped.length < 4
|
||||
? clipped
|
||||
: smoothElevatedPath(clipped, smoothingIterations);
|
||||
|
||||
return smoothed.map((p) => [p[0], p[1], Math.max(0, p[2])]);
|
||||
}
|
||||
@ -800,11 +869,23 @@ export function FlightLayers({
|
||||
const smoothed =
|
||||
denseBasePath.length < 4
|
||||
? denseBasePath
|
||||
: smoothElevatedPath(denseBasePath);
|
||||
: smoothElevatedPath(denseBasePath, smoothingIterations);
|
||||
|
||||
return smoothed.map((p) => [p[0], p[1], Math.max(0, p[2])]);
|
||||
};
|
||||
|
||||
const visibleTrailCache = new Map<string, ElevatedPoint[]>();
|
||||
const getVisibleTrailPoints = (
|
||||
trail: TrailEntry,
|
||||
animFlight: FlightState | undefined,
|
||||
): ElevatedPoint[] => {
|
||||
const cached = visibleTrailCache.get(trail.icao24);
|
||||
if (cached) return cached;
|
||||
const computed = buildVisibleTrailPoints(trail, animFlight);
|
||||
visibleTrailCache.set(trail.icao24, computed);
|
||||
return computed;
|
||||
};
|
||||
|
||||
for (const f of interpolated) {
|
||||
if (f.longitude == null || f.latitude == null) continue;
|
||||
|
||||
@ -844,7 +925,7 @@ export function FlightLayers({
|
||||
},
|
||||
getPath: (d) => {
|
||||
const animFlight = interpolatedMap.get(d.icao24);
|
||||
const visiblePoints = buildVisibleTrailPoints(d, animFlight);
|
||||
const visiblePoints = getVisibleTrailPoints(d, animFlight);
|
||||
return visiblePoints.map(
|
||||
(p) =>
|
||||
[
|
||||
@ -859,7 +940,7 @@ export function FlightLayers({
|
||||
},
|
||||
getColor: (d) => {
|
||||
const animFlight = interpolatedMap.get(d.icao24);
|
||||
const visiblePoints = buildVisibleTrailPoints(d, animFlight);
|
||||
const visiblePoints = getVisibleTrailPoints(d, animFlight);
|
||||
const len = visiblePoints.length;
|
||||
return visiblePoints.map((point, i) => {
|
||||
const tVal = len > 1 ? i / (len - 1) : 1;
|
||||
@ -988,10 +1069,18 @@ export function FlightLayers({
|
||||
const yaw = -(d.trueTrack ?? 0);
|
||||
return [pitch, yaw, 90];
|
||||
},
|
||||
getColor: (d) =>
|
||||
altColors ? altitudeToColor(d.baroAltitude) : defaultColor,
|
||||
getColor: (d) => {
|
||||
const base = altColors
|
||||
? altitudeToColor(d.baroAltitude)
|
||||
: defaultColor;
|
||||
return tintAircraftColor(base, d.category);
|
||||
},
|
||||
scenegraph: AIRCRAFT_SCENEGRAPH_URL,
|
||||
sizeScale: 25,
|
||||
getScale: (d) => {
|
||||
const scale = categorySizeMultiplier(d.category);
|
||||
return [scale, scale, scale];
|
||||
},
|
||||
sizeScale: BASE_AIRCRAFT_SIZE,
|
||||
sizeMinPixels: AIRCRAFT_PX_PER_UNIT,
|
||||
sizeMaxPixels: AIRCRAFT_PX_PER_UNIT,
|
||||
_lighting: "pbr",
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Image from "next/image";
|
||||
import { motion, AnimatePresence } from "motion/react";
|
||||
import {
|
||||
Plane,
|
||||
@ -20,17 +22,39 @@ import {
|
||||
headingToCardinal,
|
||||
} from "@/lib/flight-utils";
|
||||
import { lookupAirline, parseFlightNumber } from "@/lib/airlines";
|
||||
import { aircraftTypeHint } from "@/lib/aircraft";
|
||||
import { airlineLogoCandidates } from "@/lib/airline-logos";
|
||||
|
||||
type FlightCardProps = {
|
||||
flight: FlightState | null;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
const loadedLogoUrls = new Set<string>();
|
||||
|
||||
export function FlightCard({ flight, onClose }: FlightCardProps) {
|
||||
const airline = flight ? lookupAirline(flight.callsign) : null;
|
||||
const flightNum = flight ? parseFlightNumber(flight.callsign) : null;
|
||||
const company =
|
||||
airline ?? (flight ? `${flight.originCountry} operator` : null);
|
||||
const model = flight ? aircraftTypeHint(flight.category) : null;
|
||||
const logoCandidates = airlineLogoCandidates(airline);
|
||||
const heading = flight?.trueTrack ?? null;
|
||||
const cardinal = heading !== null ? headingToCardinal(heading) : null;
|
||||
const [logoIndexByAirline, setLogoIndexByAirline] = useState<
|
||||
Record<string, number>
|
||||
>({});
|
||||
const [logoLoadedByKey, setLogoLoadedByKey] = useState<
|
||||
Record<string, boolean>
|
||||
>({});
|
||||
const airlineKey = airline ?? "__none__";
|
||||
const logoIndex = logoIndexByAirline[airlineKey] ?? 0;
|
||||
const logoLoadKey = `${airlineKey}:${logoIndex}`;
|
||||
const logoUrl = logoCandidates[logoIndex] ?? null;
|
||||
const logoLoaded =
|
||||
(logoUrl ? loadedLogoUrls.has(logoUrl) : false) ||
|
||||
(logoLoadedByKey[logoLoadKey] ?? false);
|
||||
const showLogo = Boolean(logoUrl);
|
||||
|
||||
return (
|
||||
<AnimatePresence mode="wait">
|
||||
@ -46,17 +70,57 @@ export function FlightCard({ flight, onClose }: FlightCardProps) {
|
||||
damping: 28,
|
||||
mass: 0.8,
|
||||
}}
|
||||
className="w-64 sm:w-72"
|
||||
className="w-72 sm:w-80"
|
||||
role="complementary"
|
||||
aria-label="Selected flight details"
|
||||
aria-live="polite"
|
||||
>
|
||||
<div className="rounded-2xl border border-white/8 bg-black/60 p-4 shadow-2xl shadow-black/40 backdrop-blur-2xl">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="relative flex h-8 w-8 items-center justify-center rounded-lg bg-sky-500/10">
|
||||
<Plane className="h-4 w-4 text-sky-400/80" />
|
||||
<span className="absolute -right-0.5 -top-0.5 h-2 w-2 rounded-full bg-sky-400 shadow-[0_0_6px_rgba(56,189,248,0.6)]" />
|
||||
<div className="flex items-center gap-3.5">
|
||||
<div className="relative flex h-20 w-20 items-center justify-center rounded-2xl border border-white/14 bg-white/10 shadow-lg shadow-black/25">
|
||||
{showLogo ? (
|
||||
<span className="relative flex h-18 w-18 items-center justify-center overflow-hidden rounded-xl border border-black/10 bg-white/95 p-3.5 shadow-sm">
|
||||
{!logoLoaded && (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="absolute inset-0 animate-pulse bg-linear-to-br from-white/85 via-neutral-200/65 to-white/80"
|
||||
/>
|
||||
)}
|
||||
<Image
|
||||
src={logoUrl ?? undefined}
|
||||
alt={company ? `${company} logo` : "Airline logo"}
|
||||
width={68}
|
||||
height={68}
|
||||
className={`relative h-13 w-13 object-contain transition-opacity duration-200 ${
|
||||
logoLoaded ? "opacity-100" : "opacity-0"
|
||||
}`}
|
||||
unoptimized
|
||||
onLoad={() => {
|
||||
if (logoUrl) loadedLogoUrls.add(logoUrl);
|
||||
setLogoLoadedByKey((current) => ({
|
||||
...current,
|
||||
[logoLoadKey]: true,
|
||||
}));
|
||||
}}
|
||||
onError={() => {
|
||||
if (logoIndex + 1 < logoCandidates.length) {
|
||||
setLogoIndexByAirline((current) => ({
|
||||
...current,
|
||||
[airlineKey]: logoIndex + 1,
|
||||
}));
|
||||
return;
|
||||
}
|
||||
setLogoIndexByAirline((current) => ({
|
||||
...current,
|
||||
[airlineKey]: logoCandidates.length,
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
) : (
|
||||
<Plane className="h-10 w-10 text-sky-400/85" />
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-semibold tracking-wide text-white">
|
||||
@ -79,11 +143,14 @@ export function FlightCard({ flight, onClose }: FlightCardProps) {
|
||||
</motion.button>
|
||||
</div>
|
||||
|
||||
{airline && (
|
||||
{company && (
|
||||
<div className="mt-2.5 flex items-center gap-1.5">
|
||||
<Building2 className="h-3 w-3 text-white/25" />
|
||||
<p className="text-[11px] font-semibold tracking-wide text-white/55">
|
||||
{airline}
|
||||
{company}
|
||||
{model ? (
|
||||
<span className="text-white/30"> · {model}</span>
|
||||
) : null}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
36
src/lib/aircraft.ts
Normal file
36
src/lib/aircraft.ts
Normal file
@ -0,0 +1,36 @@
|
||||
const CATEGORY_LABELS: Record<number, string> = {
|
||||
2: "Light aircraft",
|
||||
3: "Small aircraft",
|
||||
4: "Large aircraft",
|
||||
5: "High vortex large",
|
||||
6: "Heavy aircraft",
|
||||
7: "High-performance aircraft",
|
||||
8: "Rotorcraft",
|
||||
9: "Glider / sailplane",
|
||||
10: "Lighter-than-air",
|
||||
11: "Parachutist / skydiver",
|
||||
12: "Ultralight / hang-glider",
|
||||
13: "Reserved",
|
||||
14: "Unmanned aerial vehicle",
|
||||
15: "Space / trans-atmospheric",
|
||||
16: "Surface emergency vehicle",
|
||||
17: "Surface service vehicle",
|
||||
18: "Point obstacle",
|
||||
19: "Cluster obstacle",
|
||||
20: "Line obstacle",
|
||||
};
|
||||
|
||||
export function categoryToAircraftLabel(category: number | null): string | null {
|
||||
if (category === null) return null;
|
||||
return CATEGORY_LABELS[category] ?? null;
|
||||
}
|
||||
|
||||
export function aircraftModelHint(category: number | null): string | null {
|
||||
const label = categoryToAircraftLabel(category);
|
||||
if (!label) return null;
|
||||
return `${label} class`;
|
||||
}
|
||||
|
||||
export function aircraftTypeHint(category: number | null): string | null {
|
||||
return aircraftModelHint(category);
|
||||
}
|
||||
77
src/lib/airline-logos.ts
Normal file
77
src/lib/airline-logos.ts
Normal file
@ -0,0 +1,77 @@
|
||||
function normalizeAirlineText(value: string): string {
|
||||
return value
|
||||
.normalize("NFD")
|
||||
.replace(/[\u0300-\u036f]/g, "")
|
||||
.toLowerCase()
|
||||
.trim();
|
||||
}
|
||||
|
||||
function toAirlineLogoSlug(airlineName: string): string {
|
||||
return normalizeAirlineText(airlineName)
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
}
|
||||
|
||||
function toAirlineAliasKey(airlineName: string): string {
|
||||
return normalizeAirlineText(airlineName).replace(/[^a-z0-9]+/g, "");
|
||||
}
|
||||
|
||||
const LOGO_SLUG_ALIASES: Record<string, string> = {
|
||||
allnipponairways: "all-nippon-airways",
|
||||
ana: "all-nippon-airways",
|
||||
jal: "japan-airlines",
|
||||
elal: "el-al",
|
||||
itaairways: "ita-airways",
|
||||
latam: "latam-airlines",
|
||||
latamairlines: "latam-airlines",
|
||||
norwegian: "norwegian-air-shuttle",
|
||||
swiss: "swiss",
|
||||
tapairportugal: "tap-air-portugal",
|
||||
vietjetair: "vietjet-air",
|
||||
xiamenair: "xiamenair",
|
||||
pakistaninternationalairlines: "pakistan-international-airlines",
|
||||
pakistanintlairlines: "pakistan-int-l-airlines",
|
||||
indigo: "indigo",
|
||||
indigoairlines: "indigo",
|
||||
goindigo: "indigo",
|
||||
};
|
||||
|
||||
function buildSlugVariants(baseSlug: string): string[] {
|
||||
if (!baseSlug) return [];
|
||||
|
||||
const variants = new Set<string>([baseSlug]);
|
||||
variants.add(baseSlug.replace(/-airlines$/, ""));
|
||||
variants.add(baseSlug.replace(/-airline$/, ""));
|
||||
variants.add(baseSlug.replace(/-airways$/, ""));
|
||||
variants.add(baseSlug.replace(/-air$/, ""));
|
||||
variants.add(baseSlug.replace(/-international$/, ""));
|
||||
variants.add(baseSlug.replace(/-int-l$/, ""));
|
||||
variants.add(baseSlug.replace(/-intl$/, ""));
|
||||
|
||||
return Array.from(variants).filter(Boolean);
|
||||
}
|
||||
|
||||
export function airlineLogoCandidates(airlineName: string | null): string[] {
|
||||
if (!airlineName) return [];
|
||||
|
||||
const slug = toAirlineLogoSlug(airlineName);
|
||||
const aliasKey = toAirlineAliasKey(airlineName);
|
||||
const aliasSlug = LOGO_SLUG_ALIASES[aliasKey] ?? null;
|
||||
|
||||
const orderedSlugs = Array.from(
|
||||
new Set([
|
||||
...buildSlugVariants(slug),
|
||||
...(aliasSlug ? buildSlugVariants(aliasSlug) : []),
|
||||
]),
|
||||
);
|
||||
|
||||
if (orderedSlugs.length === 0) return [];
|
||||
|
||||
const candidates: string[] = [];
|
||||
for (const s of orderedSlugs) {
|
||||
candidates.push(`/airline-logos/${s}.svg`);
|
||||
candidates.push(`/airline-logos/${s}.png`);
|
||||
}
|
||||
|
||||
return candidates;
|
||||
}
|
||||
@ -5,10 +5,17 @@ type AirlineInfo = {
|
||||
const ICAO_AIRLINES: Record<string, AirlineInfo> = {
|
||||
AAL: { name: "American Airlines" },
|
||||
AAR: { name: "Asiana Airlines" },
|
||||
AAY: { name: "Allegiant Air" },
|
||||
ABY: { name: "Air Arabia" },
|
||||
ACA: { name: "Air Canada" },
|
||||
AEA: { name: "Air Europa" },
|
||||
AEE: { name: "Aegean Airlines" },
|
||||
AFL: { name: "Aeroflot" },
|
||||
AFR: { name: "Air France" },
|
||||
AIC: { name: "Air India" },
|
||||
AIQ: { name: "AirAsia" },
|
||||
ASL: { name: "Air Serbia" },
|
||||
ANE: { name: "Air Nostrum" },
|
||||
AIJ: { name: "Interjet" },
|
||||
AJT: { name: "Amerijet" },
|
||||
ALK: { name: "SriLankan Airlines" },
|
||||
@ -18,23 +25,40 @@ const ICAO_AIRLINES: Record<string, AirlineInfo> = {
|
||||
ASA: { name: "Alaska Airlines" },
|
||||
AUA: { name: "Austrian Airlines" },
|
||||
AVA: { name: "Avianca" },
|
||||
ARG: { name: "Aerolíneas Argentinas" },
|
||||
AWE: { name: "US Airways" },
|
||||
AXM: { name: "AirAsia" },
|
||||
AXB: { name: "Air India Express" },
|
||||
AZA: { name: "Alitalia / ITA Airways" },
|
||||
AZU: { name: "Azul" },
|
||||
BAW: { name: "British Airways" },
|
||||
BEL: { name: "Brussels Airlines" },
|
||||
BER: { name: "Air Berlin" },
|
||||
BTI: { name: "Air Baltic" },
|
||||
CAL: { name: "China Airlines" },
|
||||
CCA: { name: "Air China" },
|
||||
CEB: { name: "Cebu Pacific" },
|
||||
CES: { name: "China Eastern" },
|
||||
CHH: { name: "Hainan Airlines" },
|
||||
CFG: { name: "Condor" },
|
||||
CLH: { name: "Lufthansa CityLine" },
|
||||
CMP: { name: "Copa Airlines" },
|
||||
CPA: { name: "Cathay Pacific" },
|
||||
CRK: { name: "Hong Kong Airlines" },
|
||||
CQH: { name: "Spring Airlines" },
|
||||
CSC: { name: "Sichuan Airlines" },
|
||||
CSN: { name: "China Southern" },
|
||||
CSZ: { name: "Shenzhen Airlines" },
|
||||
CTN: { name: "Croatia Airlines" },
|
||||
CXA: { name: "Xiamen Airlines" },
|
||||
DAH: { name: "Air Algerie" },
|
||||
DAL: { name: "Delta Air Lines" },
|
||||
DKH: { name: "Juneyao Airlines" },
|
||||
DAT: { name: "Brussels Airlines" },
|
||||
DLA: { name: "Air Dolomiti" },
|
||||
DLH: { name: "Lufthansa" },
|
||||
EIN: { name: "Aer Lingus" },
|
||||
ENY: { name: "Envoy Air" },
|
||||
EJU: { name: "easyJet Europe" },
|
||||
ELY: { name: "El Al" },
|
||||
ETD: { name: "Etihad Airways" },
|
||||
@ -42,41 +66,68 @@ const ICAO_AIRLINES: Record<string, AirlineInfo> = {
|
||||
EVA: { name: "EVA Air" },
|
||||
EWG: { name: "Eurowings" },
|
||||
EZY: { name: "easyJet" },
|
||||
EXS: { name: "Jet2" },
|
||||
FFT: { name: "Frontier Airlines" },
|
||||
FDX: { name: "FedEx Express" },
|
||||
FDB: { name: "flydubai" },
|
||||
FIN: { name: "Finnair" },
|
||||
FJI: { name: "Fiji Airways" },
|
||||
GAF: { name: "German Air Force" },
|
||||
GFA: { name: "Gulf Air" },
|
||||
GIA: { name: "Garuda Indonesia" },
|
||||
GLO: { name: "GOL" },
|
||||
GTI: { name: "Atlas Air" },
|
||||
HAL: { name: "Hawaiian Airlines" },
|
||||
HKE: { name: "Hong Kong Express" },
|
||||
HVN: { name: "Vietnam Airlines" },
|
||||
IGO: { name: "IndiGo" },
|
||||
IBE: { name: "Iberia" },
|
||||
IBK: { name: "Norwegian Air Int'l" },
|
||||
IBB: { name: "Binter Canarias" },
|
||||
IBU: { name: "IndiGo" },
|
||||
ICE: { name: "Icelandair" },
|
||||
IBS: { name: "Iberia Express" },
|
||||
JAL: { name: "Japan Airlines" },
|
||||
JBU: { name: "JetBlue" },
|
||||
JJA: { name: "Jeju Air" },
|
||||
JJP: { name: "Jetstar" },
|
||||
JST: { name: "Jetstar" },
|
||||
JZA: { name: "Air Canada Jazz" },
|
||||
KAL: { name: "Korean Air" },
|
||||
KLM: { name: "KLM" },
|
||||
KZR: { name: "Air Astana" },
|
||||
LAN: { name: "LATAM Airlines" },
|
||||
LGL: { name: "Luxair" },
|
||||
LPE: { name: "LATAM Perú" },
|
||||
LOT: { name: "LOT Polish Airlines" },
|
||||
MAU: { name: "Air Mauritius" },
|
||||
MAS: { name: "Malaysia Airlines" },
|
||||
MSR: { name: "EgyptAir" },
|
||||
NAX: { name: "Norwegian Air Shuttle" },
|
||||
NKS: { name: "Spirit Airlines" },
|
||||
OMA: { name: "Oman Air" },
|
||||
OZW: { name: "SkyWest Airlines" },
|
||||
PAL: { name: "Philippine Airlines" },
|
||||
PIA: { name: "Pakistan Int'l Airlines" },
|
||||
PGT: { name: "Pegasus Airlines" },
|
||||
POE: { name: "Porter Airlines" },
|
||||
QFA: { name: "Qantas" },
|
||||
QTR: { name: "Qatar Airways" },
|
||||
RAM: { name: "Royal Air Maroc" },
|
||||
RJA: { name: "Royal Jordanian" },
|
||||
RPA: { name: "Republic Airways" },
|
||||
ROT: { name: "TAROM" },
|
||||
RYR: { name: "Ryanair" },
|
||||
SAS: { name: "Scandinavian Airlines" },
|
||||
SCO: { name: "Scoot" },
|
||||
SDM: { name: "Rossiya" },
|
||||
SCX: { name: "Sun Country Airlines" },
|
||||
SEJ: { name: "SpiceJet" },
|
||||
SEH: { name: "Sky Express" },
|
||||
SAA: { name: "South African Airways" },
|
||||
SIA: { name: "Singapore Airlines" },
|
||||
SKW: { name: "SkyWest Airlines" },
|
||||
SKY: { name: "Skymark Airlines" },
|
||||
SVA: { name: "Saudia" },
|
||||
SWA: { name: "Southwest Airlines" },
|
||||
SWR: { name: "Swiss Int'l Air Lines" },
|
||||
@ -84,16 +135,26 @@ const ICAO_AIRLINES: Record<string, AirlineInfo> = {
|
||||
TAP: { name: "TAP Air Portugal" },
|
||||
THA: { name: "Thai Airways" },
|
||||
THY: { name: "Turkish Airlines" },
|
||||
TOM: { name: "TUI Airways" },
|
||||
TRA: { name: "Transavia" },
|
||||
TSC: { name: "Air Transat" },
|
||||
TWB: { name: "Tway Airlines" },
|
||||
TUI: { name: "TUI Airways" },
|
||||
TVF: { name: "Transavia France" },
|
||||
UAE: { name: "Emirates" },
|
||||
UAL: { name: "United Airlines" },
|
||||
USA: { name: "US Airways" },
|
||||
UPS: { name: "UPS Airlines" },
|
||||
VJC: { name: "VietJet Air" },
|
||||
VIR: { name: "Virgin Atlantic" },
|
||||
VOE: { name: "Volotea" },
|
||||
VOI: { name: "Volaris" },
|
||||
VOZ: { name: "Virgin Australia" },
|
||||
VLG: { name: "Vueling" },
|
||||
WJA: { name: "WestJet" },
|
||||
WIF: { name: "Widerøe" },
|
||||
WZZ: { name: "Wizz Air" },
|
||||
XAX: { name: "AirAsia X" },
|
||||
};
|
||||
|
||||
export function lookupAirline(callsign: string | null): string | null {
|
||||
|
||||
@ -72510,43 +72510,91 @@ export const AIRPORTS: Airport[] = [
|
||||
},
|
||||
];
|
||||
|
||||
type SearchAirportEntry = {
|
||||
airport: Airport;
|
||||
iata: string;
|
||||
city: string;
|
||||
name: string;
|
||||
country: string;
|
||||
};
|
||||
|
||||
const AIRPORT_SEARCH_INDEX: SearchAirportEntry[] = AIRPORTS.map((airport) => ({
|
||||
airport,
|
||||
iata: airport.iata.toLowerCase(),
|
||||
city: airport.city.toLowerCase(),
|
||||
name: airport.name.toLowerCase(),
|
||||
country: airport.country.toLowerCase(),
|
||||
}));
|
||||
|
||||
const IATA_LOOKUP = new Map(AIRPORTS.map((airport) => [airport.iata, airport]));
|
||||
|
||||
const SEARCH_CACHE_LIMIT = 80;
|
||||
const SEARCH_CACHE = new Map<string, Airport[]>();
|
||||
|
||||
function getCachedAirportSearch(query: string): Airport[] | undefined {
|
||||
const cached = SEARCH_CACHE.get(query);
|
||||
if (!cached) return undefined;
|
||||
|
||||
SEARCH_CACHE.delete(query);
|
||||
SEARCH_CACHE.set(query, cached);
|
||||
return cached;
|
||||
}
|
||||
|
||||
function setCachedAirportSearch(query: string, airports: Airport[]) {
|
||||
if (SEARCH_CACHE.has(query)) SEARCH_CACHE.delete(query);
|
||||
SEARCH_CACHE.set(query, airports);
|
||||
|
||||
if (SEARCH_CACHE.size > SEARCH_CACHE_LIMIT) {
|
||||
const oldest = SEARCH_CACHE.keys().next().value;
|
||||
if (oldest) SEARCH_CACHE.delete(oldest);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export function searchAirports(query: string, limit = 20): Airport[] {
|
||||
const q = query.toLowerCase().trim();
|
||||
if (!q) return [];
|
||||
|
||||
const cached = getCachedAirportSearch(q);
|
||||
if (cached) return cached.slice(0, limit);
|
||||
|
||||
const exact: Airport[] = [];
|
||||
const iataPrefix: Airport[] = [];
|
||||
const cityStart: Airport[] = [];
|
||||
const nameStart: Airport[] = [];
|
||||
const contains: Airport[] = [];
|
||||
|
||||
for (const a of AIRPORTS) {
|
||||
const iata = a.iata.toLowerCase();
|
||||
const city = a.city.toLowerCase();
|
||||
const name = a.name.toLowerCase();
|
||||
const country = a.country.toLowerCase();
|
||||
for (const entry of AIRPORT_SEARCH_INDEX) {
|
||||
const { airport, iata, city, name, country } = entry;
|
||||
|
||||
if (iata === q) exact.push(a);
|
||||
else if (iata.startsWith(q)) iataPrefix.push(a);
|
||||
else if (city.startsWith(q)) cityStart.push(a);
|
||||
else if (name.startsWith(q)) nameStart.push(a);
|
||||
if (iata === q) {
|
||||
if (exact.length < limit) exact.push(airport);
|
||||
} else if (iata.startsWith(q)) {
|
||||
if (iataPrefix.length < limit) iataPrefix.push(airport);
|
||||
} else if (city.startsWith(q)) {
|
||||
if (cityStart.length < limit) cityStart.push(airport);
|
||||
} else if (name.startsWith(q)) {
|
||||
if (nameStart.length < limit) nameStart.push(airport);
|
||||
}
|
||||
else if (city.includes(q) || name.includes(q) || country.startsWith(q))
|
||||
contains.push(a);
|
||||
if (contains.length < limit) contains.push(airport);
|
||||
}
|
||||
|
||||
return [
|
||||
const results = [
|
||||
...exact,
|
||||
...iataPrefix,
|
||||
...cityStart,
|
||||
...nameStart,
|
||||
...contains,
|
||||
].slice(0, limit);
|
||||
];
|
||||
|
||||
setCachedAirportSearch(q, results);
|
||||
return results.slice(0, limit);
|
||||
}
|
||||
|
||||
export function findByIata(iata: string): Airport | undefined {
|
||||
const code = iata.toUpperCase();
|
||||
return AIRPORTS.find((a) => a.iata === code);
|
||||
return IATA_LOOKUP.get(code);
|
||||
}
|
||||
|
||||
export function airportToCity(airport: Airport): City {
|
||||
|
||||
@ -18,6 +18,7 @@ export type FlightState = {
|
||||
squawk: string | null;
|
||||
spiFlag: boolean;
|
||||
positionSource: number;
|
||||
category: number | null;
|
||||
};
|
||||
|
||||
type OpenSkyResponse = {
|
||||
@ -44,6 +45,7 @@ function parseStates(raw: OpenSkyResponse): FlightState[] {
|
||||
squawk: s[14] as string | null,
|
||||
spiFlag: s[15] as boolean,
|
||||
positionSource: s[16] as number,
|
||||
category: (s[17] as number | null) ?? null,
|
||||
}))
|
||||
.filter(
|
||||
(f) =>
|
||||
@ -75,7 +77,7 @@ export async function fetchFlightsByBbox(
|
||||
const lo0 = clamp(lomin, -180, 180);
|
||||
const lo1 = clamp(lomax, -180, 180);
|
||||
|
||||
const url = `${OPENSKY_API}/states/all?lamin=${la0}&lamax=${la1}&lomin=${lo0}&lomax=${lo1}`;
|
||||
const url = `${OPENSKY_API}/states/all?lamin=${la0}&lamax=${la1}&lomin=${lo0}&lomax=${lo1}&extended=1`;
|
||||
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
||||
|
||||
Reference in New Issue
Block a user