- 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>
274 lines
15 KiB
TypeScript
274 lines
15 KiB
TypeScript
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<string, { viewBox: string; w: number; h: number; path: string }> = {
|
|
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<Record<AircraftCategory, string>> = {
|
|
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<string, PhotoResult | null>();
|
|
|
|
function AircraftPhoto({ hex }: { hex: string }) {
|
|
const { t } = useTranslation('ships');
|
|
const [photo, setPhoto] = useState<PhotoResult | null | undefined>(
|
|
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 <div className="text-center p-2 text-kcg-muted text-[10px]">{t('aircraftPopup.loadingPhoto')}</div>;
|
|
}
|
|
if (!photo) return null;
|
|
return (
|
|
<div className="mb-1.5">
|
|
<a href={photo.link} target="_blank" rel="noopener noreferrer">
|
|
<img src={photo.url} alt="Aircraft"
|
|
className="w-full rounded block"
|
|
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }}
|
|
/>
|
|
</a>
|
|
{photo.photographer && (
|
|
<div className="text-[9px] text-[#999] mt-0.5 text-right">
|
|
© {photo.photographer}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ═══ 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 && (
|
|
<Source id="aircraft-trails" type="geojson" data={trailData}>
|
|
<Layer
|
|
id="aircraft-trail-lines"
|
|
type="line"
|
|
paint={{
|
|
'line-color': ['get', 'color'],
|
|
'line-width': 1.5,
|
|
'line-opacity': 0.4,
|
|
'line-dasharray': [4, 4],
|
|
}}
|
|
/>
|
|
</Source>
|
|
)}
|
|
{filtered.map(ac => (
|
|
<AircraftMarker key={ac.icao24} ac={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 (
|
|
<>
|
|
<Marker longitude={ac.lng} latitude={ac.lat} anchor="center">
|
|
<div className="relative">
|
|
<div
|
|
className="cursor-pointer"
|
|
style={{
|
|
width: size, height: size,
|
|
transform: `rotate(${ac.heading}deg)`,
|
|
filter: 'drop-shadow(0 0 2px rgba(0,0,0,0.7))',
|
|
}}
|
|
onClick={(e) => { e.stopPropagation(); setShowPopup(true); }}
|
|
>
|
|
<svg viewBox={shape.viewBox} width={size} height={size}
|
|
fill={color} stroke="#000" strokeWidth={strokeWidth}
|
|
strokeLinejoin="round" opacity={0.95}>
|
|
<path d={shape.path} />
|
|
</svg>
|
|
</div>
|
|
{showLabel && (
|
|
<div className="gl-marker-label" style={{ color }}>
|
|
{ac.callsign || ac.icao24}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</Marker>
|
|
{showPopup && (
|
|
<Popup longitude={ac.lng} latitude={ac.lat}
|
|
onClose={() => setShowPopup(false)} closeOnClick={false}
|
|
anchor="bottom" maxWidth="300px" className="gl-popup">
|
|
<div className="min-w-[240px] max-w-[300px] font-mono text-xs">
|
|
<div className="flex items-center gap-2 mb-1.5">
|
|
<strong className="text-sm">{ac.callsign || 'N/A'}</strong>
|
|
<span
|
|
className="px-1.5 py-px rounded text-[10px] font-bold ml-auto text-black"
|
|
style={{ background: color }}
|
|
>
|
|
{t(`aircraftLabel.${ac.category}`)}
|
|
</span>
|
|
</div>
|
|
<AircraftPhoto hex={ac.icao24} />
|
|
<table className="w-full text-[11px] border-collapse">
|
|
<tbody>
|
|
<tr><td className="text-kcg-muted pr-2">{t('aircraftPopup.hex')}</td><td><strong>{ac.icao24.toUpperCase()}</strong></td></tr>
|
|
{ac.registration && <tr><td className="text-kcg-muted">{t('aircraftPopup.reg')}</td><td><strong>{ac.registration}</strong></td></tr>}
|
|
{ac.operator && <tr><td className="text-kcg-muted">{t('aircraftPopup.operator')}</td><td>{ac.operator}</td></tr>}
|
|
{ac.typecode && (
|
|
<tr><td className="text-kcg-muted">{t('aircraftPopup.type')}</td>
|
|
<td><strong>{ac.typecode}</strong>{ac.typeDesc ? ` — ${ac.typeDesc}` : ''}</td></tr>
|
|
)}
|
|
{ac.squawk && <tr><td className="text-kcg-muted">{t('aircraftPopup.squawk')}</td><td>{ac.squawk}</td></tr>}
|
|
<tr><td className="text-kcg-muted">{t('aircraftPopup.alt')}</td>
|
|
<td>{ac.onGround ? t('aircraftPopup.ground') : `${Math.round(ac.altitude * 3.281).toLocaleString()} ft`}</td></tr>
|
|
<tr><td className="text-kcg-muted">{t('aircraftPopup.speed')}</td><td>{Math.round(ac.velocity * 1.944)} kts</td></tr>
|
|
<tr><td className="text-kcg-muted">{t('aircraftPopup.hdg')}</td><td>{Math.round(ac.heading)}°</td></tr>
|
|
<tr><td className="text-kcg-muted">{t('aircraftPopup.verticalSpeed')}</td><td>{Math.round(ac.verticalRate * 196.85)} fpm</td></tr>
|
|
</tbody>
|
|
</table>
|
|
<div className="mt-1.5 text-[10px] text-right">
|
|
<a href={`https://globe.airplanes.live/?icao=${ac.icao24}`}
|
|
target="_blank" rel="noopener noreferrer" className="text-kcg-accent">
|
|
Airplanes.live →
|
|
</a>
|
|
</div>
|
|
</div>
|
|
</Popup>
|
|
)}
|
|
</>
|
|
);
|
|
}, (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);
|
|
});
|