From 0c94c631c41e8399b1e7d71e744363daeacc50b5 Mon Sep 17 00:00:00 2001 From: leedano Date: Thu, 12 Mar 2026 11:03:17 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat(weather):=20=ED=95=B4=EB=A5=98=20?= =?UTF-8?q?=EC=BA=94=EB=B2=84=EC=8A=A4=20=ED=8C=8C=ED=8B=B0=ED=81=B4=20?= =?UTF-8?q?=EB=A0=88=EC=9D=B4=EC=96=B4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- .../components/OceanCurrentParticleLayer.tsx | 324 ++++++++++++++++++ .../tabs/weather/components/WeatherView.tsx | 127 +++---- 2 files changed, 370 insertions(+), 81 deletions(-) create mode 100644 frontend/src/tabs/weather/components/OceanCurrentParticleLayer.tsx diff --git a/frontend/src/tabs/weather/components/OceanCurrentParticleLayer.tsx b/frontend/src/tabs/weather/components/OceanCurrentParticleLayer.tsx new file mode 100644 index 0000000..db18f7c --- /dev/null +++ b/frontend/src/tabs/weather/components/OceanCurrentParticleLayer.tsx @@ -0,0 +1,324 @@ +import { useEffect, useRef, useCallback } from 'react' +import { useMap } from '@vis.gl/react-maplibre' +import type { Map as MapLibreMap } from 'maplibre-gl' + +interface CurrentVectorPoint { + lat: number + lon: number + u: number // 동서 방향 속도 (양수=동, 음수=서) [m/s] + v: number // 남북 방향 속도 (양수=북, 음수=남) [m/s] +} + +interface Particle { + x: number + y: number + age: number + maxAge: number +} + +interface OceanCurrentParticleLayerProps { + visible: boolean +} + +// 해류 속도 기반 색상 +function getCurrentColor(speed: number): string { + if (speed < 0.2) return 'rgba(59, 130, 246, 0.8)' // 파랑 + if (speed < 0.4) return 'rgba(6, 182, 212, 0.8)' // 청록 + if (speed < 0.6) return 'rgba(34, 197, 94, 0.8)' // 초록 + return 'rgba(249, 115, 22, 0.8)' // 주황 +} + +// 한반도 육지 영역 판별 (간략화된 폴리곤) +const isOnLand = (lat: number, lon: number): boolean => { + const peninsula: [number, number][] = [ + [38.5, 124.5], [38.5, 128.3], + [37.8, 128.8], [37.0, 129.2], + [36.0, 129.5], [35.1, 129.2], + [34.8, 128.6], [34.5, 127.8], + [34.3, 126.5], [34.8, 126.1], + [35.5, 126.0], [36.0, 126.3], + [36.8, 126.0], [37.5, 126.2], + [38.5, 124.5], + ] + + // 제주도 영역 + if (lat >= 33.1 && lat <= 33.7 && lon >= 126.1 && lon <= 127.0) return true + + // Ray casting algorithm + let inside = false + for (let i = 0, j = peninsula.length - 1; i < peninsula.length; j = i++) { + const [yi, xi] = peninsula[i] + const [yj, xj] = peninsula[j] + if ((yi > lat) !== (yj > lat) && lon < ((xj - xi) * (lat - yi)) / (yj - yi) + xi) { + inside = !inside + } + } + return inside +} + +// 한국 해역의 해류 u,v 벡터 데이터 생성 (Mock) +const generateOceanCurrentData = (): CurrentVectorPoint[] => { + const data: CurrentVectorPoint[] = [] + + for (let lat = 33.5; lat <= 38.0; lat += 0.8) { + for (let lon = 125.0; lon <= 130.5; lon += 0.8) { + if (isOnLand(lat, lon)) continue + + let u = 0 + let v = 0 + + if (lon > 128.5) { + // 동해 — 북동진하는 동한난류 + u = 0.2 + Math.random() * 0.2 // 동쪽 0.2~0.4 + v = 0.3 + Math.random() * 0.2 // 북쪽 0.3~0.5 + } else if (lon < 126.5) { + // 서해 — 북진 + u = -0.05 + Math.random() * 0.1 // 동서 -0.05~0.05 + v = 0.15 + Math.random() * 0.15 // 북쪽 0.15~0.3 + } else { + // 남해 — 동진 + u = 0.3 + Math.random() * 0.2 // 동쪽 0.3~0.5 + v = -0.05 + Math.random() * 0.15 // 남북 -0.05~0.1 + } + + data.push({ lat, lon, u, v }) + } + } + + return data +} + +// 해류 데이터는 한 번만 생성 +const CURRENT_DATA = generateOceanCurrentData() + +// IDW 보간으로 특정 위치의 u,v 벡터 추정 → speed/direction 반환 +function interpolateCurrent( + lat: number, + lon: number, + points: CurrentVectorPoint[] +): { speed: number; direction: number } { + if (points.length === 0) return { speed: 0.3, direction: 90 } + + let totalWeight = 0 + let weightedU = 0 + let weightedV = 0 + + for (const point of points) { + const dist = Math.sqrt( + Math.pow(point.lat - lat, 2) + Math.pow(point.lon - lon, 2) + ) + const weight = 1 / Math.pow(Math.max(dist, 0.01), 2) + totalWeight += weight + weightedU += point.u * weight + weightedV += point.v * weight + } + + const u = weightedU / totalWeight + const v = weightedV / totalWeight + const speed = Math.sqrt(u * u + v * v) + // u=동(+), v=북(+) → 화면 방향: sin=동(+x), -cos=남(+y) + const direction = (Math.atan2(u, v) * 180) / Math.PI + return { speed, direction: (direction + 360) % 360 } +} + +// MapLibre map.unproject()를 통해 픽셀 → 경위도 변환 +function containerPointToLatLng( + map: MapLibreMap, + x: number, + y: number +): { lat: number; lng: number } { + const lngLat = map.unproject([x, y]) + return { lat: lngLat.lat, lng: lngLat.lng } +} + +const PARTICLE_COUNT = 400 +const FADE_ALPHA = 0.93 + +/** + * OceanCurrentParticleLayer + * + * Canvas 2D + requestAnimationFrame 패턴으로 해류 흐름 시각화 + * u,v 벡터 격자 데이터를 IDW 보간하여 파티클 애니메이션 렌더링 + * 바람 파티클 대비: 적은 입자, 느린 속도, 긴 트레일 + */ +export function OceanCurrentParticleLayer({ visible }: OceanCurrentParticleLayerProps) { + const { current: mapRef } = useMap() + const canvasRef = useRef(null) + const particlesRef = useRef([]) + const animFrameRef = useRef(0) + + const initParticles = useCallback((width: number, height: number) => { + particlesRef.current = [] + for (let i = 0; i < PARTICLE_COUNT; i++) { + particlesRef.current.push({ + x: Math.random() * width, + y: Math.random() * height, + age: Math.floor(Math.random() * 150), + maxAge: 120 + Math.floor(Math.random() * 60), + }) + } + }, []) + + useEffect(() => { + const map = mapRef?.getMap() + if (!map) return + + if (!visible) { + if (canvasRef.current) { + canvasRef.current.remove() + canvasRef.current = null + } + cancelAnimationFrame(animFrameRef.current) + return + } + + const container = map.getContainer() + + // Canvas 생성 또는 재사용 + let canvas = canvasRef.current + if (!canvas) { + canvas = document.createElement('canvas') + canvas.style.position = 'absolute' + canvas.style.top = '0' + canvas.style.left = '0' + canvas.style.pointerEvents = 'none' + canvas.style.zIndex = '440' + container.appendChild(canvas) + canvasRef.current = canvas + } + + const resize = () => { + if (!canvas) return + const { clientWidth: w, clientHeight: h } = container + canvas.width = w + canvas.height = h + } + resize() + + const ctx = canvas.getContext('2d') + if (!ctx) return + + initParticles(canvas.width, canvas.height) + + // 오프스크린 캔버스 (트레일 효과) + let offCanvas: HTMLCanvasElement | null = null + let offCtx: CanvasRenderingContext2D | null = null + + function animate() { + if (!ctx || !canvas) return + + // 오프스크린 캔버스 크기 동기화 + if (!offCanvas || offCanvas.width !== canvas.width || offCanvas.height !== canvas.height) { + offCanvas = document.createElement('canvas') + offCanvas.width = canvas.width + offCanvas.height = canvas.height + offCtx = offCanvas.getContext('2d') + } + + if (!offCtx) return + + // 트레일 페이드 효과 (느린 페이드 = 부드러운 흐름) + offCtx.globalCompositeOperation = 'destination-in' + offCtx.fillStyle = `rgba(0, 0, 0, ${FADE_ALPHA})` + offCtx.fillRect(0, 0, offCanvas.width, offCanvas.height) + offCtx.globalCompositeOperation = 'source-over' + + // 현재 지도 bounds 확인 + const bounds = map!.getBounds() + + for (const particle of particlesRef.current) { + particle.age++ + + // 수명 초과 시 리셋 + if (particle.age > particle.maxAge) { + particle.x = Math.random() * canvas.width + particle.y = Math.random() * canvas.height + particle.age = 0 + particle.maxAge = 120 + Math.floor(Math.random() * 60) + continue + } + + const { lat, lng } = containerPointToLatLng(map!, particle.x, particle.y) + + // 화면 밖이면 리셋 + if (!bounds.contains([lng, lat])) { + particle.x = Math.random() * canvas.width + particle.y = Math.random() * canvas.height + particle.age = 0 + continue + } + + // 육지 위이면 리셋 + if (isOnLand(lat, lng)) { + particle.x = Math.random() * canvas.width + particle.y = Math.random() * canvas.height + particle.age = 0 + continue + } + + const current = interpolateCurrent(lat, lng, CURRENT_DATA) + const rad = (current.direction * Math.PI) / 180 + const pixelSpeed = current.speed * 2.0 + + const newX = particle.x + Math.sin(rad) * pixelSpeed + const newY = particle.y + -Math.cos(rad) * pixelSpeed + + // 다음 위치가 육지이면 리셋 + const nextPos = containerPointToLatLng(map!, newX, newY) + if (isOnLand(nextPos.lat, nextPos.lng)) { + particle.x = Math.random() * canvas.width + particle.y = Math.random() * canvas.height + particle.age = 0 + continue + } + + const oldX = particle.x + const oldY = particle.y + particle.x = newX + particle.y = newY + + // 파티클 트레일 그리기 + const alpha = 1 - particle.age / particle.maxAge + offCtx.strokeStyle = getCurrentColor(current.speed).replace('0.8', String(alpha * 0.8)) + offCtx.lineWidth = 0.8 + offCtx.beginPath() + offCtx.moveTo(oldX, oldY) + offCtx.lineTo(particle.x, particle.y) + offCtx.stroke() + } + + // 메인 캔버스에 합성 (배경 오버레이 없이 파티클만) + ctx.clearRect(0, 0, canvas.width, canvas.height) + ctx.drawImage(offCanvas, 0, 0) + + animFrameRef.current = requestAnimationFrame(animate) + } + + animate() + + // 지도 이동/줌 시 리셋 + const onMoveEnd = () => { + resize() + if (canvas) initParticles(canvas.width, canvas.height) + if (offCanvas && canvas) { + offCanvas.width = canvas.width + offCanvas.height = canvas.height + } + } + map.on('moveend', onMoveEnd) + map.on('zoomend', onMoveEnd) + + return () => { + cancelAnimationFrame(animFrameRef.current) + map.off('moveend', onMoveEnd) + map.off('zoomend', onMoveEnd) + if (canvasRef.current) { + canvasRef.current.remove() + canvasRef.current = null + } + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [mapRef, visible, initParticles]) + + return null +} diff --git a/frontend/src/tabs/weather/components/WeatherView.tsx b/frontend/src/tabs/weather/components/WeatherView.tsx index 04fe62e..35414db 100755 --- a/frontend/src/tabs/weather/components/WeatherView.tsx +++ b/frontend/src/tabs/weather/components/WeatherView.tsx @@ -6,12 +6,13 @@ import type { StyleSpecification, MapLayerMouseEvent } from 'maplibre-gl' import 'maplibre-gl/dist/maplibre-gl.css' import { WeatherRightPanel } from './WeatherRightPanel' import { WeatherMapOverlay, useWeatherDeckLayers } from './WeatherMapOverlay' -import { OceanForecastOverlay } from './OceanForecastOverlay' -import { useOceanCurrentLayers } from './OceanCurrentLayer' +// import { OceanForecastOverlay } from './OceanForecastOverlay' +// import { useOceanCurrentLayers } from './OceanCurrentLayer' import { useWaterTemperatureLayers } from './WaterTemperatureLayer' import { WindParticleLayer } from './WindParticleLayer' +import { OceanCurrentParticleLayer } from './OceanCurrentParticleLayer' import { useWeatherData } from '../hooks/useWeatherData' -import { useOceanForecast } from '../hooks/useOceanForecast' +// import { useOceanForecast } from '../hooks/useOceanForecast' import { WeatherMapControls } from './WeatherMapControls' type TimeOffset = '0' | '3' | '6' | '9' @@ -125,8 +126,6 @@ interface WeatherMapInnerProps { weatherStations: WeatherStation[] enabledLayers: Set selectedStationId: string | null - oceanForecastOpacity: number - selectedForecast: ReturnType['selectedForecast'] onStationClick: (station: WeatherStation) => void mapCenter: [number, number] mapZoom: number @@ -137,8 +136,6 @@ function WeatherMapInner({ weatherStations, enabledLayers, selectedStationId, - oceanForecastOpacity, - selectedForecast, onStationClick, mapCenter, mapZoom, @@ -151,18 +148,18 @@ function WeatherMapInner({ selectedStationId, onStationClick ) - const oceanCurrentLayers = useOceanCurrentLayers({ - visible: enabledLayers.has('oceanCurrent'), - opacity: 0.7, - }) + // const oceanCurrentLayers = useOceanCurrentLayers({ + // visible: enabledLayers.has('oceanCurrent'), + // opacity: 0.7, + // }) const waterTempLayers = useWaterTemperatureLayers({ visible: enabledLayers.has('waterTemperature'), opacity: 0.5, }) const deckLayers = useMemo( - () => [...oceanCurrentLayers, ...waterTempLayers, ...weatherDeckLayers], - [oceanCurrentLayers, waterTempLayers, weatherDeckLayers] + () => [...waterTempLayers, ...weatherDeckLayers], + [waterTempLayers, weatherDeckLayers] ) return ( @@ -170,11 +167,16 @@ function WeatherMapInner({ {/* deck.gl 오버레이 */} - {/* 해황예보도 — MapLibre image source + raster layer */} + {/* 해황예보도 — 임시 비활성화 */} + + {/* 해류 흐름 파티클 애니메이션 (Canvas 직접 조작) */} + {/* 기상 관측소 HTML 오버레이 (풍향 화살표 + 라벨) */} @@ -224,13 +226,13 @@ export function WeatherView() { const { weatherStations, loading, error, lastUpdate } = useWeatherData(BASE_STATIONS) - const { - selectedForecast, - availableTimes, - loading: oceanLoading, - error: oceanError, - selectForecast, - } = useOceanForecast('KOREA') + // const { + // selectedForecast, + // availableTimes, + // loading: oceanLoading, + // error: oceanError, + // selectForecast, + // } = useOceanForecast('KOREA') const [timeOffset, setTimeOffset] = useState('0') const [selectedStationRaw, setSelectedStation] = useState(null) @@ -238,7 +240,7 @@ export function WeatherView() { null ) const [enabledLayers, setEnabledLayers] = useState>(new Set(['wind'])) - const [oceanForecastOpacity, setOceanForecastOpacity] = useState(0.6) + // const [oceanForecastOpacity, setOceanForecastOpacity] = useState(0.6) // 첫 관측소 자동 선택 (파생 값) const selectedStation = selectedStationRaw ?? weatherStations[0] ?? null @@ -361,8 +363,6 @@ export function WeatherView() { weatherStations={weatherStations} enabledLayers={enabledLayers} selectedStationId={selectedStation?.id || null} - oceanForecastOpacity={oceanForecastOpacity} - selectedForecast={selectedForecast} onStationClick={handleStationClick} mapCenter={WEATHER_MAP_CENTER} mapZoom={WEATHER_MAP_ZOOM} @@ -424,11 +424,11 @@ export function WeatherView() { - {/* 해황예보도 레이어 */} + {/* 해황예보도 레이어 — 임시 비활성화
- - {enabledLayers.has('oceanForecast') && ( -
-
- 투명도: - - setOceanForecastOpacity(Number(e.target.value) / 100) - } - className="flex-1 h-1 bg-bg-3 rounded-lg appearance-none cursor-pointer" - /> - - {Math.round(oceanForecastOpacity * 100)}% - -
- - {availableTimes.length > 0 && ( -
-
예보 시간:
-
- {availableTimes.map((time) => ( - - ))} -
-
- )} - - {oceanLoading &&
로딩 중...
} - {oceanError &&
오류 발생
} - {selectedForecast && ( -
- 현재: {selectedForecast.ofcBrnchNm} •{' '} - {selectedForecast.ofcFrcstYmd.slice(4, 6)}/{selectedForecast.ofcFrcstYmd.slice(6, 8)}{' '} - {selectedForecast.ofcFrcstTm}:00 -
- )} -
- )}
+ */} @@ -536,6 +484,23 @@ export function WeatherView() { + {/* 해류 */} +
+
해류 (m/s)
+
+
+
+
+
+
+
+ 0.2 + 0.4 + 0.6 + 0.6+ +
+
+ {/* 파고 */}
파고 (m)
-- 2.45.2 From 61ac3b42c0de2aa3b8f1f78b3a789da07202d733 Mon Sep 17 00:00:00 2001 From: leedano Date: Thu, 12 Mar 2026 16:47:06 +0900 Subject: [PATCH 2/2] =?UTF-8?q?docs:=20=EB=A6=B4=EB=A6=AC=EC=A6=88=20?= =?UTF-8?q?=EB=85=B8=ED=8A=B8=20=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/RELEASE-NOTES.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/RELEASE-NOTES.md b/docs/RELEASE-NOTES.md index a6ab4a4..973c91e 100644 --- a/docs/RELEASE-NOTES.md +++ b/docs/RELEASE-NOTES.md @@ -4,6 +4,9 @@ ## [Unreleased] +### 추가 +- 해류 캔버스 파티클 레이어 추가 + ## [2026-03-11.2] ### 추가 -- 2.45.2