139 lines
5.9 KiB
TypeScript
139 lines
5.9 KiB
TypeScript
import { memo, useMemo, useState } from 'react';
|
|
import { Marker, Popup } from 'react-map-gl/maplibre';
|
|
import type { Airport } from '../data/airports';
|
|
|
|
const US_BASE_ICAOS = new Set([
|
|
'OMAD', 'OTBH', 'OKAJ', 'LTAG', 'OEPS', 'ORAA', 'ORBD', 'OBBS', 'OMTH', 'HDCL',
|
|
]);
|
|
|
|
function isUSBase(airport: Airport): boolean {
|
|
return airport.type === 'military' && US_BASE_ICAOS.has(airport.icao);
|
|
}
|
|
|
|
const FLAG_EMOJI: Record<string, string> = {
|
|
IR: '\u{1F1EE}\u{1F1F7}', IQ: '\u{1F1EE}\u{1F1F6}', IL: '\u{1F1EE}\u{1F1F1}',
|
|
AE: '\u{1F1E6}\u{1F1EA}', SA: '\u{1F1F8}\u{1F1E6}', QA: '\u{1F1F6}\u{1F1E6}',
|
|
BH: '\u{1F1E7}\u{1F1ED}', KW: '\u{1F1F0}\u{1F1FC}', OM: '\u{1F1F4}\u{1F1F2}',
|
|
TR: '\u{1F1F9}\u{1F1F7}', JO: '\u{1F1EF}\u{1F1F4}', LB: '\u{1F1F1}\u{1F1E7}',
|
|
SY: '\u{1F1F8}\u{1F1FE}', EG: '\u{1F1EA}\u{1F1EC}', PK: '\u{1F1F5}\u{1F1F0}',
|
|
DJ: '\u{1F1E9}\u{1F1EF}', YE: '\u{1F1FE}\u{1F1EA}', SO: '\u{1F1F8}\u{1F1F4}',
|
|
};
|
|
|
|
const TYPE_LABELS: Record<Airport['type'], string> = {
|
|
large: 'International Airport', medium: 'Airport',
|
|
small: 'Regional Airport', military: 'Military Airbase',
|
|
};
|
|
|
|
interface Props { airports: Airport[]; }
|
|
|
|
const TYPE_PRIORITY: Record<Airport['type'], number> = {
|
|
military: 3, large: 2, medium: 1, small: 0,
|
|
};
|
|
|
|
// Keep one airport per area (~50km radius). Priority: military/US base > large > medium > small.
|
|
function deduplicateByArea(airports: Airport[]): Airport[] {
|
|
const sorted = [...airports].sort((a, b) => {
|
|
const pa = TYPE_PRIORITY[a.type] + (isUSBase(a) ? 1 : 0);
|
|
const pb = TYPE_PRIORITY[b.type] + (isUSBase(b) ? 1 : 0);
|
|
return pb - pa;
|
|
});
|
|
const kept: Airport[] = [];
|
|
for (const ap of sorted) {
|
|
const tooClose = kept.some(
|
|
k => Math.abs(k.lat - ap.lat) < 0.8 && Math.abs(k.lng - ap.lng) < 0.8,
|
|
);
|
|
if (!tooClose) kept.push(ap);
|
|
}
|
|
return kept;
|
|
}
|
|
|
|
export const AirportLayer = memo(function AirportLayer({ airports }: Props) {
|
|
const filtered = useMemo(() => deduplicateByArea(airports), [airports]);
|
|
return (
|
|
<>
|
|
{filtered.map(ap => (
|
|
<AirportMarker key={ap.icao} airport={ap} />
|
|
))}
|
|
</>
|
|
);
|
|
});
|
|
|
|
function AirportMarker({ airport }: { airport: Airport }) {
|
|
const [showPopup, setShowPopup] = useState(false);
|
|
const isMil = airport.type === 'military';
|
|
const isUS = isUSBase(airport);
|
|
const color = isUS ? '#3b82f6' : isMil ? '#ef4444' : '#f59e0b';
|
|
const size = airport.type === 'large' ? 18 : airport.type === 'small' ? 12 : 16;
|
|
const flag = FLAG_EMOJI[airport.country] || '';
|
|
|
|
// Single circle with airplane inside (plane shifted down to center in circle)
|
|
const plane = isMil
|
|
? <path d="M12 8.5 L12.8 11 L16.5 12.5 L16.5 13.5 L12.8 12.8 L12.5 15 L13.5 15.8 L13.5 16.5 L12 16 L10.5 16.5 L10.5 15.8 L11.5 15 L11.2 12.8 L7.5 13.5 L7.5 12.5 L11.2 11 L12 8.5Z"
|
|
fill={color} />
|
|
: <path d="M12 8c-.5 0-.8.3-.8.5v2.7l-4.2 2v1l4.2-1.1v2.5l-1.1.8v.8L12 16l1.9.5v-.8l-1.1-.8v-2.5l4.2 1.1v-1l-4.2-2V8.5c0-.2-.3-.5-.8-.5z"
|
|
fill={color} />;
|
|
const icon = (
|
|
<svg viewBox="0 0 24 24" width={size} height={size}>
|
|
<circle cx={12} cy={12} r={10} fill="rgba(0,0,0,0.5)" stroke={color} strokeWidth={2} />
|
|
{plane}
|
|
</svg>
|
|
);
|
|
|
|
return (
|
|
<>
|
|
<Marker longitude={airport.lng} latitude={airport.lat} anchor="center">
|
|
<div style={{ width: size, height: size, cursor: 'pointer' }}
|
|
onClick={(e) => { e.stopPropagation(); setShowPopup(true); }}>
|
|
{icon}
|
|
</div>
|
|
</Marker>
|
|
{showPopup && (
|
|
<Popup longitude={airport.lng} latitude={airport.lat}
|
|
onClose={() => setShowPopup(false)} closeOnClick={false}
|
|
anchor="bottom" offset={[0, -size / 2]} maxWidth="280px" className="gl-popup">
|
|
<div style={{ minWidth: 220, fontFamily: 'monospace', fontSize: 12 }}>
|
|
<div style={{
|
|
background: isUS ? '#1e3a5f' : isMil ? '#991b1b' : '#92400e',
|
|
color: '#fff', padding: '6px 10px', borderRadius: '4px 4px 0 0',
|
|
margin: '-10px -10px 8px -10px',
|
|
display: 'flex', alignItems: 'center', gap: 8,
|
|
}}>
|
|
{isUS ? <span style={{ fontSize: 16 }}>{'\u{1F1FA}\u{1F1F8}'}</span>
|
|
: flag ? <span style={{ fontSize: 16 }}>{flag}</span> : null}
|
|
<strong style={{ fontSize: 13, flex: 1 }}>{airport.name}</strong>
|
|
</div>
|
|
{airport.nameKo && (
|
|
<div style={{ fontSize: 12, color: '#ccc', marginBottom: 6 }}>{airport.nameKo}</div>
|
|
)}
|
|
<div style={{ marginBottom: 8 }}>
|
|
<span style={{
|
|
background: color, color: isUS || isMil ? '#fff' : '#000',
|
|
padding: '2px 8px', borderRadius: 3, fontSize: 10, fontWeight: 700,
|
|
}}>
|
|
{isUS ? 'US Military Base' : TYPE_LABELS[airport.type]}
|
|
</span>
|
|
</div>
|
|
<div style={{ fontSize: 11, display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '2px 12px' }}>
|
|
{airport.iata && <div><span style={{ color: '#888' }}>IATA : </span><strong>{airport.iata}</strong></div>}
|
|
<div><span style={{ color: '#888' }}>ICAO : </span><strong>{airport.icao}</strong></div>
|
|
{airport.city && <div><span style={{ color: '#888' }}>City : </span>{airport.city}</div>}
|
|
<div><span style={{ color: '#888' }}>Country : </span>{airport.country}</div>
|
|
</div>
|
|
<div style={{ marginTop: 6, fontSize: 10, color: '#999' }}>
|
|
{airport.lat.toFixed(4)}°{airport.lat >= 0 ? 'N' : 'S'}, {airport.lng.toFixed(4)}°{airport.lng >= 0 ? 'E' : 'W'}
|
|
</div>
|
|
{airport.iata && (
|
|
<div style={{ marginTop: 4, fontSize: 10, textAlign: 'right' }}>
|
|
<a href={`https://www.flightradar24.com/airport/${airport.iata.toLowerCase()}`}
|
|
target="_blank" rel="noopener noreferrer" style={{ color: '#60a5fa' }}>
|
|
Flightradar24 →
|
|
</a>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</Popup>
|
|
)}
|
|
</>
|
|
);
|
|
}
|