항공탐색 탭: - CctvView 크래시 수정 (cctvCameras → cameras 필드 매핑) - AerialView 이중 서브메뉴 분기 → 플랫 switch 단순화 - SensorAnalysis SVG 300pt → Canvas 2D 5000/8000pt 고밀도 전환 - RealtimeDrone CSS 시뮬레이션 → MapLibre + deck.gl 실제 지도 전환 확산분석 탭: - 시뮬레이션 백엔드 미구현 시 클라이언트 데모 궤적 fallback 생성 - AI 방어선 3개(직교차단/U형포위/연안보호) 자동 배치 - 민감자원 5개소(양식장/해수욕장/보호구역) deck.gl 레이어 표시 - 해류 화살표 11x11 그리드 TextLayer 추가 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
974 lines
36 KiB
TypeScript
Executable File
974 lines
36 KiB
TypeScript
Executable File
import { useState, useMemo, useEffect, useCallback } from 'react'
|
||
import { Map, Marker, Popup, Source, Layer, useControl, useMap } from '@vis.gl/react-maplibre'
|
||
import { MapboxOverlay } from '@deck.gl/mapbox'
|
||
import { ScatterplotLayer, PathLayer, TextLayer } from '@deck.gl/layers'
|
||
import type { PickingInfo } from '@deck.gl/core'
|
||
import type { StyleSpecification } from 'maplibre-gl'
|
||
import type { MapLayerMouseEvent } from 'maplibre-gl'
|
||
import 'maplibre-gl/dist/maplibre-gl.css'
|
||
import { layerDatabase } from '@common/services/layerService'
|
||
import { decimalToDMS } from '@common/utils/coordinates'
|
||
import type { PredictionModel, SensitiveResource } from '@tabs/prediction/components/OilSpillView'
|
||
import type { BoomLine, BoomLineCoord } from '@common/types/boomLine'
|
||
import type { ReplayShip, CollisionEvent } from '@common/types/backtrack'
|
||
import { createBacktrackLayers } from './BacktrackReplayOverlay'
|
||
import { hexToRgba } from './mapUtils'
|
||
|
||
const GEOSERVER_URL = import.meta.env.VITE_GEOSERVER_URL || 'http://localhost:8080'
|
||
|
||
// 남해안 중심 좌표 (여수 앞바다)
|
||
const DEFAULT_CENTER: [number, number] = [34.5, 127.8]
|
||
const DEFAULT_ZOOM = 10
|
||
|
||
// CartoDB Dark Matter 스타일
|
||
const BASE_STYLE: StyleSpecification = {
|
||
version: 8,
|
||
sources: {
|
||
'carto-dark': {
|
||
type: 'raster',
|
||
tiles: [
|
||
'https://a.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}@2x.png',
|
||
'https://b.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}@2x.png',
|
||
'https://c.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}@2x.png',
|
||
],
|
||
tileSize: 256,
|
||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> © <a href="https://carto.com/attributions">CARTO</a>',
|
||
},
|
||
},
|
||
layers: [
|
||
{
|
||
id: 'carto-dark-layer',
|
||
type: 'raster',
|
||
source: 'carto-dark',
|
||
minzoom: 0,
|
||
maxzoom: 22,
|
||
},
|
||
],
|
||
}
|
||
|
||
// 모델별 색상 매핑
|
||
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': '보통',
|
||
}
|
||
|
||
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?: PredictionModel }>
|
||
selectedModels?: Set<PredictionModel>
|
||
dispersionResult?: DispersionResult | null
|
||
boomLines?: BoomLine[]
|
||
isDrawingBoom?: boolean
|
||
drawingPoints?: BoomLineCoord[]
|
||
layerOpacity?: number
|
||
layerBrightness?: number
|
||
backtrackReplay?: {
|
||
isActive: boolean
|
||
ships: ReplayShip[]
|
||
collisionEvent: CollisionEvent | null
|
||
replayFrame: number
|
||
totalFrames: number
|
||
incidentCoord: { lat: number; lon: number }
|
||
}
|
||
sensitiveResources?: SensitiveResource[]
|
||
}
|
||
|
||
// deck.gl 오버레이 컴포넌트 (MapLibre 컨트롤로 등록)
|
||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||
function DeckGLOverlay({ layers }: { layers: any[] }) {
|
||
const overlay = useControl<MapboxOverlay>(() => new MapboxOverlay({ interleaved: true }))
|
||
overlay.setProps({ layers })
|
||
return null
|
||
}
|
||
|
||
// 팝업 정보
|
||
interface PopupInfo {
|
||
longitude: number
|
||
latitude: number
|
||
content: React.ReactNode
|
||
}
|
||
|
||
export function MapView({
|
||
center = DEFAULT_CENTER,
|
||
zoom = DEFAULT_ZOOM,
|
||
enabledLayers = new Set(),
|
||
incidentCoord,
|
||
isSelectingLocation = false,
|
||
onMapClick,
|
||
oilTrajectory = [],
|
||
selectedModels = new Set(['OpenDrift'] as PredictionModel[]),
|
||
dispersionResult = null,
|
||
boomLines = [],
|
||
isDrawingBoom = false,
|
||
drawingPoints = [],
|
||
layerOpacity = 50,
|
||
layerBrightness = 50,
|
||
backtrackReplay,
|
||
sensitiveResources = [],
|
||
}: MapViewProps) {
|
||
const [currentPosition, setCurrentPosition] = useState<[number, number]>(DEFAULT_CENTER)
|
||
const [currentTime, setCurrentTime] = useState(0)
|
||
const [isPlaying, setIsPlaying] = useState(false)
|
||
const [playbackSpeed, setPlaybackSpeed] = useState(1)
|
||
const [popupInfo, setPopupInfo] = useState<PopupInfo | null>(null)
|
||
|
||
const handleMapClick = useCallback((e: MapLayerMouseEvent) => {
|
||
const { lng, lat } = e.lngLat
|
||
setCurrentPosition([lat, lng])
|
||
if (onMapClick) {
|
||
onMapClick(lng, lat)
|
||
}
|
||
setPopupInfo(null)
|
||
}, [onMapClick])
|
||
|
||
// 애니메이션 재생 로직
|
||
useEffect(() => {
|
||
if (!isPlaying || oilTrajectory.length === 0) return
|
||
|
||
const maxTime = Math.max(...oilTrajectory.map(p => p.time))
|
||
if (currentTime >= maxTime) {
|
||
setIsPlaying(false)
|
||
return
|
||
}
|
||
|
||
const interval = setInterval(() => {
|
||
setCurrentTime(prev => {
|
||
const next = prev + (1 * playbackSpeed)
|
||
return next > maxTime ? maxTime : next
|
||
})
|
||
}, 200)
|
||
|
||
return () => clearInterval(interval)
|
||
}, [isPlaying, currentTime, playbackSpeed, oilTrajectory])
|
||
|
||
// 시뮬레이션 시작 시 자동으로 애니메이션 재생
|
||
useEffect(() => {
|
||
if (oilTrajectory.length > 0) {
|
||
setCurrentTime(0)
|
||
setIsPlaying(true)
|
||
}
|
||
}, [oilTrajectory.length])
|
||
|
||
// WMS 레이어 목록
|
||
const wmsLayers = useMemo(() => {
|
||
return Array.from(enabledLayers)
|
||
.map(layerId => {
|
||
const layer = layerDatabase.find(l => l.id === layerId)
|
||
return layer?.wmsLayer ? { id: layerId, wmsLayer: layer.wmsLayer } : null
|
||
})
|
||
.filter((l): l is { id: string; wmsLayer: string } => l !== null)
|
||
}, [enabledLayers])
|
||
|
||
// WMS 밝기 값 (MapLibre raster paint)
|
||
const wmsBrightnessMax = Math.min(layerBrightness / 50, 2)
|
||
const wmsBrightnessMin = wmsBrightnessMax > 1 ? wmsBrightnessMax - 1 : 0
|
||
const wmsOpacity = layerOpacity / 100
|
||
|
||
// deck.gl 레이어 구축
|
||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||
const deckLayers = useMemo((): any[] => {
|
||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||
const result: any[] = []
|
||
|
||
// --- 유류 확산 입자 (ScatterplotLayer) ---
|
||
const visibleParticles = oilTrajectory.filter(p => p.time <= currentTime)
|
||
if (visibleParticles.length > 0) {
|
||
result.push(
|
||
new ScatterplotLayer({
|
||
id: 'oil-particles',
|
||
data: visibleParticles,
|
||
getPosition: (d: (typeof visibleParticles)[0]) => [d.lon, d.lat],
|
||
getRadius: 3,
|
||
getFillColor: (d: (typeof visibleParticles)[0]) => {
|
||
const modelKey = d.model || Array.from(selectedModels)[0] || 'OpenDrift'
|
||
return hexToRgba(MODEL_COLORS[modelKey] || '#3b82f6', 180)
|
||
},
|
||
radiusMinPixels: 2.5,
|
||
radiusMaxPixels: 5,
|
||
pickable: true,
|
||
onClick: (info: PickingInfo) => {
|
||
if (info.object) {
|
||
const d = info.object as (typeof visibleParticles)[0]
|
||
const modelKey = d.model || Array.from(selectedModels)[0] || 'OpenDrift'
|
||
setPopupInfo({
|
||
longitude: d.lon,
|
||
latitude: d.lat,
|
||
content: (
|
||
<div className="text-xs">
|
||
<strong>{modelKey} 입자 #{(d.particle ?? 0) + 1}</strong>
|
||
<br />
|
||
시간: +{d.time}h
|
||
<br />
|
||
위치: {d.lat.toFixed(4)}°, {d.lon.toFixed(4)}°
|
||
</div>
|
||
),
|
||
})
|
||
}
|
||
},
|
||
updateTriggers: {
|
||
getFillColor: [selectedModels],
|
||
},
|
||
})
|
||
)
|
||
}
|
||
|
||
// --- 오일펜스 라인 (PathLayer) ---
|
||
if (boomLines.length > 0) {
|
||
result.push(
|
||
new PathLayer({
|
||
id: 'boom-lines',
|
||
data: boomLines,
|
||
getPath: (d: BoomLine) => d.coords.map(c => [c.lon, c.lat] as [number, number]),
|
||
getColor: (d: BoomLine) => hexToRgba(PRIORITY_COLORS[d.priority] || '#f59e0b', 230),
|
||
getWidth: (d: BoomLine) => PRIORITY_WEIGHTS[d.priority] || 2,
|
||
getDashArray: (d: BoomLine) => d.status === 'PLANNED' ? [10, 5] : null,
|
||
dashJustified: true,
|
||
widthMinPixels: 2,
|
||
widthMaxPixels: 6,
|
||
pickable: true,
|
||
onClick: (info: PickingInfo) => {
|
||
if (info.object) {
|
||
const d = info.object as BoomLine
|
||
setPopupInfo({
|
||
longitude: info.coordinate?.[0] ?? 0,
|
||
latitude: info.coordinate?.[1] ?? 0,
|
||
content: (
|
||
<div className="text-xs" style={{ fontFamily: 'var(--fK)', 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,
|
||
})
|
||
)
|
||
}
|
||
|
||
// --- HNS 대기확산 구역 (ScatterplotLayer, meters 단위) ---
|
||
if (dispersionResult && incidentCoord) {
|
||
const zones = dispersionResult.zones.map((zone, idx) => ({
|
||
position: [incidentCoord.lon, incidentCoord.lat] as [number, number],
|
||
radius: zone.radius,
|
||
fillColor: hexToRgba(zone.color, 100),
|
||
lineColor: hexToRgba(zone.color, 180),
|
||
level: zone.level,
|
||
idx,
|
||
}))
|
||
|
||
result.push(
|
||
new ScatterplotLayer({
|
||
id: 'hns-zones',
|
||
data: zones,
|
||
getPosition: (d: (typeof zones)[0]) => d.position,
|
||
getRadius: (d: (typeof zones)[0]) => d.radius,
|
||
getFillColor: (d: (typeof zones)[0]) => d.fillColor,
|
||
getLineColor: (d: (typeof zones)[0]) => d.lineColor,
|
||
getLineWidth: 2,
|
||
stroked: true,
|
||
radiusUnits: 'meters' as const,
|
||
pickable: true,
|
||
onClick: (info: PickingInfo) => {
|
||
if (info.object) {
|
||
const d = info.object as (typeof zones)[0]
|
||
setPopupInfo({
|
||
longitude: incidentCoord.lon,
|
||
latitude: incidentCoord.lat,
|
||
content: (
|
||
<div className="text-xs" style={{ fontFamily: 'var(--fK)' }}>
|
||
<strong style={{ color: 'var(--orange)' }}>{d.level}</strong>
|
||
<br />
|
||
물질: {dispersionResult.substance}
|
||
<br />
|
||
농도: {dispersionResult.concentration[d.level]}
|
||
<br />
|
||
반경: {d.radius}m
|
||
</div>
|
||
),
|
||
})
|
||
}
|
||
},
|
||
})
|
||
)
|
||
}
|
||
|
||
// --- 역추적 리플레이 ---
|
||
if (backtrackReplay?.isActive) {
|
||
result.push(...createBacktrackLayers({
|
||
replayShips: backtrackReplay.ships,
|
||
collisionEvent: backtrackReplay.collisionEvent,
|
||
replayFrame: backtrackReplay.replayFrame,
|
||
totalFrames: backtrackReplay.totalFrames,
|
||
incidentCoord: backtrackReplay.incidentCoord,
|
||
}))
|
||
}
|
||
|
||
// --- 민감자원 영역 (ScatterplotLayer) ---
|
||
if (sensitiveResources.length > 0) {
|
||
result.push(
|
||
new ScatterplotLayer({
|
||
id: 'sensitive-zones',
|
||
data: sensitiveResources,
|
||
getPosition: (d: SensitiveResource) => [d.lon, d.lat],
|
||
getRadius: (d: SensitiveResource) => d.radiusM,
|
||
getFillColor: (d: SensitiveResource) => hexToRgba(SENSITIVE_COLORS[d.type] || '#22c55e', 40),
|
||
getLineColor: (d: SensitiveResource) => hexToRgba(SENSITIVE_COLORS[d.type] || '#22c55e', 150),
|
||
getLineWidth: 2,
|
||
stroked: true,
|
||
radiusUnits: 'meters' as const,
|
||
pickable: true,
|
||
onClick: (info: PickingInfo) => {
|
||
if (info.object) {
|
||
const d = info.object as SensitiveResource
|
||
setPopupInfo({
|
||
longitude: d.lon,
|
||
latitude: d.lat,
|
||
content: (
|
||
<div className="text-xs" style={{ fontFamily: 'var(--fK)', minWidth: '130px' }}>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: '4px', marginBottom: '4px' }}>
|
||
<span>{SENSITIVE_ICONS[d.type]}</span>
|
||
<strong style={{ color: SENSITIVE_COLORS[d.type] }}>{d.name}</strong>
|
||
</div>
|
||
<div style={{ fontSize: '10px', color: '#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: [255, 255, 255, 200],
|
||
getPixelOffset: [0, -20],
|
||
fontFamily: 'var(--fK), sans-serif',
|
||
fontSettings: { sdf: false },
|
||
billboard: true,
|
||
sizeUnits: 'pixels' as const,
|
||
background: true,
|
||
getBackgroundColor: [15, 21, 36, 180],
|
||
backgroundPadding: [4, 2],
|
||
})
|
||
)
|
||
}
|
||
|
||
// --- 해류 화살표 (TextLayer) ---
|
||
if (incidentCoord) {
|
||
const currentArrows: Array<{ lon: number; lat: number; bearing: number; speed: number }> = []
|
||
const gridSize = 5
|
||
const spacing = 0.04 // 약 4km 간격
|
||
const mainBearing = 42 // NE 방향 (도)
|
||
|
||
for (let row = -gridSize; row <= gridSize; row++) {
|
||
for (let col = -gridSize; col <= gridSize; col++) {
|
||
const lat = incidentCoord.lat + row * spacing
|
||
const lon = incidentCoord.lon + col * spacing / Math.cos(incidentCoord.lat * Math.PI / 180)
|
||
// 사고 지점에서 멀어질수록 해류 방향 약간 변화
|
||
const distFactor = Math.sqrt(row * row + col * col) / gridSize
|
||
const localBearing = mainBearing + (col * 3) + (row * 2)
|
||
const speed = 0.3 + (1 - distFactor) * 0.2
|
||
currentArrows.push({ lon, lat, bearing: localBearing, speed })
|
||
}
|
||
}
|
||
|
||
result.push(
|
||
new TextLayer({
|
||
id: 'current-arrows',
|
||
data: currentArrows,
|
||
getPosition: (d: (typeof currentArrows)[0]) => [d.lon, d.lat],
|
||
getText: () => '→',
|
||
getAngle: (d: (typeof currentArrows)[0]) => -d.bearing,
|
||
getSize: 14,
|
||
getColor: [6, 182, 212, 70],
|
||
sizeUnits: 'pixels' as const,
|
||
billboard: true,
|
||
})
|
||
)
|
||
}
|
||
|
||
return result
|
||
}, [
|
||
oilTrajectory, currentTime, selectedModels,
|
||
boomLines, isDrawingBoom, drawingPoints,
|
||
dispersionResult, incidentCoord, backtrackReplay,
|
||
sensitiveResources,
|
||
])
|
||
|
||
return (
|
||
<div className="w-full h-full relative">
|
||
<Map
|
||
initialViewState={{
|
||
longitude: center[1],
|
||
latitude: center[0],
|
||
zoom: zoom,
|
||
}}
|
||
mapStyle={BASE_STYLE}
|
||
style={{ width: '100%', height: '100%', cursor: isSelectingLocation ? 'crosshair' : 'grab' }}
|
||
onClick={handleMapClick}
|
||
attributionControl={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} />
|
||
|
||
{/* 사고 위치 마커 (MapLibre Marker) */}
|
||
{incidentCoord && !isNaN(incidentCoord.lat) && !isNaN(incidentCoord.lon) && (
|
||
<Marker longitude={incidentCoord.lon} latitude={incidentCoord.lat} anchor="bottom">
|
||
<div
|
||
style={{
|
||
width: 24, height: 24, background: 'var(--cyan)', borderRadius: '50% 50% 50% 0',
|
||
transform: 'rotate(-45deg)', border: '2px solid #fff',
|
||
boxShadow: '0 2px 8px rgba(6,182,212,0.5)',
|
||
}}
|
||
/>
|
||
</Marker>
|
||
)}
|
||
|
||
{/* 사고 위치 팝업 (클릭 시) */}
|
||
{incidentCoord && !isNaN(incidentCoord.lat) && !isNaN(incidentCoord.lon) && !popupInfo && (
|
||
<Popup longitude={incidentCoord.lon} latitude={incidentCoord.lat} anchor="bottom" offset={30} closeButton={false} closeOnClick={false}>
|
||
<div className="text-sm" style={{ color: '#333' }}>
|
||
<strong>사고 지점</strong>
|
||
<br />
|
||
<span className="text-xs" style={{ color: '#666' }}>
|
||
{decimalToDMS(incidentCoord.lat, true)}
|
||
<br />
|
||
{decimalToDMS(incidentCoord.lon, false)}
|
||
</span>
|
||
<br />
|
||
<span className="text-xs" style={{ fontFamily: 'monospace', color: '#888' }}>
|
||
({incidentCoord.lat.toFixed(4)}°, {incidentCoord.lon.toFixed(4)}°)
|
||
</span>
|
||
</div>
|
||
</Popup>
|
||
)}
|
||
|
||
{/* deck.gl 객체 클릭 팝업 */}
|
||
{popupInfo && (
|
||
<Popup
|
||
longitude={popupInfo.longitude}
|
||
latitude={popupInfo.latitude}
|
||
anchor="bottom"
|
||
onClose={() => setPopupInfo(null)}
|
||
>
|
||
<div style={{ color: '#333' }}>{popupInfo.content}</div>
|
||
</Popup>
|
||
)}
|
||
|
||
{/* 커스텀 줌 컨트롤 */}
|
||
<MapControls center={center} zoom={zoom} />
|
||
</Map>
|
||
|
||
{/* 드로잉 모드 안내 */}
|
||
{isDrawingBoom && (
|
||
<div className="boom-drawing-indicator">
|
||
오일펜스 배치 모드 — 지도를 클릭하여 포인트를 추가하세요 ({drawingPoints.length}개 포인트)
|
||
</div>
|
||
)}
|
||
|
||
{/* 기상청 연계 정보 */}
|
||
<WeatherInfoPanel position={currentPosition} />
|
||
|
||
{/* 범례 */}
|
||
<MapLegend dispersionResult={dispersionResult} incidentCoord={incidentCoord} oilTrajectory={oilTrajectory} boomLines={boomLines} selectedModels={selectedModels} />
|
||
|
||
{/* 좌표 표시 */}
|
||
<CoordinateDisplay
|
||
position={incidentCoord ? [incidentCoord.lat, incidentCoord.lon] : currentPosition}
|
||
/>
|
||
|
||
{/* 타임라인 컨트롤 */}
|
||
{oilTrajectory.length > 0 && (
|
||
<TimelineControl
|
||
currentTime={currentTime}
|
||
maxTime={Math.max(...oilTrajectory.map(p => p.time))}
|
||
isPlaying={isPlaying}
|
||
playbackSpeed={playbackSpeed}
|
||
onTimeChange={setCurrentTime}
|
||
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 style={{ position: 'absolute', top: 16, left: 16, zIndex: 10 }}>
|
||
<div className="flex flex-col gap-2">
|
||
<button
|
||
onClick={() => map?.zoomIn()}
|
||
className="w-[38px] h-[38px] bg-[rgba(18,25,41,0.9)] backdrop-blur-xl border border-border rounded-sm text-text-2 flex items-center justify-center hover:bg-bg-hover hover:text-text-1 transition-all text-base"
|
||
>
|
||
+
|
||
</button>
|
||
<button
|
||
onClick={() => map?.zoomOut()}
|
||
className="w-[38px] h-[38px] bg-[rgba(18,25,41,0.9)] backdrop-blur-xl border border-border rounded-sm text-text-2 flex items-center justify-center hover:bg-bg-hover hover:text-text-1 transition-all text-base"
|
||
>
|
||
−
|
||
</button>
|
||
<button
|
||
onClick={() => map?.flyTo({ center: [center[1], center[0]], zoom, duration: 1000 })}
|
||
className="w-[38px] h-[38px] bg-[rgba(18,25,41,0.9)] backdrop-blur-xl border border-border rounded-sm text-text-2 flex items-center justify-center hover:text-text-1 transition-all text-sm"
|
||
>
|
||
🎯
|
||
</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 = [], boomLines = [], selectedModels = new Set(['OpenDrift'] as PredictionModel[]) }: MapLegendProps) {
|
||
if (dispersionResult && incidentCoord) {
|
||
return (
|
||
<div className="absolute top-4 right-4 bg-[rgba(18,25,41,0.95)] backdrop-blur-xl border border-border rounded-lg p-3.5 min-w-[200px]" style={{ fontFamily: 'var(--fK)', zIndex: 20 }}>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: '6px', marginBottom: '10px' }}>
|
||
<div style={{ fontSize: '16px' }}>📍</div>
|
||
<div>
|
||
<h4 className="text-[11px] font-bold text-primary-orange">사고 위치</h4>
|
||
<div className="text-[8px] text-text-3" style={{ fontFamily: 'var(--fM)' }}>
|
||
{incidentCoord.lat.toFixed(4)}°N, {incidentCoord.lon.toFixed(4)}°E
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div style={{ background: 'rgba(249,115,22,0.08)', padding: '8px', borderRadius: '6px', marginBottom: '8px', fontSize: '9px', color: 'var(--t2)' }}>
|
||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '3px' }}>
|
||
<span style={{ color: 'var(--t3)' }}>물질</span>
|
||
<span style={{ fontWeight: 600, color: 'var(--orange)' }}>{dispersionResult.substance}</span>
|
||
</div>
|
||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '3px' }}>
|
||
<span style={{ color: 'var(--t3)' }}>풍향</span>
|
||
<span style={{ fontWeight: 600, fontFamily: 'var(--fM)' }}>SW {dispersionResult.windDirection}°</span>
|
||
</div>
|
||
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
||
<span style={{ color: 'var(--t3)' }}>확산 구역</span>
|
||
<span style={{ fontWeight: 600, color: 'var(--cyan)' }}>{dispersionResult.zones.length}개</span>
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<h5 className="text-[9px] font-bold text-text-3 mb-2">위험 구역</h5>
|
||
<div className="flex flex-col gap-1.5">
|
||
<div className="flex items-center gap-2 text-[10px] text-text-2">
|
||
<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-text-2">
|
||
<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-text-2">
|
||
<div className="w-3 h-3 rounded-full" style={{ background: 'rgba(234,179,8,0.7)' }} />
|
||
<span>중간 위험 구역 (AEGL-1)</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div style={{ marginTop: '8px', padding: '6px', background: 'rgba(168,85,247,0.08)', borderRadius: '4px', display: 'flex', alignItems: 'center', gap: '6px' }}>
|
||
<div style={{ fontSize: '12px' }}>🧭</div>
|
||
<span className="text-[9px] text-text-3">풍향 (방사형)</span>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
if (oilTrajectory.length > 0) {
|
||
return (
|
||
<div className="absolute top-4 right-4 bg-[rgba(18,25,41,0.9)] backdrop-blur-xl border border-border rounded-md p-3.5 min-w-[180px]" style={{ zIndex: 20 }}>
|
||
<h4 className="text-[11px] font-bold uppercase tracking-wider text-text-3 mb-2.5">범례</h4>
|
||
<div className="flex flex-col gap-1.5">
|
||
{Array.from(selectedModels).map(model => (
|
||
<div key={model} className="flex items-center gap-2 text-xs text-text-2">
|
||
<div className="w-3.5 h-3.5 rounded-full" style={{ background: MODEL_COLORS[model] }} />
|
||
<span className="font-korean">{model}</span>
|
||
</div>
|
||
))}
|
||
{selectedModels.size === 3 && (
|
||
<div className="flex items-center gap-2 text-xs text-text-3" style={{ fontSize: '9px' }}>
|
||
<span className="font-korean">(앙상블 모드)</span>
|
||
</div>
|
||
)}
|
||
<div style={{ height: '1px', background: 'var(--bd)', margin: '4px 0' }} />
|
||
<div className="flex items-center gap-2 text-xs text-text-2">
|
||
<div className="w-3.5 h-3.5 rounded-full bg-primary-cyan" />
|
||
<span className="font-korean">사고 지점</span>
|
||
</div>
|
||
{boomLines.length > 0 && (
|
||
<>
|
||
<div style={{ height: '1px', background: 'var(--bd)', margin: '4px 0' }} />
|
||
<div className="flex items-center gap-2 text-xs text-text-2">
|
||
<div style={{ width: '14px', height: '3px', background: '#ef4444', borderRadius: '1px' }} />
|
||
<span className="font-korean">긴급 오일펜스</span>
|
||
</div>
|
||
<div className="flex items-center gap-2 text-xs text-text-2">
|
||
<div style={{ width: '14px', height: '3px', background: '#f97316', borderRadius: '1px' }} />
|
||
<span className="font-korean">중요 오일펜스</span>
|
||
</div>
|
||
<div className="flex items-center gap-2 text-xs text-text-2">
|
||
<div style={{ width: '14px', height: '3px', background: '#eab308', borderRadius: '1px' }} />
|
||
<span className="font-korean">보통 오일펜스</span>
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
return null
|
||
}
|
||
|
||
// 좌표 표시
|
||
function CoordinateDisplay({ position }: { position: [number, number] }) {
|
||
const [lat, lng] = position
|
||
const latDirection = lat >= 0 ? 'N' : 'S'
|
||
const lngDirection = lng >= 0 ? 'E' : 'W'
|
||
|
||
return (
|
||
<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">1:50,000</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 style={{ width: '8px' }} />
|
||
<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 — {new Date(Date.now() + currentTime * 3600000).toLocaleDateString('ko-KR', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })} 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 style={{
|
||
position: 'absolute', bottom: 80, left: '50%', transform: 'translateX(-50%)',
|
||
background: 'rgba(10,14,26,0.92)', backdropFilter: 'blur(12px)',
|
||
border: '1px solid var(--bdL)', borderRadius: '10px',
|
||
padding: '12px 18px', zIndex: 50,
|
||
display: 'flex', alignItems: 'center', gap: '16px', minWidth: '340px',
|
||
}}>
|
||
<div style={{ fontSize: '14px', color: 'var(--purple)', fontFamily: 'var(--fM)', fontWeight: 700 }}>
|
||
{progress.toFixed(0)}%
|
||
</div>
|
||
<div style={{ flex: 1, height: '4px', background: 'var(--bd)', borderRadius: '2px', position: 'relative' }}>
|
||
<div style={{ width: `${progress}%`, height: '100%', background: 'linear-gradient(90deg, var(--purple), var(--cyan))', borderRadius: '2px', transition: 'width 0.05s' }} />
|
||
</div>
|
||
<div style={{ display: 'flex', gap: '6px' }}>
|
||
{ships.map(s => (
|
||
<div key={s.vesselName} style={{
|
||
width: 8, height: 8, borderRadius: '50%', background: s.color,
|
||
border: '1px solid rgba(255,255,255,0.3)',
|
||
}} title={s.vesselName} />
|
||
))}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|