wing-ops/frontend/src/tabs/aerial/components/CctvView.tsx

1456 lines
53 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.662,
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.378,
lat: 34.778,
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.471,
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.765,
lat: 34.737,
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.078,
lat: 35.098,
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.741,
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.576,
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.387,
lat: 35.5,
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.55,
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.698,
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-caption 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-caption 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-caption 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-[rgba(6,182,212,0.5)] transition-colors">
<span className="text-fg-disabled text-label-2">🔍</span>
<input
type="text"
placeholder="지점명 또는 지역 검색..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="flex-1 bg-transparent border-none text-fg text-label-2 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-caption 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-caption text-fg-disabled font-korean">
출처: 국립해양조사원 · KBS
</div>
<div className="text-label-1 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-label-2 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-label-2 font-semibold text-fg font-korean truncate">
{cam.cameraNm}
</div>
<div className="text-caption 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-caption font-bold px-1.5 py-px rounded-full"
style={{
background: 'color-mix(in srgb, var(--color-success) 12%, transparent)',
color: 'var(--color-success)',
}}
>
LIVE
</span>
) : (
<span
className="text-caption 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-caption 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-caption font-bold shrink-0"
style={{
background: 'color-mix(in srgb, var(--color-danger) 14%, transparent)',
border: '1px solid color-mix(in srgb, var(--color-danger) 35%, transparent)',
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-caption 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-caption 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-caption 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-label-2 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-label-1 font-semibold cursor-pointer font-korean transition-colors"
style={
oilDetectionEnabled
? {
background: 'color-mix(in srgb, var(--color-danger) 15%, transparent)',
borderColor: 'color-mix(in srgb, var(--color-danger) 40%, transparent)',
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-label-1 font-semibold cursor-pointer font-korean transition-colors"
style={
vesselDetectionEnabled
? {
background: 'color-mix(in srgb, var(--color-info) 15%, transparent)',
borderColor: 'color-mix(in srgb, var(--color-info) 40%, transparent)',
color: 'var(--color-info)',
}
: {
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-label-1 font-semibold cursor-pointer font-korean transition-colors"
style={
intrusionDetectionEnabled
? {
background: 'color-mix(in srgb, var(--color-warning) 15%, transparent)',
borderColor: 'color-mix(in srgb, var(--color-warning) 40%, transparent)',
color: 'var(--color-warning)',
}
: {
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-label-1 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-label-1 font-bold text-fg font-korean">
{group.label}
</span>
<span className="text-label-1 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-label-1 font-bold text-color-accent font-korean">
{rgn}
</span>
<span className="text-caption text-fg-disabled">({cams.length})</span>
</div>
{/* 테이블 헤더 */}
<div
className="grid px-2 py-1 bg-bg-card rounded-t text-caption 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-label-1 text-fg font-korean font-semibold truncate">
{cam.cameraNm}
</span>
<span className="text-caption text-fg-disabled font-korean truncate">
{cam.locDc ?? '—'}
</span>
<span className="text-center">
{cam.sttsCd === 'LIVE' ? (
<span
className="text-caption font-bold px-1.5 py-px rounded-full inline-block"
style={{
background:
'color-mix(in srgb, var(--color-success) 12%, transparent)',
color: 'var(--color-success)',
}}
>
LIVE
</span>
) : (
<span
className="text-caption 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-caption 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' ? 'var(--color-success)' : 'var(--fg-disabled)'
}
stroke="var(--static-white)"
strokeWidth="1.5"
/>
{/* 렌즈 */}
<circle
cx="10"
cy="10.5"
r="2.5"
fill="var(--static-white)"
fillOpacity="0.8"
/>
<circle
cx="10"
cy="10.5"
r="1.2"
fill={
cam.sttsCd === 'LIVE' ? 'var(--color-success)' : 'var(--fg-disabled)'
}
/>
{/* 마운트 기둥 */}
<rect
x="17"
y="8"
width="3"
height="2"
rx="0.5"
fill={
cam.sttsCd === 'LIVE' ? 'var(--color-success)' : 'var(--fg-disabled)'
}
stroke="var(--static-white)"
strokeWidth="1"
/>
<rect
x="19"
y="6"
width="1.5"
height="12"
fill="var(--static-white)"
fillOpacity="0.9"
/>
{/* LIVE 표시등 */}
{cam.sttsCd === 'LIVE' && (
<circle cx="6.5" cy="8" r="1" fill="var(--color-danger)">
<animate
attributeName="opacity"
values="1;0.3;1"
dur="1.5s"
repeatCount="indefinite"
/>
</circle>
)}
</svg>
{/* 이름 라벨 */}
<div
className="px-1 py-px rounded text-caption 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-label-2 font-bold text-fg mb-1">{mapPopup.cameraNm}</div>
<div className="text-caption text-fg-disabled mb-1.5">
{mapPopup.locDc ?? ''}
</div>
<div className="flex items-center gap-1.5 mb-2">
<span
className="text-caption font-bold px-1.5 py-px rounded-full"
style={
mapPopup.sttsCd === 'LIVE'
? {
background:
'color-mix(in srgb, var(--color-success) 20%, transparent)',
color: 'var(--color-success)',
}
: {
background:
'color-mix(in srgb, var(--fg-disabled) 15%, transparent)',
color: 'var(--fg-disabled)',
}
}
>
{mapPopup.sttsCd === 'LIVE' ? '● LIVE' : '● OFF'}
</span>
<span className="text-caption text-fg-disabled">{mapPopup.sourceNm}</span>
</div>
<button
onClick={() => {
handleSelectCamera(mapPopup);
setMapPopup(null);
}}
className="w-full px-2 py-1.5 rounded text-label-1 font-bold cursor-pointer border transition-colors hover:brightness-125"
style={{
background: 'rgba(6,182,212,.15)',
borderColor: 'rgba(6,182,212,.3)',
color: 'var(--color-accent)',
}}
>
</button>
</div>
</Popup>
)}
</Map>
{/* 지도 위 안내 배지 */}
<div
className="absolute top-3 left-1/2 -translate-x-1/2 px-3 py-1.5 rounded-full text-label-1 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}
onClose={() => {
setActiveCells((prev) => prev.filter((c) => c.cctvSn !== cam.cctvSn));
if (gridMode === 1) setSelectedCamera(null);
}}
oilDetectionEnabled={oilDetectionEnabled && gridMode !== 9}
vesselDetectionEnabled={vesselDetectionEnabled && gridMode !== 9}
intrusionDetectionEnabled={intrusionDetectionEnabled && gridMode !== 9}
/>
) : (
<div className="text-label-1 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-label-1 text-fg-disabled font-korean">
: <b className="text-fg">{selectedCamera?.cameraNm ?? ''}</b>
</div>
<div className="text-label-1 text-fg-disabled font-korean">
: <span className="text-fg-sub">{selectedCamera?.locDc ?? ''}</span>
</div>
<div className="text-label-1 text-fg-disabled font-korean">
:{' '}
<span className="text-color-accent font-mono text-caption">
{selectedCamera?.coordDc ?? ''}
</span>
</div>
<div className="ml-auto text-caption 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-label-2 font-bold text-fg font-korean bg-bg-elevated shrink-0 flex items-center justify-between">
<span>🗺 </span>
<span className="text-caption 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-label-1 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-caption"
>
<span className="text-fg-disabled font-korean">{k}</span>
<span className="font-mono text-fg">{v}</span>
</div>
))}
</div>
) : (
<div className="text-label-1 text-fg-disabled font-korean"> </div>
)}
{/* 방제 즐겨찾기 */}
<div className="mt-3 pt-2.5 border-t border-stroke">
<div className="text-label-1 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-caption"></span>
<div className="flex-1 min-w-0">
<div className="text-caption font-semibold text-fg font-korean truncate">
{fav.name}
</div>
<div className="text-caption 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-label-1 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 color-mix(in srgb, var(--color-success) 20%, transparent)',
}}
>
<span className="text-caption text-fg-sub font-korean">{api.name}</span>
<span className="text-caption 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 color-mix(in srgb, var(--color-info) 20%, transparent)',
}}
>
<span className="text-caption text-fg-sub font-korean"> </span>
<span className="text-caption font-bold font-mono text-color-info">1 fps</span>
</div>
<div className="text-caption text-fg-disabled font-mono text-right mt-0.5">
: {new Date().toLocaleTimeString('ko-KR')}
</div>
</div>
</div>
</div>
</div>
</div>
);
}