import { useState, useMemo, useEffect, useCallback } from 'react' import { Map, Marker, Popup, Source, Layer, useControl, useMap } from '@vis.gl/react-maplibre' import { MapboxOverlay } from '@deck.gl/mapbox' import { ScatterplotLayer, PathLayer, TextLayer } from '@deck.gl/layers' import type { PickingInfo } from '@deck.gl/core' import type { StyleSpecification } from 'maplibre-gl' import type { MapLayerMouseEvent } from 'maplibre-gl' import 'maplibre-gl/dist/maplibre-gl.css' import { layerDatabase } from '@common/services/layerService' import { decimalToDMS } from '@common/utils/coordinates' import type { PredictionModel, SensitiveResource } from '@tabs/prediction/components/OilSpillView' import type { BoomLine, BoomLineCoord } from '@common/types/boomLine' import type { ReplayShip, CollisionEvent } from '@common/types/backtrack' import { createBacktrackLayers } from './BacktrackReplayOverlay' import { hexToRgba } from './mapUtils' const GEOSERVER_URL = import.meta.env.VITE_GEOSERVER_URL || 'http://localhost:8080' // 남해안 중심 좌표 (여수 앞바다) const DEFAULT_CENTER: [number, number] = [34.5, 127.8] const DEFAULT_ZOOM = 10 // CartoDB Dark Matter 스타일 const BASE_STYLE: StyleSpecification = { version: 8, sources: { 'carto-dark': { type: 'raster', tiles: [ 'https://a.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}@2x.png', 'https://b.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}@2x.png', 'https://c.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}@2x.png', ], tileSize: 256, attribution: '© OpenStreetMap © CARTO', }, }, layers: [ { id: 'carto-dark-layer', type: 'raster', source: 'carto-dark', minzoom: 0, maxzoom: 22, }, ], } // 모델별 색상 매핑 const MODEL_COLORS: Record = { 'KOSPS': '#06b6d4', 'POSEIDON': '#ef4444', 'OpenDrift': '#3b82f6', } // 오일펜스 우선순위별 색상/두께 const PRIORITY_COLORS: Record = { 'CRITICAL': '#ef4444', 'HIGH': '#f97316', 'MEDIUM': '#eab308', } const PRIORITY_WEIGHTS: Record = { 'CRITICAL': 4, 'HIGH': 3, 'MEDIUM': 2, } const PRIORITY_LABELS: Record = { 'CRITICAL': '긴급', 'HIGH': '중요', 'MEDIUM': '보통', } const SENSITIVE_COLORS: Record = { 'aquaculture': '#22c55e', 'beach': '#0ea5e9', 'ecology': '#eab308', 'intake': '#a855f7', } const SENSITIVE_ICONS: Record = { 'aquaculture': '🐟', 'beach': '🏖', 'ecology': '🦅', 'intake': '🚰', } interface DispersionZone { level: string color: string radius: number angle: number } interface DispersionResult { zones: DispersionZone[] timestamp: string windDirection: number substance: string concentration: Record } interface MapViewProps { center?: [number, number] zoom?: number enabledLayers?: Set incidentCoord?: { lon: number; lat: number } isSelectingLocation?: boolean onMapClick?: (lon: number, lat: number) => void oilTrajectory?: Array<{ lat: number; lon: number; time: number; particle?: number; model?: PredictionModel }> selectedModels?: Set dispersionResult?: DispersionResult | null boomLines?: BoomLine[] isDrawingBoom?: boolean drawingPoints?: BoomLineCoord[] layerOpacity?: number layerBrightness?: number backtrackReplay?: { isActive: boolean ships: ReplayShip[] collisionEvent: CollisionEvent | null replayFrame: number totalFrames: number incidentCoord: { lat: number; lon: number } } sensitiveResources?: SensitiveResource[] } // deck.gl 오버레이 컴포넌트 (MapLibre 컨트롤로 등록) // eslint-disable-next-line @typescript-eslint/no-explicit-any function DeckGLOverlay({ layers }: { layers: any[] }) { const overlay = useControl(() => new MapboxOverlay({ interleaved: true })) overlay.setProps({ layers }) return null } // 팝업 정보 interface PopupInfo { longitude: number latitude: number content: React.ReactNode } export function MapView({ center = DEFAULT_CENTER, zoom = DEFAULT_ZOOM, enabledLayers = new Set(), incidentCoord, isSelectingLocation = false, onMapClick, oilTrajectory = [], selectedModels = new Set(['OpenDrift'] as PredictionModel[]), dispersionResult = null, boomLines = [], isDrawingBoom = false, drawingPoints = [], layerOpacity = 50, layerBrightness = 50, backtrackReplay, sensitiveResources = [], }: MapViewProps) { const [currentPosition, setCurrentPosition] = useState<[number, number]>(DEFAULT_CENTER) const [currentTime, setCurrentTime] = useState(0) const [isPlaying, setIsPlaying] = useState(false) const [playbackSpeed, setPlaybackSpeed] = useState(1) const [popupInfo, setPopupInfo] = useState(null) const handleMapClick = useCallback((e: MapLayerMouseEvent) => { const { lng, lat } = e.lngLat setCurrentPosition([lat, lng]) if (onMapClick) { onMapClick(lng, lat) } setPopupInfo(null) }, [onMapClick]) // 애니메이션 재생 로직 useEffect(() => { if (!isPlaying || oilTrajectory.length === 0) return const maxTime = Math.max(...oilTrajectory.map(p => p.time)) if (currentTime >= maxTime) { setIsPlaying(false) return } const interval = setInterval(() => { setCurrentTime(prev => { const next = prev + (1 * playbackSpeed) return next > maxTime ? maxTime : next }) }, 200) return () => clearInterval(interval) }, [isPlaying, currentTime, playbackSpeed, oilTrajectory]) // 시뮬레이션 시작 시 자동으로 애니메이션 재생 useEffect(() => { if (oilTrajectory.length > 0) { setCurrentTime(0) setIsPlaying(true) } }, [oilTrajectory.length]) // WMS 레이어 목록 const wmsLayers = useMemo(() => { return Array.from(enabledLayers) .map(layerId => { const layer = layerDatabase.find(l => l.id === layerId) return layer?.wmsLayer ? { id: layerId, wmsLayer: layer.wmsLayer } : null }) .filter((l): l is { id: string; wmsLayer: string } => l !== null) }, [enabledLayers]) // WMS 밝기 값 (MapLibre raster paint) const wmsBrightnessMax = Math.min(layerBrightness / 50, 2) const wmsBrightnessMin = wmsBrightnessMax > 1 ? wmsBrightnessMax - 1 : 0 const wmsOpacity = layerOpacity / 100 // deck.gl 레이어 구축 // eslint-disable-next-line @typescript-eslint/no-explicit-any const deckLayers = useMemo((): any[] => { // eslint-disable-next-line @typescript-eslint/no-explicit-any const result: any[] = [] // --- 유류 확산 입자 (ScatterplotLayer) --- const visibleParticles = oilTrajectory.filter(p => p.time <= currentTime) if (visibleParticles.length > 0) { result.push( new ScatterplotLayer({ id: 'oil-particles', data: visibleParticles, getPosition: (d: (typeof visibleParticles)[0]) => [d.lon, d.lat], getRadius: 3, getFillColor: (d: (typeof visibleParticles)[0]) => { const modelKey = d.model || Array.from(selectedModels)[0] || 'OpenDrift' return hexToRgba(MODEL_COLORS[modelKey] || '#3b82f6', 180) }, radiusMinPixels: 2.5, radiusMaxPixels: 5, pickable: true, onClick: (info: PickingInfo) => { if (info.object) { const d = info.object as (typeof visibleParticles)[0] const modelKey = d.model || Array.from(selectedModels)[0] || 'OpenDrift' setPopupInfo({ longitude: d.lon, latitude: d.lat, content: (
{modelKey} 입자 #{(d.particle ?? 0) + 1}
시간: +{d.time}h
위치: {d.lat.toFixed(4)}°, {d.lon.toFixed(4)}°
), }) } }, updateTriggers: { getFillColor: [selectedModels], }, }) ) } // --- 오일펜스 라인 (PathLayer) --- if (boomLines.length > 0) { result.push( new PathLayer({ id: 'boom-lines', data: boomLines, getPath: (d: BoomLine) => d.coords.map(c => [c.lon, c.lat] as [number, number]), getColor: (d: BoomLine) => hexToRgba(PRIORITY_COLORS[d.priority] || '#f59e0b', 230), getWidth: (d: BoomLine) => PRIORITY_WEIGHTS[d.priority] || 2, getDashArray: (d: BoomLine) => d.status === 'PLANNED' ? [10, 5] : null, dashJustified: true, widthMinPixels: 2, widthMaxPixels: 6, pickable: true, onClick: (info: PickingInfo) => { if (info.object) { const d = info.object as BoomLine setPopupInfo({ longitude: info.coordinate?.[0] ?? 0, latitude: info.coordinate?.[1] ?? 0, content: (
{d.name}
우선순위: {PRIORITY_LABELS[d.priority] || d.priority}
길이: {d.length.toFixed(0)}m
각도: {d.angle.toFixed(0)}°
차단 효율: {d.efficiency}%
), }) } }, }) ) // 오일펜스 끝점 마커 const endpoints: Array<{ position: [number, number]; color: [number, number, number, number] }> = [] boomLines.forEach(line => { if (line.coords.length >= 2) { const c = hexToRgba(PRIORITY_COLORS[line.priority] || '#f59e0b', 230) endpoints.push({ position: [line.coords[0].lon, line.coords[0].lat], color: c }) endpoints.push({ position: [line.coords[line.coords.length - 1].lon, line.coords[line.coords.length - 1].lat], color: c }) } }) if (endpoints.length > 0) { result.push( new ScatterplotLayer({ id: 'boom-endpoints', data: endpoints, getPosition: (d: (typeof endpoints)[0]) => d.position, getRadius: 5, getFillColor: (d: (typeof endpoints)[0]) => d.color, getLineColor: [255, 255, 255, 255], getLineWidth: 2, stroked: true, radiusMinPixels: 5, radiusMaxPixels: 8, }) ) } } // --- 드로잉 미리보기 --- if (isDrawingBoom && drawingPoints.length > 0) { result.push( new PathLayer({ id: 'drawing-preview', data: [{ path: drawingPoints.map(c => [c.lon, c.lat] as [number, number]) }], getPath: (d: { path: [number, number][] }) => d.path, getColor: [245, 158, 11, 200], getWidth: 3, getDashArray: [10, 6], dashJustified: true, widthMinPixels: 3, }) ) result.push( new ScatterplotLayer({ id: 'drawing-points', data: drawingPoints.map(c => ({ position: [c.lon, c.lat] as [number, number] })), getPosition: (d: { position: [number, number] }) => d.position, getRadius: 4, getFillColor: [245, 158, 11, 255], getLineColor: [255, 255, 255, 255], getLineWidth: 2, stroked: true, radiusMinPixels: 4, radiusMaxPixels: 6, }) ) } // --- HNS 대기확산 구역 (ScatterplotLayer, meters 단위) --- if (dispersionResult && incidentCoord) { const zones = dispersionResult.zones.map((zone, idx) => ({ position: [incidentCoord.lon, incidentCoord.lat] as [number, number], radius: zone.radius, fillColor: hexToRgba(zone.color, 100), lineColor: hexToRgba(zone.color, 180), level: zone.level, idx, })) result.push( new ScatterplotLayer({ id: 'hns-zones', data: zones, getPosition: (d: (typeof zones)[0]) => d.position, getRadius: (d: (typeof zones)[0]) => d.radius, getFillColor: (d: (typeof zones)[0]) => d.fillColor, getLineColor: (d: (typeof zones)[0]) => d.lineColor, getLineWidth: 2, stroked: true, radiusUnits: 'meters' as const, pickable: true, onClick: (info: PickingInfo) => { if (info.object) { const d = info.object as (typeof zones)[0] setPopupInfo({ longitude: incidentCoord.lon, latitude: incidentCoord.lat, content: (
{d.level}
물질: {dispersionResult.substance}
농도: {dispersionResult.concentration[d.level]}
반경: {d.radius}m
), }) } }, }) ) } // --- 역추적 리플레이 --- if (backtrackReplay?.isActive) { result.push(...createBacktrackLayers({ replayShips: backtrackReplay.ships, collisionEvent: backtrackReplay.collisionEvent, replayFrame: backtrackReplay.replayFrame, totalFrames: backtrackReplay.totalFrames, incidentCoord: backtrackReplay.incidentCoord, })) } // --- 민감자원 영역 (ScatterplotLayer) --- if (sensitiveResources.length > 0) { result.push( new ScatterplotLayer({ id: 'sensitive-zones', data: sensitiveResources, getPosition: (d: SensitiveResource) => [d.lon, d.lat], getRadius: (d: SensitiveResource) => d.radiusM, getFillColor: (d: SensitiveResource) => hexToRgba(SENSITIVE_COLORS[d.type] || '#22c55e', 40), getLineColor: (d: SensitiveResource) => hexToRgba(SENSITIVE_COLORS[d.type] || '#22c55e', 150), getLineWidth: 2, stroked: true, radiusUnits: 'meters' as const, pickable: true, onClick: (info: PickingInfo) => { if (info.object) { const d = info.object as SensitiveResource setPopupInfo({ longitude: d.lon, latitude: d.lat, content: (
{SENSITIVE_ICONS[d.type]} {d.name}
반경: {d.radiusM}m
도달 예상: {d.arrivalTimeH}h
), }) } }, }) ) // 민감자원 중심 마커 result.push( new ScatterplotLayer({ id: 'sensitive-centers', data: sensitiveResources, getPosition: (d: SensitiveResource) => [d.lon, d.lat], getRadius: 6, getFillColor: (d: SensitiveResource) => hexToRgba(SENSITIVE_COLORS[d.type] || '#22c55e', 220), getLineColor: [255, 255, 255, 200], getLineWidth: 2, stroked: true, radiusMinPixels: 6, radiusMaxPixels: 10, }) ) // 민감자원 라벨 result.push( new TextLayer({ id: 'sensitive-labels', data: sensitiveResources, getPosition: (d: SensitiveResource) => [d.lon, d.lat], getText: (d: SensitiveResource) => `${SENSITIVE_ICONS[d.type]} ${d.name} (${d.arrivalTimeH}h)`, getSize: 12, getColor: [255, 255, 255, 200], getPixelOffset: [0, -20], fontFamily: 'var(--fK), sans-serif', fontSettings: { sdf: false }, billboard: true, sizeUnits: 'pixels' as const, background: true, getBackgroundColor: [15, 21, 36, 180], backgroundPadding: [4, 2], }) ) } // --- 해류 화살표 (TextLayer) --- if (incidentCoord) { const currentArrows: Array<{ lon: number; lat: number; bearing: number; speed: number }> = [] const gridSize = 5 const spacing = 0.04 // 약 4km 간격 const mainBearing = 42 // NE 방향 (도) for (let row = -gridSize; row <= gridSize; row++) { for (let col = -gridSize; col <= gridSize; col++) { const lat = incidentCoord.lat + row * spacing const lon = incidentCoord.lon + col * spacing / Math.cos(incidentCoord.lat * Math.PI / 180) // 사고 지점에서 멀어질수록 해류 방향 약간 변화 const distFactor = Math.sqrt(row * row + col * col) / gridSize const localBearing = mainBearing + (col * 3) + (row * 2) const speed = 0.3 + (1 - distFactor) * 0.2 currentArrows.push({ lon, lat, bearing: localBearing, speed }) } } result.push( new TextLayer({ id: 'current-arrows', data: currentArrows, getPosition: (d: (typeof currentArrows)[0]) => [d.lon, d.lat], getText: () => '→', getAngle: (d: (typeof currentArrows)[0]) => -d.bearing, getSize: 14, getColor: [6, 182, 212, 70], sizeUnits: 'pixels' as const, billboard: true, }) ) } return result }, [ oilTrajectory, currentTime, selectedModels, boomLines, isDrawingBoom, drawingPoints, dispersionResult, incidentCoord, backtrackReplay, sensitiveResources, ]) return (
{/* WMS 레이어 */} {wmsLayers.map(layer => ( ))} {/* deck.gl 오버레이 */} {/* 사고 위치 마커 (MapLibre Marker) */} {incidentCoord && !isNaN(incidentCoord.lat) && !isNaN(incidentCoord.lon) && (
)} {/* 사고 위치 팝업 (클릭 시) */} {incidentCoord && !isNaN(incidentCoord.lat) && !isNaN(incidentCoord.lon) && !popupInfo && (
사고 지점
{decimalToDMS(incidentCoord.lat, true)}
{decimalToDMS(incidentCoord.lon, false)}

({incidentCoord.lat.toFixed(4)}°, {incidentCoord.lon.toFixed(4)}°)
)} {/* deck.gl 객체 클릭 팝업 */} {popupInfo && ( setPopupInfo(null)} >
{popupInfo.content}
)} {/* 커스텀 줌 컨트롤 */} {/* 드로잉 모드 안내 */} {isDrawingBoom && (
오일펜스 배치 모드 — 지도를 클릭하여 포인트를 추가하세요 ({drawingPoints.length}개 포인트)
)} {/* 기상청 연계 정보 */} {/* 범례 */} {/* 좌표 표시 */} {/* 타임라인 컨트롤 */} {oilTrajectory.length > 0 && ( p.time))} isPlaying={isPlaying} playbackSpeed={playbackSpeed} onTimeChange={setCurrentTime} onPlayPause={() => setIsPlaying(!isPlaying)} onSpeedChange={setPlaybackSpeed} /> )} {/* 역추적 리플레이 바 */} {backtrackReplay?.isActive && ( )}
) } // 지도 컨트롤 (줌, 위치 초기화) function MapControls({ center, zoom }: { center: [number, number]; zoom: number }) { const { current: map } = useMap() return (
) } // 지도 범례 interface MapLegendProps { dispersionResult?: DispersionResult | null incidentCoord?: { lon: number; lat: number } oilTrajectory?: Array<{ lat: number; lon: number; time: number }> boomLines?: BoomLine[] selectedModels?: Set } function MapLegend({ dispersionResult, incidentCoord, oilTrajectory = [], boomLines = [], selectedModels = new Set(['OpenDrift'] as PredictionModel[]) }: MapLegendProps) { if (dispersionResult && incidentCoord) { return (
📍

