- 지도 스타일 상수를 mapStyles.ts로 추출 - useBaseMapStyle 훅 생성 (mapToggles 기반 스타일 반환) - 9개 탭 컴포넌트의 하드코딩 스타일을 공유 훅으로 교체 - 각 Map에 S57EncOverlay 추가 - 초기 mapToggles를 모두 false로 변경 (기본지도 표시)
657 lines
42 KiB
TypeScript
657 lines
42 KiB
TypeScript
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.6620, 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.3780, lat: 34.7780, 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.4710, 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.7650, lat: 34.7370, 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.0780, lat: 35.0980, 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.7410, 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.5760, 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.3870, lat: 35.5000, 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.5500, 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.6980, 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-[9px] 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-[9px] 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-[9px] 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-color-accent/50 transition-colors">
|
||
<span className="text-fg-disabled text-[11px]">🔍</span>
|
||
<input
|
||
type="text"
|
||
placeholder="지점명 또는 지역 검색..."
|
||
value={searchTerm}
|
||
onChange={e => setSearchTerm(e.target.value)}
|
||
className="flex-1 bg-transparent border-none text-fg text-[11px] 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-[9px] 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-[9px] text-fg-disabled font-korean">출처: 국립해양조사원 · KBS 재난안전포털</div>
|
||
<div className="text-[10px] 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-[11px] 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-[11px] font-semibold text-fg font-korean truncate">{cam.cameraNm}</div>
|
||
<div className="text-[9px] 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-[8px] font-bold px-1.5 py-px rounded-full" style={{ background: 'rgba(34,197,94,.12)', color: 'var(--color-success)' }}>LIVE</span>
|
||
) : (
|
||
<span className="text-[8px] 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-[8px] 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-[9px] font-bold shrink-0" style={{ background: 'rgba(239,68,68,.14)', border: '1px solid rgba(239,68,68,.35)', 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-[9px] 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-[9px] 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-[9px] 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-[11px] 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-[10px] font-semibold cursor-pointer font-korean transition-colors"
|
||
style={oilDetectionEnabled
|
||
? { background: 'rgba(239,68,68,.15)', borderColor: 'rgba(239,68,68,.4)', 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-[10px] font-semibold cursor-pointer font-korean transition-colors"
|
||
style={vesselDetectionEnabled
|
||
? { background: 'rgba(59,130,246,.15)', borderColor: 'rgba(59,130,246,.4)', color: 'var(--blue, #3b82f6)' }
|
||
: { 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-[10px] font-semibold cursor-pointer font-korean transition-colors"
|
||
style={intrusionDetectionEnabled
|
||
? { background: 'rgba(249,115,22,.15)', borderColor: 'rgba(249,115,22,.4)', color: 'var(--orange, #f97316)' }
|
||
: { 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-[10px] 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-[12px] font-bold text-fg font-korean">{group.label}</span>
|
||
<span className="text-[10px] 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-[10px] font-bold text-color-accent font-korean">{rgn}</span>
|
||
<span className="text-[9px] text-fg-disabled">({cams.length})</span>
|
||
</div>
|
||
{/* 테이블 헤더 */}
|
||
<div className="grid px-2 py-1 bg-bg-card rounded-t text-[9px] 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-[10px] text-fg font-korean font-semibold truncate">{cam.cameraNm}</span>
|
||
<span className="text-[9px] text-fg-disabled 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(--color-success)' }}>● 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(--fg-disabled)' }}>● OFF</span>
|
||
)}
|
||
</span>
|
||
<span className="text-[9px] 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' ? '#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>
|
||
))}
|
||
{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-[11px] font-bold text-fg mb-1">{mapPopup.cameraNm}</div>
|
||
<div className="text-[9px] text-fg-disabled 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,.2)', color: '#4ade80' }
|
||
: { background: 'rgba(148,163,184,.15)', color: '#94a3b8' }
|
||
}
|
||
>{mapPopup.sttsCd === 'LIVE' ? '● LIVE' : '● OFF'}</span>
|
||
<span className="text-[8px] text-fg-disabled">{mapPopup.sourceNm}</span>
|
||
</div>
|
||
<button
|
||
onClick={() => { handleSelectCamera(mapPopup); setMapPopup(null) }}
|
||
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>
|
||
)}
|
||
</Map>
|
||
{/* 지도 위 안내 배지 */}
|
||
<div className="absolute top-3 left-1/2 -translate-x-1/2 px-3 py-1.5 rounded-full text-[10px] 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}
|
||
oilDetectionEnabled={oilDetectionEnabled && gridMode !== 9}
|
||
vesselDetectionEnabled={vesselDetectionEnabled && gridMode !== 9}
|
||
intrusionDetectionEnabled={intrusionDetectionEnabled && gridMode !== 9}
|
||
/>
|
||
) : (
|
||
<div className="text-[10px] 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-[10px] text-fg-disabled font-korean">선택: <b className="text-fg">{selectedCamera?.cameraNm ?? '–'}</b></div>
|
||
<div className="text-[10px] text-fg-disabled font-korean">위치: <span className="text-fg-sub">{selectedCamera?.locDc ?? '–'}</span></div>
|
||
<div className="text-[10px] text-fg-disabled font-korean">좌표: <span className="text-color-accent font-mono text-[9px]">{selectedCamera?.coordDc ?? '–'}</span></div>
|
||
<div className="ml-auto text-[9px] 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-[11px] font-bold text-fg font-korean bg-bg-elevated shrink-0 flex items-center justify-between">
|
||
<span>🗺 위치 지도</span>
|
||
<span className="text-[9px] 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-[10px] 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-[9px]">
|
||
<span className="text-fg-disabled font-korean">{k}</span>
|
||
<span className="font-mono text-fg">{v}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
) : (
|
||
<div className="text-[10px] text-fg-disabled font-korean">카메라를 선택하세요</div>
|
||
)}
|
||
|
||
{/* 방제 즐겨찾기 */}
|
||
<div className="mt-3 pt-2.5 border-t border-stroke">
|
||
<div className="text-[10px] 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-[9px]">⭐</span>
|
||
<div className="flex-1 min-w-0">
|
||
<div className="text-[9px] font-semibold text-fg font-korean truncate">{fav.name}</div>
|
||
<div className="text-[8px] 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-[10px] 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 rgba(34,197,94,.2)' }}>
|
||
<span className="text-[9px] text-fg-sub font-korean">{api.name}</span>
|
||
<span className="text-[9px] 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 rgba(59,130,246,.2)' }}>
|
||
<span className="text-[9px] text-fg-sub font-korean">갱신 주기</span>
|
||
<span className="text-[9px] font-bold font-mono text-color-info">1 fps</span>
|
||
</div>
|
||
<div className="text-[9px] text-fg-disabled font-mono text-right mt-0.5">최종갱신: {new Date().toLocaleTimeString('ko-KR')}</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|