- 지도 스타일 상수를 mapStyles.ts로 추출 - useBaseMapStyle 훅 생성 (mapToggles 기반 스타일 반환) - 9개 탭 컴포넌트의 하드코딩 스타일을 공유 훅으로 교체 - 각 Map에 S57EncOverlay 추가 - 초기 mapToggles를 모두 false로 변경 (기본지도 표시)
1625 lines
63 KiB
TypeScript
Executable File
1625 lines
63 KiB
TypeScript
Executable File
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 { 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 { S57EncOverlay } from './S57EncOverlay'
|
||
import { useMapStore } from '@common/store/mapStore'
|
||
import { useBaseMapStyle } from '@common/hooks/useBaseMapStyle'
|
||
|
||
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<PredictionModel, string> = {
|
||
'KOSPS': '#06b6d4',
|
||
'POSEIDON': '#ef4444',
|
||
'OpenDrift': '#3b82f6',
|
||
}
|
||
|
||
// 오일펜스 우선순위별 색상/두께
|
||
const PRIORITY_COLORS: Record<string, string> = {
|
||
'CRITICAL': '#ef4444',
|
||
'HIGH': '#f97316',
|
||
'MEDIUM': '#eab308',
|
||
}
|
||
const PRIORITY_WEIGHTS: Record<string, number> = {
|
||
'CRITICAL': 4,
|
||
'HIGH': 3,
|
||
'MEDIUM': 2,
|
||
}
|
||
const PRIORITY_LABELS: Record<string, string> = {
|
||
'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<string, string> = {
|
||
'aquaculture': '#22c55e',
|
||
'beach': '#0ea5e9',
|
||
'ecology': '#eab308',
|
||
'intake': '#a855f7',
|
||
}
|
||
const SENSITIVE_ICONS: Record<string, string> = {
|
||
'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<string, string>
|
||
}
|
||
|
||
interface MapViewProps {
|
||
center?: [number, number]
|
||
zoom?: number
|
||
enabledLayers?: Set<string>
|
||
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<PredictionModel>
|
||
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<Array<{ lat: number; lon: number; wind_speed: number; wind_direction: number }>>
|
||
hydrData?: (HydrDataStep | null)[]
|
||
// 외부 플레이어 제어 (prediction 하단 바에서 제어할 때 사용)
|
||
externalCurrentTime?: number
|
||
mapCaptureRef?: React.MutableRefObject<(() => Promise<string | null>) | 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<MapboxOverlay>(() => 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<string | null>) | null> }) {
|
||
const { current: map } = useMap();
|
||
useEffect(() => {
|
||
if (!map) return;
|
||
captureRef.current = () =>
|
||
new Promise<string | null>((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<number>(DEFAULT_ZOOM)
|
||
const [internalCurrentTime, setInternalCurrentTime] = useState(0)
|
||
const [isPlaying, setIsPlaying] = useState(false)
|
||
const [playbackSpeed, setPlaybackSpeed] = useState(1)
|
||
const [popupInfo, setPopupInfo] = useState<PopupInfo | null>(null)
|
||
// deck.gl 레이어 클릭 시 MapLibre 맵 클릭 핸들러 차단용 플래그 (민감자원 등)
|
||
const deckClickHandledRef = useRef(false)
|
||
// 클릭으로 열린 팝업(닫기 전까지 유지) 추적 — 호버 핸들러가 닫지 않도록 방지
|
||
const persistentPopupRef = useRef(false)
|
||
// 현재 호버 중인 민감자원 feature properties (handleMapClick에서 팝업 생성에 사용)
|
||
const hoveredSensitiveRef = useRef<Record<string, unknown> | 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: (
|
||
<div className="text-xs font-korean" style={{ minWidth: '180px', maxWidth: '260px' }}>
|
||
<div className="font-semibold mb-1.5 pb-1 border-b border-[rgba(0,0,0,0.12)]">
|
||
{String(category ?? '민감자원')}
|
||
</div>
|
||
{entries.length > 0 ? (
|
||
<div className="space-y-0.5">
|
||
{entries.map(([key, val]) => (
|
||
<div key={key} className="flex gap-2 justify-between">
|
||
<span className="text-[10px] text-[#888] shrink-0">{key}</span>
|
||
<span className="text-[10px] text-[#333] font-medium text-right break-all">
|
||
{typeof val === 'object' ? JSON.stringify(val) : String(val)}
|
||
</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
) : (
|
||
<p className="text-[10px] text-[#999]">상세 정보 없음</p>
|
||
)}
|
||
</div>
|
||
),
|
||
})
|
||
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: (
|
||
<div className="text-xs">
|
||
<strong>{modelKey} 입자 #{(d.particle ?? 0) + 1}</strong>
|
||
{d.stranded === 1 && <span className="text-red-400"> (육지 부착)</span>}
|
||
<br />
|
||
시간: +{d.time}h
|
||
<br />
|
||
위치: {d.lat.toFixed(4)}°, {d.lon.toFixed(4)}°
|
||
</div>
|
||
),
|
||
})
|
||
}
|
||
},
|
||
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: (
|
||
<div className="text-xs" style={{ minWidth: '140px' }}>
|
||
<strong style={{ color: PRIORITY_COLORS[d.priority] }}>{d.name}</strong>
|
||
<br />
|
||
우선순위: {PRIORITY_LABELS[d.priority] || d.priority}
|
||
<br />
|
||
길이: {d.length.toFixed(0)}m
|
||
<br />
|
||
각도: {d.angle.toFixed(0)}°
|
||
<br />
|
||
차단 효율: {d.efficiency}%
|
||
</div>
|
||
),
|
||
})
|
||
}
|
||
},
|
||
})
|
||
)
|
||
|
||
// 오일펜스 끝점 마커
|
||
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: (
|
||
<div className="text-xs leading-relaxed" style={{ minWidth: 180 }}>
|
||
<strong className="text-color-warning">{dispersionResult.substance} 대기확산 면적</strong>
|
||
<table style={{ width: '100%', marginTop: 4, borderCollapse: 'collapse' }}>
|
||
<tbody>
|
||
{zoneAreas.map(z => (
|
||
<tr key={z.level} style={{ borderBottom: '1px solid rgba(0,0,0,0.08)' }}>
|
||
<td style={{ padding: '2px 0', fontSize: 10 }}>{z.level}</td>
|
||
<td style={{ padding: '2px 0', fontSize: 10, textAlign: 'right', fontFamily: 'monospace' }}>{z.area.toFixed(3)} km²</td>
|
||
</tr>
|
||
))}
|
||
<tr>
|
||
<td style={{ padding: '3px 0 0', fontSize: 10, fontWeight: 700 }}>총 면적</td>
|
||
<td style={{ padding: '3px 0 0', fontSize: 10, fontWeight: 700, textAlign: 'right', fontFamily: 'monospace' }}>{totalArea.toFixed(3)} km²</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
),
|
||
});
|
||
} 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: (
|
||
<div className="text-xs" style={{ minWidth: '130px' }}>
|
||
<div className="flex items-center gap-1 mb-1">
|
||
<span>{SENSITIVE_ICONS[d.type]}</span>
|
||
<strong style={{ color: SENSITIVE_COLORS[d.type] }}>{d.name}</strong>
|
||
</div>
|
||
<div className="text-[10px] text-[#666]">
|
||
반경: {d.radiusM}m<br />
|
||
도달 예상: <strong style={{ color: d.arrivalTimeH <= 6 ? '#ef4444' : '#f97316' }}>{d.arrivalTimeH}h</strong>
|
||
</div>
|
||
</div>
|
||
),
|
||
})
|
||
}
|
||
},
|
||
})
|
||
)
|
||
|
||
// 민감자원 중심 마커
|
||
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<string, unknown> | 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<string, typeof visibleCenters> = {}
|
||
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 = useBaseMapStyle(lightMode)
|
||
|
||
return (
|
||
<div className="w-full h-full relative">
|
||
<Map
|
||
initialViewState={{
|
||
longitude: center[1],
|
||
latitude: center[0],
|
||
zoom: zoom,
|
||
}}
|
||
mapStyle={currentMapStyle}
|
||
className="w-full h-full"
|
||
style={{ cursor: (isSelectingLocation || drawAnalysisMode !== null || measureMode !== null) ? 'crosshair' : 'grab' }}
|
||
onClick={handleMapClick}
|
||
attributionControl={false}
|
||
preserveDrawingBuffer={true}
|
||
>
|
||
{/* 지도 캡처 셋업 */}
|
||
{mapCaptureRef && <MapCaptureSetup captureRef={mapCaptureRef} />}
|
||
{/* 지도 중앙 좌표 + 줌 추적 */}
|
||
<MapCenterTracker onCenterChange={handleMapCenterChange} />
|
||
{/* 3D 모드 pitch 제어 */}
|
||
<MapPitchController threeD={mapToggles['threeD'] ?? false} />
|
||
{/* 사고 지점 변경 시 지도 이동 */}
|
||
<MapFlyToIncident coord={flyToIncident} onFlyEnd={onIncidentFlyEnd} />
|
||
{/* 외부에서 flyTo 트리거 */}
|
||
<FlyToController flyToTarget={flyToTarget} />
|
||
{/* 예측 완료 시 궤적 전체 범위로 fitBounds */}
|
||
<FitBoundsController fitBoundsTarget={fitBoundsTarget} />
|
||
|
||
{/* S-57 전자해도 오버레이 (공식 style.json 기반) */}
|
||
<S57EncOverlay visible={mapToggles['s57'] ?? false} />
|
||
|
||
{/* WMS 레이어 */}
|
||
{wmsLayers.map(layer => (
|
||
<Source
|
||
key={layer.id}
|
||
id={`wms-${layer.id}`}
|
||
type="raster"
|
||
tiles={[
|
||
`${GEOSERVER_URL}/geoserver/gwc/service/wms?service=WMS&version=1.1.0&request=GetMap&layers=${layer.wmsLayer}&styles=&bbox={bbox-epsg-3857}&width=256&height=256&srs=EPSG:3857&format=image/png&transparent=true`
|
||
]}
|
||
tileSize={256}
|
||
>
|
||
<Layer
|
||
id={`wms-layer-${layer.id}`}
|
||
type="raster"
|
||
paint={{
|
||
'raster-opacity': wmsOpacity,
|
||
'raster-brightness-min': wmsBrightnessMin,
|
||
'raster-brightness-max': Math.min(wmsBrightnessMax, 1),
|
||
}}
|
||
/>
|
||
</Source>
|
||
))}
|
||
|
||
{/* deck.gl 오버레이 (인터리브드: 일반 레이어) */}
|
||
<DeckGLOverlay layers={deckLayers} />
|
||
|
||
{/* 해류 파티클 오버레이 */}
|
||
{hydrData.length > 0 && showCurrent && (
|
||
<HydrParticleOverlay hydrStep={hydrData[currentTime] ?? null} lightMode={lightMode} />
|
||
)}
|
||
|
||
{/* 사고 위치 마커 (MapLibre Marker) */}
|
||
{incidentCoord && !isNaN(incidentCoord.lat) && !isNaN(incidentCoord.lon) && !(dispersionHeatmap && dispersionHeatmap.length > 0) && (
|
||
<Marker longitude={incidentCoord.lon} latitude={incidentCoord.lat} anchor="bottom">
|
||
<div
|
||
title={`사고 지점\n${incidentCoord.lat.toFixed(4)}°N, ${incidentCoord.lon.toFixed(4)}°E`}
|
||
className="w-6 h-6 bg-color-accent border-2 border-white"
|
||
style={{
|
||
borderRadius: '50% 50% 50% 0',
|
||
transform: 'rotate(-45deg)',
|
||
boxShadow: '0 2px 8px rgba(6,182,212,0.5)',
|
||
}}
|
||
/>
|
||
</Marker>
|
||
)}
|
||
|
||
{/* deck.gl 객체 클릭 팝업 */}
|
||
{popupInfo && (
|
||
<Popup
|
||
longitude={popupInfo.longitude}
|
||
latitude={popupInfo.latitude}
|
||
anchor="bottom"
|
||
onClose={() => {
|
||
persistentPopupRef.current = false
|
||
setPopupInfo(null)
|
||
}}
|
||
>
|
||
<div className="text-[#333]">{popupInfo.content}</div>
|
||
</Popup>
|
||
)}
|
||
|
||
{/* 측정 결과 지우기 버튼 */}
|
||
<MeasureOverlay />
|
||
|
||
{/* 커스텀 줌 컨트롤 */}
|
||
<MapControls center={center} zoom={zoom} />
|
||
</Map>
|
||
|
||
{/* 드로잉 모드 안내 */}
|
||
{isDrawingBoom && (
|
||
<div className="boom-drawing-indicator">
|
||
오일펜스 배치 모드 — 지도를 클릭하여 포인트를 추가하세요 ({drawingPoints.length}개 포인트)
|
||
</div>
|
||
)}
|
||
{drawAnalysisMode === 'polygon' && (
|
||
<div className="boom-drawing-indicator" style={{ background: 'rgba(168,85,247,0.15)', borderColor: 'rgba(168,85,247,0.4)' }}>
|
||
다각형 분석 모드 — 지도를 클릭하여 꼭짓점을 추가하세요 ({analysisPolygonPoints.length}개)
|
||
</div>
|
||
)}
|
||
{drawAnalysisMode === 'circle' && (
|
||
<div className="boom-drawing-indicator" style={{ background: 'rgba(168,85,247,0.15)', borderColor: 'rgba(168,85,247,0.4)' }}>
|
||
{!analysisCircleCenter ? '원 분석 모드 — 중심점을 클릭하세요' : '반경 지점을 클릭하세요'}
|
||
</div>
|
||
)}
|
||
{measureMode === 'distance' && (
|
||
<div className="boom-drawing-indicator">
|
||
거리 재기 — {measureInProgress.length === 0 ? '시작점을 클릭하세요' : '끝점을 클릭하세요'}
|
||
</div>
|
||
)}
|
||
{measureMode === 'area' && (
|
||
<div className="boom-drawing-indicator">
|
||
면적 재기 — 꼭짓점을 클릭하세요 ({measureInProgress.length}개){measureInProgress.length >= 3 && ' → 첫 번째 점 근처를 클릭하면 닫힙니다'}
|
||
</div>
|
||
)}
|
||
|
||
{/* 기상청 연계 정보 */}
|
||
{showOverlays && <WeatherInfoPanel position={currentPosition} />}
|
||
|
||
{/* 범례 */}
|
||
{showOverlays && <MapLegend dispersionResult={dispersionResult} incidentCoord={incidentCoord} oilTrajectory={oilTrajectory} boomLines={boomLines} selectedModels={selectedModels} />}
|
||
|
||
{/* 좌표 표시 */}
|
||
{showOverlays && <CoordinateDisplay
|
||
position={mapCenter}
|
||
zoom={mapZoom}
|
||
/>}
|
||
|
||
{/* 타임라인 컨트롤 (외부 제어 모드에서는 숨김 — 하단 플레이어가 대신 담당) */}
|
||
{!isControlled && oilTrajectory.length > 0 && (
|
||
<TimelineControl
|
||
currentTime={currentTime}
|
||
maxTime={Math.max(...oilTrajectory.map(p => p.time))}
|
||
isPlaying={isPlaying}
|
||
playbackSpeed={playbackSpeed}
|
||
onTimeChange={setInternalCurrentTime}
|
||
onPlayPause={() => setIsPlaying(!isPlaying)}
|
||
onSpeedChange={setPlaybackSpeed}
|
||
/>
|
||
)}
|
||
|
||
{/* 역추적 리플레이 바 */}
|
||
{backtrackReplay?.isActive && (
|
||
<BacktrackReplayBar
|
||
replayFrame={backtrackReplay.replayFrame}
|
||
totalFrames={backtrackReplay.totalFrames}
|
||
ships={backtrackReplay.ships}
|
||
/>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// 지도 컨트롤 (줌, 위치 초기화)
|
||
function MapControls({ center, zoom }: { center: [number, number]; zoom: number }) {
|
||
const { current: map } = useMap()
|
||
|
||
return (
|
||
<div className="absolute top-[80px] left-[10px] z-10">
|
||
<div className="flex flex-col gap-1">
|
||
<button
|
||
onClick={() => 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"
|
||
>
|
||
+
|
||
</button>
|
||
<button
|
||
onClick={() => 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"
|
||
>
|
||
−
|
||
</button>
|
||
<button
|
||
onClick={() => 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]"
|
||
>
|
||
🎯
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// 지도 범례
|
||
interface MapLegendProps {
|
||
dispersionResult?: DispersionResult | null
|
||
incidentCoord?: { lon: number; lat: number }
|
||
oilTrajectory?: Array<{ lat: number; lon: number; time: number }>
|
||
boomLines?: BoomLine[]
|
||
selectedModels?: Set<PredictionModel>
|
||
}
|
||
|
||
function MapLegend({ dispersionResult, incidentCoord, oilTrajectory = [], selectedModels = new Set(['OpenDrift'] as PredictionModel[]) }: MapLegendProps) {
|
||
const [minimized, setMinimized] = useState(true)
|
||
|
||
if (dispersionResult && incidentCoord) {
|
||
return (
|
||
<div className="absolute top-4 right-4 bg-[rgba(18,25,41,0.95)] backdrop-blur-xl border border-stroke rounded-lg min-w-[200px] z-[20]">
|
||
{/* 헤더 + 최소화 버튼 */}
|
||
<div className="flex items-center justify-between px-3.5 pt-3 pb-1 cursor-pointer" onClick={() => setMinimized(!minimized)}>
|
||
<span className="text-[10px] font-bold text-fg-disabled uppercase tracking-wider">범례</span>
|
||
<span className="text-[10px] text-fg-disabled hover:text-fg transition-colors">{minimized ? '▶' : '▼'}</span>
|
||
</div>
|
||
{!minimized && (
|
||
<div className="px-3.5 pb-3.5">
|
||
<div className="flex items-center gap-1.5 mb-2.5">
|
||
<div className="text-base">📍</div>
|
||
<div>
|
||
<h4 className="text-[11px] font-bold text-color-warning">사고 위치</h4>
|
||
<div className="text-[8px] text-fg-disabled font-mono">
|
||
{incidentCoord.lat.toFixed(4)}°N, {incidentCoord.lon.toFixed(4)}°E
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div className="text-[9px] text-fg-sub mb-2 rounded" style={{ background: 'rgba(249,115,22,0.08)', padding: '8px' }}>
|
||
<div className="flex justify-between mb-[3px]">
|
||
<span className="text-fg-disabled">물질</span>
|
||
<span className="font-semibold text-color-warning">{dispersionResult.substance}</span>
|
||
</div>
|
||
<div className="flex justify-between mb-[3px]">
|
||
<span className="text-fg-disabled">풍향</span>
|
||
<span className="font-semibold font-mono">SW {dispersionResult.windDirection}°</span>
|
||
</div>
|
||
<div className="flex justify-between">
|
||
<span className="text-fg-disabled">확산 구역</span>
|
||
<span className="font-semibold text-color-accent">{dispersionResult.zones.length}개</span>
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<h5 className="text-[9px] font-bold text-fg-disabled mb-2">위험 구역</h5>
|
||
<div className="flex flex-col gap-1.5">
|
||
<div className="flex items-center gap-2 text-[10px] text-fg-sub">
|
||
<div className="w-3 h-3 rounded-full" style={{ background: 'rgba(239,68,68,0.7)' }} />
|
||
<span>치명적 위험 구역 (AEGL-3)</span>
|
||
</div>
|
||
<div className="flex items-center gap-2 text-[10px] text-fg-sub">
|
||
<div className="w-3 h-3 rounded-full" style={{ background: 'rgba(249,115,22,0.7)' }} />
|
||
<span>높은 위험 구역 (AEGL-2)</span>
|
||
</div>
|
||
<div className="flex items-center gap-2 text-[10px] text-fg-sub">
|
||
<div className="w-3 h-3 rounded-full" style={{ background: 'rgba(234,179,8,0.7)' }} />
|
||
<span>중간 위험 구역 (AEGL-1)</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div className="flex items-center gap-1.5 mt-2 rounded" style={{ padding: '6px', background: 'rgba(168,85,247,0.08)' }}>
|
||
<div className="text-xs">🧭</div>
|
||
<span className="text-[9px] text-fg-disabled">풍향 (방사형)</span>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
if (oilTrajectory.length > 0) {
|
||
return (
|
||
<div className="absolute top-4 right-4 bg-[rgba(18,25,41,0.92)] backdrop-blur-xl border border-stroke rounded-md z-[20]" style={{ minWidth: 155 }}>
|
||
{/* 헤더 + 접기/펼치기 */}
|
||
<div
|
||
className="flex items-center justify-between px-3 py-2 cursor-pointer select-none"
|
||
onClick={() => setMinimized(!minimized)}
|
||
>
|
||
<span className="text-[10px] font-bold text-fg-sub font-korean">범례</span>
|
||
<span className="text-[9px] text-fg-disabled hover:text-fg transition-colors ml-3">{minimized ? '▶' : '▼'}</span>
|
||
</div>
|
||
|
||
{!minimized && (
|
||
<div className="px-3 pb-2.5 flex flex-col gap-[5px]">
|
||
{/* 모델별 색상 */}
|
||
{Array.from(selectedModels).map(model => (
|
||
<div key={model} className="flex items-center gap-2 text-[10px] text-fg-sub">
|
||
<div className="w-[14px] h-[3px] rounded-sm" style={{ background: MODEL_COLORS[model] }} />
|
||
<span className="font-korean">{model}</span>
|
||
</div>
|
||
))}
|
||
{/* 앙상블 */}
|
||
<div className="flex items-center gap-2 text-[10px] text-fg-sub">
|
||
<div className="w-[14px] h-[3px] rounded-sm" style={{ background: '#a855f7' }} />
|
||
<span className="font-korean">앙상블</span>
|
||
</div>
|
||
|
||
{/* 오일펜스 라인 */}
|
||
<div className="h-px bg-border my-0.5" />
|
||
<div className="flex items-center gap-2 text-[10px] text-fg-sub">
|
||
<div className="flex gap-px">
|
||
<div className="w-[4px] h-[4px] rounded-full bg-[#f97316]" />
|
||
<div className="w-[4px] h-[4px] rounded-full bg-[#f97316]" />
|
||
<div className="w-[4px] h-[4px] rounded-full bg-[#f97316]" />
|
||
</div>
|
||
<span className="font-korean">오일펜스 라인</span>
|
||
</div>
|
||
|
||
{/* 도달시간별 선종 */}
|
||
<div className="h-px bg-border my-0.5" />
|
||
<div className="flex items-center gap-2 text-[10px] text-fg-sub">
|
||
<div className="w-[14px] h-[3px] rounded-sm bg-[#ef4444]" />
|
||
<span className="font-korean">위험 (<6h)</span>
|
||
</div>
|
||
<div className="flex items-center gap-2 text-[10px] text-fg-sub">
|
||
<div className="w-[14px] h-[3px] rounded-sm bg-[#f97316]" />
|
||
<span className="font-korean">경고 (6~12h)</span>
|
||
</div>
|
||
<div className="flex items-center gap-2 text-[10px] text-fg-sub">
|
||
<div className="w-[14px] h-[3px] rounded-sm bg-[#eab308]" />
|
||
<span className="font-korean">주의 (12~24h)</span>
|
||
</div>
|
||
<div className="flex items-center gap-2 text-[10px] text-fg-sub">
|
||
<div className="w-[14px] h-[3px] rounded-sm bg-[#22c55e]" />
|
||
<span className="font-korean">안전</span>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
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 (
|
||
<div className="cod">
|
||
<span>위도 <span className="cov">{Math.abs(lat).toFixed(4)}°{latDirection}</span></span>
|
||
<span>경도 <span className="cov">{Math.abs(lng).toFixed(4)}°{lngDirection}</span></span>
|
||
<span>축척 <span className="cov">{scaleLabel}</span></span>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// 타임라인 컨트롤
|
||
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<HTMLDivElement>) => {
|
||
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 (
|
||
<div className="tlb">
|
||
<div className="tlc">
|
||
<div className="tb" onClick={handleStart}>⏮</div>
|
||
<div className="tb" onClick={handleRewind}>◀</div>
|
||
<div className={`tb ${isPlaying ? 'on' : ''}`} onClick={onPlayPause}>
|
||
{isPlaying ? '⏸' : '▶'}
|
||
</div>
|
||
<div className="tb" onClick={handleForward}>▶▶</div>
|
||
<div className="tb" onClick={handleEnd}>⏭</div>
|
||
<div className="w-2" />
|
||
<div className="tb" onClick={toggleSpeed}>{playbackSpeed}×</div>
|
||
</div>
|
||
<div className="tlt">
|
||
<div className="tlls">
|
||
{timeLabels.map(t => (
|
||
<span key={t} className={`tll ${Math.abs(currentTime - t) < 1 ? 'on' : ''}`} style={{ left: `${(t / maxTime) * 100}%` }}>
|
||
{t}h
|
||
</span>
|
||
))}
|
||
</div>
|
||
<div className="tlsw" onClick={handleTimelineClick}>
|
||
<div className="tlr">
|
||
<div className="tlp" style={{ width: `${progressPercent}%` }} />
|
||
{timeLabels.map(t => (
|
||
<div key={`marker-${t}`} className={`tlm ${t % 12 === 0 ? 'mj' : ''}`} style={{ left: `${(t / maxTime) * 100}%` }} />
|
||
))}
|
||
</div>
|
||
<div className="tlth" style={{ left: `${progressPercent}%` }} />
|
||
</div>
|
||
</div>
|
||
<div className="tli">
|
||
{/* eslint-disable-next-line react-hooks/purity */}
|
||
<div className="tlct">+{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`;
|
||
})()}</div>
|
||
<div className="tlss">
|
||
<div className="tls"><span className="tlsl">진행률</span><span className="tlsv">{progressPercent.toFixed(0)}%</span></div>
|
||
<div className="tls"><span className="tlsl">속도</span><span className="tlsv">{playbackSpeed}×</span></div>
|
||
<div className="tls"><span className="tlsl">시간</span><span className="tlsv">{currentTime.toFixed(0)}/{maxTime}h</span></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// 기상 데이터 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 (
|
||
<div className="wip">
|
||
<div className="wii">
|
||
<div className="wii-icon">💨</div>
|
||
<div className="wii-value">{weather.windSpeed} m/s</div>
|
||
<div className="wii-label">풍속 ({weather.windDirection})</div>
|
||
</div>
|
||
<div className="wii">
|
||
<div className="wii-icon">🌊</div>
|
||
<div className="wii-value">{weather.waveHeight} m</div>
|
||
<div className="wii-label">파고</div>
|
||
</div>
|
||
<div className="wii">
|
||
<div className="wii-icon">🌡</div>
|
||
<div className="wii-value">{weather.waterTemp}°C</div>
|
||
<div className="wii-label">수온</div>
|
||
</div>
|
||
<div className="wii">
|
||
<div className="wii-icon">🔄</div>
|
||
<div className="wii-value">{weather.currentSpeed} m/s</div>
|
||
<div className="wii-label">해류 ({weather.currentDirection})</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// 역추적 리플레이 컨트롤 바 (HTML 오버레이)
|
||
function BacktrackReplayBar({ replayFrame, totalFrames, ships }: { replayFrame: number; totalFrames: number; ships: ReplayShip[] }) {
|
||
const progress = (replayFrame / totalFrames) * 100
|
||
return (
|
||
<div
|
||
className="absolute flex items-center gap-4"
|
||
style={{
|
||
bottom: 80, left: '50%', transform: 'translateX(-50%)',
|
||
background: 'rgba(10,14,26,0.92)', backdropFilter: 'blur(12px)',
|
||
border: '1px solid var(--stroke-light)', borderRadius: '10px',
|
||
padding: '12px 18px', zIndex: 50,
|
||
minWidth: '340px',
|
||
}}
|
||
>
|
||
<div className="text-sm text-color-tertiary font-mono font-bold">
|
||
{progress.toFixed(0)}%
|
||
</div>
|
||
<div className="flex-1 h-1 bg-border relative rounded-[2px]">
|
||
<div
|
||
className="h-full rounded-[2px]"
|
||
style={{ width: `${progress}%`, background: 'linear-gradient(90deg, var(--color-tertiary), var(--color-accent))', transition: 'width 0.05s' }}
|
||
/>
|
||
</div>
|
||
<div className="flex gap-1.5">
|
||
{ships.map(s => (
|
||
<div
|
||
key={s.vesselName}
|
||
className="w-2 h-2 rounded-full border border-white/30"
|
||
style={{ background: s.color }}
|
||
title={s.vesselName}
|
||
/>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|