import { useState, useMemo, useEffect, useCallback, useRef } 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, BitmapLayer, PolygonLayer, GeoJsonLayer } from '@deck.gl/layers' import type { PickingInfo, Layer as DeckLayer } 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 type { PredictionModel, SensitiveResource } from '@tabs/prediction/components/OilSpillView' import type { HydrDataStep, SensitiveResourceFeatureCollection } from '@tabs/prediction/services/predictionApi' import HydrParticleOverlay from './HydrParticleOverlay' import type { BoomLine, BoomLineCoord } from '@common/types/boomLine' import type { ReplayShip, CollisionEvent, BackwardParticleStep } from '@common/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 { useMapStore } from '@common/store/mapStore' 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 // 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, }, ], } // MarineTraffic 스타일 — 깔끔한 연한 회색 육지 + 흰색 바다 + 한글 라벨 const LIGHT_STYLE: StyleSpecification = { version: 8, glyphs: 'https://demotiles.maplibre.org/font/{fontstack}/{range}.pbf', sources: { 'ofm-chart': { type: 'vector', url: 'https://tiles.openfreemap.org/planet', }, }, layers: [ // ── 배경 = 육지 (연한 회색) ── { id: 'land-bg', type: 'background', paint: { 'background-color': '#e8e8e8' }, }, // ── 바다/호수/강 = water 레이어 (파란색) ── { id: 'water', type: 'fill', source: 'ofm-chart', 'source-layer': 'water', paint: { 'fill-color': '#a8cce0' }, }, // ── 주요 도로 (zoom 9+) ── { id: 'roads-major', type: 'line', source: 'ofm-chart', 'source-layer': 'transportation', minzoom: 9, filter: ['in', 'class', 'motorway', 'trunk', 'primary'], paint: { 'line-color': '#c0c0c0', 'line-width': ['interpolate', ['linear'], ['zoom'], 9, 0.4, 14, 1.5], }, }, // ── 보조 도로 (zoom 12+) ── { id: 'roads-secondary', type: 'line', source: 'ofm-chart', 'source-layer': 'transportation', minzoom: 12, filter: ['in', 'class', 'secondary', 'tertiary'], paint: { 'line-color': '#cccccc', 'line-width': ['interpolate', ['linear'], ['zoom'], 12, 0.3, 14, 1], }, }, // ── 건물 (zoom 14+) ── { id: 'buildings', type: 'fill', source: 'ofm-chart', 'source-layer': 'building', minzoom: 14, paint: { 'fill-color': '#cbcbcb', 'fill-opacity': 0.5 }, }, // ── 국경선 ── { id: 'boundaries-country', type: 'line', source: 'ofm-chart', 'source-layer': 'boundary', filter: ['==', 'admin_level', 2], paint: { 'line-color': '#999999', 'line-width': 1, 'line-dasharray': [4, 2] }, }, // ── 시도 경계 (zoom 5+) ── { id: 'boundaries-province', type: 'line', source: 'ofm-chart', 'source-layer': 'boundary', minzoom: 5, filter: ['==', 'admin_level', 4], paint: { 'line-color': '#bbbbbb', 'line-width': 0.5, 'line-dasharray': [3, 2] }, }, // ── 국가/시도 라벨 (한글) ── { id: 'place-labels-major', type: 'symbol', source: 'ofm-chart', 'source-layer': 'place', minzoom: 3, filter: ['in', 'class', 'country', 'state'], layout: { 'text-field': ['coalesce', ['get', 'name:ko'], ['get', 'name']], 'text-font': ['Open Sans Bold'], 'text-size': ['interpolate', ['linear'], ['zoom'], 3, 11, 8, 16], 'text-max-width': 8, }, paint: { 'text-color': '#555555', 'text-halo-color': '#ffffff', 'text-halo-width': 2, }, }, { id: 'place-labels-city', type: 'symbol', source: 'ofm-chart', 'source-layer': 'place', minzoom: 5, filter: ['in', 'class', 'city', 'town'], layout: { 'text-field': ['coalesce', ['get', 'name:ko'], ['get', 'name']], 'text-font': ['Open Sans Regular'], 'text-size': ['interpolate', ['linear'], ['zoom'], 5, 10, 12, 14], 'text-max-width': 7, }, paint: { 'text-color': '#666666', 'text-halo-color': '#ffffff', 'text-halo-width': 1.5, }, }, // ── 해양 지명 (water_name) ── { id: 'water-labels', type: 'symbol', source: 'ofm-chart', 'source-layer': 'water_name', layout: { 'text-field': ['coalesce', ['get', 'name:ko'], ['get', 'name']], 'text-font': ['Open Sans Italic'], 'text-size': ['interpolate', ['linear'], ['zoom'], 3, 10, 8, 14], 'text-max-width': 10, 'text-letter-spacing': 0.15, }, paint: { 'text-color': '#8899aa', 'text-halo-color': 'rgba(168,204,224,0.7)', 'text-halo-width': 1, }, }, // ── 마을/소지명 (zoom 10+) ── { id: 'place-labels-village', type: 'symbol', source: 'ofm-chart', 'source-layer': 'place', minzoom: 10, filter: ['in', 'class', 'village', 'suburb', 'hamlet'], layout: { 'text-field': ['coalesce', ['get', 'name:ko'], ['get', 'name']], 'text-font': ['Open Sans Regular'], 'text-size': ['interpolate', ['linear'], ['zoom'], 10, 9, 14, 12], 'text-max-width': 6, }, paint: { 'text-color': '#777777', 'text-halo-color': '#ffffff', 'text-halo-width': 1, }, }, ], } // 3D 위성 스타일 (VWorld 위성 배경 + OpenFreeMap 건물 extrusion) // VWorld WMTS: {z}/{y}/{x} (row/col 순서) // OpenFreeMap: CORS 허용, 전 세계 OSM 벡터타일 (building: render_height 포함) const SATELLITE_3D_STYLE: StyleSpecification = { version: 8, sources: { 'vworld-satellite': { type: 'raster', tiles: ['/api/tiles/vworld/{z}/{y}/{x}.jpeg'], tileSize: 256, attribution: '© 국토지리정보원 VWorld', }, 'ofm': { type: 'vector', url: 'https://tiles.openfreemap.org/planet', }, }, layers: [ { id: 'satellite-base', type: 'raster', source: 'vworld-satellite', minzoom: 0, maxzoom: 22, }, { id: 'roads-3d', type: 'line', source: 'ofm', 'source-layer': 'transportation', filter: ['in', ['get', 'class'], ['literal', ['motorway', 'trunk', 'primary', 'secondary']]], paint: { 'line-color': 'rgba(255,255,200,0.3)', 'line-width': ['interpolate', ['linear'], ['zoom'], 10, 1, 16, 3], }, }, { id: '3d-buildings', type: 'fill-extrusion', source: 'ofm', 'source-layer': 'building', minzoom: 13, filter: ['!=', ['get', 'hide_3d'], true], paint: { 'fill-extrusion-color': '#c8b99a', 'fill-extrusion-height': ['max', ['coalesce', ['get', 'render_height'], 0], 3], 'fill-extrusion-base': ['coalesce', ['get', 'render_min_height'], 0], 'fill-extrusion-opacity': 0.85, }, }, ], } // 모델별 색상 매핑 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 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?: string; stranded?: 0 | 1 }> selectedModels?: Set dispersionResult?: DispersionResult | null dispersionHeatmap?: Array<{ lon: number; lat: number; concentration: number }> 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 } 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 /** 밝은 톤 지도 스타일 사용 (CartoDB Positron) */ lightMode?: boolean /** false로 설정 시 WeatherInfoPanel, MapLegend, CoordinateDisplay 숨김 (기본: true) */ showOverlays?: boolean } // deck.gl 오버레이 컴포넌트 (MapLibre 컨트롤로 등록, interleaved) // 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 } // flyTo 트리거 컴포넌트 (Map 내부에서 useMap() 사용) function FlyToController({ flyToTarget }: { flyToTarget?: { lng: number; lat: number; zoom?: number } | null }) { const { current: map } = useMap() useEffect(() => { if (!map || !flyToTarget) return map.flyTo({ center: [flyToTarget.lng, flyToTarget.lat], zoom: flyToTarget.zoom ?? 10, duration: 1200, }) }, [flyToTarget, map]) return null } // 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 = [], isDrawingBoom = false, drawingPoints = [], layerOpacity = 50, layerBrightness = 50, 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, lightMode = false, showOverlays = true, }: MapViewProps) { 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 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 (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.01).map(p => p.concentration)); const filtered = dispersionHeatmap.filter(p => p.concentration > 0.01); 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, 6, 0, Math.PI * 2); ctx.fill(); } result.push( new BitmapLayer({ id: 'hns-dispersion-bitmap', image: canvas, bounds: [minLon, minLat, maxLon, maxLat], opacity: 1.0, pickable: false, }) as unknown as DeckLayer, ); } } // --- 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, 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); } } }, }) ) } // --- 역추적 리플레이 --- 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)) return result.filter(Boolean) }, [ oilTrajectory, currentTime, selectedModels, boomLines, isDrawingBoom, drawingPoints, dispersionResult, dispersionHeatmap, incidentCoord, backtrackReplay, sensitiveResources, sensitiveResourceGeojson, centerPoints, windData, showWind, showBeached, showTimeLabel, simulationStartTime, analysisPolygonPoints, analysisCircleCenter, analysisCircleRadiusM, lightMode, ]) // 3D 모드 / 밝은 톤에 따른 지도 스타일 전환 const currentMapStyle = mapToggles['threeD'] ? SATELLITE_3D_STYLE : lightMode ? LIGHT_STYLE : BASE_STYLE return ( {/* 지도 캡처 셋업 */} {mapCaptureRef && } {/* 지도 중앙 좌표 + 줌 추적 */} {/* 3D 모드 pitch 제어 */} {/* 사고 지점 변경 시 지도 이동 */} {/* 외부에서 flyTo 트리거 */} {/* 예측 완료 시 궤적 전체 범위로 fitBounds */} {/* 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} /> )} {/* 역추적 리플레이 바 */} {backtrackReplay?.isActive && ( )} ) } // 지도 컨트롤 (줌, 위치 초기화) function MapControls({ center, zoom }: { center: [number, number]; zoom: number }) { const { current: map } = useMap() return ( map?.zoomIn()} className="w-[28px] h-[28px] bg-[rgba(18,25,41,0.65)] backdrop-blur-sm border border-[rgba(30,42,66,0.5)] rounded-sm text-fg-sub flex items-center justify-center hover:bg-bg-surface-hover hover:text-fg transition-all text-xs" > + map?.zoomOut()} className="w-[28px] h-[28px] bg-[rgba(18,25,41,0.65)] backdrop-blur-sm border border-[rgba(30,42,66,0.5)] rounded-sm text-fg-sub flex items-center justify-center hover:bg-bg-surface-hover hover:text-fg transition-all text-xs" > − map?.flyTo({ center: [center[1], center[0]], zoom, duration: 1000 })} className="w-[28px] h-[28px] bg-[rgba(18,25,41,0.65)] backdrop-blur-sm border border-[rgba(30,42,66,0.5)] rounded-sm text-fg-sub flex items-center justify-center hover:text-fg transition-all text-[10px]" > 🎯 ) } // 지도 범례 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} ) } // 타임라인 컨트롤 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 — {(() => { const base = simulationStartTime ? new Date(simulationStartTime) : new Date(); const d = new Date(base.getTime() + currentTime * 3600 * 1000); return `${String(d.getMonth() + 1).padStart(2, '0')}/${String(d.getDate()).padStart(2, '0')} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')} 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 => ( ))} ) }
상세 정보 없음