import { useState, useCallback, useEffect, useRef } from 'react' import { Map, Marker, Popup } from '@vis.gl/react-maplibre' import 'maplibre-gl/dist/maplibre-gl.css' import { fetchCctvCameras } from '../services/aerialApi' import { useBaseMapStyle } from '@common/hooks/useBaseMapStyle' import { S57EncOverlay } from '@common/components/map/S57EncOverlay' import { useMapStore } from '@common/store/mapStore' import type { CctvCameraItem } from '../services/aerialApi' import { CCTVPlayer } from './CCTVPlayer' import type { CCTVPlayerHandle } from './CCTVPlayer' /** KHOA HLS 스트림 베이스 URL */ const KHOA_HLS = 'https://www.khoa.go.kr/SEAFOG/m4NiLawsC202gM5ixA7MPTYtO19KmV/hls/khoa'; /** KHOA HLS 스트림 URL 생성 */ function khoaHlsUrl(siteName: string): string { return `${KHOA_HLS}/${siteName}/s.m3u8`; } /** KBS 재난안전포탈 CCTV — 백엔드 HLS 리졸버 경유 */ function kbsCctvUrl(cctvId: number): string { return `/api/aerial/cctv/kbs-hls/${cctvId}/stream.m3u8`; } const cctvFavorites = [ { name: '여수항 해무관측', reason: '유출 사고 인접' }, { name: '부산항 조위관측소', reason: '주요 방제 거점' }, { name: '목포항 해무관측', reason: '서해 모니터링' }, ] /** badatime.com 실제 해안 CCTV 데이터 (API 미연결 시 폴백) */ const FALLBACK_CAMERAS: CctvCameraItem[] = [ // 서해 { cctvSn: 29, cameraNm: '인천항 조위관측소', regionNm: '서해', lon: 126.5922, lat: 37.4519, locDc: '인천광역시 중구 항동', coordDc: '37.45°N 126.59°E', sttsCd: 'LIVE', ptzYn: 'N', sourceNm: 'KHOA', streamUrl: khoaHlsUrl('Incheon') }, { cctvSn: 30, cameraNm: '인천항 해무관측', regionNm: '서해', lon: 126.6161, lat: 37.3797, locDc: '인천광역시 중구 항동', coordDc: 'N 37°22\'47" E 126°36\'58"', sttsCd: 'LIVE', ptzYn: 'N', sourceNm: 'KHOA', streamUrl: khoaHlsUrl('SeaFog_Incheon') }, { cctvSn: 31, cameraNm: '대산항 해무관측', regionNm: '서해', lon: 126.3526, lat: 37.0058, locDc: '충남 서산시 대산읍', coordDc: '37.01°N 126.35°E', sttsCd: 'LIVE', ptzYn: 'N', sourceNm: 'KHOA', streamUrl: khoaHlsUrl('SeaFog_Daesan') }, { cctvSn: 32, cameraNm: '평택·당진항 해무관측', regionNm: '서해', lon: 126.3936, lat: 37.1131, locDc: '충남 당진시 송악읍', coordDc: 'N 37°06\'47" E 126°23\'37"', sttsCd: 'LIVE', ptzYn: 'N', sourceNm: 'KHOA', streamUrl: khoaHlsUrl('SeaFog_PTDJ') }, { cctvSn: 100, cameraNm: '인천 연안부두', regionNm: '서해', lon: 126.5986, lat: 37.4541, locDc: '인천광역시 중구 연안부두', coordDc: '37.45°N 126.60°E', sttsCd: 'LIVE', ptzYn: 'N', sourceNm: 'KBS', streamUrl: kbsCctvUrl(9981) }, { cctvSn: 200, cameraNm: '연평도', regionNm: '서해', lon: 125.6945, lat: 37.6620, locDc: '인천 옹진군 연평면', coordDc: '37.66°N 125.69°E', sttsCd: 'LIVE', ptzYn: 'N', sourceNm: 'KBS', streamUrl: kbsCctvUrl(9958) }, { cctvSn: 201, cameraNm: '군산 비응항', regionNm: '서해', lon: 126.5265, lat: 35.9353, locDc: '전북 군산시 비응도동', coordDc: '35.94°N 126.53°E', sttsCd: 'LIVE', ptzYn: 'N', sourceNm: 'KBS', streamUrl: kbsCctvUrl(9979) }, { cctvSn: 202, cameraNm: '태안 신진항', regionNm: '서해', lon: 126.1365, lat: 36.6779, locDc: '충남 태안군 근흥면', coordDc: '36.68°N 126.14°E', sttsCd: 'LIVE', ptzYn: 'N', sourceNm: 'KBS', streamUrl: kbsCctvUrl(9980) }, // 남해 { cctvSn: 35, cameraNm: '목포항 해무관측', regionNm: '남해', lon: 126.3780, lat: 34.7780, locDc: '전남 목포시 항동', coordDc: '34.78°N 126.38°E', sttsCd: 'LIVE', ptzYn: 'N', sourceNm: 'KHOA', streamUrl: khoaHlsUrl('SeaFog_Mokpo') }, { cctvSn: 36, cameraNm: '진도항 조위관측소', regionNm: '남해', lon: 126.3085, lat: 34.4710, locDc: '전남 진도군 진도읍', coordDc: '34.47°N 126.31°E', sttsCd: 'LIVE', ptzYn: 'N', sourceNm: 'KHOA', streamUrl: khoaHlsUrl('Jindo') }, { cctvSn: 37, cameraNm: '여수항 해무관측', regionNm: '남해', lon: 127.7669, lat: 34.7384, locDc: '전남 여수시 종화동', coordDc: '34.74°N 127.77°E', sttsCd: 'LIVE', ptzYn: 'N', sourceNm: 'KHOA', streamUrl: khoaHlsUrl('SeaFog_Yeosu') }, { cctvSn: 38, cameraNm: '여수항 조위관측소', regionNm: '남해', lon: 127.7650, lat: 34.7370, locDc: '전남 여수시 종화동', coordDc: '34.74°N 127.77°E', sttsCd: 'LIVE', ptzYn: 'N', sourceNm: 'KHOA', streamUrl: khoaHlsUrl('Yeosu') }, { cctvSn: 39, cameraNm: '부산항 조위관측소', regionNm: '남해', lon: 129.0756, lat: 35.0969, locDc: '부산광역시 중구 중앙동', coordDc: '35.10°N 129.08°E', sttsCd: 'LIVE', ptzYn: 'N', sourceNm: 'KHOA', streamUrl: khoaHlsUrl('Busan') }, { cctvSn: 40, cameraNm: '부산항 해무관측', regionNm: '남해', lon: 129.0780, lat: 35.0980, locDc: '부산광역시 중구', coordDc: '35.10°N 129.08°E', sttsCd: 'LIVE', ptzYn: 'N', sourceNm: 'KHOA', streamUrl: khoaHlsUrl('SeaFog_Busan') }, { cctvSn: 41, cameraNm: '해운대 해무관측', regionNm: '남해', lon: 129.1718, lat: 35.1587, locDc: '부산광역시 해운대구', coordDc: '35.16°N 129.17°E', sttsCd: 'LIVE', ptzYn: 'N', sourceNm: 'KHOA', streamUrl: khoaHlsUrl('SeaFog_Haeundae') }, { cctvSn: 97, cameraNm: '여수 오동도 앞', regionNm: '남해', lon: 127.7557, lat: 34.7410, locDc: '전남 여수시 수정동', coordDc: '34.74°N 127.76°E', sttsCd: 'LIVE', ptzYn: 'N', sourceNm: 'KBS', streamUrl: kbsCctvUrl(9994) }, { cctvSn: 108, cameraNm: '완도항', regionNm: '남해', lon: 126.7489, lat: 34.3209, locDc: '전남 완도군 완도읍', coordDc: '34.32°N 126.75°E', sttsCd: 'LIVE', ptzYn: 'N', sourceNm: 'KBS', streamUrl: kbsCctvUrl(9984) }, { cctvSn: 203, cameraNm: '창원 마산항', regionNm: '남해', lon: 128.5760, lat: 35.1979, locDc: '경남 창원시 마산합포구', coordDc: '35.20°N 128.58°E', sttsCd: 'LIVE', ptzYn: 'N', sourceNm: 'KBS', streamUrl: kbsCctvUrl(9985) }, { cctvSn: 204, cameraNm: '부산 민락항', regionNm: '남해', lon: 129.1312, lat: 35.1538, locDc: '부산 수영구 민락동', coordDc: '35.15°N 129.13°E', sttsCd: 'LIVE', ptzYn: 'N', sourceNm: 'KBS', streamUrl: kbsCctvUrl(9991) }, { cctvSn: 205, cameraNm: '목포 북항', regionNm: '남해', lon: 126.3652, lat: 34.8042, locDc: '전남 목포시 죽교동', coordDc: '34.80°N 126.37°E', sttsCd: 'LIVE', ptzYn: 'N', sourceNm: 'KBS', streamUrl: kbsCctvUrl(9992) }, { cctvSn: 206, cameraNm: '신안 가거도', regionNm: '남해', lon: 125.1293, lat: 34.0529, locDc: '전남 신안군 흑산면', coordDc: '34.05°N 125.13°E', sttsCd: 'LIVE', ptzYn: 'N', sourceNm: 'KBS', streamUrl: kbsCctvUrl(9983) }, { cctvSn: 207, cameraNm: '여수 거문도', regionNm: '남해', lon: 127.3074, lat: 34.0232, locDc: '전남 여수시 삼산면', coordDc: '34.02°N 127.31°E', sttsCd: 'LIVE', ptzYn: 'N', sourceNm: 'KBS', streamUrl: kbsCctvUrl(9993) }, // 동해 { cctvSn: 42, cameraNm: '울산항 해무관측', regionNm: '동해', lon: 129.3870, lat: 35.5000, locDc: '울산광역시 남구', coordDc: '35.50°N 129.39°E', sttsCd: 'LIVE', ptzYn: 'N', sourceNm: 'KHOA', streamUrl: khoaHlsUrl('SeaFog_Ulsan') }, { cctvSn: 43, cameraNm: '포항항 해무관측', regionNm: '동해', lon: 129.3798, lat: 36.0323, locDc: '경북 포항시 북구', coordDc: '36.03°N 129.38°E', sttsCd: 'LIVE', ptzYn: 'N', sourceNm: 'KHOA', streamUrl: khoaHlsUrl('SeaFog_Pohang') }, { cctvSn: 44, cameraNm: '묵호항 조위관측소', regionNm: '동해', lon: 129.1146, lat: 37.5500, locDc: '강원 동해시 묵호동', coordDc: '37.55°N 129.11°E', sttsCd: 'LIVE', ptzYn: 'N', sourceNm: 'KHOA', streamUrl: khoaHlsUrl('Mukho') }, { cctvSn: 113, cameraNm: '속초 등대전망대', regionNm: '동해', lon: 128.6001, lat: 38.2134, locDc: '강원 속초시 영랑동', coordDc: '38.21°N 128.60°E', sttsCd: 'LIVE', ptzYn: 'N', sourceNm: 'KBS', streamUrl: kbsCctvUrl(9986) }, { cctvSn: 115, cameraNm: '독도', regionNm: '동해', lon: 131.8686, lat: 37.2394, locDc: '경북 울릉군 울릉읍 독도리', coordDc: '37.24°N 131.87°E', sttsCd: 'LIVE', ptzYn: 'N', sourceNm: 'KBS', streamUrl: kbsCctvUrl(9957) }, { cctvSn: 208, cameraNm: '강릉 용강동', regionNm: '동해', lon: 128.8912, lat: 37.7521, locDc: '강원 강릉시 용강동', coordDc: '37.75°N 128.89°E', sttsCd: 'LIVE', ptzYn: 'N', sourceNm: 'KBS', streamUrl: kbsCctvUrl(9952) }, { cctvSn: 209, cameraNm: '강릉 주문진방파제', regionNm: '동해', lon: 128.8335, lat: 37.8934, locDc: '강원 강릉시 주문진읍', coordDc: '37.89°N 128.83°E', sttsCd: 'LIVE', ptzYn: 'N', sourceNm: 'KBS', streamUrl: kbsCctvUrl(9995) }, { cctvSn: 210, cameraNm: '대관령', regionNm: '동해', lon: 128.7553, lat: 37.6980, locDc: '강원 평창군 대관령면', coordDc: '37.70°N 128.76°E', sttsCd: 'LIVE', ptzYn: 'N', sourceNm: 'KBS', streamUrl: kbsCctvUrl(9989) }, { cctvSn: 211, cameraNm: '울릉 저동항', regionNm: '동해', lon: 130.9122, lat: 37.4913, locDc: '경북 울릉군 울릉읍', coordDc: '37.49°N 130.91°E', sttsCd: 'LIVE', ptzYn: 'N', sourceNm: 'KBS', streamUrl: kbsCctvUrl(9987) }, { cctvSn: 212, cameraNm: '포항 두호동 해안로', regionNm: '동해', lon: 129.3896, lat: 36.0627, locDc: '경북 포항시 북구 두호동', coordDc: '36.06°N 129.39°E', sttsCd: 'LIVE', ptzYn: 'N', sourceNm: 'KBS', streamUrl: kbsCctvUrl(9988) }, { cctvSn: 213, cameraNm: '울산 달동', regionNm: '동해', lon: 129.3265, lat: 35.5442, locDc: '울산 남구 달동', coordDc: '35.54°N 129.33°E', sttsCd: 'LIVE', ptzYn: 'N', sourceNm: 'KBS', streamUrl: kbsCctvUrl(9955) }, // 제주 { cctvSn: 45, cameraNm: '모슬포항 조위관측소', regionNm: '제주', lon: 126.2519, lat: 33.2136, locDc: '제주 서귀포시 대정읍', coordDc: '33.21°N 126.25°E', sttsCd: 'LIVE', ptzYn: 'N', sourceNm: 'KHOA', streamUrl: khoaHlsUrl('Moseulpo') }, { cctvSn: 116, cameraNm: '마라도', regionNm: '제주', lon: 126.2684, lat: 33.1139, locDc: '제주 서귀포시 대정읍 마라리', coordDc: '33.11°N 126.27°E', sttsCd: 'LIVE', ptzYn: 'N', sourceNm: 'KBS', streamUrl: kbsCctvUrl(9982) }, { cctvSn: 214, cameraNm: '제주 도남동', regionNm: '제주', lon: 126.5195, lat: 33.4891, locDc: '제주시 도남동', coordDc: '33.49°N 126.52°E', sttsCd: 'LIVE', ptzYn: 'N', sourceNm: 'KBS', streamUrl: kbsCctvUrl(9954) }, ] export function CctvView() { const [cameras, setCameras] = useState([]) const [loading, setLoading] = useState(true) const [searchTerm, setSearchTerm] = useState('') const [regionFilter, setRegionFilter] = useState('전체') const [selectedCamera, setSelectedCamera] = useState(null) const [gridMode, setGridMode] = useState(1) const [activeCells, setActiveCells] = useState([]) const [oilDetectionEnabled, setOilDetectionEnabled] = useState(false) const [vesselDetectionEnabled, setVesselDetectionEnabled] = useState(false) const [intrusionDetectionEnabled, setIntrusionDetectionEnabled] = useState(false) const [mapPopup, setMapPopup] = useState(null) const [viewMode, setViewMode] = useState<'list' | 'map'>('map') const playerRefs = useRef<(CCTVPlayerHandle | null)[]>([]) const currentMapStyle = useBaseMapStyle() const mapToggles = useMapStore((s) => s.mapToggles) /** 지도 모드이거나, 리스트 모드에서 카메라 미선택 시 지도 표시 */ const showMap = viewMode === 'map' && activeCells.length === 0 const loadData = useCallback(async () => { setLoading(true) try { const items = await fetchCctvCameras() if (items.length > 0) { // DB 데이터에 폴백 전용 카메라(DB 미등록분) 병합 const dbIds = new Set(items.map(i => i.cctvSn)) const missing = FALLBACK_CAMERAS.filter(f => !dbIds.has(f.cctvSn)) setCameras([...items, ...missing]) } else { setCameras(FALLBACK_CAMERAS) } } catch { setCameras(FALLBACK_CAMERAS) } finally { setLoading(false) } }, []) useEffect(() => { loadData() }, [loadData]) const regions = ['전체', '제주', '남해', '서해', '동해'] const regionIcons: Record = { '전체': '', '제주': '🌊', '남해': '⚓', '서해': '🐟', '동해': '🌅' } const filtered = cameras.filter(c => { if (regionFilter !== '전체' && c.regionNm !== regionFilter) return false if (searchTerm && !c.cameraNm.includes(searchTerm) && !(c.locDc ?? '').includes(searchTerm)) return false return true }) const handleSelectCamera = (cam: CctvCameraItem) => { setSelectedCamera(cam) if (gridMode === 1) { setActiveCells([cam]) } else { setActiveCells(prev => { if (prev.length < gridMode && !prev.find(c => c.cctvSn === cam.cctvSn)) return [...prev, cam] return prev }) } } const gridCols = gridMode === 1 ? 1 : gridMode === 4 ? 2 : 3 const totalCells = gridMode return (
{/* 왼쪽: 목록 패널 */}
{/* 헤더 */}
실시간 해안 CCTV
{/* 지도/리스트 뷰 토글 */}
API
{/* 검색 */}
🔍 setSearchTerm(e.target.value)} className="flex-1 bg-transparent border-none text-fg text-[11px] font-korean outline-none" />
{/* 지역 필터 */}
{regions.map(r => ( ))}
{/* 상태 바 */}
출처: 국립해양조사원 · KBS 재난안전포털
{filtered.length}
{/* 카메라 목록 */}
{loading ? (
불러오는 중...
) : filtered.map(cam => (
handleSelectCamera(cam)} className="flex items-center gap-2.5 px-3.5 py-2.5 border-b cursor-pointer transition-colors" style={{ borderColor: 'rgba(255,255,255,.04)', background: selectedCamera?.cctvSn === cam.cctvSn ? 'rgba(6,182,212,.08)' : 'transparent', }} >
{cam.cameraNm}
{cam.locDc ?? ''}
{cam.sttsCd === 'LIVE' ? ( LIVE ) : ( OFF )} {cam.ptzYn === 'Y' && PTZ}
))}
{/* 가운데: 영상 뷰어 */}
{/* 뷰어 툴바 */}
{selectedCamera ? `📹 ${selectedCamera.cameraNm}` : '📹 카메라를 선택하세요'}
{selectedCamera?.sttsCd === 'LIVE' && (
LIVE
)}
{/* PTZ 컨트롤 */} {selectedCamera?.ptzYn === 'Y' && (
PTZ {['◀', '▲', '▼', '▶'].map((d, i) => ( ))}
{['+', '−'].map((z, i) => ( ))}
)} {/* 분할 모드 */}
{[ { mode: 1, icon: '▣', label: '1화면' }, { mode: 4, icon: '⊞', label: '4분할' }, { mode: 9, icon: '⊟', label: '9분할' }, ].map(g => ( ))}
{/* 영상 그리드 / CCTV 위치 지도 / 리스트 뷰 */} {viewMode === 'list' && activeCells.length === 0 ? ( /* ── 리스트 뷰: 출처별 · 지역별 그리드 ── */
{(() => { // 출처별 그룹핑 const sourceGroups: Record = {} for (const cam of filtered) { const src = cam.sourceNm ?? '기타' if (!sourceGroups[src]) { sourceGroups[src] = { label: src === 'KHOA' ? '국립해양조사원 (KHOA)' : src === 'KBS' ? 'KBS 재난안전포털' : src, icon: src === 'KHOA' ? '🌊' : src === 'KBS' ? '📡' : '📹', cameras: [], } } sourceGroups[src].cameras.push(cam) } const now = new Date().toLocaleString('ko-KR', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }) return Object.entries(sourceGroups).map(([srcKey, group]) => { // 출처 내에서 지역별 그룹핑 const regionGroups: Record = {} for (const cam of group.cameras) { const rgn = cam.regionNm ?? '기타' if (!regionGroups[rgn]) regionGroups[rgn] = [] regionGroups[rgn].push(cam) } return (
{/* 출처 헤더 */}
{group.icon} {group.label} {group.cameras.length}개
{Object.entries(regionGroups).map(([rgn, cams]) => (
{/* 지역 소제목 */}
{rgn} ({cams.length})
{/* 테이블 헤더 */}
카메라명 위치 상태 최종갱신
{/* 테이블 행 */} {cams.map(cam => (
{ handleSelectCamera(cam); setViewMode('map') }} className="grid px-2 py-1.5 border-b border-x border-stroke cursor-pointer transition-colors hover:bg-bg-surface-hover" style={{ gridTemplateColumns: '1fr 1.2fr 70px 130px', background: selectedCamera?.cctvSn === cam.cctvSn ? 'rgba(6,182,212,.08)' : 'transparent', }} > {cam.cameraNm} {cam.locDc ?? '—'} {cam.sttsCd === 'LIVE' ? ( ● LIVE ) : ( ● OFF )} {now}
))}
))}
) }) })()}
) : showMap ? (
{filtered.filter(c => c.lon && c.lat).map(cam => ( { e.originalEvent.stopPropagation(); setMapPopup(cam) }} >
{/* CCTV 아이콘 */} {/* 카메라 본체 */} {/* 렌즈 */} {/* 마운트 기둥 */} {/* LIVE 표시등 */} {cam.sttsCd === 'LIVE' && } {/* 이름 라벨 */}
{cam.cameraNm}
))} {mapPopup && mapPopup.lon && mapPopup.lat && ( setMapPopup(null)} closeOnClick={false} offset={14} className="cctv-dark-popup" >
{mapPopup.cameraNm}
{mapPopup.locDc ?? ''}
{mapPopup.sttsCd === 'LIVE' ? '● LIVE' : '● OFF'} {mapPopup.sourceNm}
)}
{/* 지도 위 안내 배지 */}
📹 CCTV 마커를 클릭하여 영상을 선택하세요 ({filtered.length}개)
) : (
{Array.from({ length: totalCells }).map((_, i) => { const cam = activeCells[i] return (
{cam ? ( { playerRefs.current[i] = el }} cameraNm={cam.cameraNm} streamUrl={cam.streamUrl} sttsCd={cam.sttsCd} coordDc={cam.coordDc} sourceNm={cam.sourceNm} cellIndex={i} oilDetectionEnabled={oilDetectionEnabled && gridMode !== 9} vesselDetectionEnabled={vesselDetectionEnabled && gridMode !== 9} intrusionDetectionEnabled={intrusionDetectionEnabled && gridMode !== 9} /> ) : (
카메라를 선택하세요
)}
) })}
)} {/* 하단 정보 바 */}
선택: {selectedCamera?.cameraNm ?? '–'}
위치: {selectedCamera?.locDc ?? '–'}
좌표: {selectedCamera?.coordDc ?? '–'}
API: 국립해양조사원 TAGO 해양 CCTV
{/* 오른쪽: 미니맵 + 정보 */}
{/* 지도 헤더 */}
🗺 위치 지도 클릭하여 선택
{/* 미니맵 */}
{cameras.filter(c => c.lon && c.lat).map(cam => ( { e.originalEvent.stopPropagation(); handleSelectCamera(cam) }} >
))}
{/* 카메라 정보 */}
📋 카메라 정보
{selectedCamera ? (
{[ ['카메라명', selectedCamera.cameraNm], ['지역', selectedCamera.regionNm], ['위치', selectedCamera.locDc ?? '—'], ['좌표', selectedCamera.coordDc ?? '—'], ['상태', selectedCamera.sttsCd === 'LIVE' ? '● 송출중' : '● 오프라인'], ['PTZ', selectedCamera.ptzYn === 'Y' ? '지원' : '미지원'], ['출처', selectedCamera.sourceNm ?? '—'], ].map(([k, v], i) => (
{k} {v}
))}
) : (
카메라를 선택하세요
)} {/* 방제 즐겨찾기 */}
⭐ 방제 핵심 지점
{cctvFavorites.map((fav, i) => (
{ const found = cameras.find(c => c.cameraNm === fav.name) if (found) handleSelectCamera(found) }} >
{fav.name}
{fav.reason}
))}
{/* API 연동 현황 */}
🔌 API 연동 현황
{[ { name: '해양조사원 TAGO', status: '● 연결', color: 'var(--color-success)' }, { name: 'KBS 재난안전포털', status: '● 연결', color: 'var(--color-success)' }, ].map((api, i) => (
{api.name} {api.status}
))}
갱신 주기 1 fps
최종갱신: {new Date().toLocaleTimeString('ko-KR')}
) }