feat(weather): KHOA API 엔드포인트 교체 및 해양예측 오버레이 Canvas 렌더링 전환

This commit is contained in:
leedano 2026-03-11 10:42:27 +09:00
부모 ea529cb510
커밋 c87b5085f7
6개의 변경된 파일178개의 추가작업 그리고 162개의 파일을 삭제

파일 보기

@ -1,5 +1,5 @@
import { useEffect, useState } from 'react' import { useEffect, useRef } from 'react'
import { Source, Layer } from '@vis.gl/react-maplibre' import { useMap } from '@vis.gl/react-maplibre'
import type { OceanForecastData } from '../services/khoaApi' import type { OceanForecastData } from '../services/khoaApi'
interface OceanForecastOverlayProps { interface OceanForecastOverlayProps {
@ -8,62 +8,118 @@ interface OceanForecastOverlayProps {
visible?: boolean visible?: boolean
} }
// 한국 해역 범위 (MapLibre image source용 좌표 배열) // 한국 해역 범위 [lon, lat]
// [left, bottom, right, top] → MapLibre coordinates 순서: [sw, nw, ne, se] const BOUNDS = {
// [lon, lat] 순서 nw: [124.5, 38.5] as [number, number],
const KOREA_IMAGE_COORDINATES: [[number, number], [number, number], [number, number], [number, number]] = [ ne: [132.0, 38.5] as [number, number],
[124.5, 33.0], // 남서 (제주 남쪽) se: [132.0, 33.0] as [number, number],
[124.5, 38.5], // 북서 sw: [124.5, 33.0] as [number, number],
[132.0, 38.5], // 북동 (동해 북쪽) }
[132.0, 33.0], // 남동
] // www.khoa.go.kr 이미지는 CORS 미지원 → Vite 프록시 경유
function toProxyUrl(url: string): string {
return url.replace('https://www.khoa.go.kr', '')
}
/** /**
* OceanForecastOverlay * OceanForecastOverlay
* *
* 기존: react-leaflet ImageOverlay + LatLngBounds * MapLibre raster layer는 deck.gl ,
* : @vis.gl/react-maplibre Source(type=image) + Layer(type=raster) * WindParticleLayer와 canvas를 map .
* * z-index 500 WindParticleLayer(450) .
* MapLibre image source는 Map
*/ */
export function OceanForecastOverlay({ export function OceanForecastOverlay({
forecast, forecast,
opacity = 0.6, opacity = 0.6,
visible = true, visible = true,
}: OceanForecastOverlayProps) { }: OceanForecastOverlayProps) {
const [loadedUrl, setLoadedUrl] = useState<string | null>(null) const { current: mapRef } = useMap()
const canvasRef = useRef<HTMLCanvasElement | null>(null)
const imgRef = useRef<HTMLImageElement | null>(null)
useEffect(() => { useEffect(() => {
if (!forecast?.filePath) return const map = mapRef?.getMap()
let cancelled = false if (!map) return
const img = new Image()
img.onload = () => { if (!cancelled) setLoadedUrl(forecast.filePath) }
img.onerror = () => { if (!cancelled) setLoadedUrl(null) }
img.src = forecast.filePath
return () => { cancelled = true }
}, [forecast?.filePath])
const imageLoaded = !!loadedUrl && loadedUrl === forecast?.filePath const container = map.getContainer()
if (!visible || !forecast || !imageLoaded) { // canvas 생성 (최초 1회)
return null if (!canvasRef.current) {
} const canvas = document.createElement('canvas')
canvas.style.position = 'absolute'
canvas.style.top = '0'
canvas.style.left = '0'
canvas.style.pointerEvents = 'none'
canvas.style.zIndex = '500' // WindParticleLayer(450) 위
container.appendChild(canvas)
canvasRef.current = canvas
}
return ( const canvas = canvasRef.current
<Source
id="ocean-forecast-image" if (!visible || !forecast?.imgFilePath) {
type="image" canvas.style.display = 'none'
url={forecast.filePath} return
coordinates={KOREA_IMAGE_COORDINATES} }
>
<Layer canvas.style.display = 'block'
id="ocean-forecast-raster" const proxyUrl = toProxyUrl(forecast.imgFilePath)
type="raster"
paint={{ function draw() {
'raster-opacity': opacity, const img = imgRef.current
'raster-resampling': 'linear', if (!canvas || !img || !img.complete || img.naturalWidth === 0) return
}}
/> const { clientWidth: w, clientHeight: h } = container
</Source> canvas.width = w
) canvas.height = h
const ctx = canvas.getContext('2d')
if (!ctx) return
ctx.clearRect(0, 0, w, h)
// 4개 꼭짓점을 픽셀 좌표로 변환
const nw = map!.project(BOUNDS.nw)
const ne = map!.project(BOUNDS.ne)
const sw = map!.project(BOUNDS.sw)
const x = Math.min(nw.x, sw.x)
const y = nw.y
const w2 = ne.x - nw.x
const h2 = sw.y - nw.y
ctx.globalAlpha = opacity
ctx.drawImage(img, x, y, w2, h2)
}
// 이미지가 바뀌었으면 새로 로드
if (!imgRef.current || imgRef.current.dataset.src !== proxyUrl) {
const img = new Image()
img.dataset.src = proxyUrl
img.onload = draw
img.src = proxyUrl
imgRef.current = img
} else {
draw()
}
map.on('move', draw)
map.on('zoom', draw)
map.on('resize', draw)
return () => {
map.off('move', draw)
map.off('zoom', draw)
map.off('resize', draw)
}
}, [mapRef, visible, forecast?.imgFilePath, opacity])
// 언마운트 시 canvas 제거
useEffect(() => {
return () => {
canvasRef.current?.remove()
canvasRef.current = null
}
}, [])
return null
} }

파일 보기

@ -77,8 +77,6 @@ export function WeatherMapOverlay({
stations.map((station) => { stations.map((station) => {
const isSelected = selectedStationId === station.id const isSelected = selectedStationId === station.id
const color = getWindHexColor(station.wind.speed, isSelected) const color = getWindHexColor(station.wind.speed, isSelected)
const size = Math.min(40 + station.wind.speed * 2, 80)
return ( return (
<Marker <Marker
key={`wind-${station.id}`} key={`wind-${station.id}`}
@ -87,35 +85,34 @@ export function WeatherMapOverlay({
anchor="center" anchor="center"
onClick={() => onStationClick(station)} onClick={() => onStationClick(station)}
> >
<div <div className="flex items-center gap-1 cursor-pointer">
style={{ <div style={{ transform: `rotate(${station.wind.direction}deg)` }}>
width: size, <svg
height: size, width={24}
transform: `rotate(${station.wind.direction}deg)`, height={24}
}} viewBox="0 0 24 24"
className="flex items-center justify-center cursor-pointer" style={{ filter: 'drop-shadow(0 2px 4px rgba(0,0,0,0.3))' }}
> >
<svg {/* 위쪽이 바람 방향을 나타내는 삼각형 */}
width={size} <polygon
height={size} points="12,2 4,22 12,16 20,22"
viewBox="0 0 24 24" fill={color}
style={{ filter: 'drop-shadow(0 2px 4px rgba(0,0,0,0.3))' }} opacity="0.9"
/>
</svg>
</div>
<span
style={{ color, textShadow: '0 1px 3px rgba(0,0,0,0.8)' }}
className="text-xs font-bold leading-none"
> >
<path {station.wind.speed.toFixed(1)}
d="M12 2L12 20M12 2L8 6M12 2L16 6M12 20L8 16M12 20L16 16" </span>
stroke={color}
strokeWidth="2.5"
fill="none"
strokeLinecap="round"
/>
<circle cx="12" cy="12" r="3" fill={color} opacity="0.8" />
</svg>
</div> </div>
</Marker> </Marker>
) )
})} })}
{/* 기상 데이터 라벨 — MapLibre Marker */} {/*
{enabledLayers.has('labels') && {enabledLayers.has('labels') &&
stations.map((station) => { stations.map((station) => {
const isSelected = selectedStationId === station.id const isSelected = selectedStationId === station.id
@ -139,7 +136,6 @@ export function WeatherMapOverlay({
}} }}
className="rounded-[10px] p-2 flex flex-col gap-1 min-w-[70px] cursor-pointer" className="rounded-[10px] p-2 flex flex-col gap-1 min-w-[70px] cursor-pointer"
> >
{/* 관측소명 */}
<div <div
style={{ style={{
color: textColor, color: textColor,
@ -150,8 +146,6 @@ export function WeatherMapOverlay({
> >
{station.name} {station.name}
</div> </div>
{/* 수온 */}
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<div <div
className="w-6 h-6 rounded-full flex items-center justify-center text-[11px] text-white font-bold" className="w-6 h-6 rounded-full flex items-center justify-center text-[11px] text-white font-bold"
@ -160,22 +154,12 @@ export function WeatherMapOverlay({
🌡 🌡
</div> </div>
<div className="flex items-baseline gap-0.5"> <div className="flex items-baseline gap-0.5">
<span <span className="text-sm font-bold text-white" style={{ textShadow: '1px 1px 2px rgba(0,0,0,0.5)' }}>
className="text-sm font-bold text-white"
style={{ textShadow: '1px 1px 2px rgba(0,0,0,0.5)' }}
>
{station.temperature.current.toFixed(1)} {station.temperature.current.toFixed(1)}
</span> </span>
<span <span className="text-[10px] text-white opacity-90" style={{ textShadow: '1px 1px 2px rgba(0,0,0,0.5)' }}>°C</span>
className="text-[10px] text-white opacity-90"
style={{ textShadow: '1px 1px 2px rgba(0,0,0,0.5)' }}
>
°C
</span>
</div> </div>
</div> </div>
{/* 파고 */}
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<div <div
className="w-6 h-6 rounded-full flex items-center justify-center text-[11px] text-white font-bold" className="w-6 h-6 rounded-full flex items-center justify-center text-[11px] text-white font-bold"
@ -184,22 +168,12 @@ export function WeatherMapOverlay({
🌊 🌊
</div> </div>
<div className="flex items-baseline gap-0.5"> <div className="flex items-baseline gap-0.5">
<span <span className="text-sm font-bold text-white" style={{ textShadow: '1px 1px 2px rgba(0,0,0,0.5)' }}>
className="text-sm font-bold text-white"
style={{ textShadow: '1px 1px 2px rgba(0,0,0,0.5)' }}
>
{station.wave.height.toFixed(1)} {station.wave.height.toFixed(1)}
</span> </span>
<span <span className="text-[10px] text-white opacity-90" style={{ textShadow: '1px 1px 2px rgba(0,0,0,0.5)' }}>m</span>
className="text-[10px] text-white opacity-90"
style={{ textShadow: '1px 1px 2px rgba(0,0,0,0.5)' }}
>
m
</span>
</div> </div>
</div> </div>
{/* 풍속 */}
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<div <div
className="w-6 h-6 rounded-full flex items-center justify-center text-[11px] text-white font-bold" className="w-6 h-6 rounded-full flex items-center justify-center text-[11px] text-white font-bold"
@ -208,24 +182,17 @@ export function WeatherMapOverlay({
💨 💨
</div> </div>
<div className="flex items-baseline gap-0.5"> <div className="flex items-baseline gap-0.5">
<span <span className="text-sm font-bold text-white" style={{ textShadow: '1px 1px 2px rgba(0,0,0,0.5)' }}>
className="text-sm font-bold text-white"
style={{ textShadow: '1px 1px 2px rgba(0,0,0,0.5)' }}
>
{station.wind.speed.toFixed(1)} {station.wind.speed.toFixed(1)}
</span> </span>
<span <span className="text-[10px] text-white opacity-90" style={{ textShadow: '1px 1px 2px rgba(0,0,0,0.5)' }}>m/s</span>
className="text-[10px] text-white opacity-90"
style={{ textShadow: '1px 1px 2px rgba(0,0,0,0.5)' }}
>
m/s
</span>
</div> </div>
</div> </div>
</div> </div>
</Marker> </Marker>
) )
})} })}
*/}
</> </>
) )
} }

파일 보기

@ -223,7 +223,6 @@ function WeatherMapInner({
export function WeatherView() { export function WeatherView() {
const { weatherStations, loading, error, lastUpdate } = useWeatherData(BASE_STATIONS) const { weatherStations, loading, error, lastUpdate } = useWeatherData(BASE_STATIONS)
console.log(weatherStations,'날씨');
const { const {
selectedForecast, selectedForecast,
@ -238,7 +237,7 @@ export function WeatherView() {
const [selectedLocation, setSelectedLocation] = useState<{ lat: number; lon: number } | null>( const [selectedLocation, setSelectedLocation] = useState<{ lat: number; lon: number } | null>(
null null
) )
const [enabledLayers, setEnabledLayers] = useState<Set<string>>(new Set(['wind', 'labels'])) const [enabledLayers, setEnabledLayers] = useState<Set<string>>(new Set(['wind']))
const [oceanForecastOpacity, setOceanForecastOpacity] = useState(0.6) const [oceanForecastOpacity, setOceanForecastOpacity] = useState(0.6)
// 첫 관측소 자동 선택 (파생 값) // 첫 관측소 자동 선택 (파생 값)
@ -393,6 +392,7 @@ export function WeatherView() {
/> />
<span className="text-xs text-text-2">🌬 </span> <span className="text-xs text-text-2">🌬 </span>
</label> </label>
{/*
<label className="flex items-center gap-2 cursor-pointer"> <label className="flex items-center gap-2 cursor-pointer">
<input <input
type="checkbox" type="checkbox"
@ -402,6 +402,7 @@ export function WeatherView() {
/> />
<span className="text-xs text-text-2">📊 </span> <span className="text-xs text-text-2">📊 </span>
</label> </label>
*/}
<label className="flex items-center gap-2 cursor-pointer"> <label className="flex items-center gap-2 cursor-pointer">
<input <input
type="checkbox" type="checkbox"
@ -479,8 +480,8 @@ export function WeatherView() {
key={`${time.day}-${time.hour}`} key={`${time.day}-${time.hour}`}
onClick={() => selectForecast(time.day, time.hour)} onClick={() => selectForecast(time.day, time.hour)}
className={`w-full px-2 py-1 text-xs rounded transition-colors ${ className={`w-full px-2 py-1 text-xs rounded transition-colors ${
selectedForecast?.day === time.day && selectedForecast?.ofcFrcstYmd === time.day &&
selectedForecast?.hour === time.hour selectedForecast?.ofcFrcstTm === time.hour
? 'bg-primary-cyan text-bg-0 font-semibold' ? 'bg-primary-cyan text-bg-0 font-semibold'
: 'bg-bg-2 text-text-3 hover:bg-bg-3' : 'bg-bg-2 text-text-3 hover:bg-bg-3'
}`} }`}
@ -496,9 +497,9 @@ export function WeatherView() {
{oceanError && <div className="text-xs text-status-red"> </div>} {oceanError && <div className="text-xs text-status-red"> </div>}
{selectedForecast && ( {selectedForecast && (
<div className="text-xs text-text-3 pt-2 border-t border-border"> <div className="text-xs text-text-3 pt-2 border-t border-border">
: {selectedForecast.name} {' '} : {selectedForecast.ofcBrnchNm} {' '}
{selectedForecast.day.slice(4, 6)}/{selectedForecast.day.slice(6, 8)}{' '} {selectedForecast.ofcFrcstYmd.slice(4, 6)}/{selectedForecast.ofcFrcstYmd.slice(6, 8)}{' '}
{selectedForecast.hour}:00 {selectedForecast.ofcFrcstTm}:00
</div> </div>
)} )}
</div> </div>

파일 보기

@ -70,9 +70,9 @@ export function useOceanForecast(
// 사용 가능한 시간대 목록 생성 // 사용 가능한 시간대 목록 생성
const availableTimes = forecasts const availableTimes = forecasts
.map((f) => ({ .map((f) => ({
day: f.day, day: f.ofcFrcstYmd,
hour: f.hour, hour: f.ofcFrcstTm,
label: `${f.day.slice(4, 6)}/${f.day.slice(6, 8)} ${f.hour}:00` label: `${f.ofcFrcstYmd.slice(4, 6)}/${f.ofcFrcstYmd.slice(6, 8)} ${f.ofcFrcstTm}:00`
})) }))
.sort((a, b) => `${a.day}${a.hour}`.localeCompare(`${b.day}${b.hour}`)) .sort((a, b) => `${a.day}${a.hour}`.localeCompare(`${b.day}${b.hour}`))

파일 보기

@ -2,7 +2,7 @@
// API Key를 환경변수에서 로드 (소스코드 노출 방지) // API Key를 환경변수에서 로드 (소스코드 노출 방지)
const API_KEY = import.meta.env.VITE_DATA_GO_KR_API_KEY || '' const API_KEY = import.meta.env.VITE_DATA_GO_KR_API_KEY || ''
const BASE_URL = 'https://www.khoa.go.kr/api/oceangrid/DataType/search.do' const BASE_URL = 'https://apis.data.go.kr/1192136/oceanCondition/GetOceanConditionApiService'
const RECENT_OBS_URL = 'https://apis.data.go.kr/1192136/dtRecent/GetDTRecentApiService' const RECENT_OBS_URL = 'https://apis.data.go.kr/1192136/dtRecent/GetDTRecentApiService'
// 지역 유형 (총 20개 지역) // 지역 유형 (총 20개 지역)
@ -22,20 +22,21 @@ export const OCEAN_REGIONS = {
} as const } as const
export interface OceanForecastData { export interface OceanForecastData {
name: string // 지역 imgFileNm: string // 이미지 파일
type: string // 지역유형 imgFilePath: string // 이미지 파일경로
day: string // 예보날짜 (YYYYMMDD) ofcBrnchId: string // 해황예보도 지점코드
hour: string // 예보시간 (HH) ofcBrnchNm: string // 해황예보도 지점이름
fileName: string // 이미지 파일명 ofcFrcstTm: string // 해황예보도 예보시각
filePath: string // 해양예측 이미지 URL ofcFrcstYmd: string // 해황예보도 예보일자
} }
export interface OceanForecastResponse { interface OceanForecastApiResponse {
result: { header: { resultCode: string; resultMsg: string }
data: OceanForecastData[] body: {
meta: { items: { item: OceanForecastData[] }
totalCount: number pageNo: number
} numOfRows: number
totalCount: number
} }
} }
@ -49,9 +50,9 @@ export async function getOceanForecast(
): Promise<OceanForecastData[]> { ): Promise<OceanForecastData[]> {
try { try {
const params = new URLSearchParams({ const params = new URLSearchParams({
ServiceKey: API_KEY, serviceKey: API_KEY,
type: regionType, areaCode: regionType,
ResultType: 'json' type: 'json',
}) })
const response = await fetch(`${BASE_URL}?${params}`) const response = await fetch(`${BASE_URL}?${params}`)
@ -60,20 +61,8 @@ export async function getOceanForecast(
throw new Error(`HTTP Error: ${response.status}`) throw new Error(`HTTP Error: ${response.status}`)
} }
const data = await response.json() const data = await response.json() as OceanForecastApiResponse
return data?.body?.items?.item ?? []
// API 응답 구조에 따라 데이터 추출
if (data?.result?.data) {
return data.result.data
}
// 응답이 배열 형태인 경우
if (Array.isArray(data)) {
return data
}
console.warn('Unexpected API response structure:', data)
return []
} catch (error) { } catch (error) {
console.error('해황예보도 조회 오류:', error) console.error('해황예보도 조회 오류:', error)
@ -89,10 +78,9 @@ export async function getOceanForecast(
export function getLatestForecast(forecasts: OceanForecastData[]): OceanForecastData | null { export function getLatestForecast(forecasts: OceanForecastData[]): OceanForecastData | null {
if (!forecasts || forecasts.length === 0) return null if (!forecasts || forecasts.length === 0) return null
// 날짜와 시간을 기준으로 정렬
const sorted = [...forecasts].sort((a, b) => { const sorted = [...forecasts].sort((a, b) => {
const dateTimeA = `${a.day}${a.hour}` const dateTimeA = `${a.ofcFrcstYmd}${a.ofcFrcstTm}`
const dateTimeB = `${b.day}${b.hour}` const dateTimeB = `${b.ofcFrcstYmd}${b.ofcFrcstTm}`
return dateTimeB.localeCompare(dateTimeA) return dateTimeB.localeCompare(dateTimeA)
}) })
@ -112,7 +100,7 @@ export function getForecastByTime(
targetHour: string targetHour: string
): OceanForecastData | null { ): OceanForecastData | null {
return ( return (
forecasts.find((f) => f.day === targetDay && f.hour === targetHour) || null forecasts.find((f) => f.ofcFrcstYmd === targetDay && f.ofcFrcstTm === targetHour) || null
) )
} }
@ -157,25 +145,25 @@ export async function getRecentObservation(obsCode: string): Promise<RecentObser
}) })
const response = await fetch(`${RECENT_OBS_URL}?${params}`) const response = await fetch(`${RECENT_OBS_URL}?${params}`)
console.log(response,'리스폰스');
if (!response.ok) { if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`) throw new Error(`HTTP ${response.status}: ${response.statusText}`)
} }
const data = await response.json() const data = await response.json()
const item = data?.result?.data?.[0] const item = data.body ? data.body.items.item[0] : null
if (!item) return null if (!item) return null
return { return {
water_temp: item.water_temp != null ? parseFloat(item.water_temp) : null, water_temp: item.wtem != null ? parseFloat(item.wtem) : null,
air_temp: item.air_temp != null ? parseFloat(item.air_temp) : null, air_temp: item.artmp != null ? parseFloat(item.artmp) : null,
air_pres: item.air_pres != null ? parseFloat(item.air_pres) : null, air_pres: item.atmpr != null ? parseFloat(item.atmpr) : null,
wind_dir: item.wind_dir != null ? parseFloat(item.wind_dir) : null, wind_dir: item.wndrct != null ? parseFloat(item.wndrct) : null,
wind_speed: item.wind_speed != null ? parseFloat(item.wind_speed) : null, wind_speed: item.wspd != null ? parseFloat(item.wspd) : null,
current_dir: item.current_dir != null ? parseFloat(item.current_dir) : null, current_dir: item.crdir != null ? parseFloat(item.crdir) : null,
current_speed: item.current_speed != null ? parseFloat(item.current_speed) : null, current_speed: item.crsp != null ? parseFloat(item.crsp) : null,
tide_level: item.tide_level != null ? parseFloat(item.tide_level) : null, tide_level: item.bscTdlvHgt != null ? parseFloat(item.bscTdlvHgt) : null,
} }
} catch (error) { } catch (error) {
console.error(`관측소 ${obsCode} 데이터 조회 오류:`, error) console.error(`관측소 ${obsCode} 데이터 조회 오류:`, error)

파일 보기

@ -12,6 +12,10 @@ export default defineConfig({
target: 'http://localhost:3001', target: 'http://localhost:3001',
changeOrigin: true, changeOrigin: true,
}, },
'/daily_ocean': {
target: 'https://www.khoa.go.kr',
changeOrigin: true,
},
}, },
}, },
resolve: { resolve: {