import { memo, useMemo, useState, useEffect } from 'react'; import { Marker, Popup, Source, Layer } from 'react-map-gl/maplibre'; import { useTranslation } from 'react-i18next'; import type { Aircraft, AircraftCategory } from '../../types'; interface Props { aircraft: Aircraft[]; militaryOnly: boolean; } // ═══ tar1090 / Airplanes.live style SVG icons ═══ const SHAPES: Record = { airliner: { viewBox: '-1 -2 34 34', w: 24, h: 24, path: 'M16 1c-.17 0-.67.58-.9 1.03-.6 1.21-.6 1.15-.65 5.2-.04 2.97-.08 3.77-.18 3.9-.15.17-1.82 1.1-1.98 1.1-.08 0-.1-.25-.05-.83.03-.5.01-.92-.05-1.08-.1-.25-.13-.26-.71-.26-.82 0-.86.07-.78 1.5.03.6.08 1.17.11 1.25.05.12-.02.2-.25.33l-8 4.2c-.2.2-.18.1-.19 1.29 3.9-1.2 3.71-1.21 3.93-1.21.06 0 .1 0 .13.14.08.3.28.3.28-.04 0-.25.03-.27 1.16-.6.65-.2 1.22-.35 1.28-.35.05 0 .12.04.15.17.07.3.27.27.27-.08 0-.25.01-.27.7-.47.68-.1.98-.09 1.47-.1.18 0 .22 0 .26.18.06.34.22.35.27-.01.04-.2.1-.17 1.06-.14l1.07.02.05 4.2c.05 3.84.07 4.28.26 5.09.11.49.2.99.2 1.11 0 .19-.31.43-1.93 1.5l-1.93 1.26v1.02l4.13-.95.63 1.54c.05.07.12.09.19.09s.14-.02.19-.09l.63-1.54 4.13.95V29.3l-1.93-1.27c-1.62-1.06-1.93-1.3-1.93-1.49 0-.12.09-.62.2-1.11.19-.81.2-1.25.26-5.09l.05-4.2 1.07-.02c.96-.03 1.02-.05 1.06.14.05.36.21.35.27 0 .04-.17.08-.16.26-.16.49 0 .8-.02 1.48.1.68.2.69.21.69.46 0 .35.2.38.27.08.03-.13.1-.17.15-.17.06 0 .63.15 1.28.34 1.13.34 1.16.36 1.16.61 0 .35.2.34.28.04.03-.13.07-.14.13-.14.22 0 .03 0 3.93 1.2-.01-1.18.02-1.07-.19-1.27l-8-4.21c-.23-.12-.3-.21-.25-.33.03-.08.08-.65.11-1.25.08-1.43.04-1.5-.78-1.5-.58 0-.61.01-.71.26-.06.16-.08.58-.05 1.08.04.58.03.83-.05.83-.16 0-1.83-.93-1.98-1.1-.1-.13-.14-.93-.18-3.9-.05-4.05-.05-3.99-.65-5.2C16.67 1.58 16.17 1 16 1z', }, hi_perf: { viewBox: '-7.8 0 80 80', w: 24, h: 24, path: 'M 30.82,61.32 29.19,54.84 29.06,60.19 27.70,60.70 22.27,60.63 21.68,59.60 l -0.01,-2.71 6.26,-5.52 -0.03,-3.99 -13.35,-0.01 -3e-6,1.15 -1.94,0.00 -0.01,-1.31 0.68,-0.65 L 13.30,37.20 c -0.01,-0.71 0.57,-0.77 0.60,0 l 0.05,1.57 0.28,0.23 0.26,4.09 L 19.90,38.48 c 0,0 -0.04,-1.26 0.20,-1.28 0.16,-0.02 0.20,0.98 0.20,0.98 l 4.40,-3.70 c 0,0 0.02,-1.28 0.20,-1.28 0.14,-0.00 0.20,0.98 0.20,0.98 l 1.80,-1.54 C 27.02,28.77 28.82,25.58 29,21.20 c 0.06,-1.41 0.23,-3.34 0.86,-3.85 0.21,-4.40 1.32,-11.03 2.39,-11.03 1.07,0 2.17,6.64 2.39,11.03 0.63,0.51 0.80,2.45 0.86,3.85 0.18,4.38 1.98,7.57 2.10,11.44 l 1.80,1.54 c 0,0 0.06,-0.99 0.20,-0.98 0.18,0.01 0.20,1.28 0.20,1.28 l 4.40,3.70 c 0,0 0.04,-1.00 0.20,-0.98 0.24,0.03 0.20,1.28 0.20,1.28 l 5.41,4.60 0.26,-4.09 0.28,-0.23 L 50.59,37.20 c 0.03,-0.77 0.61,-0.71 0.60,0 l 0.02,9.37 0.68,0.65 -0.01,1.31 -1.94,-0.00 -3e-6,-1.15 -13.35,0.01 -0.03,3.99 6.26,5.52 L 42.81,59.60 42.22,60.63 36.79,60.70 35.43,60.19 35.30,54.84 33.67,61.32 Z', }, jet_nonSweep: { viewBox: '-2 -2.4 22 22', w: 18, h: 18, path: 'M9,17.09l-3.51.61v-.3c0-.65.11-1,.33-1.09L8.5,15a5.61,5.61,0,0,1-.28-1.32l-.53-.41-.1-.69H7.12l0-.21a7.19,7.19,0,0,1-.15-2.19L.24,9.05V8.84c0-1.1.51-1.15.61-1.15L7.8,7.18V2.88C7.8.64,8.89.3,8.93.28L9,.26l.07,0s1.13.36,1.13,2.6v4.3l7,.51c.09,0,.59.06.59,1.15v.21l-6.69,1.16a7.17,7.17,0,0,1-.15,2.19l0,.21h-.47l-.1.69-.53.41A5.61,5.61,0,0,1,9.5,15l2.74,1.28c.2.07.31.43.31,1.08v.3Z', }, heavy_2e: { viewBox: '0 -3.2 64.2 64.2', w: 26, h: 26, path: 'm 31.414,2.728 c -0.314,0.712 -1.296,2.377 -1.534,6.133 l -0.086,13.379 c 0.006,0.400 -0.380,0.888 -0.945,1.252 l -2.631,1.729 c 0.157,-0.904 0.237,-3.403 -0.162,-3.850 l -2.686,0.006 c -0.336,1.065 -0.358,2.518 -0.109,4.088 h 0.434 L 24.057,26.689 8.611,36.852 7.418,38.432 7.381,39.027 8.875,38.166 l 8.295,-2.771 0.072,0.730 0.156,-0.004 0.150,-0.859 3.799,-1.234 0.074,0.727 0.119,0.004 0.117,-0.832 2.182,-0.730 h 1.670 l 0.061,0.822 h 0.176 l 0.062,-0.822 4.018,-0.002 v 13.602 c 0.051,1.559 0.465,3.272 0.826,4.963 l -6.836,5.426 c -0.097,0.802 -0.003,1.372 0.049,1.885 l 7.734,-2.795 0.477,1.973 h 0.232 l 0.477,-1.973 7.736,2.795 c 0.052,-0.513 0.146,-1.083 0.049,-1.885 l -6.836,-5.426 c 0.361,-1.691 0.775,-3.404 0.826,-4.963 V 33.193 l 4.016,0.002 0.062,0.822 h 0.178 L 38.875,33.195 h 1.672 l 2.182,0.730 0.117,0.832 0.119,-0.004 0.072,-0.727 3.799,1.234 0.152,0.859 0.154,0.004 0.072,-0.730 8.297,2.771 1.492,0.861 -0.037,-0.596 -1.191,-1.580 -15.447,-10.162 0.363,-1.225 H 41.125 c 0.248,-1.569 0.225,-3.023 -0.111,-4.088 l -2.686,-0.006 c -0.399,0.447 -0.317,2.945 -0.160,3.850 L 35.535,23.492 C 34.970,23.128 34.584,22.640 34.590,22.240 L 34.504,8.910 C 34.193,4.926 33.369,3.602 32.934,2.722 32.442,1.732 31.894,1.828 31.414,2.728 Z', }, helicopter: { viewBox: '-13 -13 90 90', w: 22, h: 22, path: 'm 24.698,60.712 c 0,0 -0.450,2.134 -0.861,2.142 -0.561,0.011 -0.480,-3.836 -0.593,-5.761 -0.064,-1.098 1.381,-1.192 1.481,-0.042 l 5.464,0.007 -0.068,-9.482 -0.104,-1.108 c -2.410,-2.131 -3.028,-3.449 -3.152,-7.083 l -12.460,13.179 c -0.773,0.813 -2.977,0.599 -3.483,-0.428 L 26.920,35.416 26.866,29.159 11.471,14.513 c -0.813,-0.773 -0.599,-2.977 0.428,-3.483 l 14.971,14.428 0.150,-5.614 c -0.042,-1.324 1.075,-4.784 3.391,-5.633 0.686,-0.251 2.131,-0.293 3.033,0.008 2.349,0.783 3.433,4.309 3.391,5.633 l 0.073,4.400 12.573,-12.763 c 0.779,-0.807 2.977,-0.599 3.483,0.428 L 37.054,28.325 37.027,35.027 52.411,49.365 c 0.813,0.773 0.599,2.977 -0.428,3.483 L 36.992,38.359 c -0.124,3.634 -0.742,5.987 -3.152,8.118 l -0.104,1.108 -0.068,9.482 5.321,-0.068 c 0.101,-1.150 1.546,-1.057 1.481,0.042 -0.113,1.925 -0.032,5.772 -0.593,5.761 -0.412,-0.008 -0.861,-2.142 -0.861,-2.142 l -5.387,-0.011 0.085,9.377 -1.094,2.059 -1.386,-0.018 -1.093,-2.049 0.085,-9.377 z', }, cessna: { viewBox: '0 -1 32 31', w: 20, h: 20, path: 'M16.36 20.96l2.57.27s.44.05.4.54l-.02.63s-.03.47-.45.54l-2.31.34-.44-.74-.22 1.63-.25-1.62-.38.73-2.35-.35s-.44-.1-.43-.6l-.02-.6s0-.5.48-.5l2.5-.27-.56-5.4-3.64-.1-5.83-1.02h-.45v-2.06s-.07-.37.46-.34l5.8-.17 3.55.12s-.1-2.52.52-2.82l-1.68-.04s-.1-.06 0-.14l1.94-.03s.35-1.18.7 0l1.91.04s.11.05 0 .14l-1.7.02s.62-.09.56 2.82l3.54-.1 5.81.17s.51-.04.48.35l-.01 2.06h-.47l-5.8 1-3.67.11z', }, ground: { viewBox: '0 0 24 24', w: 12, h: 12, path: 'M12 2a8 8 0 1 0 0 16 8 8 0 0 0 0-16zm0 2a6 6 0 1 1 0 12 6 6 0 0 1 0-12zm0 2a4 4 0 1 0 0 8 4 4 0 0 0 0-8z', }, }; function getShape(ac: Aircraft) { if (ac.onGround) return SHAPES.ground; switch (ac.category) { case 'fighter': case 'military': return SHAPES.hi_perf; case 'tanker': case 'surveillance': case 'cargo': return SHAPES.heavy_2e; case 'civilian': return SHAPES.airliner; default: return SHAPES.jet_nonSweep; } } const ALT_COLORS: [number, string][] = [ [0, '#00c000'], [150, '#2AD62A'], [300, '#55EC55'], [600, '#7CFC00'], [1200, '#BFFF00'], [1800, '#FFFF00'], [3000, '#FFD700'], [6000, '#FF8C00'], [9000, '#FF4500'], [12000, '#FF1493'], [15000, '#BA55D3'], ]; const MIL_HEX: Partial> = { fighter: '#ff4444', military: '#ff6600', surveillance: '#ffcc00', tanker: '#00ccff', }; function getAltitudeColor(altMeters: number): string { if (altMeters <= 0) return ALT_COLORS[0][1]; for (let i = ALT_COLORS.length - 1; i >= 0; i--) { if (altMeters >= ALT_COLORS[i][0]) return ALT_COLORS[i][1]; } return ALT_COLORS[0][1]; } function getAircraftColor(ac: Aircraft): string { const milColor = MIL_HEX[ac.category]; if (milColor) return milColor; if (ac.onGround) return '#555555'; return getAltitudeColor(ac.altitude); } // ═══ Planespotters.net photo API ═══ interface PhotoResult { url: string; photographer: string; link: string; } const photoCache = new Map(); function AircraftPhoto({ hex }: { hex: string }) { const { t } = useTranslation('ships'); const [photo, setPhoto] = useState( photoCache.has(hex) ? photoCache.get(hex) : undefined, ); useEffect(() => { if (photo !== undefined) return; let cancelled = false; (async () => { try { const res = await fetch(`https://api.planespotters.net/pub/photos/hex/${hex}`); if (!res.ok) throw new Error(`${res.status}`); const data = await res.json(); if (cancelled) return; if (data.photos && data.photos.length > 0) { const p = data.photos[0]; const result: PhotoResult = { url: p.thumbnail_large?.src || p.thumbnail?.src || '', photographer: p.photographer || '', link: p.link || '', }; photoCache.set(hex, result); setPhoto(result); } else { photoCache.set(hex, null); setPhoto(null); } } catch { photoCache.set(hex, null); setPhoto(null); } })(); return () => { cancelled = true; }; }, [hex, photo]); if (photo === undefined) { return
{t('aircraftPopup.loadingPhoto')}
; } if (!photo) return null; return (
Aircraft { (e.target as HTMLImageElement).style.display = 'none'; }} /> {photo.photographer && (
© {photo.photographer}
)}
); } // ═══ Main layer ═══ export function AircraftLayer({ aircraft, militaryOnly }: Props) { const filtered = useMemo(() => { if (militaryOnly) { return aircraft.filter(a => a.category !== 'civilian' && a.category !== 'unknown'); } return aircraft; }, [aircraft, militaryOnly]); // Aircraft trails as GeoJSON const trailData = useMemo(() => ({ type: 'FeatureCollection' as const, features: filtered .filter(ac => ac.trail && ac.trail.length > 1) .map(ac => ({ type: 'Feature' as const, properties: { color: getAircraftColor(ac) }, geometry: { type: 'LineString' as const, coordinates: ac.trail!.map(([lat, lng]) => [lng, lat]), }, })), }), [filtered]); return ( <> {trailData.features.length > 0 && ( )} {filtered.map(ac => ( ))} ); } // ═══ Aircraft Marker ═══ const AircraftMarker = memo(function AircraftMarker({ ac }: { ac: Aircraft }) { const { t } = useTranslation('ships'); const [showPopup, setShowPopup] = useState(false); const color = getAircraftColor(ac); const shape = getShape(ac); const size = shape.w; const showLabel = ac.category === 'fighter' || ac.category === 'surveillance'; const strokeWidth = ac.category === 'fighter' || ac.category === 'military' ? 1.5 : 0.8; return ( <>
{ e.stopPropagation(); setShowPopup(true); }} >
{showLabel && (
{ac.callsign || ac.icao24}
)}
{showPopup && ( setShowPopup(false)} closeOnClick={false} anchor="bottom" maxWidth="300px" className="gl-popup">
{ac.callsign || 'N/A'} {t(`aircraftLabel.${ac.category}`)}
{ac.registration && } {ac.operator && } {ac.typecode && ( )} {ac.squawk && }
{t('aircraftPopup.hex')}{ac.icao24.toUpperCase()}
{t('aircraftPopup.reg')}{ac.registration}
{t('aircraftPopup.operator')}{ac.operator}
{t('aircraftPopup.type')} {ac.typecode}{ac.typeDesc ? ` — ${ac.typeDesc}` : ''}
{t('aircraftPopup.squawk')}{ac.squawk}
{t('aircraftPopup.alt')} {ac.onGround ? t('aircraftPopup.ground') : `${Math.round(ac.altitude * 3.281).toLocaleString()} ft`}
{t('aircraftPopup.speed')}{Math.round(ac.velocity * 1.944)} kts
{t('aircraftPopup.hdg')}{Math.round(ac.heading)}°
{t('aircraftPopup.verticalSpeed')}{Math.round(ac.verticalRate * 196.85)} fpm
)} ); }, (prev, next) => { const a = prev.ac, b = next.ac; return a.icao24 === b.icao24 && Math.abs(a.lat - b.lat) < 0.001 && Math.abs(a.lng - b.lng) < 0.001 && Math.round(a.heading / 10) === Math.round(b.heading / 10) && a.category === b.category && Math.round(a.altitude / 500) === Math.round(b.altitude / 500); });