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:
부모
d9a51d2101
커밋
a470df5518
@ -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 우회)
|
// CCTV HLS 스트림 프록시 (CORS 우회)
|
||||||
// ============================================================
|
// ============================================================
|
||||||
@ -95,6 +181,7 @@ router.get('/cctv', requireAuth, requirePermission('aerial', 'READ'), async (req
|
|||||||
const ALLOWED_STREAM_HOSTS = [
|
const ALLOWED_STREAM_HOSTS = [
|
||||||
'www.khoa.go.kr',
|
'www.khoa.go.kr',
|
||||||
'kbsapi.loomex.net',
|
'kbsapi.loomex.net',
|
||||||
|
'kbscctv-cache.loomex.net',
|
||||||
];
|
];
|
||||||
|
|
||||||
// GET /api/aerial/cctv/stream-proxy — HLS 스트림 프록시 (호스트 화이트리스트로 보안)
|
// GET /api/aerial/cctv/stream-proxy — HLS 스트림 프록시 (호스트 화이트리스트로 보안)
|
||||||
|
|||||||
39
database/migration/021_kbs_cctv_stream_urls.sql
Normal file
39
database/migration/021_kbs_cctv_stream_urls.sql
Normal file
@ -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 { 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 { fetchCctvCameras } from '../services/aerialApi'
|
||||||
import type { CctvCameraItem } from '../services/aerialApi'
|
import type { CctvCameraItem } from '../services/aerialApi'
|
||||||
import { CCTVPlayer } from './CCTVPlayer'
|
import { CCTVPlayer } from './CCTVPlayer'
|
||||||
@ -12,6 +15,28 @@ function khoaHlsUrl(siteName: string): string {
|
|||||||
return `${KHOA_HLS}/${siteName}/s.m3u8`;
|
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 = [
|
const cctvFavorites = [
|
||||||
{ name: '여수항 해무관측', reason: '유출 사고 인접' },
|
{ name: '여수항 해무관측', reason: '유출 사고 인접' },
|
||||||
{ 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: 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: 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: 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: 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: 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: 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: 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: 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: 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.7550, lat: 34.3114, locDc: '전남 완도군 완도읍', coordDc: '34.31°N 126.76°E', sttsCd: 'LIVE', ptzYn: 'N', sourceNm: 'KBS', streamUrl: null },
|
{ 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: 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: 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: 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: 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.8689, lat: 37.2394, locDc: '경북 울릉군 울릉읍 독도리', coordDc: '37.24°N 131.87°E', sttsCd: 'LIVE', ptzYn: 'N', sourceNm: 'KBS', streamUrl: null },
|
{ 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: 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() {
|
export function CctvView() {
|
||||||
@ -58,13 +98,24 @@ export function CctvView() {
|
|||||||
const [oilDetectionEnabled, setOilDetectionEnabled] = useState(false)
|
const [oilDetectionEnabled, setOilDetectionEnabled] = useState(false)
|
||||||
const [vesselDetectionEnabled, setVesselDetectionEnabled] = useState(false)
|
const [vesselDetectionEnabled, setVesselDetectionEnabled] = useState(false)
|
||||||
const [intrusionDetectionEnabled, setIntrusionDetectionEnabled] = useState(false)
|
const [intrusionDetectionEnabled, setIntrusionDetectionEnabled] = useState(false)
|
||||||
|
const [mapPopup, setMapPopup] = useState<CctvCameraItem | null>(null)
|
||||||
const playerRefs = useRef<(CCTVPlayerHandle | null)[]>([])
|
const playerRefs = useRef<(CCTVPlayerHandle | null)[]>([])
|
||||||
|
|
||||||
|
/** 활성 셀이 비어 있으면 지도를 표시 */
|
||||||
|
const showMap = activeCells.length === 0
|
||||||
|
|
||||||
const loadData = useCallback(async () => {
|
const loadData = useCallback(async () => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
try {
|
try {
|
||||||
const items = await fetchCctvCameras()
|
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 {
|
} catch {
|
||||||
setCameras(FALLBACK_CAMERAS)
|
setCameras(FALLBACK_CAMERAS)
|
||||||
} finally {
|
} finally {
|
||||||
@ -273,36 +324,104 @@ export function CctvView() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 영상 그리드 */}
|
{/* 영상 그리드 또는 CCTV 위치 지도 */}
|
||||||
<div className="flex-1 gap-0.5 p-0.5 overflow-hidden relative grid bg-black"
|
{showMap ? (
|
||||||
style={{
|
<div className="flex-1 overflow-hidden relative">
|
||||||
gridTemplateColumns: `repeat(${gridCols}, 1fr)`,
|
<Map
|
||||||
gridTemplateRows: `repeat(${gridCols}, 1fr)`,
|
initialViewState={{ longitude: 127.8, latitude: 35.5, zoom: 6.2 }}
|
||||||
}}>
|
mapStyle={MAP_STYLE}
|
||||||
{Array.from({ length: totalCells }).map((_, i) => {
|
style={{ width: '100%', height: '100%' }}
|
||||||
const cam = activeCells[i]
|
attributionControl={false}
|
||||||
return (
|
>
|
||||||
<div key={i} className="relative flex items-center justify-center overflow-hidden bg-[#0a0e18]" style={{ border: '1px solid rgba(255,255,255,.06)' }}>
|
{filtered.filter(c => c.lon && c.lat).map(cam => (
|
||||||
{cam ? (
|
<Marker
|
||||||
<CCTVPlayer
|
key={cam.cctvSn}
|
||||||
ref={el => { playerRefs.current[i] = el }}
|
longitude={cam.lon!}
|
||||||
cameraNm={cam.cameraNm}
|
latitude={cam.lat!}
|
||||||
streamUrl={cam.streamUrl}
|
anchor="center"
|
||||||
sttsCd={cam.sttsCd}
|
onClick={e => { e.originalEvent.stopPropagation(); setMapPopup(cam) }}
|
||||||
coordDc={cam.coordDc}
|
>
|
||||||
sourceNm={cam.sourceNm}
|
<div
|
||||||
cellIndex={i}
|
className="flex items-center justify-center cursor-pointer transition-transform hover:scale-125"
|
||||||
oilDetectionEnabled={oilDetectionEnabled && gridMode !== 9}
|
title={cam.cameraNm}
|
||||||
vesselDetectionEnabled={vesselDetectionEnabled && gridMode !== 9}
|
style={{
|
||||||
intrusionDetectionEnabled={intrusionDetectionEnabled && gridMode !== 9}
|
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)',
|
||||||
<div className="text-[10px] text-text-3 font-korean opacity-40">카메라를 선택하세요</div>
|
boxShadow: cam.sttsCd === 'LIVE' ? '0 0 8px rgba(34,197,94,.5)' : 'none',
|
||||||
)}
|
}}
|
||||||
</div>
|
>
|
||||||
)
|
<span style={{ fontSize: 9 }}>📹</span>
|
||||||
})}
|
</div>
|
||||||
</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)`,
|
||||||
|
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-[#0a0e18]" style={{ border: '1px solid rgba(255,255,255,.06)' }}>
|
||||||
|
{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-text-3 font-korean opacity-40">카메라를 선택하세요</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 하단 정보 바 */}
|
{/* 하단 정보 바 */}
|
||||||
<div className="flex items-center gap-3.5 px-4 py-1.5 border-t border-border bg-bg-2 shrink-0">
|
<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>🗺 위치 지도</span>
|
||||||
<span className="text-[9px] text-text-3 font-normal">클릭하여 선택</span>
|
<span className="text-[9px] text-text-3 font-normal">클릭하여 선택</span>
|
||||||
</div>
|
</div>
|
||||||
{/* 미니맵 (placeholder) */}
|
{/* 미니맵 */}
|
||||||
<div className="w-full bg-bg-3 flex items-center justify-center shrink-0 relative h-[210px]">
|
<div className="w-full shrink-0 relative h-[210px] overflow-hidden">
|
||||||
<div className="text-[10px] text-text-3 font-korean opacity-50">지도 영역</div>
|
<Map
|
||||||
{/* 간략 지도 표현 */}
|
initialViewState={{ longitude: 127.8, latitude: 35.5, zoom: 5.3 }}
|
||||||
<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))' }}>
|
mapStyle={MAP_STYLE}
|
||||||
{cameras.filter(c => c.sttsCd === 'LIVE').slice(0, 6).map((c, i) => (
|
style={{ width: '100%', height: '100%' }}
|
||||||
<div
|
attributionControl={false}
|
||||||
key={i}
|
interactive={true}
|
||||||
className="absolute w-2 h-2 rounded-full cursor-pointer"
|
>
|
||||||
style={{
|
{cameras.filter(c => c.lon && c.lat).map(cam => (
|
||||||
background: selectedCamera?.cctvSn === c.cctvSn ? 'var(--cyan)' : 'var(--green)',
|
<Marker
|
||||||
boxShadow: selectedCamera?.cctvSn === c.cctvSn ? '0 0 6px var(--cyan)' : 'none',
|
key={`mini-${cam.cctvSn}`}
|
||||||
top: `${20 + (i * 25) % 70}%`,
|
longitude={cam.lon!}
|
||||||
left: `${15 + (i * 30) % 70}%`,
|
latitude={cam.lat!}
|
||||||
}}
|
anchor="center"
|
||||||
title={c.cameraNm}
|
onClick={e => { e.originalEvent.stopPropagation(); handleSelectCamera(cam) }}
|
||||||
onClick={() => handleSelectCamera(c)}
|
>
|
||||||
/>
|
<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(--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)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Marker>
|
||||||
))}
|
))}
|
||||||
</div>
|
</Map>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 카메라 정보 */}
|
{/* 카메라 정보 */}
|
||||||
|
|||||||
@ -13,6 +13,11 @@ export function detectStreamType(url: string): StreamType {
|
|||||||
return 'iframe';
|
return 'iframe';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// KBS 재난안전포탈 CCTV 공유 페이지 (iframe 임베드)
|
||||||
|
if (lower.includes('d.kbs.co.kr')) {
|
||||||
|
return 'iframe';
|
||||||
|
}
|
||||||
|
|
||||||
if (lower.includes('.m3u8') || lower.includes('/hls/')) {
|
if (lower.includes('.m3u8') || lower.includes('/hls/')) {
|
||||||
return 'hls';
|
return 'hls';
|
||||||
}
|
}
|
||||||
|
|||||||
@ -88,8 +88,7 @@ const PredictionInputSection = ({
|
|||||||
name="prdType"
|
name="prdType"
|
||||||
checked={inputMode === 'direct'}
|
checked={inputMode === 'direct'}
|
||||||
onChange={() => setInputMode('direct')}
|
onChange={() => setInputMode('direct')}
|
||||||
className="m-0 w-[11px] h-[11px]"
|
className="m-0 w-[11px] h-[11px] accent-[var(--cyan)]"
|
||||||
className="accent-[var(--cyan)]"
|
|
||||||
/>
|
/>
|
||||||
직접 입력
|
직접 입력
|
||||||
</label>
|
</label>
|
||||||
@ -99,8 +98,7 @@ const PredictionInputSection = ({
|
|||||||
name="prdType"
|
name="prdType"
|
||||||
checked={inputMode === 'upload'}
|
checked={inputMode === 'upload'}
|
||||||
onChange={() => setInputMode('upload')}
|
onChange={() => setInputMode('upload')}
|
||||||
className="m-0 w-[11px] h-[11px]"
|
className="m-0 w-[11px] h-[11px] accent-[var(--cyan)]"
|
||||||
className="accent-[var(--cyan)]"
|
|
||||||
/>
|
/>
|
||||||
이미지 업로드
|
이미지 업로드
|
||||||
</label>
|
</label>
|
||||||
|
|||||||
불러오는 중...
Reference in New Issue
Block a user