wing-ops/frontend/src/tabs/aerial/components/CCTVPlayer.tsx
leedano 3743027ce7 feat(weather): 기상 정보 기상 레이어 업데이트 (#78)
## Summary
- 기상 맵 컨트롤 컴포넌트 추가 및 KHOA API 연동 개선
- KHOA API 엔드포인트 교체 및 해양예측 오버레이 Canvas 렌더링 전환

## 변경 파일
- OceanForecastOverlay.tsx
- WeatherMapOverlay.tsx
- WeatherView.tsx
- useOceanForecast.ts
- khoaApi.ts
- vite.config.ts

## Test plan
- [ ] 기상정보 -> 기상 레이어 -> 해황 예보도 클릭 -> 이미지 렌더링 확인
- [ ] 기상정보 -> 기상 레이어 -> 백터 바람 클릭 -> 백터 이미지 렌더링 확인

Co-authored-by: Nan Kyung Lee <nankyunglee@Nanui-Macmini.local>
Reviewed-on: #78
Co-authored-by: leedano <dnlee@gcsc.co.kr>
Co-committed-by: leedano <dnlee@gcsc.co.kr>
2026-03-11 11:14:25 +09:00

356 lines
12 KiB
TypeScript
Raw Blame 히스토리

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useRef, useEffect, useState, useCallback, useMemo, forwardRef, useImperativeHandle } from 'react';
import Hls from 'hls.js';
import { detectStreamType } from '../utils/streamUtils';
import { useOilDetection } from '../hooks/useOilDetection';
import OilDetectionOverlay from './OilDetectionOverlay';
interface CCTVPlayerProps {
cameraNm: string;
streamUrl: string | null;
sttsCd: string;
coordDc?: string | null;
sourceNm?: string | null;
cellIndex?: number;
oilDetectionEnabled?: boolean;
vesselDetectionEnabled?: boolean;
intrusionDetectionEnabled?: boolean;
}
export interface CCTVPlayerHandle {
capture: () => void;
}
type PlayerState = 'loading' | 'playing' | 'error' | 'offline' | 'no-url';
/** 외부 HLS URL을 백엔드 프록시 경유 URL로 변환 */
function toProxyUrl(url: string): string {
if (url.startsWith('http://') || url.startsWith('https://')) {
return `/api/aerial/cctv/stream-proxy?url=${encodeURIComponent(url)}`;
}
return url;
}
export const CCTVPlayer = forwardRef<CCTVPlayerHandle, CCTVPlayerProps>(({
cameraNm,
streamUrl,
sttsCd,
coordDc,
sourceNm,
cellIndex = 0,
oilDetectionEnabled = false,
vesselDetectionEnabled = false,
intrusionDetectionEnabled = false,
}, ref) => {
const videoRef = useRef<HTMLVideoElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const hlsRef = useRef<Hls | null>(null);
const [hlsPlayerState, setHlsPlayerState] = useState<'loading' | 'playing' | 'error'>('loading');
const [retryKey, setRetryKey] = useState(0);
/** 원본 URL 기반으로 타입 감지, 재생은 프록시 URL 사용 */
const proxiedUrl = useMemo(
() => (streamUrl ? toProxyUrl(streamUrl) : null),
[streamUrl],
);
/** props 기반으로 상태를 동기적으로 파생 */
const isOffline = sttsCd === 'OFFLINE' || sttsCd === 'MAINT';
const hasNoUrl = !isOffline && (!streamUrl || !proxiedUrl);
const streamType = useMemo(
() => (streamUrl && !isOffline ? detectStreamType(streamUrl) : null),
[streamUrl, isOffline],
);
const playerState: PlayerState = isOffline
? 'offline'
: hasNoUrl
? 'no-url'
: (streamType === 'mjpeg' || streamType === 'iframe')
? 'playing'
: hlsPlayerState;
const { result: oilResult, isAnalyzing: oilAnalyzing, error: oilError } = useOilDetection({
videoRef,
enabled: oilDetectionEnabled && playerState === 'playing' && (streamType === 'hls' || streamType === 'mp4'),
});
useImperativeHandle(ref, () => ({
capture: () => {
const container = containerRef.current;
if (!container) return;
const w = container.clientWidth;
const h = container.clientHeight;
const canvas = document.createElement('canvas');
canvas.width = w * 2;
canvas.height = h * 2;
const ctx = canvas.getContext('2d');
if (!ctx) return;
ctx.scale(2, 2);
// 1) video frame
const video = videoRef.current;
if (video && video.readyState >= 2) {
ctx.drawImage(video, 0, 0, w, h);
}
// 2) oil detection overlay
const overlayCanvas = container.querySelector<HTMLCanvasElement>('canvas');
if (overlayCanvas) {
ctx.drawImage(overlayCanvas, 0, 0, w, h);
}
// 3) OSD: camera name + timestamp
ctx.fillStyle = 'rgba(0,0,0,0.7)';
ctx.fillRect(8, 8, ctx.measureText(cameraNm).width + 20, 22);
ctx.font = 'bold 12px sans-serif';
ctx.fillStyle = '#ffffff';
ctx.fillText(cameraNm, 18, 23);
const ts = new Date().toLocaleString('ko-KR');
ctx.font = '10px monospace';
ctx.fillStyle = 'rgba(0,0,0,0.7)';
const tsW = ctx.measureText(ts).width + 16;
ctx.fillRect(8, h - 26, tsW, 20);
ctx.fillStyle = '#a0aec0';
ctx.fillText(ts, 16, h - 12);
// 4) oil detection info
if (oilResult && oilResult.regions.length > 0) {
const areaText = oilResult.totalAreaM2 >= 1000
? `오일 감지: ${(oilResult.totalAreaM2 / 1_000_000).toFixed(1)} km² (${oilResult.totalPercentage.toFixed(1)}%)`
: `오일 감지: ~${Math.round(oilResult.totalAreaM2)} m² (${oilResult.totalPercentage.toFixed(1)}%)`;
ctx.font = 'bold 11px sans-serif';
const atW = ctx.measureText(areaText).width + 16;
ctx.fillStyle = 'rgba(239,68,68,0.25)';
ctx.fillRect(8, h - 48, atW, 18);
ctx.fillStyle = '#f87171';
ctx.fillText(areaText, 16, h - 34);
}
// download
const link = document.createElement('a');
link.download = `CCTV_${cameraNm}_${new Date().toISOString().slice(0, 19).replace(/:/g, '')}.png`;
link.href = canvas.toDataURL('image/png');
link.click();
},
}), [cameraNm, oilResult]);
const destroyHls = useCallback(() => {
if (hlsRef.current) {
hlsRef.current.destroy();
hlsRef.current = null;
}
}, []);
useEffect(() => {
if (isOffline || hasNoUrl || !streamUrl || !proxiedUrl) {
destroyHls();
return;
}
const type = detectStreamType(streamUrl);
queueMicrotask(() => setHlsPlayerState('loading'));
if (type === 'hls') {
const video = videoRef.current;
if (!video) return;
if (Hls.isSupported()) {
destroyHls();
const hls = new Hls({
enableWorker: true,
lowLatencyMode: true,
maxBufferLength: 10,
maxMaxBufferLength: 30,
});
hlsRef.current = hls;
hls.loadSource(proxiedUrl);
hls.attachMedia(video);
hls.on(Hls.Events.MANIFEST_PARSED, () => {
setHlsPlayerState('playing');
video.play().catch(() => {});
});
hls.on(Hls.Events.ERROR, (_event, data) => {
if (data.fatal) {
setHlsPlayerState('error');
if (data.type === Hls.ErrorTypes.NETWORK_ERROR) {
setTimeout(() => hls.startLoad(), 3000);
}
}
});
return () => destroyHls();
}
// Safari 네이티브 HLS (프록시 경유)
if (video.canPlayType('application/vnd.apple.mpegurl')) {
video.src = proxiedUrl;
const onLoaded = () => setHlsPlayerState('playing');
const onError = () => setHlsPlayerState('error');
video.addEventListener('loadeddata', onLoaded);
video.addEventListener('error', onError);
video.play().catch(() => {});
return () => {
video.removeEventListener('loadeddata', onLoaded);
video.removeEventListener('error', onError);
};
}
queueMicrotask(() => setHlsPlayerState('error'));
return;
}
if (type === 'mp4') {
const video = videoRef.current;
if (!video) return;
video.src = proxiedUrl;
const onLoaded = () => setHlsPlayerState('playing');
const onError = () => setHlsPlayerState('error');
video.addEventListener('loadeddata', onLoaded);
video.addEventListener('error', onError);
video.play().catch(() => {});
return () => {
video.removeEventListener('loadeddata', onLoaded);
video.removeEventListener('error', onError);
};
}
if (type === 'mjpeg' || type === 'iframe') {
return;
}
queueMicrotask(() => setHlsPlayerState('error'));
return () => destroyHls();
}, [streamUrl, proxiedUrl, isOffline, hasNoUrl, destroyHls, retryKey]);
// 오프라인
if (playerState === 'offline') {
return (
<div className="absolute inset-0 flex flex-col items-center justify-center bg-[#0a0e18]">
<div className="text-2xl opacity-30 mb-2">📹</div>
<div className="text-[11px] font-korean text-text-3 opacity-70">
{sttsCd === 'MAINT' ? '점검중' : '오프라인'}
</div>
<div className="text-[9px] font-korean text-text-3 opacity-40 mt-1">{cameraNm}</div>
</div>
);
}
// URL 미설정
if (playerState === 'no-url') {
return (
<div className="absolute inset-0 flex flex-col items-center justify-center bg-[#0a0e18]">
<div className="text-2xl opacity-20 mb-2">📹</div>
<div className="text-[10px] font-korean text-text-3 opacity-50"> URL </div>
<div className="text-[9px] font-korean text-text-3 opacity-30 mt-1">{cameraNm}</div>
</div>
);
}
// 에러
if (playerState === 'error') {
return (
<div className="absolute inset-0 flex flex-col items-center justify-center bg-[#0a0e18]">
<div className="text-2xl opacity-30 mb-2"></div>
<div className="text-[10px] font-korean text-status-red opacity-70"> </div>
<div className="text-[9px] font-korean text-text-3 opacity-40 mt-1">{cameraNm}</div>
<button
onClick={() => setRetryKey(k => k + 1)}
className="mt-2 px-2.5 py-1 rounded text-[9px] font-korean bg-bg-3 border border-border text-text-2 cursor-pointer hover:bg-bg-hover transition-colors"
>
</button>
</div>
);
}
return (
<div ref={containerRef} className="absolute inset-0">
{/* 로딩 오버레이 */}
{playerState === 'loading' && (
<div className="absolute inset-0 flex flex-col items-center justify-center bg-[#0a0e18] z-10">
<div className="text-lg opacity-40 animate-pulse mb-2">📹</div>
<div className="text-[10px] font-korean text-text-3 opacity-50"> ...</div>
</div>
)}
{/* HLS / MP4 */}
{(streamType === 'hls' || streamType === 'mp4') && (
<video
ref={videoRef}
key={`video-${cellIndex}-${retryKey}`}
className="absolute inset-0 w-full h-full object-cover"
muted
autoPlay
playsInline
loop={streamType === 'mp4'}
/>
)}
{/* 오일 감지 오버레이 */}
{oilDetectionEnabled && (
<OilDetectionOverlay result={oilResult} isAnalyzing={oilAnalyzing} error={oilError} />
)}
{/* MJPEG */}
{streamType === 'mjpeg' && proxiedUrl && (
<img
src={proxiedUrl}
alt={cameraNm}
className="absolute inset-0 w-full h-full object-cover"
onError={() => setHlsPlayerState('error')}
/>
)}
{/* iframe (원본 URL 사용 — iframe은 자체 CORS) */}
{streamType === 'iframe' && streamUrl && (
<iframe
src={streamUrl}
title={cameraNm}
className="absolute inset-0 w-full h-full border-none"
allow="autoplay; encrypted-media"
onError={() => setHlsPlayerState('error')}
/>
)}
{/* 안전관리 감지 상태 배지 */}
{(vesselDetectionEnabled || intrusionDetectionEnabled) && (
<div className="absolute top-2 right-2 flex flex-col gap-1 z-20">
{vesselDetectionEnabled && (
<div className="flex items-center gap-1 px-1.5 py-0.5 rounded text-[8px] font-bold"
style={{ background: 'rgba(59,130,246,.3)', color: '#93c5fd' }}>
🚢
</div>
)}
{intrusionDetectionEnabled && (
<div className="flex items-center gap-1 px-1.5 py-0.5 rounded text-[8px] font-bold"
style={{ background: 'rgba(249,115,22,.3)', color: '#fdba74' }}>
🚨
</div>
)}
</div>
)}
{/* OSD 오버레이 */}
<div className="absolute top-2 left-2 flex items-center gap-1.5 z-20">
<span className="text-[9px] font-bold px-1.5 py-0.5 rounded bg-black/70 text-white">
{cameraNm}
</span>
{sttsCd === 'LIVE' && (
<span
className="text-[8px] font-bold px-1 py-0.5 rounded text-[#f87171]"
style={{ background: 'rgba(239,68,68,.3)' }}
>
REC
</span>
)}
</div>
<div className="absolute bottom-2 left-2 text-[9px] font-mono px-1.5 py-0.5 rounded text-text-3 bg-black/70 z-20">
{coordDc ?? ''}{sourceNm ? ` · ${sourceNm}` : ''}
</div>
</div>
);
});
CCTVPlayer.displayName = 'CCTVPlayer';