feat(aerial): CCTV 지도/리스트 뷰 전환 + CCTV 아이콘 + 다크 팝업 UI
- 지도/리스트 뷰 토글 버튼 추가 (🗺 지도 / ☰ 리스트)
- 리스트 뷰: 출처별(KHOA/KBS) · 지역별 그룹핑 테이블 그리드
카메라명, 위치, 상태, 최종갱신 컬럼 표시
- 지도 마커: 📹 이모지 → CCTV 카메라 SVG 아이콘 (LIVE 표시등 애니메이션)
- 좌측 목록: CCTV SVG 아이콘으로 교체
- 지도 팝업 다크 테마 적용 (배경, 테두리, 삼각형, 버튼 모두 어두운 톤)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
부모
9c834c4e5e
커밋
bb3bd8358b
@ -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;
|
||||
|
||||
@ -99,10 +99,11 @@ export function CctvView() {
|
||||
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 showMap = activeCells.length === 0
|
||||
/** 지도 모드이거나, 리스트 모드에서 카메라 미선택 시 지도 표시 */
|
||||
const showMap = viewMode === 'map' && activeCells.length === 0
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
setLoading(true)
|
||||
@ -162,8 +163,29 @@ export function CctvView() {
|
||||
<span className="w-[7px] h-[7px] rounded-full inline-block animate-pulse" style={{ background: 'var(--red)' }} />
|
||||
실시간 해안 CCTV
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-[9px] text-text-3 font-korean">API 상태</span>
|
||||
<div className="flex items-center gap-1">
|
||||
{/* 지도/리스트 뷰 토글 */}
|
||||
<div className="flex border border-border 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(--cyan)' }
|
||||
: { background: 'var(--bg3)', color: 'var(--t3)' }
|
||||
}
|
||||
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(--cyan)' }
|
||||
: { background: 'var(--bg3)', color: 'var(--t3)' }
|
||||
}
|
||||
title="리스트 보기"
|
||||
>☰ 리스트</button>
|
||||
</div>
|
||||
<span className="text-[9px] text-text-3 font-korean">API</span>
|
||||
<span className="w-[7px] h-[7px] rounded-full inline-block" style={{ background: 'var(--green)' }} />
|
||||
</div>
|
||||
</div>
|
||||
@ -215,7 +237,14 @@ export function CctvView() {
|
||||
}}
|
||||
>
|
||||
<div className="relative shrink-0">
|
||||
<div className="w-8 h-8 rounded-md bg-bg-3 flex items-center justify-center text-sm">📹</div>
|
||||
<div className="w-8 h-8 rounded-md bg-bg-3 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(--green)' : 'var(--t3)'} />
|
||||
<circle cx="9.5" cy="11" r="2.8" fill="var(--bg3)" />
|
||||
<circle cx="9.5" cy="11" r="1.3" fill={cam.sttsCd === 'LIVE' ? 'var(--green)' : 'var(--t3)'} />
|
||||
<path d="M17 9l4-2v10l-4-2V9z" fill={cam.sttsCd === 'LIVE' ? 'var(--green)' : 'var(--t3)'} 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(--green)' : 'var(--t3)' }} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
@ -324,8 +353,90 @@ export function CctvView() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 영상 그리드 또는 CCTV 위치 지도 */}
|
||||
{showMap ? (
|
||||
{/* 영상 그리드 / CCTV 위치 지도 / 리스트 뷰 */}
|
||||
{viewMode === 'list' && activeCells.length === 0 ? (
|
||||
/* ── 리스트 뷰: 출처별 · 지역별 그리드 ── */
|
||||
<div className="flex-1 overflow-y-auto p-4" style={{ scrollbarWidth: 'thin', scrollbarColor: 'var(--bdL) 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-border">
|
||||
<span className="text-sm">{group.icon}</span>
|
||||
<span className="text-[12px] font-bold text-text-1 font-korean">{group.label}</span>
|
||||
<span className="text-[10px] text-text-3 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-primary-cyan font-korean">{rgn}</span>
|
||||
<span className="text-[9px] text-text-3">({cams.length})</span>
|
||||
</div>
|
||||
{/* 테이블 헤더 */}
|
||||
<div className="grid px-2 py-1 bg-bg-3 rounded-t text-[9px] font-bold text-text-3 font-korean border border-border"
|
||||
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-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',
|
||||
}}
|
||||
>
|
||||
<span className="text-[10px] text-text-1 font-korean font-semibold truncate">{cam.cameraNm}</span>
|
||||
<span className="text-[9px] text-text-3 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(--green)' }}>● 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(--t3)' }}>● OFF</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="text-[9px] text-text-3 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 }}
|
||||
@ -338,20 +449,28 @@ export function CctvView() {
|
||||
key={cam.cctvSn}
|
||||
longitude={cam.lon!}
|
||||
latitude={cam.lat!}
|
||||
anchor="center"
|
||||
anchor="bottom"
|
||||
onClick={e => { e.originalEvent.stopPropagation(); setMapPopup(cam) }}
|
||||
>
|
||||
<div
|
||||
className="flex items-center justify-center cursor-pointer transition-transform hover:scale-125"
|
||||
title={cam.cameraNm}
|
||||
style={{
|
||||
width: 18, height: 18, borderRadius: '50%',
|
||||
background: cam.sttsCd === 'LIVE' ? 'rgba(34,197,94,.85)' : 'rgba(148,163,184,.6)',
|
||||
border: '2px solid rgba(255,255,255,.8)',
|
||||
boxShadow: cam.sttsCd === 'LIVE' ? '0 0 8px rgba(34,197,94,.5)' : 'none',
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: 9 }}>📹</span>
|
||||
<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>
|
||||
))}
|
||||
@ -363,23 +482,24 @@ export function CctvView() {
|
||||
onClose={() => setMapPopup(null)}
|
||||
closeOnClick={false}
|
||||
offset={14}
|
||||
className="cctv-dark-popup"
|
||||
>
|
||||
<div className="p-1.5" style={{ minWidth: 140 }}>
|
||||
<div className="text-[11px] font-bold text-gray-800 mb-1">{mapPopup.cameraNm}</div>
|
||||
<div className="text-[9px] text-gray-500 mb-1.5">{mapPopup.locDc ?? ''}</div>
|
||||
<div className="flex items-center gap-1.5 mb-1.5">
|
||||
<div className="p-2" style={{ minWidth: 150, background: '#1a1f2e', borderRadius: 6 }}>
|
||||
<div className="text-[11px] font-bold text-white mb-1">{mapPopup.cameraNm}</div>
|
||||
<div className="text-[9px] text-gray-400 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,.15)', color: '#16a34a' }
|
||||
? { 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-gray-400">{mapPopup.sourceNm}</span>
|
||||
<span className="text-[8px] text-gray-500">{mapPopup.sourceNm}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => { handleSelectCamera(mapPopup); setMapPopup(null) }}
|
||||
className="w-full px-2 py-1 rounded text-[10px] font-bold text-white cursor-pointer border-none"
|
||||
style={{ background: '#0891b2' }}
|
||||
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>
|
||||
|
||||
불러오는 중...
Reference in New Issue
Block a user