지도 엔진을 Leaflet 1.9에서 MapLibre GL JS 5.x + deck.gl 9.x로 전환. 15개 파일 수정, Leaflet 완전 제거. WebGL 단일 canvas로 z-index 충돌 해결, 유류 입자 ScatterplotLayer GPU 렌더링으로 10~100배 성능 향상. - MapView.tsx: MapLibre Map + DeckGLOverlay(MapboxOverlay interleaved) - 유류 입자/오일펜스/HNS: deck.gl ScatterplotLayer/PathLayer - 역추적 리플레이: createBacktrackLayers() 함수 패턴 - 기상 오버레이: WeatherMapOverlay/OceanCurrent/WindParticle deck.gl 전환 - 수온 히트맵: WaterTemperatureLayer deck.gl ScatterplotLayer - 해황예보도: MapLibre image source + raster layer - SCAT/Assets/Incidents: MapLibre Map + deck.gl 레이어 - WMS 밝기: raster-brightness-min/max 네이티브 속성 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
289 lines
8.6 KiB
TypeScript
Executable File
289 lines
8.6 KiB
TypeScript
Executable File
import { useEffect, useRef, useCallback } from 'react'
|
|
import { useMap } from '@vis.gl/react-maplibre'
|
|
import type { Map as MapLibreMap } from 'maplibre-gl'
|
|
|
|
interface WindPoint {
|
|
lat: number
|
|
lon: number
|
|
speed: number // m/s
|
|
direction: number // degrees (0=N, 90=E, 180=S, 270=W)
|
|
}
|
|
|
|
interface Particle {
|
|
x: number
|
|
y: number
|
|
age: number
|
|
maxAge: number
|
|
}
|
|
|
|
interface WindParticleLayerProps {
|
|
visible: boolean
|
|
stations: {
|
|
location: { lat: number; lon: number }
|
|
wind: { speed: number; direction: number }
|
|
}[]
|
|
}
|
|
|
|
// 풍속 기반 색상 (Windy.com 스타일)
|
|
function getWindColor(speed: number): string {
|
|
if (speed < 3) return 'rgba(98, 113, 183, 0.8)'
|
|
if (speed < 5) return 'rgba(57, 160, 246, 0.8)'
|
|
if (speed < 7) return 'rgba(80, 213, 145, 0.8)'
|
|
if (speed < 10) return 'rgba(165, 226, 63, 0.8)'
|
|
if (speed < 13) return 'rgba(250, 226, 30, 0.8)'
|
|
if (speed < 16) return 'rgba(250, 170, 25, 0.8)'
|
|
if (speed < 20) return 'rgba(240, 84, 33, 0.8)'
|
|
return 'rgba(180, 30, 70, 0.8)'
|
|
}
|
|
|
|
// 풍속 기반 배경 색상 (반투명 오버레이)
|
|
function getWindBgColor(speed: number): string {
|
|
if (speed < 3) return 'rgba(98, 113, 183, 0.12)'
|
|
if (speed < 5) return 'rgba(57, 160, 246, 0.12)'
|
|
if (speed < 7) return 'rgba(80, 213, 145, 0.12)'
|
|
if (speed < 10) return 'rgba(165, 226, 63, 0.12)'
|
|
if (speed < 13) return 'rgba(250, 226, 30, 0.12)'
|
|
if (speed < 16) return 'rgba(250, 170, 25, 0.12)'
|
|
if (speed < 20) return 'rgba(240, 84, 33, 0.12)'
|
|
return 'rgba(180, 30, 70, 0.12)'
|
|
}
|
|
|
|
// 격자 보간으로 특정 위치의 풍속/풍향 추정 (IDW)
|
|
function interpolateWind(
|
|
lat: number,
|
|
lon: number,
|
|
windPoints: WindPoint[]
|
|
): { speed: number; direction: number } {
|
|
if (windPoints.length === 0) return { speed: 5, direction: 270 }
|
|
|
|
let totalWeight = 0
|
|
let weightedSpeed = 0
|
|
let weightedDx = 0
|
|
let weightedDy = 0
|
|
|
|
for (const point of windPoints) {
|
|
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
|
|
weightedSpeed += point.speed * weight
|
|
|
|
const rad = (point.direction * Math.PI) / 180
|
|
weightedDx += Math.sin(rad) * weight
|
|
weightedDy += -Math.cos(rad) * weight
|
|
}
|
|
|
|
const speed = weightedSpeed / totalWeight
|
|
const direction =
|
|
(Math.atan2(weightedDx / totalWeight, -weightedDy / totalWeight) * 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 = 800
|
|
const FADE_ALPHA = 0.93
|
|
|
|
/**
|
|
* WindParticleLayer
|
|
*
|
|
* 기존: Canvas 2D + requestAnimationFrame + map.containerPointToLatLng() (Leaflet)
|
|
* 전환: Canvas 2D + requestAnimationFrame + map.unproject() (MapLibre)
|
|
*
|
|
* @vis.gl/react-maplibre의 useMap()으로 MapLibre Map 인스턴스를 참조
|
|
* Canvas는 MapLibre 컨테이너 위에 absolute 레이어로 삽입
|
|
*/
|
|
export function WindParticleLayer({ visible, stations }: WindParticleLayerProps) {
|
|
const { current: mapRef } = useMap()
|
|
const canvasRef = useRef<HTMLCanvasElement | null>(null)
|
|
const particlesRef = useRef<Particle[]>([])
|
|
const animFrameRef = useRef<number>(0)
|
|
|
|
const windPoints: WindPoint[] = stations.map((s) => ({
|
|
lat: s.location.lat,
|
|
lon: s.location.lon,
|
|
speed: s.wind.speed,
|
|
direction: s.wind.direction,
|
|
}))
|
|
|
|
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() * 80),
|
|
maxAge: 60 + Math.floor(Math.random() * 40),
|
|
})
|
|
}
|
|
}, [])
|
|
|
|
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 = '450'
|
|
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 drawWindOverlay() {
|
|
if (!ctx || !canvas) return
|
|
const gridSize = 30
|
|
|
|
for (let x = 0; x < canvas.width; x += gridSize) {
|
|
for (let y = 0; y < canvas.height; y += gridSize) {
|
|
const { lat, lng } = containerPointToLatLng(map!, x + gridSize / 2, y + gridSize / 2)
|
|
const wind = interpolateWind(lat, lng, windPoints)
|
|
ctx.fillStyle = getWindBgColor(wind.speed)
|
|
ctx.fillRect(x, y, gridSize, gridSize)
|
|
}
|
|
}
|
|
}
|
|
|
|
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 = 60 + Math.floor(Math.random() * 40)
|
|
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
|
|
}
|
|
|
|
const wind = interpolateWind(lat, lng, windPoints)
|
|
const rad = (wind.direction * Math.PI) / 180
|
|
const pixelSpeed = wind.speed * 0.4
|
|
|
|
const oldX = particle.x
|
|
const oldY = particle.y
|
|
particle.x += Math.sin(rad) * pixelSpeed
|
|
particle.y += -Math.cos(rad) * pixelSpeed
|
|
|
|
// 파티클 트레일 그리기
|
|
const alpha = 1 - particle.age / particle.maxAge
|
|
offCtx.strokeStyle = getWindColor(wind.speed).replace('0.8', String(alpha * 0.8))
|
|
offCtx.lineWidth = 1.2
|
|
offCtx.beginPath()
|
|
offCtx.moveTo(oldX, oldY)
|
|
offCtx.lineTo(particle.x, particle.y)
|
|
offCtx.stroke()
|
|
}
|
|
|
|
// 메인 캔버스에 합성
|
|
ctx.clearRect(0, 0, canvas.width, canvas.height)
|
|
drawWindOverlay()
|
|
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
|
|
}
|
|
}
|
|
// windPoints 배열은 렌더마다 재생성되므로 stations만 의존
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [mapRef, visible, stations, initParticles])
|
|
|
|
return null
|
|
}
|