From bb3bd8358bc1efb10bb52e0f0bdb3dd7537b9142 Mon Sep 17 00:00:00 2001 From: Nan Kyung Lee Date: Mon, 16 Mar 2026 08:06:05 +0900 Subject: [PATCH] =?UTF-8?q?feat(aerial):=20CCTV=20=EC=A7=80=EB=8F=84/?= =?UTF-8?q?=EB=A6=AC=EC=8A=A4=ED=8A=B8=20=EB=B7=B0=20=EC=A0=84=ED=99=98=20?= =?UTF-8?q?+=20CCTV=20=EC=95=84=EC=9D=B4=EC=BD=98=20+=20=EB=8B=A4=ED=81=AC?= =?UTF-8?q?=20=ED=8C=9D=EC=97=85=20UI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 지도/리스트 뷰 토글 버튼 추가 (🗺 지도 / ☰ 리스트) - 리스트 뷰: 출처별(KHOA/KBS) · 지역별 그룹핑 테이블 그리드 카메라명, 위치, 상태, 최종갱신 컬럼 표시 - 지도 마커: 📹 이모지 → CCTV 카메라 SVG 아이콘 (LIVE 표시등 애니메이션) - 좌측 목록: CCTV SVG 아이콘으로 교체 - 지도 팝업 다크 테마 적용 (배경, 테두리, 삼각형, 버튼 모두 어두운 톤) Co-Authored-By: Claude Opus 4.6 (1M context) --- frontend/src/common/styles/components.css | 25 +++ .../src/tabs/aerial/components/CctvView.tsx | 174 +++++++++++++++--- 2 files changed, 172 insertions(+), 27 deletions(-) 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}