- 국적 분류 필터 추가 (한국/중국/북한/일본/미분류) - S&P Global / MarineTraffic 탭 디자인 개선 - CCTV 백엔드 프록시 연결 (CctvProxyController) - 풍력단지 레이어 (8개소 해상풍력) - 항구 레이어 (한국/중국/일본/북한/대만 46개) - 공항 확장 (중국 20, 일본 18, 북한 5, 대만 9개 추가) - 군사시설 레이어 (중국/일본/북한/대만 38개소) - 정부기관 레이어 (중국/일본 32개소) - 북한 발사/포병진지 레이어 (19개소) - 북한 미사일 낙하 시각화 (2026년 4건, 궤적 라인, 인근 선박 감지) - 항행정보/팝업 공통 스타일 정리 - 선박 현황 정렬 스타일 개선 - 레이어 패널 폰트 축소 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
280 lines
11 KiB
TypeScript
280 lines
11 KiB
TypeScript
import { useState, useRef, useEffect, useCallback } from 'react';
|
||
import { Marker, Popup } from 'react-map-gl/maplibre';
|
||
import { useTranslation } from 'react-i18next';
|
||
import Hls from 'hls.js';
|
||
import { KOREA_CCTV_CAMERAS } from '../../services/cctv';
|
||
import type { CctvCamera } from '../../services/cctv';
|
||
|
||
const REGION_COLOR: Record<string, string> = {
|
||
'제주': '#ff6b6b',
|
||
'남해': '#ffa94d',
|
||
'서해': '#69db7c',
|
||
'동해': '#74c0fc',
|
||
};
|
||
|
||
/** 백엔드 프록시 경유 — streamUrl이 이미 /api/kcg/cctv/hls/... 형태 */
|
||
function toProxyUrl(cam: CctvCamera): string {
|
||
return cam.streamUrl;
|
||
}
|
||
|
||
export function CctvLayer() {
|
||
const { t } = useTranslation('ships');
|
||
const [selected, setSelected] = useState<CctvCamera | null>(null);
|
||
const [streamCam, setStreamCam] = useState<CctvCamera | null>(null);
|
||
|
||
return (
|
||
<>
|
||
{KOREA_CCTV_CAMERAS.map(cam => {
|
||
const color = REGION_COLOR[cam.region] || '#aaa';
|
||
return (
|
||
<Marker key={cam.id} longitude={cam.lng} latitude={cam.lat} anchor="center"
|
||
onClick={(e) => { e.originalEvent.stopPropagation(); setSelected(cam); }}>
|
||
<div
|
||
className="relative cursor-pointer flex flex-col items-center"
|
||
style={{ filter: `drop-shadow(0 0 2px ${color}88)` }}
|
||
>
|
||
<svg width={14} height={14} viewBox="0 0 24 24" fill="none">
|
||
<rect x="2" y="5" width="15" height="13" rx="2" fill={color} stroke="#fff" strokeWidth="0.8" />
|
||
<polygon points="17,8 23,5 23,18 17,15" fill={color} stroke="#fff" strokeWidth="0.5" />
|
||
<circle cx="6" cy="8.5" r="2.5" fill="#ff0000">
|
||
<animate attributeName="opacity" values="1;0.3;1" dur="1.5s" repeatCount="indefinite" />
|
||
</circle>
|
||
</svg>
|
||
<div
|
||
className="text-[6px] text-white mt-0 whitespace-nowrap font-bold tracking-wide"
|
||
style={{ textShadow: `0 0 3px ${color}, 0 0 2px #000, 0 0 2px #000` }}
|
||
>
|
||
{cam.name.length > 8 ? cam.name.slice(0, 8) + '..' : cam.name}
|
||
</div>
|
||
</div>
|
||
</Marker>
|
||
);
|
||
})}
|
||
|
||
{selected && (
|
||
<Popup longitude={selected.lng} latitude={selected.lat}
|
||
onClose={() => setSelected(null)} closeOnClick={false}
|
||
anchor="bottom" maxWidth="280px" className="gl-popup">
|
||
<div className="font-mono" style={{ minWidth: 200 }}>
|
||
{/* 헤더 - 팝업 가장자리까지 꽉 채움 */}
|
||
<div
|
||
className="font-bold flex items-center gap-2 text-black"
|
||
style={{
|
||
background: REGION_COLOR[selected.region] || '#888',
|
||
margin: '-10px -10px 0',
|
||
padding: '10px 12px',
|
||
borderRadius: '5px 5px 0 0',
|
||
fontSize: 14,
|
||
}}
|
||
>
|
||
<span>📹</span> {selected.name}
|
||
</div>
|
||
{/* 태그 */}
|
||
<div className="flex gap-1.5 flex-wrap" style={{ marginTop: 16 }}>
|
||
<span className="bg-kcg-success text-white font-bold" style={{ padding: '3px 8px', borderRadius: 4, fontSize: 10 }}>
|
||
● {t('cctv.live')}
|
||
</span>
|
||
<span
|
||
className="font-bold text-black"
|
||
style={{ padding: '3px 8px', borderRadius: 4, fontSize: 10, background: REGION_COLOR[selected.region] || '#888' }}
|
||
>{selected.region}</span>
|
||
<span className="bg-kcg-border text-kcg-text-secondary" style={{ padding: '3px 8px', borderRadius: 4, fontSize: 10 }}>
|
||
{t(`cctv.type.${selected.type}`, { defaultValue: selected.type })}
|
||
</span>
|
||
<span className="bg-kcg-card text-kcg-muted" style={{ padding: '3px 8px', borderRadius: 4, fontSize: 10 }}>
|
||
{t('cctv.khoa')}
|
||
</span>
|
||
</div>
|
||
{/* 좌표 */}
|
||
<div className="text-kcg-dim" style={{ fontSize: 10, marginTop: 10 }}>
|
||
{selected.lat.toFixed(4)}°N, {selected.lng.toFixed(4)}°E
|
||
</div>
|
||
{/* 버튼 */}
|
||
<button
|
||
onClick={() => { setStreamCam(selected); setSelected(null); }}
|
||
className="w-full inline-flex items-center justify-center gap-1 bg-kcg-accent text-white border-none cursor-pointer font-mono"
|
||
style={{ padding: '4px 10px', fontSize: 11, borderRadius: 4, fontWeight: 700, marginTop: 10 }}
|
||
>
|
||
📺 {t('cctv.viewStream')}
|
||
</button>
|
||
</div>
|
||
</Popup>
|
||
)}
|
||
|
||
{/* CCTV HLS Stream Modal */}
|
||
{streamCam && (
|
||
<CctvStreamModal cam={streamCam} onClose={() => setStreamCam(null)} />
|
||
)}
|
||
</>
|
||
);
|
||
}
|
||
|
||
/** KHOA HLS 영상 모달 — 지도 하단 오버레이 */
|
||
function CctvStreamModal({ cam, onClose }: { cam: CctvCamera; onClose: () => void }) {
|
||
const { t } = useTranslation('ships');
|
||
const videoRef = useRef<HTMLVideoElement>(null);
|
||
const hlsRef = useRef<Hls | null>(null);
|
||
const [status, setStatus] = useState<'loading' | 'playing' | 'error'>('loading');
|
||
|
||
const destroyHls = useCallback(() => {
|
||
if (hlsRef.current) {
|
||
hlsRef.current.destroy();
|
||
hlsRef.current = null;
|
||
}
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
const video = videoRef.current;
|
||
if (!video) return;
|
||
|
||
const proxied = toProxyUrl(cam);
|
||
setStatus('loading');
|
||
|
||
if (Hls.isSupported()) {
|
||
destroyHls();
|
||
const hls = new Hls({
|
||
enableWorker: true,
|
||
lowLatencyMode: true,
|
||
maxBufferLength: 10,
|
||
maxMaxBufferLength: 30,
|
||
});
|
||
hlsRef.current = hls;
|
||
hls.loadSource(proxied);
|
||
hls.attachMedia(video);
|
||
hls.on(Hls.Events.MANIFEST_PARSED, () => {
|
||
setStatus('playing');
|
||
video.play().catch(() => {});
|
||
});
|
||
hls.on(Hls.Events.ERROR, (_event, data) => {
|
||
if (data.fatal) setStatus('error');
|
||
});
|
||
return () => destroyHls();
|
||
}
|
||
|
||
// Safari 네이티브 HLS
|
||
if (video.canPlayType('application/vnd.apple.mpegurl')) {
|
||
video.src = proxied;
|
||
const onLoaded = () => setStatus('playing');
|
||
const onError = () => setStatus('error');
|
||
video.addEventListener('loadeddata', onLoaded);
|
||
video.addEventListener('error', onError);
|
||
video.play().catch(() => {});
|
||
return () => {
|
||
video.removeEventListener('loadeddata', onLoaded);
|
||
video.removeEventListener('error', onError);
|
||
};
|
||
}
|
||
|
||
setStatus('error');
|
||
return () => destroyHls();
|
||
}, [cam, destroyHls]);
|
||
|
||
const color = REGION_COLOR[cam.region] || '#888';
|
||
|
||
return (
|
||
/* Backdrop */
|
||
<div
|
||
onClick={onClose}
|
||
className="fixed inset-0 z-[9999] flex items-center justify-center backdrop-blur-sm"
|
||
style={{ background: 'rgba(0,0,0,0.6)' }}
|
||
>
|
||
{/* Modal */}
|
||
<div
|
||
onClick={e => e.stopPropagation()}
|
||
className="bg-kcg-bg rounded-lg overflow-hidden"
|
||
style={{
|
||
width: 640,
|
||
maxWidth: '90vw',
|
||
border: `1px solid ${color}`,
|
||
boxShadow: `0 0 40px ${color}33, 0 4px 30px rgba(0,0,0,0.8)`,
|
||
}}
|
||
>
|
||
{/* Header */}
|
||
<div
|
||
className="flex items-center justify-between bg-kcg-overlay border-b border-[#222]"
|
||
style={{ padding: '8px 14px' }}
|
||
>
|
||
<div className="flex items-center gap-2 font-mono text-kcg-text" style={{ fontSize: 11 }}>
|
||
<span
|
||
className="text-white font-bold"
|
||
style={{
|
||
padding: '1px 6px',
|
||
borderRadius: 3,
|
||
fontSize: 9,
|
||
background: status === 'playing' ? '#22c55e' : status === 'loading' ? '#eab308' : '#ef4444',
|
||
}}
|
||
>
|
||
● {status === 'playing' ? t('cctv.live') : status === 'loading' ? t('cctv.connecting') : 'ERROR'}
|
||
</span>
|
||
<span className="font-bold">📹 {cam.name}</span>
|
||
<span
|
||
className="font-bold text-black"
|
||
style={{ padding: '1px 6px', borderRadius: 3, fontSize: 9, background: color }}
|
||
>{cam.region}</span>
|
||
</div>
|
||
<button
|
||
onClick={onClose}
|
||
className="bg-kcg-border border-none text-white rounded cursor-pointer font-bold flex items-center justify-center"
|
||
style={{ width: 24, height: 24, fontSize: 14 }}
|
||
>✕</button>
|
||
</div>
|
||
|
||
{/* Video */}
|
||
<div className="relative w-full bg-black" style={{ aspectRatio: '16/9' }}>
|
||
<video
|
||
ref={videoRef}
|
||
className="w-full h-full object-contain"
|
||
muted autoPlay playsInline
|
||
/>
|
||
|
||
{status === 'loading' && (
|
||
<div className="absolute inset-0 flex flex-col items-center justify-center bg-black/80">
|
||
<div className="text-[28px] opacity-40 mb-2">📹</div>
|
||
<div className="text-kcg-muted font-mono" style={{ fontSize: 11 }}>{t('cctv.connectingEllipsis')}</div>
|
||
</div>
|
||
)}
|
||
|
||
{status === 'error' && (
|
||
<div className="absolute inset-0 flex flex-col items-center justify-center bg-black/80">
|
||
<div className="text-[28px] opacity-40 mb-2">⚠️</div>
|
||
<div className="text-xs text-kcg-danger font-mono mb-2">{t('cctv.connectionFailed')}</div>
|
||
<a
|
||
href={cam.url}
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
className="text-kcg-accent font-mono underline"
|
||
style={{ fontSize: 10 }}
|
||
>{t('cctv.viewOnBadatime')}</a>
|
||
</div>
|
||
)}
|
||
|
||
{status === 'playing' && (
|
||
<>
|
||
<div className="absolute top-2.5 left-2.5 flex items-center gap-1.5">
|
||
<span className="font-bold font-mono px-2 py-0.5 rounded bg-black/70 text-white" style={{ fontSize: 10 }}>
|
||
{cam.name}
|
||
</span>
|
||
<span className="font-bold px-1.5 py-0.5 rounded bg-[rgba(239,68,68,0.3)] text-[#f87171]" style={{ fontSize: 9 }}>
|
||
● {t('cctv.rec')}
|
||
</span>
|
||
</div>
|
||
<div className="absolute bottom-2.5 left-2.5 font-mono px-2 py-0.5 rounded bg-black/70 text-kcg-muted" style={{ fontSize: 9 }}>
|
||
{cam.lat.toFixed(4)}°N {cam.lng.toFixed(4)}°E · {t('cctv.khoa')}
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
|
||
{/* Footer info */}
|
||
<div
|
||
className="flex items-center justify-between bg-kcg-overlay border-t border-[#222] font-mono text-kcg-dim"
|
||
style={{ padding: '6px 14px', fontSize: 9 }}
|
||
>
|
||
<span>{cam.lat.toFixed(4)}°N {cam.lng.toFixed(4)}°E</span>
|
||
<span>{t('cctv.khoaFull')}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|