kcg-monitoring/frontend/src/components/korea/CctvLayer.tsx
Nan Kyung Lee e9ce6ecdd2 feat(korea): 한국 현황 레이어 대규모 확장 — 국적 필터, 풍력단지, 항구, 군사시설, 정부기관, 미사일 낙하
- 국적 분류 필터 추가 (한국/중국/북한/일본/미분류)
- 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>
2026-03-19 10:34:16 +09:00

280 lines
11 KiB
TypeScript
Raw Blame 히스토리

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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>
);
}