- frontend/ 폴더로 프론트엔드 전체 이관 - signal-batch API 연동 (한국 선박 위치 데이터) - Tailwind CSS 4 + CSS 변수 테마 토큰 (dark/light) - i18next 다국어 (ko/en) 인프라 + 28개 컴포넌트 적용 - 레이어 패널 트리 구조 재설계 (카테고리별 온/오프, 범례) - Google OAuth 로그인 화면 + DEV LOGIN 우회 - 외부 API CORS 프록시 전환 (Airplanes.live, OpenSky, CelesTrak) - ShipLayer 이미지 탭 전환 (signal-batch / MarineTraffic) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
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>
|
|
)}
|
|
</>
|
|
);
|
|
}
|