wing-ops/frontend/src/common/components/map/MapView.tsx
jeonghyo.k e4b9c3e5dd refactor(map): 지도 항상 라이트 모드로 고정
useBaseMapStyle에서 테마 구독 제거, 항상 LIGHT_STYLE 반환.
MapView lightMode를 true로 고정하여 앱 다크 모드와 무관하게
지도는 라이트 모드로 표시.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-06 22:27:39 +09:00

1631 lines
63 KiB
TypeScript
Executable File
Raw Blame 히스토리

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 { SrOverlay } from './SrOverlay'
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
layerColors?: Record<string, string>
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
/** 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,
layerColors,
backtrackReplay,
sensitiveResources = [],
sensitiveResourceGeojson,
flyToTarget,
fitBoundsTarget,
centerPoints = [],
windData = [],
hydrData = [],
externalCurrentTime,
mapCaptureRef,
onIncidentFlyEnd,
flyToIncident,
showCurrent = true,
showWind = true,
showBeached = false,
showTimeLabel = false,
simulationStartTime,
drawAnalysisMode = null,
analysisPolygonPoints = [],
analysisCircleCenter,
analysisCircleRadiusM = 0,
showOverlays = true,
}: MapViewProps) {
const lightMode = true
const { mapToggles, measureMode, measureInProgress, measurements } = useMapStore()
const { handleMeasureClick } = useMeasureTool()
const isControlled = externalCurrentTime !== undefined
const [currentPosition, setCurrentPosition] = useState<[number, number]>(DEFAULT_CENTER)
const [mapCenter, setMapCenter] = useState<[number, number]>(DEFAULT_CENTER)
const [mapZoom, setMapZoom] = useState<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();
}
// Canvas → data URL 변환 (interleaved MapboxOverlay에서 HTMLCanvasElement 직접 전달 시 렌더링 안 되는 문제 회피)
const imageUrl = canvas.toDataURL('image/png');
result.push(
new BitmapLayer({
id: 'hns-dispersion-bitmap',
image: imageUrl,
bounds: [minLon, minLat, maxLon, maxLat],
opacity: 1.0,
pickable: false,
}) as unknown as DeckLayer,
);
}
}
// --- HNS 대기확산 구역 (ScatterplotLayer, meters 단위) ---
if (dispersionResult && incidentCoord) {
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()
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} />
{/* SR 민감자원 벡터타일 오버레이 */}
<SrOverlay enabledLayers={enabledLayers} opacity={layerOpacity} layerColors={layerColors} />
{/* 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} />
)}
{/* 사고 위치 마커 (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]"
>
&#x1F3AF;
</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"> (&lt;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>
)
}