wing-ops/frontend/src/tabs/aerial/components/CctvView.tsx
jeonghyo.k a86188f473 feat(map): 전체 탭 지도 배경 토글 통합 및 기본지도 변경
- 지도 스타일 상수를 mapStyles.ts로 추출
- useBaseMapStyle 훅 생성 (mapToggles 기반 스타일 반환)
- 9개 탭 컴포넌트의 하드코딩 스타일을 공유 훅으로 교체
- 각 Map에 S57EncOverlay 추가
- 초기 mapToggles를 모두 false로 변경 (기본지도 표시)
2026-03-31 17:56:40 +09:00

657 lines
42 KiB
TypeScript
Raw Blame 히스토리

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<CctvCameraItem[]>([])
const [loading, setLoading] = useState(true)
const [searchTerm, setSearchTerm] = useState('')
const [regionFilter, setRegionFilter] = useState('전체')
const [selectedCamera, setSelectedCamera] = useState<CctvCameraItem | null>(null)
const [gridMode, setGridMode] = useState(1)
const [activeCells, setActiveCells] = useState<CctvCameraItem[]>([])
const [oilDetectionEnabled, setOilDetectionEnabled] = useState(false)
const [vesselDetectionEnabled, setVesselDetectionEnabled] = useState(false)
const [intrusionDetectionEnabled, setIntrusionDetectionEnabled] = useState(false)
const [mapPopup, setMapPopup] = useState<CctvCameraItem | null>(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<string, string> = { '전체': '', '제주': '🌊', '남해': '⚓', '서해': '🐟', '동해': '🌅' }
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 (
<div className="flex h-full overflow-hidden" style={{ margin: '-20px -24px', height: 'calc(100% + 40px)' }}>
{/* 왼쪽: 목록 패널 */}
<div className="flex flex-col overflow-hidden bg-bg-surface border-r border-stroke w-[290px] min-w-[290px]">
{/* 헤더 */}
<div className="p-3 pb-2.5 border-b border-stroke shrink-0 bg-bg-elevated">
<div className="flex items-center justify-between mb-2">
<div className="text-xs font-bold text-fg font-korean flex items-center gap-1.5">
<span className="w-[7px] h-[7px] rounded-full inline-block animate-pulse" style={{ background: 'var(--color-danger)' }} />
CCTV
</div>
<div className="flex items-center gap-1">
{/* 지도/리스트 뷰 토글 */}
<div className="flex border border-stroke rounded-[5px] overflow-hidden mr-1.5">
<button
onClick={() => setViewMode('map')}
className="px-1.5 py-0.5 text-[9px] font-semibold cursor-pointer border-none font-korean transition-colors"
style={viewMode === 'map'
? { background: 'rgba(6,182,212,.15)', color: 'var(--color-accent)' }
: { background: 'var(--bg-card)', color: 'var(--fg-disabled)' }
}
title="지도 보기"
>🗺 </button>
<button
onClick={() => setViewMode('list')}
className="px-1.5 py-0.5 text-[9px] font-semibold cursor-pointer border-none font-korean transition-colors"
style={viewMode === 'list'
? { background: 'rgba(6,182,212,.15)', color: 'var(--color-accent)' }
: { background: 'var(--bg-card)', color: 'var(--fg-disabled)' }
}
title="리스트 보기"
> </button>
</div>
<span className="text-[9px] text-fg-disabled font-korean">API</span>
<span className="w-[7px] h-[7px] rounded-full inline-block" style={{ background: 'var(--color-success)' }} />
</div>
</div>
{/* 검색 */}
<div className="flex items-center gap-2 bg-bg-base border border-stroke rounded-md px-2.5 py-1.5 mb-2 focus-within:border-color-accent/50 transition-colors">
<span className="text-fg-disabled text-[11px]">🔍</span>
<input
type="text"
placeholder="지점명 또는 지역 검색..."
value={searchTerm}
onChange={e => setSearchTerm(e.target.value)}
className="flex-1 bg-transparent border-none text-fg text-[11px] font-korean outline-none"
/>
</div>
{/* 지역 필터 */}
<div className="flex gap-1 flex-wrap">
{regions.map(r => (
<button
key={r}
onClick={() => setRegionFilter(r)}
className="px-2 py-0.5 rounded text-[9px] font-semibold cursor-pointer font-korean border transition-colors"
style={regionFilter === r
? { background: 'rgba(6,182,212,.15)', color: 'var(--color-accent)', borderColor: 'rgba(6,182,212,.3)' }
: { background: 'var(--bg-card)', color: 'var(--fg-sub)', borderColor: 'var(--stroke-default)' }
}
>{regionIcons[r] ? `${regionIcons[r]} ` : ''}{r}</button>
))}
</div>
</div>
{/* 상태 바 */}
<div className="flex items-center justify-between px-3.5 py-1 border-b border-stroke shrink-0 bg-bg-surface">
<div className="text-[9px] text-fg-disabled font-korean">출처: 국립해양조사원 · KBS </div>
<div className="text-[10px] text-fg-sub font-korean"><b className="text-fg">{filtered.length}</b></div>
</div>
{/* 카메라 목록 */}
<div className="flex-1 overflow-y-auto" style={{ scrollbarWidth: 'thin', scrollbarColor: 'var(--stroke-light) transparent' }}>
{loading ? (
<div className="px-3.5 py-4 text-[11px] text-fg-disabled font-korean"> ...</div>
) : filtered.map(cam => (
<div
key={cam.cctvSn}
onClick={() => 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',
}}
>
<div className="relative shrink-0">
<div className="w-8 h-8 rounded-md bg-bg-card flex items-center justify-center">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none">
<rect x="3" y="6" width="13" height="10" rx="2" fill={cam.sttsCd === 'LIVE' ? 'var(--color-success)' : 'var(--fg-disabled)'} />
<circle cx="9.5" cy="11" r="2.8" fill="var(--bg-card)" />
<circle cx="9.5" cy="11" r="1.3" fill={cam.sttsCd === 'LIVE' ? 'var(--color-success)' : 'var(--fg-disabled)'} />
<path d="M17 9l4-2v10l-4-2V9z" fill={cam.sttsCd === 'LIVE' ? 'var(--color-success)' : 'var(--fg-disabled)'} opacity="0.7" />
</svg>
</div>
<div className="absolute -top-0.5 -right-0.5 w-2 h-2 rounded-full border border-bg-1" style={{ background: cam.sttsCd === 'LIVE' ? 'var(--color-success)' : 'var(--fg-disabled)' }} />
</div>
<div className="flex-1 min-w-0">
<div className="text-[11px] font-semibold text-fg font-korean truncate">{cam.cameraNm}</div>
<div className="text-[9px] text-fg-disabled font-korean truncate">{cam.locDc ?? ''}</div>
</div>
<div className="flex flex-col items-end gap-0.5 shrink-0">
{cam.sttsCd === 'LIVE' ? (
<span className="text-[8px] font-bold px-1.5 py-px rounded-full" style={{ background: 'rgba(34,197,94,.12)', color: 'var(--color-success)' }}>LIVE</span>
) : (
<span className="text-[8px] font-bold px-1.5 py-px rounded-full" style={{ background: 'rgba(255,255,255,.06)', color: 'var(--fg-disabled)' }}>OFF</span>
)}
{cam.ptzYn === 'Y' && <span className="text-[8px] text-fg-disabled font-mono">PTZ</span>}
</div>
</div>
))}
</div>
</div>
{/* 가운데: 영상 뷰어 */}
<div className="flex-1 flex flex-col overflow-hidden min-w-0 bg-bg-base">
{/* 뷰어 툴바 */}
<div className="flex items-center justify-between px-4 py-2 border-b border-stroke bg-bg-elevated shrink-0 gap-2.5">
<div className="flex items-center gap-2 min-w-0">
<div className="text-xs font-bold text-fg font-korean whitespace-nowrap overflow-hidden text-ellipsis">
{selectedCamera ? `📹 ${selectedCamera.cameraNm}` : '📹 카메라를 선택하세요'}
</div>
{selectedCamera?.sttsCd === 'LIVE' && (
<div className="flex items-center gap-1 px-2 py-0.5 rounded-full text-[9px] font-bold shrink-0" style={{ background: 'rgba(239,68,68,.14)', border: '1px solid rgba(239,68,68,.35)', color: 'var(--color-danger)' }}>
<span className="w-[5px] h-[5px] rounded-full inline-block animate-pulse" style={{ background: 'var(--color-danger)' }} />LIVE
</div>
)}
</div>
<div className="flex items-center gap-1.5 shrink-0">
{/* PTZ 컨트롤 */}
{selectedCamera?.ptzYn === 'Y' && (
<div className="flex items-center gap-1 px-2 py-1 bg-bg-card border border-stroke rounded-[5px]">
<span className="text-[9px] text-fg-disabled font-korean mr-1">PTZ</span>
{['◀', '▲', '▼', '▶'].map((d, i) => (
<button key={i} className="w-5 h-5 flex items-center justify-center bg-bg-base border border-stroke rounded text-[9px] text-fg-sub cursor-pointer hover:bg-bg-surface-hover transition-colors">{d}</button>
))}
<div className="w-px h-4 bg-border mx-0.5" />
{['+', ''].map((z, i) => (
<button key={i} className="w-5 h-5 flex items-center justify-center bg-bg-base border border-stroke rounded text-[9px] text-fg-sub cursor-pointer hover:bg-bg-surface-hover transition-colors">{z}</button>
))}
</div>
)}
{/* 분할 모드 */}
<div className="flex border border-stroke rounded-[5px] overflow-hidden">
{[
{ mode: 1, icon: '▣', label: '1화면' },
{ mode: 4, icon: '⊞', label: '4분할' },
{ mode: 9, icon: '⊟', label: '9분할' },
].map(g => (
<button
key={g.mode}
onClick={() => { setGridMode(g.mode); setActiveCells(prev => prev.slice(0, g.mode)) }}
title={g.label}
className="px-2 py-1 text-[11px] cursor-pointer border-none transition-colors"
style={gridMode === g.mode
? { background: 'rgba(6,182,212,.15)', color: 'var(--color-accent)' }
: { background: 'var(--bg-card)', color: 'var(--fg-sub)' }
}
>{g.icon}</button>
))}
</div>
<button
onClick={() => setOilDetectionEnabled(v => !v)}
className="px-2.5 py-1 border rounded-[5px] text-[10px] font-semibold cursor-pointer font-korean transition-colors"
style={oilDetectionEnabled
? { background: 'rgba(239,68,68,.15)', borderColor: 'rgba(239,68,68,.4)', color: 'var(--color-danger)' }
: { background: 'var(--bg-card)', borderColor: 'var(--stroke-default)', color: 'var(--fg-sub)' }
}
title={gridMode === 9 ? '9분할 모드에서는 비활성화됩니다' : '오일 유출 감지'}
>
{oilDetectionEnabled ? '🛢 감지 ON' : '🛢 오일 감지'}
</button>
<button
onClick={() => setVesselDetectionEnabled(v => !v)}
className="px-2.5 py-1 border rounded-[5px] text-[10px] font-semibold cursor-pointer font-korean transition-colors"
style={vesselDetectionEnabled
? { background: 'rgba(59,130,246,.15)', borderColor: 'rgba(59,130,246,.4)', color: 'var(--blue, #3b82f6)' }
: { background: 'var(--bg-card)', borderColor: 'var(--stroke-default)', color: 'var(--fg-sub)' }
}
title={gridMode === 9 ? '9분할 모드에서는 비활성화됩니다' : '선박 출입 감지'}
>
{vesselDetectionEnabled ? '🚢 감지 ON' : '🚢 선박 출입'}
</button>
<button
onClick={() => setIntrusionDetectionEnabled(v => !v)}
className="px-2.5 py-1 border rounded-[5px] text-[10px] font-semibold cursor-pointer font-korean transition-colors"
style={intrusionDetectionEnabled
? { background: 'rgba(249,115,22,.15)', borderColor: 'rgba(249,115,22,.4)', color: 'var(--orange, #f97316)' }
: { background: 'var(--bg-card)', borderColor: 'var(--stroke-default)', color: 'var(--fg-sub)' }
}
title={gridMode === 9 ? '9분할 모드에서는 비활성화됩니다' : '침입 감지'}
>
{intrusionDetectionEnabled ? '🚨 감지 ON' : '🚨 침입 감지'}
</button>
<button
onClick={() => {
playerRefs.current.forEach(r => r?.capture())
}}
className="px-2.5 py-1 bg-bg-card border border-stroke rounded-[5px] text-fg-sub text-[10px] font-semibold cursor-pointer font-korean hover:bg-bg-surface-hover transition-colors"
>📷 </button>
</div>
</div>
{/* 영상 그리드 / CCTV 위치 지도 / 리스트 뷰 */}
{viewMode === 'list' && activeCells.length === 0 ? (
/* ── 리스트 뷰: 출처별 · 지역별 그리드 ── */
<div className="flex-1 overflow-y-auto p-4" style={{ scrollbarWidth: 'thin', scrollbarColor: 'var(--stroke-light) transparent' }}>
{(() => {
// 출처별 그룹핑
const sourceGroups: Record<string, { label: string; icon: string; cameras: CctvCameraItem[] }> = {}
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<string, CctvCameraItem[]> = {}
for (const cam of group.cameras) {
const rgn = cam.regionNm ?? '기타'
if (!regionGroups[rgn]) regionGroups[rgn] = []
regionGroups[rgn].push(cam)
}
return (
<div key={srcKey} className="mb-5">
{/* 출처 헤더 */}
<div className="flex items-center gap-2 mb-2 pb-1.5 border-b border-stroke">
<span className="text-sm">{group.icon}</span>
<span className="text-[12px] font-bold text-fg font-korean">{group.label}</span>
<span className="text-[10px] text-fg-disabled font-korean ml-auto">{group.cameras.length}</span>
</div>
{Object.entries(regionGroups).map(([rgn, cams]) => (
<div key={rgn} className="mb-3">
{/* 지역 소제목 */}
<div className="flex items-center gap-1.5 mb-1.5 px-1">
<span className="text-[10px] font-bold text-color-accent font-korean">{rgn}</span>
<span className="text-[9px] text-fg-disabled">({cams.length})</span>
</div>
{/* 테이블 헤더 */}
<div className="grid px-2 py-1 bg-bg-card rounded-t text-[9px] font-bold text-fg-disabled font-korean border border-stroke"
style={{ gridTemplateColumns: '1fr 1.2fr 70px 130px' }}>
<span></span>
<span></span>
<span className="text-center"></span>
<span className="text-center"></span>
</div>
{/* 테이블 행 */}
{cams.map(cam => (
<div
key={cam.cctvSn}
onClick={() => { 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',
}}
>
<span className="text-[10px] text-fg font-korean font-semibold truncate">{cam.cameraNm}</span>
<span className="text-[9px] text-fg-disabled font-korean truncate">{cam.locDc ?? '—'}</span>
<span className="text-center">
{cam.sttsCd === 'LIVE' ? (
<span className="text-[8px] font-bold px-1.5 py-px rounded-full inline-block" style={{ background: 'rgba(34,197,94,.12)', color: 'var(--color-success)' }}> LIVE</span>
) : (
<span className="text-[8px] font-bold px-1.5 py-px rounded-full inline-block" style={{ background: 'rgba(255,255,255,.06)', color: 'var(--fg-disabled)' }}> OFF</span>
)}
</span>
<span className="text-[9px] text-fg-disabled font-mono text-center">{now}</span>
</div>
))}
</div>
))}
</div>
)
})
})()}
</div>
) : showMap ? (
<div className="flex-1 overflow-hidden relative">
<Map
initialViewState={{ longitude: 127.8, latitude: 35.5, zoom: 6.2 }}
mapStyle={currentMapStyle}
style={{ width: '100%', height: '100%' }}
attributionControl={false}
>
<S57EncOverlay visible={mapToggles['s57'] ?? false} />
{filtered.filter(c => c.lon && c.lat).map(cam => (
<Marker
key={cam.cctvSn}
longitude={cam.lon!}
latitude={cam.lat!}
anchor="bottom"
onClick={e => { e.originalEvent.stopPropagation(); setMapPopup(cam) }}
>
<div className="flex flex-col items-center cursor-pointer group" title={cam.cameraNm}>
{/* CCTV 아이콘 */}
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" className="drop-shadow-md transition-transform group-hover:scale-110">
{/* 카메라 본체 */}
<rect x="4" y="6" width="12" height="9" rx="2" fill={cam.sttsCd === 'LIVE' ? '#10b981' : '#94a3b8'} stroke="#fff" strokeWidth="1.5" />
{/* 렌즈 */}
<circle cx="10" cy="10.5" r="2.5" fill="#fff" fillOpacity="0.8" />
<circle cx="10" cy="10.5" r="1.2" fill={cam.sttsCd === 'LIVE' ? '#065f46' : '#64748b'} />
{/* 마운트 기둥 */}
<rect x="17" y="8" width="3" height="2" rx="0.5" fill={cam.sttsCd === 'LIVE' ? '#10b981' : '#94a3b8'} stroke="#fff" strokeWidth="1" />
<rect x="19" y="6" width="1.5" height="12" fill="#fff" fillOpacity="0.9" />
{/* LIVE 표시등 */}
{cam.sttsCd === 'LIVE' && <circle cx="6.5" cy="8" r="1" fill="#ef4444"><animate attributeName="opacity" values="1;0.3;1" dur="1.5s" repeatCount="indefinite" /></circle>}
</svg>
{/* 이름 라벨 */}
<div className="px-1 py-px rounded text-[7px] font-bold font-korean whitespace-nowrap mt-0.5"
style={{ background: 'rgba(0,0,0,.65)', color: '#fff', maxWidth: 80, overflow: 'hidden', textOverflow: 'ellipsis' }}>
{cam.cameraNm}
</div>
</div>
</Marker>
))}
{mapPopup && mapPopup.lon && mapPopup.lat && (
<Popup
longitude={mapPopup.lon}
latitude={mapPopup.lat}
anchor="bottom"
onClose={() => setMapPopup(null)}
closeOnClick={false}
offset={14}
className="cctv-dark-popup"
>
<div className="p-2" style={{ minWidth: 150, background: 'var(--bg-card)', borderRadius: 6 }}>
<div className="text-[11px] font-bold text-fg mb-1">{mapPopup.cameraNm}</div>
<div className="text-[9px] text-fg-disabled mb-1.5">{mapPopup.locDc ?? ''}</div>
<div className="flex items-center gap-1.5 mb-2">
<span className="text-[8px] font-bold px-1.5 py-px rounded-full"
style={mapPopup.sttsCd === 'LIVE'
? { background: 'rgba(34,197,94,.2)', color: '#4ade80' }
: { background: 'rgba(148,163,184,.15)', color: '#94a3b8' }
}
>{mapPopup.sttsCd === 'LIVE' ? '● LIVE' : '● OFF'}</span>
<span className="text-[8px] text-fg-disabled">{mapPopup.sourceNm}</span>
</div>
<button
onClick={() => { handleSelectCamera(mapPopup); setMapPopup(null) }}
className="w-full px-2 py-1.5 rounded text-[10px] font-bold cursor-pointer border transition-colors hover:brightness-125"
style={{ background: 'rgba(6,182,212,.15)', borderColor: 'rgba(6,182,212,.3)', color: '#67e8f9' }}
> </button>
</div>
</Popup>
)}
</Map>
{/* 지도 위 안내 배지 */}
<div className="absolute top-3 left-1/2 -translate-x-1/2 px-3 py-1.5 rounded-full text-[10px] font-bold font-korean z-10"
style={{ background: 'rgba(0,0,0,.7)', color: 'rgba(255,255,255,.7)', backdropFilter: 'blur(4px)' }}>
📹 CCTV ({filtered.length})
</div>
</div>
) : (
<div className="flex-1 gap-0.5 p-0.5 overflow-hidden relative grid bg-black"
style={{
gridTemplateColumns: `repeat(${gridCols}, 1fr)`,
gridTemplateRows: `repeat(${gridCols}, 1fr)`,
}}>
{Array.from({ length: totalCells }).map((_, i) => {
const cam = activeCells[i]
return (
<div key={i} className="relative flex items-center justify-center overflow-hidden bg-bg-base" style={{ border: '1px solid var(--stroke-light)' }}>
{cam ? (
<CCTVPlayer
ref={el => { 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}
/>
) : (
<div className="text-[10px] text-fg-disabled font-korean opacity-40"> </div>
)}
</div>
)
})}
</div>
)}
{/* 하단 정보 바 */}
<div className="flex items-center gap-3.5 px-4 py-1.5 border-t border-stroke bg-bg-elevated shrink-0">
<div className="text-[10px] text-fg-disabled font-korean">: <b className="text-fg">{selectedCamera?.cameraNm ?? ''}</b></div>
<div className="text-[10px] text-fg-disabled font-korean">: <span className="text-fg-sub">{selectedCamera?.locDc ?? ''}</span></div>
<div className="text-[10px] text-fg-disabled font-korean">: <span className="text-color-accent font-mono text-[9px]">{selectedCamera?.coordDc ?? ''}</span></div>
<div className="ml-auto text-[9px] text-fg-disabled font-korean">API: 국립해양조사원 TAGO CCTV</div>
</div>
</div>
{/* 오른쪽: 미니맵 + 정보 */}
<div className="flex flex-col overflow-hidden bg-bg-surface border-l border-stroke w-[232px] min-w-[232px]">
{/* 지도 헤더 */}
<div className="px-3 py-2 border-b border-stroke text-[11px] font-bold text-fg font-korean bg-bg-elevated shrink-0 flex items-center justify-between">
<span>🗺 </span>
<span className="text-[9px] text-fg-disabled font-normal"> </span>
</div>
{/* 미니맵 */}
<div className="w-full shrink-0 relative h-[210px] overflow-hidden">
<Map
initialViewState={{ longitude: 127.8, latitude: 35.5, zoom: 5.3 }}
mapStyle={currentMapStyle}
style={{ width: '100%', height: '100%' }}
attributionControl={false}
interactive={true}
>
<S57EncOverlay visible={mapToggles['s57'] ?? false} />
{cameras.filter(c => c.lon && c.lat).map(cam => (
<Marker
key={`mini-${cam.cctvSn}`}
longitude={cam.lon!}
latitude={cam.lat!}
anchor="center"
onClick={e => { e.originalEvent.stopPropagation(); handleSelectCamera(cam) }}
>
<div
className="cursor-pointer"
style={{
width: selectedCamera?.cctvSn === cam.cctvSn ? 10 : 7,
height: selectedCamera?.cctvSn === cam.cctvSn ? 10 : 7,
borderRadius: '50%',
background: selectedCamera?.cctvSn === cam.cctvSn ? 'var(--color-accent)' : cam.sttsCd === 'LIVE' ? 'var(--color-success)' : 'var(--fg-disabled)',
boxShadow: selectedCamera?.cctvSn === cam.cctvSn ? '0 0 8px var(--color-accent)' : 'none',
border: '1.5px solid rgba(255,255,255,.7)',
}}
/>
</Marker>
))}
</Map>
</div>
{/* 카메라 정보 */}
<div className="flex-1 overflow-y-auto px-3 py-2.5 border-t border-stroke" style={{ scrollbarWidth: 'thin', scrollbarColor: 'var(--stroke-light) transparent' }}>
<div className="text-[10px] font-bold text-fg-sub font-korean mb-2">📋 </div>
{selectedCamera ? (
<div className="flex flex-col gap-1.5">
{[
['카메라명', selectedCamera.cameraNm],
['지역', selectedCamera.regionNm],
['위치', selectedCamera.locDc ?? '—'],
['좌표', selectedCamera.coordDc ?? '—'],
['상태', selectedCamera.sttsCd === 'LIVE' ? '● 송출중' : '● 오프라인'],
['PTZ', selectedCamera.ptzYn === 'Y' ? '지원' : '미지원'],
['출처', selectedCamera.sourceNm ?? '—'],
].map(([k, v], i) => (
<div key={i} className="flex justify-between px-2 py-1 bg-bg-base rounded text-[9px]">
<span className="text-fg-disabled font-korean">{k}</span>
<span className="font-mono text-fg">{v}</span>
</div>
))}
</div>
) : (
<div className="text-[10px] text-fg-disabled font-korean"> </div>
)}
{/* 방제 즐겨찾기 */}
<div className="mt-3 pt-2.5 border-t border-stroke">
<div className="text-[10px] font-bold text-fg-sub font-korean mb-2"> </div>
<div className="flex flex-col gap-1">
{cctvFavorites.map((fav, i) => (
<div
key={i}
className="flex items-center gap-2 px-2 py-1.5 bg-bg-card rounded-[5px] cursor-pointer hover:bg-bg-surface-hover transition-colors"
onClick={() => {
const found = cameras.find(c => c.cameraNm === fav.name)
if (found) handleSelectCamera(found)
}}
>
<span className="text-[9px]"></span>
<div className="flex-1 min-w-0">
<div className="text-[9px] font-semibold text-fg font-korean truncate">{fav.name}</div>
<div className="text-[8px] text-fg-disabled font-korean">{fav.reason}</div>
</div>
</div>
))}
</div>
</div>
{/* API 연동 현황 */}
<div className="mt-3 pt-2.5 border-t border-stroke">
<div className="text-[10px] font-bold text-fg-sub font-korean mb-2">🔌 API </div>
<div className="flex flex-col gap-1.5">
{[
{ name: '해양조사원 TAGO', status: '● 연결', color: 'var(--color-success)' },
{ name: 'KBS 재난안전포털', status: '● 연결', color: 'var(--color-success)' },
].map((api, i) => (
<div key={i} className="flex items-center justify-between px-2 py-1 bg-bg-card rounded-[5px]" style={{ border: '1px solid rgba(34,197,94,.2)' }}>
<span className="text-[9px] text-fg-sub font-korean">{api.name}</span>
<span className="text-[9px] font-bold" style={{ color: api.color }}>{api.status}</span>
</div>
))}
<div className="flex items-center justify-between px-2 py-1 bg-bg-card rounded-[5px]" style={{ border: '1px solid rgba(59,130,246,.2)' }}>
<span className="text-[9px] text-fg-sub font-korean"> </span>
<span className="text-[9px] font-bold font-mono text-color-info">1 fps</span>
</div>
<div className="text-[9px] text-fg-disabled font-mono text-right mt-0.5">: {new Date().toLocaleTimeString('ko-KR')}</div>
</div>
</div>
</div>
</div>
</div>
)
}