## 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>
193 lines
5.6 KiB
TypeScript
Executable File
193 lines
5.6 KiB
TypeScript
Executable File
// KHOA (국립해양조사원) API 서비스
|
|
|
|
// API Key를 환경변수에서 로드 (소스코드 노출 방지)
|
|
const API_KEY = import.meta.env.VITE_DATA_GO_KR_API_KEY || ''
|
|
const BASE_URL = 'https://apis.data.go.kr/1192136/oceanCondition/GetOceanConditionApiService'
|
|
const RECENT_OBS_URL = 'https://apis.data.go.kr/1192136/dtRecent/GetDTRecentApiService'
|
|
|
|
// 지역 유형 (총 20개 지역)
|
|
export const OCEAN_REGIONS = {
|
|
전국: 'KOREA',
|
|
인천: 'INCHEON',
|
|
군산: 'GUNSAN',
|
|
목포: 'MOKPO',
|
|
제주: 'JEJU',
|
|
여수: 'YEOSU',
|
|
통영: 'TONGYEONG',
|
|
부산: 'BUSAN',
|
|
울산: 'ULSAN',
|
|
포항: 'POHANG',
|
|
속초: 'SOKCHO',
|
|
동해: 'DONGHAE'
|
|
} as const
|
|
|
|
export interface OceanForecastData {
|
|
imgFileNm: string // 이미지 파일명
|
|
imgFilePath: string // 이미지 파일경로
|
|
ofcBrnchId: string // 해황예보도 지점코드
|
|
ofcBrnchNm: string // 해황예보도 지점이름
|
|
ofcFrcstTm: string // 해황예보도 예보시각
|
|
ofcFrcstYmd: string // 해황예보도 예보일자
|
|
}
|
|
|
|
interface OceanForecastApiResponse {
|
|
header: { resultCode: string; resultMsg: string }
|
|
body: {
|
|
items: { item: OceanForecastData[] }
|
|
pageNo: number
|
|
numOfRows: number
|
|
totalCount: number
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 해황예보도 조회
|
|
* @param regionType 지역 유형 (KOREA, INCHEON, BUSAN 등)
|
|
* @returns 해황예보 이미지 데이터 배열
|
|
*/
|
|
export async function getOceanForecast(
|
|
regionType: string = 'KOREA'
|
|
): Promise<OceanForecastData[]> {
|
|
try {
|
|
const params = new URLSearchParams({
|
|
serviceKey: API_KEY,
|
|
areaCode: regionType,
|
|
type: 'json',
|
|
})
|
|
|
|
const response = await fetch(`${BASE_URL}?${params}`)
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`HTTP Error: ${response.status}`)
|
|
}
|
|
|
|
const data = await response.json() as OceanForecastApiResponse
|
|
return data?.body?.items?.item ?? []
|
|
|
|
} catch (error) {
|
|
console.error('해황예보도 조회 오류:', error)
|
|
throw error
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 현재 시간에 가장 가까운 예보 데이터 찾기
|
|
* @param forecasts 예보 데이터 배열
|
|
* @returns 가장 최근 예보 데이터
|
|
*/
|
|
export function getLatestForecast(forecasts: OceanForecastData[]): OceanForecastData | null {
|
|
if (!forecasts || forecasts.length === 0) return null
|
|
|
|
const sorted = [...forecasts].sort((a, b) => {
|
|
const dateTimeA = `${a.ofcFrcstYmd}${a.ofcFrcstTm}`
|
|
const dateTimeB = `${b.ofcFrcstYmd}${b.ofcFrcstTm}`
|
|
return dateTimeB.localeCompare(dateTimeA)
|
|
})
|
|
|
|
return sorted[0]
|
|
}
|
|
|
|
/**
|
|
* 특정 시간대의 예보 데이터 필터링
|
|
* @param forecasts 예보 데이터 배열
|
|
* @param targetDay 목표 날짜 (YYYYMMDD)
|
|
* @param targetHour 목표 시간 (HH)
|
|
* @returns 필터링된 예보 데이터
|
|
*/
|
|
export function getForecastByTime(
|
|
forecasts: OceanForecastData[],
|
|
targetDay: string,
|
|
targetHour: string
|
|
): OceanForecastData | null {
|
|
return (
|
|
forecasts.find((f) => f.ofcFrcstYmd === targetDay && f.ofcFrcstTm === targetHour) || null
|
|
)
|
|
}
|
|
|
|
// 조위관측소 코드 매핑
|
|
export const OBS_STATION_CODES: Record<string, string> = {
|
|
incheon: 'DT_0001',
|
|
gunsan: 'DT_0005',
|
|
mokpo: 'DT_0008',
|
|
yeosu: 'DT_0010',
|
|
tongyeong: 'DT_0012',
|
|
ulsan: 'DT_0015',
|
|
pohang: 'DT_0016',
|
|
donghae: 'DT_0018',
|
|
sokcho: 'DT_0019',
|
|
jeju: 'DT_0020',
|
|
}
|
|
|
|
export interface RecentObservation {
|
|
water_temp: number | null // 수온 (°C)
|
|
air_temp: number | null // 기온 (°C)
|
|
air_pres: number | null // 기압 (hPa)
|
|
wind_dir: number | null // 풍향 (°)
|
|
wind_speed: number | null // 풍속 (m/s)
|
|
current_dir: number | null // 유향 (°)
|
|
current_speed: number | null // 유속 (m/s)
|
|
tide_level: number | null // 조위 (cm)
|
|
}
|
|
|
|
/**
|
|
* 조위관측소 최신 관측데이터 조회
|
|
*/
|
|
export async function getRecentObservation(obsCode: string): Promise<RecentObservation | null> {
|
|
try {
|
|
const now = new Date()
|
|
const reqDate = formatDateForAPI(now)
|
|
|
|
const params = new URLSearchParams({
|
|
serviceKey: API_KEY,
|
|
obsCode,
|
|
type: 'json',
|
|
reqDate,
|
|
})
|
|
|
|
const response = await fetch(`${RECENT_OBS_URL}?${params}`)
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
|
|
}
|
|
|
|
const data = await response.json()
|
|
const item = data.body ? data.body.items.item[0] : null
|
|
if (!item) return null
|
|
|
|
|
|
return {
|
|
water_temp: item.wtem != null ? parseFloat(item.wtem) : null,
|
|
air_temp: item.artmp != null ? parseFloat(item.artmp) : null,
|
|
air_pres: item.atmpr != null ? parseFloat(item.atmpr) : null,
|
|
wind_dir: item.wndrct != null ? parseFloat(item.wndrct) : null,
|
|
wind_speed: item.wspd != null ? parseFloat(item.wspd) : null,
|
|
current_dir: item.crdir != null ? parseFloat(item.crdir) : null,
|
|
current_speed: item.crsp != null ? parseFloat(item.crsp) : null,
|
|
tide_level: item.bscTdlvHgt != null ? parseFloat(item.bscTdlvHgt) : null,
|
|
}
|
|
} catch (error) {
|
|
console.error(`관측소 ${obsCode} 데이터 조회 오류:`, error)
|
|
return null
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 날짜 문자열을 YYYYMMDD 형식으로 변환
|
|
*/
|
|
export function formatDateForAPI(date: Date): string {
|
|
const year = date.getFullYear()
|
|
const month = String(date.getMonth() + 1).padStart(2, '0')
|
|
const day = String(date.getDate()).padStart(2, '0')
|
|
return `${year}${month}${day}`
|
|
}
|
|
|
|
/**
|
|
* 시간을 HH 형식으로 변환 (3시간 단위로 반올림)
|
|
*/
|
|
export function formatHourForAPI(date: Date): string {
|
|
const hour = date.getHours()
|
|
// 3시간 단위로 반올림 (00, 03, 06, 09, 12, 15, 18, 21)
|
|
const roundedHour = Math.floor(hour / 3) * 3
|
|
return String(roundedHour).padStart(2, '0')
|
|
}
|