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:
kew
2026-02-17 21:50:39 +05:30
committed by GitHub
parent 5125107d5b
commit bf99d4843f
173 changed files with 721 additions and 73 deletions

View File

@ -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;

View File

@ -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",

View File

@ -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>
)}