feat(aerial): KBS CCTV HLS 직접 재생 + CCTV 위치 지도 + 좌표 정확도 개선

- KBS 재난안전포탈 CCTV를 iframe에서 HLS 직접 재생으로 전환
- 백엔드 KBS HLS 리졸버 엔드포인트 추가 (/api/aerial/cctv/kbs-hls/:cctvId/stream.m3u8)
- KBS API 3단계 리졸브: 팝업API → loomex API → m3u8 (5분 캐시)
- CCTV 미선택 시 MapLibre 지도에 마커 표시 + 팝업 영상 선택
- 우측 미니맵을 실제 MapLibre 지도로 교체
- KBS API 정확 좌표로 19개 CCTV 업데이트 + 신규 2건 추가 (울산 달동, 제주 도남동)
- PredictionInputSection 중복 className 수정

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Nan Kyung Lee 2026-03-16 07:40:52 +09:00
부모 d9a51d2101
커밋 a470df5518
5개의 변경된 파일319개의 추가작업 그리고 60개의 파일을 삭제

파일 보기

@ -87,6 +87,92 @@ router.get('/cctv', requireAuth, requirePermission('aerial', 'READ'), async (req
}
});
// ============================================================
// KBS 재난안전포탈 CCTV HLS 리졸버
// ============================================================
/** KBS cctvId → 실제 HLS m3u8 URL 캐시 (5분 TTL) */
const kbsHlsCache = new Map<string, { url: string; ts: number }>();
const KBS_CACHE_TTL = 5 * 60 * 1000;
// GET /api/aerial/cctv/kbs-hls/:cctvId/stream.m3u8 — KBS CCTV를 HLS로 리졸브 + 프록시
router.get('/cctv/kbs-hls/:cctvId/stream.m3u8', async (req, res) => {
try {
const cctvId = req.params.cctvId as string;
if (!/^\d+$/.test(cctvId)) {
res.status(400).json({ error: '유효하지 않은 cctvId' });
return;
}
let m3u8Url: string | null = null;
// 캐시 확인
const cached = kbsHlsCache.get(cctvId);
if (cached && Date.now() - cached.ts < KBS_CACHE_TTL) {
m3u8Url = cached.url;
} else {
// 1단계: KBS 팝업 API에서 loomex API URL 추출
const popupRes = await fetch(
`https://d.kbs.co.kr/special/cctv/cctvPopup?type=LIVE&cctvId=${cctvId}`,
{ headers: { 'User-Agent': 'Mozilla/5.0 (compatible; WING-OPS/1.0)' } },
);
if (!popupRes.ok) {
res.status(502).json({ error: 'KBS 팝업 API 응답 실패' });
return;
}
const popupHtml = await popupRes.text();
const urlMatch = popupHtml.match(/id="url"\s+value="([^"]+)"/);
if (!urlMatch) {
res.status(502).json({ error: 'KBS 스트림 URL을 찾을 수 없습니다' });
return;
}
// 2단계: loomex API에서 실제 m3u8 URL 획득
const loomexRes = await fetch(urlMatch[1], {
headers: { 'User-Agent': 'Mozilla/5.0 (compatible; WING-OPS/1.0)' },
});
if (!loomexRes.ok) {
res.status(502).json({ error: 'KBS 스트림 서버 응답 실패' });
return;
}
m3u8Url = (await loomexRes.text()).trim();
kbsHlsCache.set(cctvId, { url: m3u8Url, ts: Date.now() });
}
// 3단계: m3u8 매니페스트를 프록시하여 세그먼트 URL 재작성
const upstream = await fetch(m3u8Url, {
headers: { 'User-Agent': 'Mozilla/5.0 (compatible; WING-OPS/1.0)' },
});
if (!upstream.ok) {
// 캐시 무효화 후 재시도 유도
kbsHlsCache.delete(cctvId);
res.status(502).json({ error: 'HLS 매니페스트 가져오기 실패' });
return;
}
const text = await upstream.text();
const baseUrl = m3u8Url.substring(0, m3u8Url.lastIndexOf('/') + 1);
const proxyBase = '/api/aerial/cctv/stream-proxy?url=';
const rewritten = text.replace(/^(?!#)(\S+)/gm, (line) => {
if (line.startsWith('http://') || line.startsWith('https://')) {
return `${proxyBase}${encodeURIComponent(line)}`;
}
return `${proxyBase}${encodeURIComponent(baseUrl + line)}`;
});
res.set({
'Content-Type': 'application/vnd.apple.mpegurl',
'Cache-Control': 'no-cache',
'Access-Control-Allow-Origin': '*',
});
res.send(rewritten);
} catch (err) {
console.error('[aerial] KBS HLS 리졸버 오류:', err);
res.status(502).json({ error: 'KBS HLS 스트림 리졸브 실패' });
}
});
// ============================================================
// CCTV HLS 스트림 프록시 (CORS 우회)
// ============================================================
@ -95,6 +181,7 @@ router.get('/cctv', requireAuth, requirePermission('aerial', 'READ'), async (req
const ALLOWED_STREAM_HOSTS = [
'www.khoa.go.kr',
'kbsapi.loomex.net',
'kbscctv-cache.loomex.net',
];
// GET /api/aerial/cctv/stream-proxy — HLS 스트림 프록시 (호스트 화이트리스트로 보안)

파일 보기

@ -0,0 +1,39 @@
-- KBS 재난안전포탈 CCTV 스트림 URL 추가 마이그레이션
-- 기존 KBS 카메라 6건 streamUrl + 좌표 업데이트 + 신규 15건 INSERT
-- URL: 백엔드 KBS HLS 리졸버 경유 (/api/aerial/cctv/kbs-hls/:cctvId/stream.m3u8)
-- 좌표: KBS API (d.kbs.co.kr/special/cctv/list) 정확 좌표 반영
SET search_path TO wing, public;
-- 기존 KBS 카메라 streamUrl + 좌표 업데이트 (KBS API 정확 좌표)
UPDATE CCTV_CAMERA SET STREAM_URL = '/api/aerial/cctv/kbs-hls/9981/stream.m3u8', LON = 126.5986, LAT = 37.4541, GEOM = ST_SetSRID(ST_MakePoint(126.5986, 37.4541), 4326), CAMERA_NM = '인천 연안부두' WHERE CCTV_SN = 100;
UPDATE CCTV_CAMERA SET STREAM_URL = '/api/aerial/cctv/kbs-hls/9994/stream.m3u8', LON = 127.7557, LAT = 34.7410, GEOM = ST_SetSRID(ST_MakePoint(127.7557, 34.7410), 4326), CAMERA_NM = '여수 오동도 앞' WHERE CCTV_SN = 97;
UPDATE CCTV_CAMERA SET STREAM_URL = '/api/aerial/cctv/kbs-hls/9984/stream.m3u8', LON = 126.7489, LAT = 34.3209, GEOM = ST_SetSRID(ST_MakePoint(126.7489, 34.3209), 4326) WHERE CCTV_SN = 108;
UPDATE CCTV_CAMERA SET STREAM_URL = '/api/aerial/cctv/kbs-hls/9986/stream.m3u8', LON = 128.6001, LAT = 38.2134, GEOM = ST_SetSRID(ST_MakePoint(128.6001, 38.2134), 4326), CAMERA_NM = '속초 등대전망대' WHERE CCTV_SN = 113;
UPDATE CCTV_CAMERA SET STREAM_URL = '/api/aerial/cctv/kbs-hls/9957/stream.m3u8', LON = 131.8686, LAT = 37.2394, GEOM = ST_SetSRID(ST_MakePoint(131.8686, 37.2394), 4326) WHERE CCTV_SN = 115;
UPDATE CCTV_CAMERA SET STREAM_URL = '/api/aerial/cctv/kbs-hls/9982/stream.m3u8', LON = 126.2684, LAT = 33.1139, GEOM = ST_SetSRID(ST_MakePoint(126.2684, 33.1139), 4326) WHERE CCTV_SN = 116;
-- 신규 KBS 재난안전포탈 CCTV 추가 (15건) — KBS API 정확 좌표
INSERT INTO CCTV_CAMERA (CCTV_SN, CAMERA_NM, REGION_NM, LON, LAT, GEOM, LOC_DC, COORD_DC, STTS_CD, PTZ_YN, SOURCE_NM, STREAM_URL) VALUES
-- 서해
(200, '연평도', '서해', 125.6945, 37.6620, ST_SetSRID(ST_MakePoint(125.6945, 37.6620), 4326), '인천 옹진군 연평면', '37.66°N 125.69°E', 'LIVE', 'N', 'KBS', '/api/aerial/cctv/kbs-hls/9958/stream.m3u8'),
(201, '군산 비응항', '서해', 126.5265, 35.9353, ST_SetSRID(ST_MakePoint(126.5265, 35.9353), 4326), '전북 군산시 비응도동', '35.94°N 126.53°E', 'LIVE', 'N', 'KBS', '/api/aerial/cctv/kbs-hls/9979/stream.m3u8'),
(202, '태안 신진항', '서해', 126.1365, 36.6779, ST_SetSRID(ST_MakePoint(126.1365, 36.6779), 4326), '충남 태안군 근흥면', '36.68°N 126.14°E', 'LIVE', 'N', 'KBS', '/api/aerial/cctv/kbs-hls/9980/stream.m3u8'),
-- 남해
(203, '창원 마산항', '남해', 128.5760, 35.1979, ST_SetSRID(ST_MakePoint(128.5760, 35.1979), 4326), '경남 창원시 마산합포구', '35.20°N 128.58°E', 'LIVE', 'N', 'KBS', '/api/aerial/cctv/kbs-hls/9985/stream.m3u8'),
(204, '부산 민락항', '남해', 129.1312, 35.1538, ST_SetSRID(ST_MakePoint(129.1312, 35.1538), 4326), '부산 수영구 민락동', '35.15°N 129.13°E', 'LIVE', 'N', 'KBS', '/api/aerial/cctv/kbs-hls/9991/stream.m3u8'),
(205, '목포 북항', '남해', 126.3652, 34.8042, ST_SetSRID(ST_MakePoint(126.3652, 34.8042), 4326), '전남 목포시 죽교동', '34.80°N 126.37°E', 'LIVE', 'N', 'KBS', '/api/aerial/cctv/kbs-hls/9992/stream.m3u8'),
(206, '신안 가거도', '남해', 125.1293, 34.0529, ST_SetSRID(ST_MakePoint(125.1293, 34.0529), 4326), '전남 신안군 흑산면', '34.05°N 125.13°E', 'LIVE', 'N', 'KBS', '/api/aerial/cctv/kbs-hls/9983/stream.m3u8'),
(207, '여수 거문도', '남해', 127.3074, 34.0232, ST_SetSRID(ST_MakePoint(127.3074, 34.0232), 4326), '전남 여수시 삼산면', '34.02°N 127.31°E', 'LIVE', 'N', 'KBS', '/api/aerial/cctv/kbs-hls/9993/stream.m3u8'),
-- 동해
(208, '강릉 용강동', '동해', 128.8912, 37.7521, ST_SetSRID(ST_MakePoint(128.8912, 37.7521), 4326), '강원 강릉시 용강동', '37.75°N 128.89°E', 'LIVE', 'N', 'KBS', '/api/aerial/cctv/kbs-hls/9952/stream.m3u8'),
(209, '강릉 주문진방파제', '동해', 128.8335, 37.8934, ST_SetSRID(ST_MakePoint(128.8335, 37.8934), 4326), '강원 강릉시 주문진읍', '37.89°N 128.83°E', 'LIVE', 'N', 'KBS', '/api/aerial/cctv/kbs-hls/9995/stream.m3u8'),
(210, '대관령', '동해', 128.7553, 37.6980, ST_SetSRID(ST_MakePoint(128.7553, 37.6980), 4326), '강원 평창군 대관령면', '37.70°N 128.76°E', 'LIVE', 'N', 'KBS', '/api/aerial/cctv/kbs-hls/9989/stream.m3u8'),
(211, '울릉 저동항', '동해', 130.9122, 37.4913, ST_SetSRID(ST_MakePoint(130.9122, 37.4913), 4326), '경북 울릉군 울릉읍', '37.49°N 130.91°E', 'LIVE', 'N', 'KBS', '/api/aerial/cctv/kbs-hls/9987/stream.m3u8'),
(212, '포항 두호동 해안로', '동해', 129.3896, 36.0627, ST_SetSRID(ST_MakePoint(129.3896, 36.0627), 4326), '경북 포항시 북구 두호동', '36.06°N 129.39°E', 'LIVE', 'N', 'KBS', '/api/aerial/cctv/kbs-hls/9988/stream.m3u8'),
(213, '울산 달동', '동해', 129.3265, 35.5442, ST_SetSRID(ST_MakePoint(129.3265, 35.5442), 4326), '울산 남구 달동', '35.54°N 129.33°E', 'LIVE', 'N', 'KBS', '/api/aerial/cctv/kbs-hls/9955/stream.m3u8'),
-- 제주
(214, '제주 도남동', '제주', 126.5195, 33.4891, ST_SetSRID(ST_MakePoint(126.5195, 33.4891), 4326), '제주시 도남동', '33.49°N 126.52°E', 'LIVE', 'N', 'KBS', '/api/aerial/cctv/kbs-hls/9954/stream.m3u8');
-- 시퀀스 리셋
SELECT setval('cctv_camera_cctv_sn_seq', (SELECT MAX(cctv_sn) FROM cctv_camera));

파일 보기

@ -1,4 +1,7 @@
import { useState, useCallback, useEffect, useRef } from 'react'
import { Map, Marker, Popup } from '@vis.gl/react-maplibre'
import type { StyleSpecification } from 'maplibre-gl'
import 'maplibre-gl/dist/maplibre-gl.css'
import { fetchCctvCameras } from '../services/aerialApi'
import type { CctvCameraItem } from '../services/aerialApi'
import { CCTVPlayer } from './CCTVPlayer'
@ -12,6 +15,28 @@ 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`;
}
/** 지도 스타일 (CartoDB Dark Matter) */
const MAP_STYLE: StyleSpecification = {
version: 8,
sources: {
'carto-dark': {
type: 'raster',
tiles: [
'https://a.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}@2x.png',
'https://b.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}@2x.png',
'https://c.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}@2x.png',
],
tileSize: 256,
},
},
layers: [{ id: 'carto-dark-layer', type: 'raster', source: 'carto-dark', minzoom: 0, maxzoom: 22 }],
}
const cctvFavorites = [
{ name: '여수항 해무관측', reason: '유출 사고 인접' },
{ name: '부산항 조위관측소', reason: '주요 방제 거점' },
@ -25,7 +50,10 @@ const FALLBACK_CAMERAS: CctvCameraItem[] = [
{ 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.6125, lat: 37.4625, locDc: '인천광역시 중구 연안부두', coordDc: '37.46°N 126.61°E', sttsCd: 'LIVE', ptzYn: 'N', sourceNm: 'KBS', streamUrl: null },
{ 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') },
@ -34,17 +62,29 @@ const FALLBACK_CAMERAS: CctvCameraItem[] = [
{ 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.7836, lat: 34.7369, locDc: '전남 여수시 수정동', coordDc: '34.74°N 127.78°E', sttsCd: 'LIVE', ptzYn: 'N', sourceNm: 'KBS', streamUrl: null },
{ cctvSn: 108, cameraNm: '완도항', regionNm: '남해', lon: 126.7550, lat: 34.3114, locDc: '전남 완도군 완도읍', coordDc: '34.31°N 126.76°E', sttsCd: 'LIVE', ptzYn: 'N', sourceNm: 'KBS', streamUrl: null },
{ 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.5964, lat: 38.2070, locDc: '강원 속초시 영랑동', coordDc: '38.21°N 128.60°E', sttsCd: 'LIVE', ptzYn: 'N', sourceNm: 'KBS', streamUrl: null },
{ cctvSn: 115, cameraNm: '독도', regionNm: '동해', lon: 131.8689, lat: 37.2394, locDc: '경북 울릉군 울릉읍 독도리', coordDc: '37.24°N 131.87°E', sttsCd: 'LIVE', ptzYn: 'N', sourceNm: 'KBS', streamUrl: null },
{ 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.2669, lat: 33.1140, locDc: '제주 서귀포시 대정읍 마라리', coordDc: '33.11°N 126.27°E', sttsCd: 'LIVE', ptzYn: 'N', sourceNm: 'KBS', streamUrl: null },
{ 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() {
@ -58,13 +98,24 @@ export function CctvView() {
const [oilDetectionEnabled, setOilDetectionEnabled] = useState(false)
const [vesselDetectionEnabled, setVesselDetectionEnabled] = useState(false)
const [intrusionDetectionEnabled, setIntrusionDetectionEnabled] = useState(false)
const [mapPopup, setMapPopup] = useState<CctvCameraItem | null>(null)
const playerRefs = useRef<(CCTVPlayerHandle | null)[]>([])
/** 활성 셀이 비어 있으면 지도를 표시 */
const showMap = activeCells.length === 0
const loadData = useCallback(async () => {
setLoading(true)
try {
const items = await fetchCctvCameras()
setCameras(items.length > 0 ? items : FALLBACK_CAMERAS)
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 {
@ -273,7 +324,74 @@ export function CctvView() {
</div>
</div>
{/* 영상 그리드 */}
{/* 영상 그리드 또는 CCTV 위치 지도 */}
{showMap ? (
<div className="flex-1 overflow-hidden relative">
<Map
initialViewState={{ longitude: 127.8, latitude: 35.5, zoom: 6.2 }}
mapStyle={MAP_STYLE}
style={{ width: '100%', height: '100%' }}
attributionControl={false}
>
{filtered.filter(c => c.lon && c.lat).map(cam => (
<Marker
key={cam.cctvSn}
longitude={cam.lon!}
latitude={cam.lat!}
anchor="center"
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>
</Marker>
))}
{mapPopup && mapPopup.lon && mapPopup.lat && (
<Popup
longitude={mapPopup.lon}
latitude={mapPopup.lat}
anchor="bottom"
onClose={() => setMapPopup(null)}
closeOnClick={false}
offset={14}
>
<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">
<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(148,163,184,.15)', color: '#94a3b8' }
}
>{mapPopup.sttsCd === 'LIVE' ? '● LIVE' : '● OFF'}</span>
<span className="text-[8px] text-gray-400">{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' }}
> </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)`,
@ -303,6 +421,7 @@ export function CctvView() {
)
})}
</div>
)}
{/* 하단 정보 바 */}
<div className="flex items-center gap-3.5 px-4 py-1.5 border-t border-border bg-bg-2 shrink-0">
@ -320,26 +439,37 @@ export function CctvView() {
<span>🗺 </span>
<span className="text-[9px] text-text-3 font-normal"> </span>
</div>
{/* 미니맵 (placeholder) */}
<div className="w-full bg-bg-3 flex items-center justify-center shrink-0 relative h-[210px]">
<div className="text-[10px] text-text-3 font-korean opacity-50"> </div>
{/* 간략 지도 표현 */}
<div className="absolute inset-2 rounded-md border border-border/30 overflow-hidden" style={{ background: 'linear-gradient(180deg, rgba(6,182,212,.03), rgba(59,130,246,.05))' }}>
{cameras.filter(c => c.sttsCd === 'LIVE').slice(0, 6).map((c, i) => (
{/* 미니맵 */}
<div className="w-full shrink-0 relative h-[210px] overflow-hidden">
<Map
initialViewState={{ longitude: 127.8, latitude: 35.5, zoom: 5.3 }}
mapStyle={MAP_STYLE}
style={{ width: '100%', height: '100%' }}
attributionControl={false}
interactive={true}
>
{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
key={i}
className="absolute w-2 h-2 rounded-full cursor-pointer"
className="cursor-pointer"
style={{
background: selectedCamera?.cctvSn === c.cctvSn ? 'var(--cyan)' : 'var(--green)',
boxShadow: selectedCamera?.cctvSn === c.cctvSn ? '0 0 6px var(--cyan)' : 'none',
top: `${20 + (i * 25) % 70}%`,
left: `${15 + (i * 30) % 70}%`,
width: selectedCamera?.cctvSn === cam.cctvSn ? 10 : 7,
height: selectedCamera?.cctvSn === cam.cctvSn ? 10 : 7,
borderRadius: '50%',
background: selectedCamera?.cctvSn === cam.cctvSn ? 'var(--cyan)' : cam.sttsCd === 'LIVE' ? 'var(--green)' : 'var(--t3)',
boxShadow: selectedCamera?.cctvSn === cam.cctvSn ? '0 0 8px var(--cyan)' : 'none',
border: '1.5px solid rgba(255,255,255,.7)',
}}
title={c.cameraNm}
onClick={() => handleSelectCamera(c)}
/>
</Marker>
))}
</div>
</Map>
</div>
{/* 카메라 정보 */}

파일 보기

@ -13,6 +13,11 @@ export function detectStreamType(url: string): StreamType {
return 'iframe';
}
// KBS 재난안전포탈 CCTV 공유 페이지 (iframe 임베드)
if (lower.includes('d.kbs.co.kr')) {
return 'iframe';
}
if (lower.includes('.m3u8') || lower.includes('/hls/')) {
return 'hls';
}

파일 보기

@ -88,8 +88,7 @@ const PredictionInputSection = ({
name="prdType"
checked={inputMode === 'direct'}
onChange={() => setInputMode('direct')}
className="m-0 w-[11px] h-[11px]"
className="accent-[var(--cyan)]"
className="m-0 w-[11px] h-[11px] accent-[var(--cyan)]"
/>
</label>
@ -99,8 +98,7 @@ const PredictionInputSection = ({
name="prdType"
checked={inputMode === 'upload'}
onChange={() => setInputMode('upload')}
className="m-0 w-[11px] h-[11px]"
className="accent-[var(--cyan)]"
className="m-0 w-[11px] h-[11px] accent-[var(--cyan)]"
/>
</label>