feat: 보고서 지도캡처 + 드론/CCTV/확산예측 UI 기능 개선 #91

병합
jhkang feature/function_develop 에서 develop 로 27 commits 를 머지했습니다 2026-03-16 18:30:04 +09:00
2개의 변경된 파일172개의 추가작업 그리고 27개의 파일을 삭제
Showing only changes of commit bb3bd8358b - Show all commits

파일 보기

@ -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>