CCTV 실시간 영상: - CCTVPlayer 컴포넌트 (hls.js 기반 HLS/MJPEG/MP4 재생) - 백엔드 HLS 프록시 엔드포인트 (CORS 우회, m3u8 URL 재작성) - KHOA 15개 + KBS 6개 실제 해안 CCTV 연동 - Vite dev proxy, 스트림 타입 자동 감지 유틸리티 HNS 분석: - HNS 시나리오 저장/불러오기/재계산 기능 - 물질 DB 검색 및 상세 정보 연동 - 좌표/파라미터 입력 UI 개선 - Python 확산 모델 스크립트 (hns_dispersion.py) 공통: - 3D 지도 토글, 보고서 생성 개선 - useSubMenu 훅, mapUtils 확장 - ESLint set-state-in-effect 수정 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
126 lines
3.7 KiB
TypeScript
126 lines
3.7 KiB
TypeScript
import { useState, useEffect, useRef } from 'react';
|
|
import {
|
|
convertToGridCoords,
|
|
getUltraShortForecast,
|
|
getCurrentBaseDateTime,
|
|
} from '@tabs/weather/services/weatherApi';
|
|
import type { StabilityClass, WeatherFetchResult } from '../utils/dispersionTypes';
|
|
|
|
/**
|
|
* Turner 간이법으로 Pasquill-Gifford 안정도 산출
|
|
* 풍속 + 시간대(주간/야간) 기반
|
|
*/
|
|
function deriveStabilityClass(windSpeed: number, hour: number): StabilityClass {
|
|
const isNight = hour < 6 || hour >= 18;
|
|
if (isNight) {
|
|
if (windSpeed < 2) return 'F';
|
|
if (windSpeed < 3) return 'E';
|
|
return 'D';
|
|
}
|
|
// 주간 (중간 수준 일사량 가정)
|
|
if (windSpeed < 2) return 'A';
|
|
if (windSpeed < 3) return 'B';
|
|
if (windSpeed < 5) return 'C';
|
|
return 'D';
|
|
}
|
|
|
|
/** 풍향(도) → 16방위 문자열 */
|
|
export function windDirToCompass(deg: number): string {
|
|
const dirs = ['N', 'NNE', 'NE', 'ENE', 'E', 'ESE', 'SE', 'SSE',
|
|
'S', 'SSW', 'SW', 'WSW', 'W', 'WNW', 'NW', 'NNW'];
|
|
const idx = Math.round(((deg % 360) + 360) % 360 / 22.5) % 16;
|
|
return dirs[idx];
|
|
}
|
|
|
|
const DEFAULT_WEATHER: WeatherFetchResult = {
|
|
windSpeed: 5.0,
|
|
windDirection: 270,
|
|
temperature: 15,
|
|
humidity: 60,
|
|
stability: 'D',
|
|
isLoading: false,
|
|
error: null,
|
|
lastUpdate: null,
|
|
};
|
|
|
|
/**
|
|
* 위치 기반 기상정보 자동조회 훅
|
|
* KMA 초단기실황 API 활용, 500ms 디바운스
|
|
* baseDate: 'YYYY-MM-DD' (선택), baseTime: 'HH:mm' (선택)
|
|
* 미제공 시 getCurrentBaseDateTime()으로 현재 시각 사용
|
|
*/
|
|
export function useWeatherFetch(lat: number, lon: number, baseDate?: string, baseTime?: string): WeatherFetchResult {
|
|
const [weather, setWeather] = useState<WeatherFetchResult>({
|
|
...DEFAULT_WEATHER,
|
|
isLoading: true,
|
|
});
|
|
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
|
|
useEffect(() => {
|
|
if (timerRef.current) clearTimeout(timerRef.current);
|
|
|
|
timerRef.current = setTimeout(async () => {
|
|
setWeather(prev => ({ ...prev, isLoading: true, error: null }));
|
|
|
|
try {
|
|
const { nx, ny } = convertToGridCoords(lat, lon);
|
|
|
|
let apiBaseDate: string;
|
|
let apiBaseTime: string;
|
|
let stabilityHour: number;
|
|
|
|
if (baseDate && baseTime) {
|
|
// 'YYYY-MM-DD' → 'YYYYMMDD'
|
|
apiBaseDate = baseDate.replace(/-/g, '');
|
|
// 'HH:mm' → 'HH00'
|
|
apiBaseTime = baseTime.slice(0, 2) + '00';
|
|
stabilityHour = parseInt(baseTime.slice(0, 2), 10);
|
|
} else {
|
|
const current = getCurrentBaseDateTime();
|
|
apiBaseDate = current.baseDate;
|
|
apiBaseTime = current.baseTime;
|
|
stabilityHour = new Date().getHours();
|
|
}
|
|
|
|
const forecasts = await getUltraShortForecast(nx, ny, apiBaseDate, apiBaseTime);
|
|
|
|
if (forecasts.length > 0) {
|
|
const f = forecasts[0];
|
|
const stability = deriveStabilityClass(f.windSpeed, stabilityHour);
|
|
|
|
setWeather({
|
|
windSpeed: f.windSpeed,
|
|
windDirection: f.windDirection,
|
|
temperature: f.temperature,
|
|
humidity: f.humidity,
|
|
stability,
|
|
isLoading: false,
|
|
error: null,
|
|
lastUpdate: new Date(),
|
|
});
|
|
} else {
|
|
setWeather({
|
|
...DEFAULT_WEATHER,
|
|
isLoading: false,
|
|
error: 'API 응답 데이터 없음',
|
|
lastUpdate: new Date(),
|
|
});
|
|
}
|
|
} catch {
|
|
setWeather({
|
|
...DEFAULT_WEATHER,
|
|
isLoading: false,
|
|
error: 'KMA API 조회 실패 (기본값 사용)',
|
|
lastUpdate: new Date(),
|
|
});
|
|
}
|
|
}, 500);
|
|
|
|
return () => {
|
|
if (timerRef.current) clearTimeout(timerRef.current);
|
|
};
|
|
}, [lat, lon, baseDate, baseTime]);
|
|
|
|
return weather;
|
|
}
|