import { useState, useMemo, useEffect, useCallback, useRef } from 'react'; import { Map, Marker, Popup, Source, Layer, useMap } from '@vis.gl/react-maplibre'; import { ScatterplotLayer, PathLayer, TextLayer, BitmapLayer, PolygonLayer, GeoJsonLayer, } from '@deck.gl/layers'; import type { PickingInfo, Layer as DeckLayer } from '@deck.gl/core'; import type { MapLayerMouseEvent } from 'maplibre-gl'; import 'maplibre-gl/dist/maplibre-gl.css'; import { layerDatabase } from '@common/services/layerService'; import type { PredictionModel } from '@/types/prediction/PredictionType'; import type { SensitiveResource } from '@interfaces/prediction/PredictionInterface'; import type { HydrDataStep, SensitiveResourceFeatureCollection, } from '@interfaces/prediction/PredictionInterface'; import HydrParticleOverlay from './HydrParticleOverlay'; import { TimelineControl } from './TimelineControl'; import type { BoomLine, BoomLineCoord } from '@/types/boomLine'; import type { ReplayShip, CollisionEvent, BackwardParticleStep } from '@/types/backtrack'; import { createBacktrackLayers } from './BacktrackReplayOverlay'; import { buildMeasureLayers } from './measureLayers'; import { MeasureOverlay } from './MeasureOverlay'; import { useMeasureTool } from '@common/hooks/useMeasureTool'; import { hexToRgba } from './mapUtils'; import { S57EncOverlay } from './S57EncOverlay'; import { SrOverlay } from './SrOverlay'; import { DeckGLOverlay } from './DeckGLOverlay'; import { FlyToController } from './FlyToController'; import { useMapStore } from '@common/store/mapStore'; import { useBaseMapStyle } from '@common/hooks/useBaseMapStyle'; import { buildVesselLayers } from './VesselLayer'; import { MapBoundsTracker } from './MapBoundsTracker'; import { VesselHoverTooltip, VesselPopupPanel, VesselDetailModal, type VesselHoverInfo, } from './VesselInteraction'; import type { VesselPosition, MapBounds } from '@/types/vessel'; /* eslint-disable react-refresh/only-export-components */ const GEOSERVER_URL = import.meta.env.VITE_GEOSERVER_URL || 'http://localhost:8080'; // 인천 송도 국제도시 const DEFAULT_CENTER: [number, number] = [37.39, 126.64]; const DEFAULT_ZOOM = 10; // 모델별 색상 매핑 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: '보통', }; function hslToRgb(h: number, s: number, l: number): [number, number, number] { const a = s * Math.min(l, 1 - l); const f = (n: number) => { const k = (n + h * 12) % 12; return l - a * Math.max(-1, Math.min(k - 3, 9 - k, 1)); }; return [Math.round(f(0) * 255), Math.round(f(8) * 255), Math.round(f(4) * 255)]; } function categoryToRgb(category: string): [number, number, number] { let hash = 0; for (let i = 0; i < category.length; i++) { hash = (hash * 31 + category.charCodeAt(i)) >>> 0; } const hue = (hash * 137) % 360; return hslToRgb(hue / 360, 0.65, 0.55); } 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 DispersionContour { level: string; threshold: number; color: string; segments: Array<[[number, number], [number, number]]>; } interface DispersionResult { zones: DispersionZone[]; timestamp: string; windDirection: number; substance: string; concentration: Record; contours?: DispersionContour[]; } 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?: string; stranded?: 0 | 1; }>; selectedModels?: Set; dispersionResult?: DispersionResult | null; dispersionHeatmap?: Array<{ lon: number; lat: number; concentration: number }>; boomLines?: BoomLine[]; showBoomLines?: boolean; isDrawingBoom?: boolean; drawingPoints?: BoomLineCoord[]; layerOpacity?: number; layerBrightness?: number; layerColors?: Record; backtrackReplay?: { isActive: boolean; ships: ReplayShip[]; collisionEvent: CollisionEvent | null; replayFrame: number; totalFrames: number; incidentCoord: { lat: number; lon: number }; backwardParticles?: BackwardParticleStep[]; }; sensitiveResources?: SensitiveResource[]; sensitiveResourceGeojson?: SensitiveResourceFeatureCollection | null; flyToTarget?: { lng: number; lat: number; zoom?: number } | null; fitBoundsTarget?: { north: number; south: number; east: number; west: number } | null; centerPoints?: Array<{ lat: number; lon: number; time: number; model?: string }>; windData?: Array>; hydrData?: (HydrDataStep | null)[]; // 외부 플레이어 제어 (prediction 하단 바에서 제어할 때 사용) externalCurrentTime?: number; mapCaptureRef?: React.MutableRefObject<(() => Promise) | null>; onIncidentFlyEnd?: () => void; flyToIncident?: { lon: number; lat: number }; showCurrent?: boolean; showWind?: boolean; showBeached?: boolean; showTimeLabel?: boolean; simulationStartTime?: string; drawAnalysisMode?: 'polygon' | 'circle' | null; analysisPolygonPoints?: Array<{ lat: number; lon: number }>; analysisCircleCenter?: { lat: number; lon: number } | null; analysisCircleRadiusM?: number; /** false로 설정 시 WeatherInfoPanel, MapLegend, CoordinateDisplay 숨김 (기본: true) */ showOverlays?: boolean; /** 선박 신호 목록 (실시간 표출) */ vessels?: VesselPosition[]; /** 지도 뷰포트 bounds 변경 콜백 (선박 신호 필터링에 사용) */ onBoundsChange?: (bounds: MapBounds) => void; } // DeckGLOverlay, FlyToController → @components/common/map/DeckGLOverlay, FlyToController 에서 import // MapBoundsTracker는 './MapBoundsTracker' 모듈로 추출됨 (IncidentsView 등 BaseMap 직접 사용처에서도 재사용) // fitBounds 트리거 컴포넌트 (Map 내부에서 useMap() 사용) function FitBoundsController({ fitBoundsTarget, }: { fitBoundsTarget?: { north: number; south: number; east: number; west: number } | null; }) { const { current: map } = useMap(); useEffect(() => { if (!map || !fitBoundsTarget) return; map.fitBounds( [ [fitBoundsTarget.west, fitBoundsTarget.south], [fitBoundsTarget.east, fitBoundsTarget.north], ], { padding: 80, duration: 1200, maxZoom: 12 }, ); }, [fitBoundsTarget, map]); return null; } // Map 중앙 좌표 + 줌 추적 컴포넌트 (Map 내부에서 useMap() 사용) function MapCenterTracker({ onCenterChange, }: { onCenterChange: (lat: number, lng: number, zoom: number) => void; }) { const { current: map } = useMap(); useEffect(() => { if (!map) return; const update = () => { const center = map.getCenter(); const zoom = map.getZoom(); onCenterChange(center.lat, center.lng, zoom); }; update(); map.on('move', update); map.on('zoom', update); return () => { map.off('move', update); map.off('zoom', update); }; }, [map, onCenterChange]); return null; } // 3D 모드 pitch/bearing 제어 컴포넌트 (Map 내부에서 useMap() 사용) function MapPitchController({ threeD }: { threeD: boolean }) { const { current: map } = useMap(); useEffect(() => { if (!map) return; map.easeTo( threeD ? { pitch: 45, bearing: -17, duration: 800 } : { pitch: 0, bearing: 0, duration: 800 }, ); }, [threeD, map]); return null; } // 사고 지점 변경 시 지도 이동 (Map 내부 컴포넌트) function MapFlyToIncident({ coord, onFlyEnd, }: { coord?: { lon: number; lat: number }; onFlyEnd?: () => void; }) { const { current: map } = useMap(); const onFlyEndRef = useRef(onFlyEnd); useEffect(() => { onFlyEndRef.current = onFlyEnd; }, [onFlyEnd]); useEffect(() => { if (!map || !coord) return; const { lon, lat } = coord; const doFly = () => { map.flyTo({ center: [lon, lat], zoom: 11, duration: 1200 }); map.once('moveend', () => onFlyEndRef.current?.()); }; if (map.loaded()) { doFly(); } else { map.once('load', doFly); } }, [coord, map]); // 객체 참조 추적: 같은 좌표라도 새 객체면 effect 재실행 return null; } // 지도 캡처 지원 (map.once('render') 후 캡처로 빈 캔버스 문제 방지) function MapCaptureSetup({ captureRef, }: { captureRef: React.MutableRefObject<(() => Promise) | null>; }) { const { current: map } = useMap(); useEffect(() => { if (!map) return; captureRef.current = () => new Promise((resolve) => { map.once('render', () => { try { // WebGL 캔버스는 alpha=0 투명 배경이므로 불투명 배경과 합성 후 추출 // 최대 1200px로 리사이즈 + JPEG 압축으로 전송 크기 절감 const src = map.getCanvas(); const maxW = 1200; const scale = src.width > maxW ? maxW / src.width : 1; const composite = document.createElement('canvas'); composite.width = Math.round(src.width * scale); composite.height = Math.round(src.height * scale); const ctx = composite.getContext('2d')!; ctx.fillStyle = '#0f1117'; ctx.fillRect(0, 0, composite.width, composite.height); ctx.drawImage(src, 0, 0, composite.width, composite.height); resolve(composite.toDataURL('image/jpeg', 0.82)); } catch { resolve(null); } }); map.triggerRepaint(); }); }, [map, captureRef]); 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, dispersionHeatmap = [], boomLines = [], showBoomLines = true, isDrawingBoom = false, drawingPoints = [], layerOpacity = 50, layerBrightness = 50, layerColors, backtrackReplay, sensitiveResources = [], sensitiveResourceGeojson, flyToTarget, fitBoundsTarget, centerPoints = [], windData = [], hydrData = [], externalCurrentTime, mapCaptureRef, onIncidentFlyEnd, flyToIncident, showCurrent = true, showWind = true, showBeached = false, showTimeLabel = false, simulationStartTime, drawAnalysisMode = null, analysisPolygonPoints = [], analysisCircleCenter, analysisCircleRadiusM = 0, showOverlays = true, vessels = [], onBoundsChange, }: MapViewProps) { const lightMode = true; const { mapToggles, measureMode, measureInProgress, measurements } = useMapStore(); const { handleMeasureClick } = useMeasureTool(); const isControlled = externalCurrentTime !== undefined; const [currentPosition, setCurrentPosition] = useState<[number, number]>(DEFAULT_CENTER); const [mapCenter, setMapCenter] = useState<[number, number]>(DEFAULT_CENTER); const [mapZoom, setMapZoom] = useState(DEFAULT_ZOOM); const [internalCurrentTime, setInternalCurrentTime] = useState(0); const [isPlaying, setIsPlaying] = useState(false); const [playbackSpeed, setPlaybackSpeed] = useState(1); const [popupInfo, setPopupInfo] = useState(null); // deck.gl 레이어 클릭 시 MapLibre 맵 클릭 핸들러 차단용 플래그 (민감자원 등) const deckClickHandledRef = useRef(false); // 클릭으로 열린 팝업(닫기 전까지 유지) 추적 — 호버 핸들러가 닫지 않도록 방지 const persistentPopupRef = useRef(false); // 현재 호버 중인 민감자원 feature properties (handleMapClick에서 팝업 생성에 사용) const hoveredSensitiveRef = useRef | null>(null); // 선박 호버/클릭 상호작용 상태 const [vesselHover, setVesselHover] = useState(null); const [selectedVessel, setSelectedVessel] = useState(null); const [detailVessel, setDetailVessel] = useState(null); const currentTime = isControlled ? externalCurrentTime : internalCurrentTime; const handleMapCenterChange = useCallback((lat: number, lng: number, zoom: number) => { setMapCenter([lat, lng]); setMapZoom(zoom); }, []); const handleMapClick = useCallback( (e: MapLayerMouseEvent) => { const { lng, lat } = e.lngLat; setCurrentPosition([lat, lng]); // deck.gl 다른 레이어 onClick이 처리한 클릭 — 팝업 유지 if (deckClickHandledRef.current) { deckClickHandledRef.current = false; return; } // 민감자원 hover 중이면 팝업 표시 if (hoveredSensitiveRef.current) { const props = hoveredSensitiveRef.current; const { category, ...rest } = props; const entries = Object.entries(rest).filter( ([k, v]) => k !== 'srId' && v !== null && v !== undefined && v !== '', ); persistentPopupRef.current = true; setPopupInfo({ longitude: lng, latitude: lat, content: (
{String(category ?? '민감자원')}
{entries.length > 0 ? (
{entries.map(([key, val]) => (
{key} {typeof val === 'object' ? JSON.stringify(val) : String(val)}
))}
) : (

상세 정보 없음

)}
), }); return; } if (measureMode !== null) { handleMeasureClick(lng, lat); return; } if (onMapClick) { onMapClick(lng, lat); } setPopupInfo(null); }, [onMapClick, measureMode, handleMeasureClick], ); // 애니메이션 재생 로직 (외부 제어 모드에서는 비활성) useEffect(() => { if (isControlled || !isPlaying || oilTrajectory.length === 0) return; const maxTime = Math.max(...oilTrajectory.map((p) => p.time)); if (internalCurrentTime >= maxTime) { setIsPlaying(false); return; } const interval = setInterval(() => { setInternalCurrentTime((prev) => { const next = prev + 1 * playbackSpeed; return next > maxTime ? maxTime : next; }); }, 200); return () => clearInterval(interval); }, [isControlled, isPlaying, internalCurrentTime, playbackSpeed, oilTrajectory]); // 시뮬레이션 시작 시 자동으로 애니메이션 재생 (외부 제어 모드에서는 비활성) useEffect(() => { if (isControlled) return; if (oilTrajectory.length > 0) { setInternalCurrentTime(0); setIsPlaying(true); } }, [isControlled, 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); const activeStep = visibleParticles.length > 0 ? Math.max(...visibleParticles.map((p) => p.time)) : -1; 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') as PredictionModel; // 1순위: stranded 입자 → showBeached=true 시 모델 색, false 시 회색 if (d.stranded === 1) return showBeached ? hexToRgba(MODEL_COLORS[modelKey] || '#3b82f6', 180) : ([130, 130, 130, 70] as [number, number, number, number]); // 2순위: 현재 활성 스텝 → 모델 기본 색상 if (d.time === activeStep) return hexToRgba(MODEL_COLORS[modelKey] || '#3b82f6', 180); // 3순위: 과거 스텝 → 회색 + 투명 return [130, 130, 130, 70] as [number, number, number, number]; }, 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.stranded === 1 && (육지 부착)}
시간: +{d.time}h
위치: {d.lat.toFixed(4)}°, {d.lon.toFixed(4)}°
), }); } }, updateTriggers: { getFillColor: [selectedModels, currentTime, showBeached], }, }), ); } // --- 육지부착 hollow ring (stranded 모양 구분) --- const strandedParticles = showBeached ? visibleParticles.filter((p) => p.stranded === 1) : []; if (strandedParticles.length > 0) { result.push( new ScatterplotLayer({ id: 'oil-stranded-ring', data: strandedParticles, getPosition: (d: (typeof strandedParticles)[0]) => [d.lon, d.lat], stroked: true, filled: false, getLineColor: (d: (typeof strandedParticles)[0]) => { const modelKey = (d.model || Array.from(selectedModels)[0] || 'OpenDrift') as PredictionModel; return hexToRgba(MODEL_COLORS[modelKey] || '#3b82f6', 255); }, lineWidthMinPixels: 2, getRadius: 4, radiusMinPixels: 5, radiusMaxPixels: 8, updateTriggers: { getLineColor: [selectedModels], }, }), ); } // --- 오일펜스 라인 (PathLayer) --- if (showBoomLines && 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] : [0, 0]), 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, }), ); } // --- 오염분석 다각형 그리기 --- if (analysisPolygonPoints.length > 0) { if (analysisPolygonPoints.length >= 3) { result.push( new PolygonLayer({ id: 'analysis-polygon-fill', data: [ { polygon: analysisPolygonPoints.map((p) => [p.lon, p.lat] as [number, number]) }, ], getPolygon: (d: { polygon: [number, number][] }) => d.polygon, getFillColor: [168, 85, 247, 40], getLineColor: [168, 85, 247, 220], getLineWidth: 2, stroked: true, filled: true, lineWidthMinPixels: 2, }), ); } result.push( new PathLayer({ id: 'analysis-polygon-outline', data: [ { path: [ ...analysisPolygonPoints.map((p) => [p.lon, p.lat] as [number, number]), ...(analysisPolygonPoints.length >= 3 ? [ [analysisPolygonPoints[0].lon, analysisPolygonPoints[0].lat] as [ number, number, ], ] : []), ], }, ], getPath: (d: { path: [number, number][] }) => d.path, getColor: [168, 85, 247, 220], getWidth: 2, getDashArray: [8, 4], dashJustified: true, widthMinPixels: 2, }), ); result.push( new ScatterplotLayer({ id: 'analysis-polygon-points', data: analysisPolygonPoints.map((p) => ({ position: [p.lon, p.lat] as [number, number], })), getPosition: (d: { position: [number, number] }) => d.position, getRadius: 5, getFillColor: [168, 85, 247, 255], getLineColor: [255, 255, 255, 255], getLineWidth: 2, stroked: true, radiusMinPixels: 5, radiusMaxPixels: 8, }), ); } // --- 오염분석 원 그리기 --- if (analysisCircleCenter) { result.push( new ScatterplotLayer({ id: 'analysis-circle-center', data: [ { position: [analysisCircleCenter.lon, analysisCircleCenter.lat] as [number, number] }, ], getPosition: (d: { position: [number, number] }) => d.position, getRadius: 6, getFillColor: [168, 85, 247, 255], getLineColor: [255, 255, 255, 255], getLineWidth: 2, stroked: true, radiusMinPixels: 6, radiusMaxPixels: 9, }), ); if (analysisCircleRadiusM > 0) { result.push( new ScatterplotLayer({ id: 'analysis-circle-area', data: [ { position: [analysisCircleCenter.lon, analysisCircleCenter.lat] as [number, number], }, ], getPosition: (d: { position: [number, number] }) => d.position, getRadius: analysisCircleRadiusM, radiusUnits: 'meters', getFillColor: [168, 85, 247, 35], getLineColor: [168, 85, 247, 200], getLineWidth: 2, stroked: true, filled: true, lineWidthMinPixels: 2, }), ); } } // --- HNS 대기확산 히트맵 (BitmapLayer, 고정 이미지) --- if (dispersionHeatmap && dispersionHeatmap.length > 0) { const maxConc = Math.max(...dispersionHeatmap.map((p) => p.concentration)); const minConc = Math.min( ...dispersionHeatmap.filter((p) => p.concentration > 0.001).map((p) => p.concentration), ); const filtered = dispersionHeatmap.filter((p) => p.concentration > 0.001); console.log( '[MapView] HNS 히트맵:', dispersionHeatmap.length, '→ filtered:', filtered.length, 'maxConc:', maxConc.toFixed(2), ); if (filtered.length > 0) { // 경위도 바운드 계산 let minLon = Infinity, maxLon = -Infinity, minLat = Infinity, maxLat = -Infinity; for (const p of dispersionHeatmap) { if (p.lon < minLon) minLon = p.lon; if (p.lon > maxLon) maxLon = p.lon; if (p.lat < minLat) minLat = p.lat; if (p.lat > maxLat) maxLat = p.lat; } const padLon = (maxLon - minLon) * 0.02; const padLat = (maxLat - minLat) * 0.02; minLon -= padLon; maxLon += padLon; minLat -= padLat; maxLat += padLat; // 캔버스에 농도 이미지 렌더링 const W = 1200, H = 960; const canvas = document.createElement('canvas'); canvas.width = W; canvas.height = H; const ctx = canvas.getContext('2d')!; ctx.clearRect(0, 0, W, H); // 로그 스케일: 농도 범위를 고르게 분포 const logMin = Math.log(minConc); const logMax = Math.log(maxConc); const logRange = logMax - logMin || 1; const stops: [number, number, number, number][] = [ [34, 197, 94, 220], // green (저농도) [234, 179, 8, 235], // yellow [249, 115, 22, 245], // orange [239, 68, 68, 250], // red (고농도) [185, 28, 28, 255], // dark red (초고농도) ]; for (const p of filtered) { // 로그 스케일 정규화 (0~1) const ratio = Math.max(0, Math.min(1, (Math.log(p.concentration) - logMin) / logRange)); const t = ratio * (stops.length - 1); const lo = Math.floor(t); const hi = Math.min(lo + 1, stops.length - 1); const f = t - lo; const r = Math.round(stops[lo][0] + (stops[hi][0] - stops[lo][0]) * f); const g = Math.round(stops[lo][1] + (stops[hi][1] - stops[lo][1]) * f); const b = Math.round(stops[lo][2] + (stops[hi][2] - stops[lo][2]) * f); const a = (stops[lo][3] + (stops[hi][3] - stops[lo][3]) * f) / 255; const px = ((p.lon - minLon) / (maxLon - minLon)) * W; const py = (1 - (p.lat - minLat) / (maxLat - minLat)) * H; ctx.fillStyle = `rgba(${r},${g},${b},${a.toFixed(2)})`; ctx.beginPath(); ctx.arc(px, py, 12, 0, Math.PI * 2); ctx.fill(); } // Canvas → data URL 변환 (interleaved MapboxOverlay에서 HTMLCanvasElement 직접 전달 시 렌더링 안 되는 문제 회피) const imageUrl = canvas.toDataURL('image/png'); result.push( new BitmapLayer({ id: 'hns-dispersion-bitmap', image: imageUrl, bounds: [minLon, minLat, maxLon, maxLat], opacity: 1.0, pickable: false, }) as unknown as DeckLayer, ); } } // --- HNS 대기확산 구역 (ScatterplotLayer, meters 단위) --- if (dispersionResult && incidentCoord) { // contour가 있으면 동심원 fill은 희미하게(contour가 실제 경계 표시), 없으면 진하게 const hasContours = !!(dispersionResult.contours && dispersionResult.contours.length > 0); const zoneFillAlpha = hasContours ? 40 : 100; const zoneLineAlpha = hasContours ? 80 : 180; const zones = dispersionResult.zones.map((zone, idx) => ({ position: [incidentCoord.lon, incidentCoord.lat] as [number, number], radius: zone.radius, fillColor: hexToRgba(zone.color, zoneFillAlpha), lineColor: hexToRgba(zone.color, zoneLineAlpha), 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, autoHighlight: true, onHover: (info: PickingInfo) => { if (info.object && info.coordinate) { const zoneAreas = zones.map((z) => ({ level: z.level, area: (Math.PI * z.radius * z.radius) / 1e6, })); const totalArea = (Math.PI * Math.max(...zones.map((z) => z.radius)) ** 2) / 1e6; setPopupInfo({ longitude: info.coordinate[0], latitude: info.coordinate[1], content: (
{dispersionResult.substance} 대기확산 면적 {zoneAreas.map((z) => ( ))}
{z.level} {z.area.toFixed(3)} km²
총 면적 {totalArea.toFixed(3)} km²
), }); } else if (!info.object) { // 클릭으로 열린 팝업(어장 등)이 있으면 호버로 닫지 않음 if (!persistentPopupRef.current) { setPopupInfo(null); } } }, }), ); // --- HNS AEGL 등농도선 (PathLayer) --- if (dispersionResult.contours) { dispersionResult.contours.forEach((contour, cIdx) => { if (contour.segments.length === 0) return; const color = hexToRgba(contour.color, 230); result.push( new PathLayer({ id: `hns-contour-${cIdx}-${contour.level}`, data: contour.segments, getPath: (d: [[number, number], [number, number]]) => d, getColor: color, getWidth: 3, widthUnits: 'pixels' as const, capRounded: true, jointRounded: true, pickable: false, }) as unknown as DeckLayer, ); }); } } // --- 역추적 리플레이 --- if (backtrackReplay?.isActive) { result.push( ...createBacktrackLayers({ replayShips: backtrackReplay.ships, collisionEvent: backtrackReplay.collisionEvent, replayFrame: backtrackReplay.replayFrame, totalFrames: backtrackReplay.totalFrames, incidentCoord: backtrackReplay.incidentCoord, backwardParticles: backtrackReplay.backwardParticles, }), ); } // --- 민감자원 영역 (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: (lightMode ? [20, 40, 100, 240] : [255, 255, 255, 200]) as [ number, number, number, number, ], getPixelOffset: [0, -20], fontFamily: 'NanumSquare, Malgun Gothic, 맑은 고딕, sans-serif', fontWeight: 'bold', characterSet: 'auto', outlineWidth: 2, outlineColor: [15, 21, 36, 200], billboard: true, sizeUnits: 'pixels' as const, }), ); } // --- 민감자원 GeoJSON 레이어 --- if (sensitiveResourceGeojson && sensitiveResourceGeojson.features.length > 0) { result.push( new GeoJsonLayer({ id: 'sensitive-resource-geojson', // eslint-disable-next-line @typescript-eslint/no-explicit-any data: sensitiveResourceGeojson as any, pickable: true, stroked: true, filled: true, pointRadiusMinPixels: 10, pointRadiusMaxPixels: 20, lineWidthMinPixels: 1, getLineWidth: 1.5, getFillColor: (f: { properties: { category?: string } | null }) => { const cat = f.properties?.category ?? ''; const [r, g, b] = categoryToRgb(cat); return [r, g, b, 80] as [number, number, number, number]; }, getLineColor: (f: { properties: { category?: string } | null }) => { const cat = f.properties?.category ?? ''; const [r, g, b] = categoryToRgb(cat); return [r, g, b, 210] as [number, number, number, number]; }, onHover: (info: PickingInfo) => { if (info.object) { hoveredSensitiveRef.current = (info.object as { properties: Record | null }).properties ?? {}; } else { hoveredSensitiveRef.current = null; } }, }) as unknown as DeckLayer, ); } // --- 입자 중심점 이동 경로 (모델별 PathLayer + ScatterplotLayer) --- const visibleCenters = centerPoints.filter((p) => p.time <= currentTime); if (visibleCenters.length > 0) { // 모델별 그룹핑 (Record 사용 — Map 컴포넌트와 이름 충돌 회피) const modelGroups: Record = {}; visibleCenters.forEach((p) => { const key = p.model || 'OpenDrift'; if (!modelGroups[key]) modelGroups[key] = []; modelGroups[key].push(p); }); Object.entries(modelGroups).forEach(([model, points]) => { const modelColor = hexToRgba(MODEL_COLORS[model as PredictionModel] || '#06b6d4', 210); if (points.length >= 2) { result.push( new PathLayer({ id: `center-path-${model}`, data: [ { path: points.map( (p: { lon: number; lat: number }) => [p.lon, p.lat] as [number, number], ), }, ], getPath: (d: { path: [number, number][] }) => d.path, getColor: modelColor, getWidth: 2, widthMinPixels: 2, widthMaxPixels: 4, }), ); } result.push( new ScatterplotLayer({ id: `center-points-${model}`, data: points, getPosition: (d: { lon: number; lat: number }) => [d.lon, d.lat], getRadius: 5, getFillColor: modelColor, radiusMinPixels: 4, radiusMaxPixels: 8, pickable: false, }), ); if (showTimeLabel) { const baseTime = simulationStartTime ? new Date(simulationStartTime) : null; const pad = (n: number) => String(n).padStart(2, '0'); result.push( new TextLayer({ id: `time-labels-${model}`, data: points, getPosition: (d: { lon: number; lat: number }) => [d.lon, d.lat], getText: (d: { time: number }) => { if (baseTime) { const dt = new Date(baseTime.getTime() + d.time * 3600 * 1000); return `${dt.getFullYear()}-${pad(dt.getMonth() + 1)}-${pad(dt.getDate())} ${pad(dt.getHours())}:${pad(dt.getMinutes())}`; } return `+${d.time}h`; }, getSize: 12, getColor: hexToRgba(MODEL_COLORS[model as PredictionModel] || '#06b6d4', 240), getPixelOffset: [0, 16] as [number, number], fontWeight: 'bold', outlineWidth: 2, outlineColor: (lightMode ? [255, 255, 255, 180] : [15, 21, 36, 200]) as [ number, number, number, number, ], billboard: true, sizeUnits: 'pixels' as const, updateTriggers: { getText: [simulationStartTime, currentTime], }, }), ); } }); } // --- 바람 화살표 (TextLayer) --- if (incidentCoord && windData.length > 0 && showWind) { type ArrowPoint = { lon: number; lat: number; bearing: number; speed: number }; const activeWindStep = windData[currentTime] ?? windData[0] ?? []; const currentArrows: ArrowPoint[] = activeWindStep .filter((d) => d.wind_speed != null && d.wind_direction != null) .map((d) => ({ lon: d.lon, lat: d.lat, bearing: d.wind_direction, speed: d.wind_speed, })); result.push( new TextLayer({ id: 'current-arrows', data: currentArrows, getPosition: (d: ArrowPoint) => [d.lon, d.lat], getText: () => '➤', getAngle: (d: ArrowPoint) => -d.bearing + 90, getSize: 22, getColor: (d: ArrowPoint): [number, number, number, number] => { const s = d.speed; if (s < 3) return [6, 182, 212, 130]; // cyan-500: calm if (s < 7) return [34, 197, 94, 150]; // green-500: light if (s < 12) return [234, 179, 8, 170]; // yellow-500: moderate if (s < 17) return [249, 115, 22, 190]; // orange-500: fresh return [239, 68, 68, 210]; // red-500: strong }, characterSet: 'auto', sizeUnits: 'pixels' as const, billboard: true, updateTriggers: { getColor: [currentTime, windData], getAngle: [currentTime, windData], }, }), ); } // 거리/면적 측정 레이어 result.push(...buildMeasureLayers(measureInProgress, measureMode, measurements)); // 선박 신호 레이어 result.push( ...buildVesselLayers( vessels, { onClick: (vessel) => { setSelectedVessel(vessel); setDetailVessel(null); }, onHover: (vessel, x, y) => { setVesselHover(vessel ? { x, y, vessel } : null); }, }, mapZoom, ), ); return result.filter(Boolean); }, [ oilTrajectory, currentTime, selectedModels, boomLines, showBoomLines, isDrawingBoom, drawingPoints, dispersionResult, dispersionHeatmap, incidentCoord, backtrackReplay, sensitiveResources, sensitiveResourceGeojson, centerPoints, windData, showWind, showBeached, showTimeLabel, simulationStartTime, analysisPolygonPoints, analysisCircleCenter, analysisCircleRadiusM, lightMode, vessels, mapZoom, ]); // 3D 모드 / 테마에 따른 지도 스타일 전환 const currentMapStyle = useBaseMapStyle(); return (
)} > {/* 지도 캡처 셋업 */} {mapCaptureRef && } {/* 지도 중앙 좌표 + 줌 추적 */} {/* 3D 모드 pitch 제어 */} {/* 사고 지점 변경 시 지도 이동 */} {/* 외부에서 flyTo 트리거 */} {/* 예측 완료 시 궤적 전체 범위로 fitBounds */} {/* 선박 신호 뷰포트 bounds 추적 */} {/* S-57 전자해도 오버레이 (공식 style.json 기반) */} {/* SR 민감자원 벡터타일 오버레이 */} {/* WMS 레이어 */} {wmsLayers.map((layer) => ( ))} {/* deck.gl 오버레이 (인터리브드: 일반 레이어) */} {/* 해류 파티클 오버레이 */} {hydrData.length > 0 && showCurrent && ( )} {/* 사고 위치 마커 (MapLibre Marker) */} {incidentCoord && !isNaN(incidentCoord.lat) && !isNaN(incidentCoord.lon) && !(dispersionHeatmap && dispersionHeatmap.length > 0) && (
)} {/* deck.gl 객체 클릭 팝업 */} {popupInfo && ( { persistentPopupRef.current = false; setPopupInfo(null); }} >
{popupInfo.content}
)} {/* 측정 결과 지우기 버튼 */} {/* 커스텀 줌 컨트롤 */} {/* 드로잉 모드 안내 */} {isDrawingBoom && (
오일펜스 배치 모드 — 지도를 클릭하여 포인트를 추가하세요 ({drawingPoints.length}개 포인트)
)} {drawAnalysisMode === 'polygon' && (
다각형 분석 모드 — 지도를 클릭하여 꼭짓점을 추가하세요 ({analysisPolygonPoints.length}개)
)} {drawAnalysisMode === 'circle' && (
{!analysisCircleCenter ? '원 분석 모드 — 중심점을 클릭하세요' : '반경 지점을 클릭하세요'}
)} {measureMode === 'distance' && (
거리 재기 — {measureInProgress.length === 0 ? '시작점을 클릭하세요' : '끝점을 클릭하세요'}
)} {measureMode === 'area' && (
면적 재기 — 꼭짓점을 클릭하세요 ({measureInProgress.length}개) {measureInProgress.length >= 3 && ' → 첫 번째 점 근처를 클릭하면 닫힙니다'}
)} {/* 기상청 연계 정보 */} {showOverlays && } {/* 범례 */} {showOverlays && ( )} {/* 좌표 표시 */} {showOverlays && } {/* 타임라인 컨트롤 (외부 제어 모드에서는 숨김 — 하단 플레이어가 대신 담당) */} {!isControlled && oilTrajectory.length > 0 && ( p.time))} isPlaying={isPlaying} playbackSpeed={playbackSpeed} onTimeChange={setInternalCurrentTime} onPlayPause={() => setIsPlaying(!isPlaying)} onSpeedChange={setPlaybackSpeed} simulationStartTime={simulationStartTime} /> )} {/* 역추적 리플레이 바 */} {backtrackReplay?.isActive && ( )} {/* 선박 호버 툴팁 */} {vesselHover && !selectedVessel && } {/* 선박 클릭 팝업 */} {selectedVessel && !detailVessel && ( setSelectedVessel(null)} onDetail={() => { setDetailVessel(selectedVessel); setSelectedVessel(null); }} /> )} {/* 선박 상세 모달 */} {detailVessel && ( setDetailVessel(null)} /> )}
); } // 지도 컨트롤 (줌, 위치 초기화) 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 = [], selectedModels = new Set(['OpenDrift'] as PredictionModel[]), }: MapLegendProps) { const [minimized, setMinimized] = useState(true); if (dispersionResult && incidentCoord) { return (
{/* 헤더 + 최소화 버튼 */}
setMinimized(!minimized)} > 범례 {minimized ? '▶' : '▼'}
{!minimized && (
📍

사고 위치

{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 (
{/* 헤더 + 접기/펼치기 */}
setMinimized(!minimized)} > 범례 {minimized ? '▶' : '▼'}
{!minimized && (
{/* 모델별 색상 */} {Array.from(selectedModels).map((model) => (
{model}
))} {/* 앙상블 */}
앙상블
{/* 오일펜스 라인 */}
오일펜스 라인
{/* 도달시간별 선종 */}
위험 (<6h)
경고 (6~12h)
주의 (12~24h)
안전
)}
); } return null; } // 좌표 표시 function CoordinateDisplay({ position, zoom }: { position: [number, number]; zoom: number }) { const [lat, lng] = position; const latDirection = lat >= 0 ? 'N' : 'S'; const lngDirection = lng >= 0 ? 'E' : 'W'; // MapLibre 줌 → 축척 변환 (96 DPI 기준) const metersPerPixel = (40075016.686 * Math.cos((lat * Math.PI) / 180)) / (256 * Math.pow(2, zoom)); const scaleRatio = Math.round(metersPerPixel * (96 / 0.0254)); const scaleLabel = scaleRatio >= 1000000 ? `1:${(scaleRatio / 1000000).toFixed(1)}M` : `1:${scaleRatio.toLocaleString()}`; return (
위도{' '} {Math.abs(lat).toFixed(4)}°{latDirection} 경도{' '} {Math.abs(lng).toFixed(4)}°{lngDirection} 축척 {scaleLabel}
); } // 기상 데이터 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) => (
))}
); }