diff --git a/frontend/src/common/styles/components.css b/frontend/src/common/styles/components.css index 485bd29..04c3458 100644 --- a/frontend/src/common/styles/components.css +++ b/frontend/src/common/styles/components.css @@ -1,4 +1,29 @@ @layer components { + /* ═══ CCTV 지도 팝업 (어두운 톤) ═══ */ + .cctv-dark-popup .maplibregl-popup-content { + background: #1a1f2e; + border-radius: 8px; + padding: 0; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5); + border: 1px solid rgba(255, 255, 255, 0.1); + } + .cctv-dark-popup .maplibregl-popup-tip { + border-top-color: #1a1f2e; + border-bottom-color: #1a1f2e; + border-left-color: transparent; + border-right-color: transparent; + } + .cctv-dark-popup .maplibregl-popup-close-button { + color: #888; + font-size: 16px; + right: 4px; + top: 2px; + } + .cctv-dark-popup .maplibregl-popup-close-button:hover { + color: #fff; + background: transparent; + } + /* ═══ Scrollbar ═══ */ .scrollbar-thin { scrollbar-width: thin; diff --git a/frontend/src/tabs/aerial/components/CctvView.tsx b/frontend/src/tabs/aerial/components/CctvView.tsx index a54da59..079c160 100644 --- a/frontend/src/tabs/aerial/components/CctvView.tsx +++ b/frontend/src/tabs/aerial/components/CctvView.tsx @@ -99,10 +99,11 @@ export function CctvView() { 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 showMap = activeCells.length === 0 + /** 지도 모드이거나, 리스트 모드에서 카메라 미선택 시 지도 표시 */ + const showMap = viewMode === 'map' && activeCells.length === 0 const loadData = useCallback(async () => { setLoading(true) @@ -162,8 +163,29 @@ export function CctvView() { 실시간 해안 CCTV -
- API 상태 +
+ {/* 지도/리스트 뷰 토글 */} +
+ + +
+ API
@@ -215,7 +237,14 @@ export function CctvView() { }} >
-
📹
+
+ + + + + + +
@@ -324,8 +353,90 @@ export function CctvView() {
- {/* 영상 그리드 또는 CCTV 위치 지도 */} - {showMap ? ( + {/* 영상 그리드 / 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-border cursor-pointer transition-colors hover:bg-bg-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 ? (
{ e.originalEvent.stopPropagation(); setMapPopup(cam) }} > -
- 📹 +
+ {/* CCTV 아이콘 */} + + {/* 카메라 본체 */} + + {/* 렌즈 */} + + + {/* 마운트 기둥 */} + + + {/* LIVE 표시등 */} + {cam.sttsCd === 'LIVE' && } + + {/* 이름 라벨 */} +
+ {cam.cameraNm} +
))} @@ -363,23 +482,24 @@ export function CctvView() { onClose={() => setMapPopup(null)} closeOnClick={false} offset={14} + className="cctv-dark-popup" > -
-
{mapPopup.cameraNm}
-
{mapPopup.locDc ?? ''}
-
+
+
{mapPopup.cameraNm}
+
{mapPopup.locDc ?? ''}
+
{mapPopup.sttsCd === 'LIVE' ? '● LIVE' : '● OFF'} - {mapPopup.sourceNm} + {mapPopup.sourceNm}