사고 위치

{incidentCoord.lat.toFixed(4)}°N, {incidentCoord.lon.toFixed(4)}°E
물질 {dispersionResult.substance}
풍향 SW {dispersionResult.windDirection}°
확산 구역 {dispersionResult.zones.length}개
위험 구역
치명적 위험 구역 (AEGL-3)
높은 위험 구역 (AEGL-2)
중간 위험 구역 (AEGL-1)
🧭
풍향 (방사형)
) } if (oilTrajectory.length > 0) { return (

범례

{Array.from(selectedModels).map(model => (
{model}
))} {selectedModels.size === 3 && (
(앙상블 모드)
)}
사고 지점
{boomLines.length > 0 && ( <>
긴급 오일펜스
중요 오일펜스
보통 오일펜스
)}
) } return null } // 좌표 표시 function CoordinateDisplay({ position }: { position: [number, number] }) { const [lat, lng] = position const latDirection = lat >= 0 ? 'N' : 'S' const lngDirection = lng >= 0 ? 'E' : 'W' return (
위도 {Math.abs(lat).toFixed(4)}°{latDirection} 경도 {Math.abs(lng).toFixed(4)}°{lngDirection} 축척 1:50,000
) } // 타임라인 컨트롤 interface TimelineControlProps { currentTime: number maxTime: number isPlaying: boolean playbackSpeed: number onTimeChange: (time: number) => void onPlayPause: () => void onSpeedChange: (speed: number) => void } function TimelineControl({ currentTime, maxTime, isPlaying, playbackSpeed, onTimeChange, onPlayPause, onSpeedChange }: TimelineControlProps) { const progressPercent = (currentTime / maxTime) * 100 const handleRewind = () => onTimeChange(Math.max(0, currentTime - 6)) const handleForward = () => onTimeChange(Math.min(maxTime, currentTime + 6)) const handleStart = () => onTimeChange(0) const handleEnd = () => onTimeChange(maxTime) const toggleSpeed = () => { const speeds = [1, 2, 4] const currentIndex = speeds.indexOf(playbackSpeed) onSpeedChange(speeds[(currentIndex + 1) % speeds.length]) } const handleTimelineClick = (e: React.MouseEvent) => { const rect = e.currentTarget.getBoundingClientRect() const percent = (e.clientX - rect.left) / rect.width onTimeChange(Math.max(0, Math.min(maxTime, Math.round(percent * maxTime)))) } const timeLabels = [] for (let t = 0; t <= maxTime; t += 6) { timeLabels.push(t) } return (
{isPlaying ? '⏸' : '▶'}
▶▶
{playbackSpeed}×
{timeLabels.map(t => ( {t}h ))}
{timeLabels.map(t => (
))}
{/* eslint-disable-next-line react-hooks/purity */}
+{currentTime.toFixed(0)}h — {new Date(Date.now() + currentTime * 3600000).toLocaleDateString('ko-KR', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })} KST
진행률{progressPercent.toFixed(0)}%
속도{playbackSpeed}×
시간{currentTime.toFixed(0)}/{maxTime}h
) } // 기상 데이터 Mock function getWeatherData(position: [number, number]) { const [lat, lng] = position const latSeed = Math.abs(lat * 100) % 10 const lngSeed = Math.abs(lng * 100) % 10 const directions = ['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW'] return { windSpeed: Number((5 + latSeed).toFixed(1)), windDirection: directions[Math.floor(lngSeed * 0.8)], waveHeight: Number((1 + latSeed * 0.2).toFixed(1)), waterTemp: Number((8 + (lngSeed - 5) * 0.5).toFixed(1)), currentSpeed: Number((0.3 + lngSeed * 0.05).toFixed(2)), currentDirection: directions[Math.floor(latSeed * 0.8)], } } function WeatherInfoPanel({ position }: { position: [number, number] }) { const weather = getWeatherData(position) return (
💨
{weather.windSpeed} m/s
풍속 ({weather.windDirection})
🌊
{weather.waveHeight} m
파고
🌡
{weather.waterTemp}°C
수온
🔄
{weather.currentSpeed} m/s
해류 ({weather.currentDirection})
) } // 역추적 리플레이 컨트롤 바 (HTML 오버레이) function BacktrackReplayBar({ replayFrame, totalFrames, ships }: { replayFrame: number; totalFrames: number; ships: ReplayShip[] }) { const progress = (replayFrame / totalFrames) * 100 return (
{progress.toFixed(0)}%
{ships.map(s => (
))}
) }