diff --git a/frontend/src/tabs/weather/components/OceanForecastOverlay.tsx b/frontend/src/tabs/weather/components/OceanForecastOverlay.tsx index c521d5f..34b54c9 100755 --- a/frontend/src/tabs/weather/components/OceanForecastOverlay.tsx +++ b/frontend/src/tabs/weather/components/OceanForecastOverlay.tsx @@ -1,5 +1,5 @@ -import { useEffect, useState } from 'react' -import { Source, Layer } from '@vis.gl/react-maplibre' +import { useEffect, useRef } from 'react' +import { useMap } from '@vis.gl/react-maplibre' import type { OceanForecastData } from '../services/khoaApi' interface OceanForecastOverlayProps { @@ -8,62 +8,118 @@ interface OceanForecastOverlayProps { visible?: boolean } -// 한국 해역 범위 (MapLibre image source용 좌표 배열) -// [left, bottom, right, top] → MapLibre coordinates 순서: [sw, nw, ne, se] -// [lon, lat] 순서 -const KOREA_IMAGE_COORDINATES: [[number, number], [number, number], [number, number], [number, number]] = [ - [124.5, 33.0], // 남서 (제주 남쪽) - [124.5, 38.5], // 북서 - [132.0, 38.5], // 북동 (동해 북쪽) - [132.0, 33.0], // 남동 -] +// 한국 해역 범위 [lon, lat] +const BOUNDS = { + nw: [124.5, 38.5] as [number, number], + ne: [132.0, 38.5] as [number, number], + se: [132.0, 33.0] as [number, number], + sw: [124.5, 33.0] as [number, number], +} + +// www.khoa.go.kr 이미지는 CORS 미지원 → Vite 프록시 경유 +function toProxyUrl(url: string): string { + return url.replace('https://www.khoa.go.kr', '') +} /** * OceanForecastOverlay * - * 기존: react-leaflet ImageOverlay + LatLngBounds - * 전환: @vis.gl/react-maplibre Source(type=image) + Layer(type=raster) - * - * MapLibre image source는 Map 컴포넌트 자식으로 직접 렌더링 가능 + * MapLibre raster layer는 deck.gl 캔버스보다 항상 아래 렌더링되므로, + * WindParticleLayer와 동일하게 canvas를 직접 map 컨테이너에 삽입하는 방식 사용. + * z-index 500으로 WindParticleLayer(450) 위에 렌더링. */ export function OceanForecastOverlay({ forecast, opacity = 0.6, visible = true, }: OceanForecastOverlayProps) { - const [loadedUrl, setLoadedUrl] = useState(null) + const { current: mapRef } = useMap() + const canvasRef = useRef(null) + const imgRef = useRef(null) useEffect(() => { - if (!forecast?.filePath) return - let cancelled = false - 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 map = mapRef?.getMap() + if (!map) return - const imageLoaded = !!loadedUrl && loadedUrl === forecast?.filePath + const container = map.getContainer() - if (!visible || !forecast || !imageLoaded) { - return null - } + // canvas 생성 (최초 1회) + 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 + + if (!visible || !forecast?.imgFilePath) { + canvas.style.display = 'none' + return + } + + canvas.style.display = 'block' + const proxyUrl = toProxyUrl(forecast.imgFilePath) + + function draw() { + const img = imgRef.current + if (!canvas || !img || !img.complete || img.naturalWidth === 0) return + + const { clientWidth: w, clientHeight: h } = container + 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 } diff --git a/frontend/src/tabs/weather/components/WeatherMapOverlay.tsx b/frontend/src/tabs/weather/components/WeatherMapOverlay.tsx index a3151d0..6800466 100755 --- a/frontend/src/tabs/weather/components/WeatherMapOverlay.tsx +++ b/frontend/src/tabs/weather/components/WeatherMapOverlay.tsx @@ -77,8 +77,6 @@ export function WeatherMapOverlay({ stations.map((station) => { const isSelected = selectedStationId === station.id const color = getWindHexColor(station.wind.speed, isSelected) - const size = Math.min(40 + station.wind.speed * 2, 80) - return ( onStationClick(station)} > -
- +
+ + {/* 위쪽이 바람 방향을 나타내는 삼각형 */} + + +
+ - - - + {station.wind.speed.toFixed(1)} +
) })} - {/* 기상 데이터 라벨 — MapLibre Marker */} + {/* 기상 데이터 라벨 — 임시 비활성화 {enabledLayers.has('labels') && stations.map((station) => { 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" > - {/* 관측소명 */}
{station.name}
- - {/* 수온 */}
- + {station.temperature.current.toFixed(1)} - - °C - + °C
- - {/* 파고 */}
- + {station.wave.height.toFixed(1)} - - m - + m
- - {/* 풍속 */}
- + {station.wind.speed.toFixed(1)} - - m/s - + m/s
) })} + */} ) } diff --git a/frontend/src/tabs/weather/components/WeatherView.tsx b/frontend/src/tabs/weather/components/WeatherView.tsx index d3b9bb1..04fe62e 100755 --- a/frontend/src/tabs/weather/components/WeatherView.tsx +++ b/frontend/src/tabs/weather/components/WeatherView.tsx @@ -223,7 +223,6 @@ function WeatherMapInner({ export function WeatherView() { const { weatherStations, loading, error, lastUpdate } = useWeatherData(BASE_STATIONS) - console.log(weatherStations,'날씨'); const { selectedForecast, @@ -238,7 +237,7 @@ export function WeatherView() { const [selectedLocation, setSelectedLocation] = useState<{ lat: number; lon: number } | null>( null ) - const [enabledLayers, setEnabledLayers] = useState>(new Set(['wind', 'labels'])) + const [enabledLayers, setEnabledLayers] = useState>(new Set(['wind'])) const [oceanForecastOpacity, setOceanForecastOpacity] = useState(0.6) // 첫 관측소 자동 선택 (파생 값) @@ -393,6 +392,7 @@ export function WeatherView() { /> 🌬️ 바람 벡터 + {/* 기상 데이터 레이어 — 임시 비활성화 + */}
diff --git a/frontend/src/tabs/weather/hooks/useOceanForecast.ts b/frontend/src/tabs/weather/hooks/useOceanForecast.ts index 7273659..1b1b201 100755 --- a/frontend/src/tabs/weather/hooks/useOceanForecast.ts +++ b/frontend/src/tabs/weather/hooks/useOceanForecast.ts @@ -70,9 +70,9 @@ export function useOceanForecast( // 사용 가능한 시간대 목록 생성 const availableTimes = forecasts .map((f) => ({ - day: f.day, - hour: f.hour, - label: `${f.day.slice(4, 6)}/${f.day.slice(6, 8)} ${f.hour}:00` + day: f.ofcFrcstYmd, + hour: f.ofcFrcstTm, + 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}`)) diff --git a/frontend/src/tabs/weather/services/khoaApi.ts b/frontend/src/tabs/weather/services/khoaApi.ts index cd20ee7..c3ec365 100755 --- a/frontend/src/tabs/weather/services/khoaApi.ts +++ b/frontend/src/tabs/weather/services/khoaApi.ts @@ -2,7 +2,7 @@ // 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' // 지역 유형 (총 20개 지역) @@ -22,20 +22,21 @@ export const OCEAN_REGIONS = { } as const export interface OceanForecastData { - name: string // 지역명 - type: string // 지역유형 - day: string // 예보날짜 (YYYYMMDD) - hour: string // 예보시간 (HH) - fileName: string // 이미지 파일명 - filePath: string // 해양예측 이미지 URL + imgFileNm: string // 이미지 파일명 + imgFilePath: string // 이미지 파일경로 + ofcBrnchId: string // 해황예보도 지점코드 + ofcBrnchNm: string // 해황예보도 지점이름 + ofcFrcstTm: string // 해황예보도 예보시각 + ofcFrcstYmd: string // 해황예보도 예보일자 } -export interface OceanForecastResponse { - result: { - data: OceanForecastData[] - meta: { - totalCount: number - } +interface OceanForecastApiResponse { + header: { resultCode: string; resultMsg: string } + body: { + items: { item: OceanForecastData[] } + pageNo: number + numOfRows: number + totalCount: number } } @@ -49,9 +50,9 @@ export async function getOceanForecast( ): Promise { try { const params = new URLSearchParams({ - ServiceKey: API_KEY, - type: regionType, - ResultType: 'json' + serviceKey: API_KEY, + areaCode: regionType, + type: 'json', }) const response = await fetch(`${BASE_URL}?${params}`) @@ -60,20 +61,8 @@ export async function getOceanForecast( throw new Error(`HTTP Error: ${response.status}`) } - const data = await response.json() - - // API 응답 구조에 따라 데이터 추출 - if (data?.result?.data) { - return data.result.data - } - - // 응답이 배열 형태인 경우 - if (Array.isArray(data)) { - return data - } - - console.warn('Unexpected API response structure:', data) - return [] + const data = await response.json() as OceanForecastApiResponse + return data?.body?.items?.item ?? [] } catch (error) { console.error('해황예보도 조회 오류:', error) @@ -89,10 +78,9 @@ export async function getOceanForecast( export function getLatestForecast(forecasts: OceanForecastData[]): OceanForecastData | null { if (!forecasts || forecasts.length === 0) return null - // 날짜와 시간을 기준으로 정렬 const sorted = [...forecasts].sort((a, b) => { - const dateTimeA = `${a.day}${a.hour}` - const dateTimeB = `${b.day}${b.hour}` + const dateTimeA = `${a.ofcFrcstYmd}${a.ofcFrcstTm}` + const dateTimeB = `${b.ofcFrcstYmd}${b.ofcFrcstTm}` return dateTimeB.localeCompare(dateTimeA) }) @@ -112,7 +100,7 @@ export function getForecastByTime( targetHour: string ): OceanForecastData | null { 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