wing-ops/frontend/src/common/components/map/MapView.tsx
htlee 9384290bf3 feat(frontend): 확산 방향 SSW 수정 + 통합조회 호버 툴팁 + 선박 아이콘 개선
- 확산분석: 오일 확산 방향 NE→SSW(200°)로 수정, 민감자원 여수 실제 좌표 적용
- 해류 화살표: 아이콘 ➤, 크기 22px, 투명도 증가, SSW 방향 동기화
- 통합조회: 선박/사고 마커 hover 시 다크 테마 툴팁 표시 (이름, 유형, 속도, 좌표)
- 선박 아이콘: SVG 삼각형 16×20 확대 + 글로우 효과 + pickable 전환
- vesselLayer(ScatterplotLayer 원형) 제거, vesselIconLayer로 통합

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 10:01:55 +09:00

975 lines
36 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 } 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: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> &copy; <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: 'NanumSquare, Malgun Gothic, 맑은 고딕, sans-serif',
fontWeight: 'bold',
characterSet: 'auto',
outlineWidth: 2,
outlineColor: [15, 21, 36, 200],
billboard: true,
sizeUnits: 'pixels' as const,
})
)
}
// --- 해류 화살표 (TextLayer) ---
if (incidentCoord) {
const currentArrows: Array<{ lon: number; lat: number; bearing: number; speed: number }> = []
const gridSize = 5
const spacing = 0.04 // 약 4km 간격
const mainBearing = 200 // SSW 방향 (도)
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 + 90,
getSize: 22,
getColor: [6, 182, 212, 100],
characterSet: 'auto',
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"
>
&#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 = [], 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>
)
}