diff --git a/.claude/settings.json b/.claude/settings.json index 868df2d..908a71e 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -84,4 +84,4 @@ } ] } -} +} \ No newline at end of file diff --git a/.claude/workflow-version.json b/.claude/workflow-version.json index 0de0b00..f9f4b86 100644 --- a/.claude/workflow-version.json +++ b/.claude/workflow-version.json @@ -4,4 +4,4 @@ "project_type": "react-ts", "gitea_url": "https://gitea.gc-si.dev", "custom_pre_commit": true -} +} \ No newline at end of file diff --git a/docs/COMMON-GUIDE.md b/docs/COMMON-GUIDE.md index cc84ae5..656f795 100644 --- a/docs/COMMON-GUIDE.md +++ b/docs/COMMON-GUIDE.md @@ -4,7 +4,7 @@ 연동할 수 있도록 정리한 문서이다. 공통 기능을 추가/변경할 때 반드시 이 문서를 최신화할 것. -> **최종 갱신**: 2026-03-01 (CSS 리팩토링 + MapLibre GL + deck.gl 전환 반영) +> **최종 갱신**: 2026-03-11 (KHOA API 교체 + Vite CORS 프록시 추가) --- @@ -1312,6 +1312,25 @@ app.use(helmet({ })); ``` +### Vite 개발 서버 프록시 + +외부 API 이미지의 CORS 문제를 해결하기 위해 `vite.config.ts`에 프록시를 설정한다: + +```typescript +server: { + proxy: { + '/api': { + target: 'http://localhost:3001', + changeOrigin: true, + }, + '/daily_ocean': { + target: 'https://www.khoa.go.kr', + changeOrigin: true, + }, + }, +}, +``` + 적용되는 보안 헤더: - `X-Content-Type-Options: nosniff` (MIME 스니핑 방지) - `X-Frame-Options: DENY` (클릭재킹 방지) diff --git a/docs/DEVELOPMENT-GUIDE.md b/docs/DEVELOPMENT-GUIDE.md index 7029ed8..429d75e 100644 --- a/docs/DEVELOPMENT-GUIDE.md +++ b/docs/DEVELOPMENT-GUIDE.md @@ -657,6 +657,7 @@ Settings -> Actions -> Secrets -> Add Secret - API 호출이 CORS 에러를 발생시키면 백엔드 `FRONTEND_URL` 환경변수를 확인한다. - 개발 환경에서는 `localhost:5173`, `localhost:5174`, `localhost:3000`이 자동 허용된다. +- KHOA 해양 이미지(`/daily_ocean`)는 Vite 프록시 경유: `vite.config.ts` → `proxy` 설정 확인 **타입 에러:** diff --git a/docs/RELEASE-NOTES.md b/docs/RELEASE-NOTES.md index aaf685a..c032f90 100644 --- a/docs/RELEASE-NOTES.md +++ b/docs/RELEASE-NOTES.md @@ -21,6 +21,29 @@ - 팀 워크플로우 v1.6.0 동기화 (해시 기반 자동 최신화, push/mr/release 워크플로우 체크, 팀 관리 파일 gitignore 처리) - 팀 워크플로우 v1.5.0 동기화 (스킬 7종 업데이트, version 스킬 신규, release-notes-guide 추가) +## [2026-03-11] + +### 추가 +- KHOA API 엔드포인트 교체 및 해양예측 오버레이 Canvas 렌더링 전환 +- 기상 맵 컨트롤 컴포넌트 추가 및 KHOA API 연동 개선 +- 기상 정보 기상 레이어 업데이트 +- CCTV 안전관리 감지 기능 추가 (선박 출입, 침입 감지) +- 관리자 화면 고도화 — 사용자/권한/게시판/선박신호 패널 +- CCTV 오일 감지 GPU 추론 연동 및 HNS 초기 핀 제거 +- 유류오염보장계약 시드 데이터 추가 (1391건) + +### 수정 +- /orgs 라우트를 /:id 앞에 등록하여 라우트 매칭 수정 + +### 문서 +- 프로젝트 문서 최신화 (KHOA API, Vite 프록시) + +### 기타 +- CLAUDE_BOT_TOKEN 갱신 +- 팀 워크플로우 v1.6.1 동기화 +- 팀 워크플로우 v1.6.0 동기화 +- 팀 워크플로우 v1.5.0 동기화 + ## [2026-03-01] ### 추가 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/WeatherMapControls.tsx b/frontend/src/tabs/weather/components/WeatherMapControls.tsx new file mode 100644 index 0000000..56173c6 --- /dev/null +++ b/frontend/src/tabs/weather/components/WeatherMapControls.tsx @@ -0,0 +1,48 @@ +import { useMap } from '@vis.gl/react-maplibre' + +interface WeatherMapControlsProps { + center: [number, number] + zoom: number +} + +export function WeatherMapControls({ center, zoom }: WeatherMapControlsProps) { + const { current: map } = useMap() + + const buttons = [ + { + label: '+', + tooltip: '확대', + onClick: () => map?.zoomIn(), + }, + { + label: '−', + tooltip: '축소', + onClick: () => map?.zoomOut(), + }, + { + label: '🎯', + tooltip: '한국 해역 초기화', + onClick: () => map?.flyTo({ center, zoom, duration: 1000 }), + }, + ] + + return ( +
+
+ {buttons.map(({ label, tooltip, onClick }) => ( +
+ +
+ {tooltip} +
+
+ ))} +
+
+ ) +} 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 c626b27..04fe62e 100755 --- a/frontend/src/tabs/weather/components/WeatherView.tsx +++ b/frontend/src/tabs/weather/components/WeatherView.tsx @@ -1,5 +1,5 @@ import { useState, useMemo, useCallback } from 'react' -import { Map, useControl, useMap } from '@vis.gl/react-maplibre' +import { Map, Marker, useControl } from '@vis.gl/react-maplibre' import { MapboxOverlay } from '@deck.gl/mapbox' import type { Layer } from '@deck.gl/core' import type { StyleSpecification, MapLayerMouseEvent } from 'maplibre-gl' @@ -12,6 +12,7 @@ import { useWaterTemperatureLayers } from './WaterTemperatureLayer' import { WindParticleLayer } from './WindParticleLayer' import { useWeatherData } from '../hooks/useWeatherData' import { useOceanForecast } from '../hooks/useOceanForecast' +import { WeatherMapControls } from './WeatherMapControls' type TimeOffset = '0' | '3' | '6' | '9' @@ -117,38 +118,6 @@ function DeckGLOverlay({ layers }: { layers: Layer[] }) { return null } -// 줌 컨트롤 -function WeatherMapControls() { - const { current: map } = useMap() - - return ( -
-
- - - -
-
- ) -} - /** * WeatherMapInner — Map 컴포넌트 내부 (useMap / useControl 사용 가능 영역) */ @@ -159,6 +128,9 @@ interface WeatherMapInnerProps { oceanForecastOpacity: number selectedForecast: ReturnType['selectedForecast'] onStationClick: (station: WeatherStation) => void + mapCenter: [number, number] + mapZoom: number + clickedLocation: { lat: number; lon: number } | null } function WeatherMapInner({ @@ -168,6 +140,9 @@ function WeatherMapInner({ oceanForecastOpacity, selectedForecast, onStationClick, + mapCenter, + mapZoom, + clickedLocation, }: WeatherMapInnerProps) { // deck.gl layers 조합 const weatherDeckLayers = useWeatherDeckLayers( @@ -216,8 +191,31 @@ function WeatherMapInner({ stations={weatherStations} /> + {/* 클릭 위치 마커 */} + {clickedLocation && ( + +
+ {/* 펄스 링 */} +
+
+
+
+ {/* 핀 꼬리 */} +
+ {/* 좌표 라벨 */} +
+ {clickedLocation.lat.toFixed(3)}°N {clickedLocation.lon.toFixed(3)}°E +
+
+ + )} + {/* 줌 컨트롤 */} - + ) } @@ -225,6 +223,7 @@ function WeatherMapInner({ export function WeatherView() { const { weatherStations, loading, error, lastUpdate } = useWeatherData(BASE_STATIONS) + const { selectedForecast, availableTimes, @@ -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) // 첫 관측소 자동 선택 (파생 값) @@ -343,12 +342,6 @@ export function WeatherView() {
- -
- -
{/* Map */} @@ -371,6 +364,9 @@ export function WeatherView() { oceanForecastOpacity={oceanForecastOpacity} selectedForecast={selectedForecast} onStationClick={handleStationClick} + mapCenter={WEATHER_MAP_CENTER} + mapZoom={WEATHER_MAP_ZOOM} + clickedLocation={selectedLocation} /> @@ -396,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/hooks/useWeatherData.ts b/frontend/src/tabs/weather/hooks/useWeatherData.ts index 37eb2df..909face 100755 --- a/frontend/src/tabs/weather/hooks/useWeatherData.ts +++ b/frontend/src/tabs/weather/hooks/useWeatherData.ts @@ -87,6 +87,7 @@ export function useWeatherData(stations: WeatherStation[]) { } const obs = await getRecentObservation(obsCode) + if (obs) { const r = (n: number) => Math.round(n * 10) / 10 diff --git a/frontend/src/tabs/weather/services/khoaApi.ts b/frontend/src/tabs/weather/services/khoaApi.ts index 5a01d36..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,23 +145,25 @@ export async function getRecentObservation(obsCode: string): Promise