wing-ops/frontend/src/common/components/map/MapView.tsx
jeonghyo.k c4f11423aa feat(reports): 보고서 확산예측 지도 캡처 기능 추가
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-16 18:23:42 +09:00

1646 lines
61 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 } from '@deck.gl/layers'
import type { PickingInfo, Layer as DeckLayer } from '@deck.gl/core'
import type { StyleSpecification } from 'maplibre-gl'
import type { MapLayerMouseEvent } from 'maplibre-gl'
import 'maplibre-gl/dist/maplibre-gl.css'
import { layerDatabase } from '@common/services/layerService'
import type { PredictionModel, SensitiveResource } from '@tabs/prediction/components/OilSpillView'
import type { HydrDataStep } from '@tabs/prediction/services/predictionApi'
import HydrParticleOverlay from './HydrParticleOverlay'
import type { BoomLine, BoomLineCoord } from '@common/types/boomLine'
import type { ReplayShip, CollisionEvent } from '@common/types/backtrack'
import { createBacktrackLayers } from './BacktrackReplayOverlay'
import { hexToRgba } from './mapUtils'
import { useMapStore } from '@common/store/mapStore'
const GEOSERVER_URL = import.meta.env.VITE_GEOSERVER_URL || 'http://localhost:8080'
const VWORLD_API_KEY = import.meta.env.VITE_VWORLD_API_KEY || ''
// 인천 송도 국제도시
const DEFAULT_CENTER: [number, number] = [37.39, 126.64]
const DEFAULT_ZOOM = 10
// CartoDB Dark Matter 스타일
const BASE_STYLE: StyleSpecification = {
version: 8,
sources: {
'carto-dark': {
type: 'raster',
tiles: [
'https://a.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}@2x.png',
'https://b.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}@2x.png',
'https://c.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}@2x.png',
],
tileSize: 256,
attribution: '&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,
},
],
}
// MarineTraffic 스타일 — 깔끔한 연한 회색 육지 + 흰색 바다 + 한글 라벨
const LIGHT_STYLE: StyleSpecification = {
version: 8,
glyphs: 'https://demotiles.maplibre.org/font/{fontstack}/{range}.pbf',
sources: {
'ofm-chart': {
type: 'vector',
url: 'https://tiles.openfreemap.org/planet',
},
},
layers: [
// ── 배경 = 육지 (연한 회색) ──
{
id: 'land-bg',
type: 'background',
paint: { 'background-color': '#e8e8e8' },
},
// ── 바다/호수/강 = water 레이어 (파란색) ──
{
id: 'water',
type: 'fill',
source: 'ofm-chart',
'source-layer': 'water',
paint: { 'fill-color': '#a8cce0' },
},
// ── 주요 도로 (zoom 9+) ──
{
id: 'roads-major',
type: 'line',
source: 'ofm-chart',
'source-layer': 'transportation',
minzoom: 9,
filter: ['in', 'class', 'motorway', 'trunk', 'primary'],
paint: {
'line-color': '#c0c0c0',
'line-width': ['interpolate', ['linear'], ['zoom'], 9, 0.4, 14, 1.5],
},
},
// ── 보조 도로 (zoom 12+) ──
{
id: 'roads-secondary',
type: 'line',
source: 'ofm-chart',
'source-layer': 'transportation',
minzoom: 12,
filter: ['in', 'class', 'secondary', 'tertiary'],
paint: {
'line-color': '#cccccc',
'line-width': ['interpolate', ['linear'], ['zoom'], 12, 0.3, 14, 1],
},
},
// ── 건물 (zoom 14+) ──
{
id: 'buildings',
type: 'fill',
source: 'ofm-chart',
'source-layer': 'building',
minzoom: 14,
paint: { 'fill-color': '#cbcbcb', 'fill-opacity': 0.5 },
},
// ── 국경선 ──
{
id: 'boundaries-country',
type: 'line',
source: 'ofm-chart',
'source-layer': 'boundary',
filter: ['==', 'admin_level', 2],
paint: { 'line-color': '#999999', 'line-width': 1, 'line-dasharray': [4, 2] },
},
// ── 시도 경계 (zoom 5+) ──
{
id: 'boundaries-province',
type: 'line',
source: 'ofm-chart',
'source-layer': 'boundary',
minzoom: 5,
filter: ['==', 'admin_level', 4],
paint: { 'line-color': '#bbbbbb', 'line-width': 0.5, 'line-dasharray': [3, 2] },
},
// ── 국가/시도 라벨 (한글) ──
{
id: 'place-labels-major',
type: 'symbol',
source: 'ofm-chart',
'source-layer': 'place',
minzoom: 3,
filter: ['in', 'class', 'country', 'state'],
layout: {
'text-field': ['coalesce', ['get', 'name:ko'], ['get', 'name']],
'text-font': ['Open Sans Bold'],
'text-size': ['interpolate', ['linear'], ['zoom'], 3, 11, 8, 16],
'text-max-width': 8,
},
paint: {
'text-color': '#555555',
'text-halo-color': '#ffffff',
'text-halo-width': 2,
},
},
{
id: 'place-labels-city',
type: 'symbol',
source: 'ofm-chart',
'source-layer': 'place',
minzoom: 5,
filter: ['in', 'class', 'city', 'town'],
layout: {
'text-field': ['coalesce', ['get', 'name:ko'], ['get', 'name']],
'text-font': ['Open Sans Regular'],
'text-size': ['interpolate', ['linear'], ['zoom'], 5, 10, 12, 14],
'text-max-width': 7,
},
paint: {
'text-color': '#666666',
'text-halo-color': '#ffffff',
'text-halo-width': 1.5,
},
},
// ── 해양 지명 (water_name) ──
{
id: 'water-labels',
type: 'symbol',
source: 'ofm-chart',
'source-layer': 'water_name',
layout: {
'text-field': ['coalesce', ['get', 'name:ko'], ['get', 'name']],
'text-font': ['Open Sans Italic'],
'text-size': ['interpolate', ['linear'], ['zoom'], 3, 10, 8, 14],
'text-max-width': 10,
'text-letter-spacing': 0.15,
},
paint: {
'text-color': '#8899aa',
'text-halo-color': 'rgba(168,204,224,0.7)',
'text-halo-width': 1,
},
},
// ── 마을/소지명 (zoom 10+) ──
{
id: 'place-labels-village',
type: 'symbol',
source: 'ofm-chart',
'source-layer': 'place',
minzoom: 10,
filter: ['in', 'class', 'village', 'suburb', 'hamlet'],
layout: {
'text-field': ['coalesce', ['get', 'name:ko'], ['get', 'name']],
'text-font': ['Open Sans Regular'],
'text-size': ['interpolate', ['linear'], ['zoom'], 10, 9, 14, 12],
'text-max-width': 6,
},
paint: {
'text-color': '#777777',
'text-halo-color': '#ffffff',
'text-halo-width': 1,
},
},
],
}
// 3D 위성 스타일 (VWorld 위성 배경 + OpenFreeMap 건물 extrusion)
// VWorld WMTS: {z}/{y}/{x} (row/col 순서)
// OpenFreeMap: CORS 허용, 전 세계 OSM 벡터타일 (building: render_height 포함)
const SATELLITE_3D_STYLE: StyleSpecification = {
version: 8,
sources: {
'vworld-satellite': {
type: 'raster',
tiles: [`https://api.vworld.kr/req/wmts/1.0.0/${VWORLD_API_KEY}/Satellite/{z}/{y}/{x}.jpeg`],
tileSize: 256,
attribution: '&copy; <a href="https://www.vworld.kr">국토지리정보원 VWorld</a>',
},
'ofm': {
type: 'vector',
url: 'https://tiles.openfreemap.org/planet',
},
},
layers: [
{
id: 'satellite-base',
type: 'raster',
source: 'vworld-satellite',
minzoom: 0,
maxzoom: 22,
},
{
id: 'roads-3d',
type: 'line',
source: 'ofm',
'source-layer': 'transportation',
filter: ['in', ['get', 'class'], ['literal', ['motorway', 'trunk', 'primary', 'secondary']]],
paint: {
'line-color': 'rgba(255,255,200,0.3)',
'line-width': ['interpolate', ['linear'], ['zoom'], 10, 1, 16, 3],
},
},
{
id: '3d-buildings',
type: 'fill-extrusion',
source: 'ofm',
'source-layer': 'building',
minzoom: 13,
filter: ['!=', ['get', 'hide_3d'], true],
paint: {
'fill-extrusion-color': '#c8b99a',
'fill-extrusion-height': ['max', ['coalesce', ['get', 'render_height'], 0], 3],
'fill-extrusion-base': ['coalesce', ['get', 'render_min_height'], 0],
'fill-extrusion-opacity': 0.85,
},
},
],
}
// 모델별 색상 매핑
const MODEL_COLORS: Record<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; stranded?: 0 | 1 }>
selectedModels?: Set<PredictionModel>
dispersionResult?: DispersionResult | null
dispersionHeatmap?: Array<{ lon: number; lat: number; concentration: number }>
boomLines?: BoomLine[]
isDrawingBoom?: boolean
drawingPoints?: BoomLineCoord[]
layerOpacity?: number
layerBrightness?: number
backtrackReplay?: {
isActive: boolean
ships: ReplayShip[]
collisionEvent: CollisionEvent | null
replayFrame: number
totalFrames: number
incidentCoord: { lat: number; lon: number }
}
sensitiveResources?: SensitiveResource[]
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 }>
windData?: Array<Array<{ lat: number; lon: number; wind_speed: number; wind_direction: number }>>
hydrData?: (HydrDataStep | null)[]
// 외부 플레이어 제어 (prediction 하단 바에서 제어할 때 사용)
externalCurrentTime?: number
mapCaptureRef?: React.MutableRefObject<(() => Promise<string | null>) | null>
onIncidentFlyEnd?: () => void
flyToIncident?: { lon: number; lat: number }
showCurrent?: boolean
showWind?: boolean
showBeached?: boolean
showTimeLabel?: boolean
simulationStartTime?: string
drawAnalysisMode?: 'polygon' | 'circle' | null
analysisPolygonPoints?: Array<{ lat: number; lon: number }>
analysisCircleCenter?: { lat: number; lon: number } | null
analysisCircleRadiusM?: number
/** 밝은 톤 지도 스타일 사용 (CartoDB Positron) */
lightMode?: boolean
/** false로 설정 시 WeatherInfoPanel, MapLegend, CoordinateDisplay 숨김 (기본: true) */
showOverlays?: boolean
}
// deck.gl 오버레이 컴포넌트 (MapLibre 컨트롤로 등록, interleaved)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function DeckGLOverlay({ layers }: { layers: any[] }) {
const overlay = useControl<MapboxOverlay>(() => new MapboxOverlay({ interleaved: true }))
overlay.setProps({ layers })
return null
}
// flyTo 트리거 컴포넌트 (Map 내부에서 useMap() 사용)
function FlyToController({ flyToTarget }: { flyToTarget?: { lng: number; lat: number; zoom?: number } | null }) {
const { current: map } = useMap()
useEffect(() => {
if (!map || !flyToTarget) return
map.flyTo({
center: [flyToTarget.lng, flyToTarget.lat],
zoom: flyToTarget.zoom ?? 10,
duration: 1200,
})
}, [flyToTarget, map])
return null
}
// fitBounds 트리거 컴포넌트 (Map 내부에서 useMap() 사용)
function FitBoundsController({ fitBoundsTarget }: { fitBoundsTarget?: { north: number; south: number; east: number; west: number } | null }) {
const { current: map } = useMap()
useEffect(() => {
if (!map || !fitBoundsTarget) return
map.fitBounds(
[[fitBoundsTarget.west, fitBoundsTarget.south], [fitBoundsTarget.east, fitBoundsTarget.north]],
{ padding: 80, duration: 1200, maxZoom: 12 }
)
}, [fitBoundsTarget, map])
return null
}
// 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({ lon, lat, onFlyEnd }: { lon?: number; lat?: number; onFlyEnd?: () => void }) {
const { current: map } = useMap()
const onFlyEndRef = useRef(onFlyEnd)
useEffect(() => { onFlyEndRef.current = onFlyEnd }, [onFlyEnd])
useEffect(() => {
if (!map || lon == null || lat == null) return
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)
}
}, [lon, lat, map])
return null
}
// 지도 캡처 지원 (map.once('render') 후 캡처로 빈 캔버스 문제 방지)
function MapCaptureSetup({ captureRef }: { captureRef: React.MutableRefObject<(() => Promise<string | null>) | null> }) {
const { current: map } = useMap();
useEffect(() => {
if (!map) return;
captureRef.current = () =>
new Promise<string | null>((resolve) => {
map.once('render', () => {
try {
// WebGL 캔버스는 alpha=0 투명 배경이므로 불투명 배경과 합성 후 추출
// 최대 1200px로 리사이즈 + JPEG 압축으로 전송 크기 절감
const src = map.getCanvas();
const maxW = 1200;
const scale = src.width > maxW ? maxW / src.width : 1;
const composite = document.createElement('canvas');
composite.width = Math.round(src.width * scale);
composite.height = Math.round(src.height * scale);
const ctx = composite.getContext('2d')!;
ctx.fillStyle = '#0f1117';
ctx.fillRect(0, 0, composite.width, composite.height);
ctx.drawImage(src, 0, 0, composite.width, composite.height);
resolve(composite.toDataURL('image/jpeg', 0.82));
} catch {
resolve(null);
}
});
map.triggerRepaint();
});
}, [map, captureRef]);
return null;
}
// 팝업 정보
interface PopupInfo {
longitude: number
latitude: number
content: React.ReactNode
}
export function MapView({
center = DEFAULT_CENTER,
zoom = DEFAULT_ZOOM,
enabledLayers = new Set(),
incidentCoord,
isSelectingLocation = false,
onMapClick,
oilTrajectory = [],
selectedModels = new Set(['OpenDrift'] as PredictionModel[]),
dispersionResult = null,
dispersionHeatmap = [],
boomLines = [],
isDrawingBoom = false,
drawingPoints = [],
layerOpacity = 50,
layerBrightness = 50,
backtrackReplay,
sensitiveResources = [],
flyToTarget,
fitBoundsTarget,
centerPoints = [],
windData = [],
hydrData = [],
externalCurrentTime,
mapCaptureRef,
onIncidentFlyEnd,
flyToIncident,
showCurrent = true,
showWind = true,
showBeached = false,
showTimeLabel = false,
simulationStartTime,
drawAnalysisMode = null,
analysisPolygonPoints = [],
analysisCircleCenter,
analysisCircleRadiusM = 0,
lightMode = false,
showOverlays = true,
}: MapViewProps) {
const { mapToggles } = useMapStore()
const isControlled = externalCurrentTime !== undefined
const [currentPosition, setCurrentPosition] = useState<[number, number]>(DEFAULT_CENTER)
const [internalCurrentTime, setInternalCurrentTime] = useState(0)
const [isPlaying, setIsPlaying] = useState(false)
const [playbackSpeed, setPlaybackSpeed] = useState(1)
const [popupInfo, setPopupInfo] = useState<PopupInfo | null>(null)
const currentTime = isControlled ? externalCurrentTime : internalCurrentTime
const handleMapClick = useCallback((e: MapLayerMouseEvent) => {
const { lng, lat } = e.lngLat
setCurrentPosition([lat, lng])
if (onMapClick) {
onMapClick(lng, lat)
}
setPopupInfo(null)
}, [onMapClick])
// 애니메이션 재생 로직 (외부 제어 모드에서는 비활성)
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]) => {
// 1순위: stranded 입자 → showBeached=true 시 빨간색, false 시 회색
if (d.stranded === 1) return showBeached
? [239, 68, 68, 220] as [number, number, number, number]
: [130, 130, 130, 70] as [number, number, number, number]
// 2순위: 현재 활성 스텝 → 모델 기본 색상
if (d.time === activeStep) {
const modelKey = d.model || Array.from(selectedModels)[0] || 'OpenDrift'
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],
},
})
)
}
// --- 오일펜스 라인 (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={{ minWidth: '140px' }}>
<strong style={{ color: PRIORITY_COLORS[d.priority] }}>{d.name}</strong>
<br />
: {PRIORITY_LABELS[d.priority] || d.priority}
<br />
: {d.length.toFixed(0)}m
<br />
: {d.angle.toFixed(0)}°
<br />
: {d.efficiency}%
</div>
),
})
}
},
})
)
// 오일펜스 끝점 마커
const endpoints: Array<{ position: [number, number]; color: [number, number, number, number] }> = []
boomLines.forEach(line => {
if (line.coords.length >= 2) {
const c = hexToRgba(PRIORITY_COLORS[line.priority] || '#f59e0b', 230)
endpoints.push({ position: [line.coords[0].lon, line.coords[0].lat], color: c })
endpoints.push({ position: [line.coords[line.coords.length - 1].lon, line.coords[line.coords.length - 1].lat], color: c })
}
})
if (endpoints.length > 0) {
result.push(
new ScatterplotLayer({
id: 'boom-endpoints',
data: endpoints,
getPosition: (d: (typeof endpoints)[0]) => d.position,
getRadius: 5,
getFillColor: (d: (typeof endpoints)[0]) => d.color,
getLineColor: [255, 255, 255, 255],
getLineWidth: 2,
stroked: true,
radiusMinPixels: 5,
radiusMaxPixels: 8,
})
)
}
}
// --- 드로잉 미리보기 ---
if (isDrawingBoom && drawingPoints.length > 0) {
result.push(
new PathLayer({
id: 'drawing-preview',
data: [{ path: drawingPoints.map(c => [c.lon, c.lat] as [number, number]) }],
getPath: (d: { path: [number, number][] }) => d.path,
getColor: [245, 158, 11, 200],
getWidth: 3,
getDashArray: [10, 6],
dashJustified: true,
widthMinPixels: 3,
})
)
result.push(
new ScatterplotLayer({
id: 'drawing-points',
data: drawingPoints.map(c => ({ position: [c.lon, c.lat] as [number, number] })),
getPosition: (d: { position: [number, number] }) => d.position,
getRadius: 4,
getFillColor: [245, 158, 11, 255],
getLineColor: [255, 255, 255, 255],
getLineWidth: 2,
stroked: true,
radiusMinPixels: 4,
radiusMaxPixels: 6,
})
)
}
// --- 오염분석 다각형 그리기 ---
if (analysisPolygonPoints.length > 0) {
if (analysisPolygonPoints.length >= 3) {
result.push(
new PolygonLayer({
id: 'analysis-polygon-fill',
data: [{ polygon: analysisPolygonPoints.map(p => [p.lon, p.lat] as [number, number]) }],
getPolygon: (d: { polygon: [number, number][] }) => d.polygon,
getFillColor: [168, 85, 247, 40],
getLineColor: [168, 85, 247, 220],
getLineWidth: 2,
stroked: true,
filled: true,
lineWidthMinPixels: 2,
})
)
}
result.push(
new PathLayer({
id: 'analysis-polygon-outline',
data: [{
path: [
...analysisPolygonPoints.map(p => [p.lon, p.lat] as [number, number]),
...(analysisPolygonPoints.length >= 3 ? [[analysisPolygonPoints[0].lon, analysisPolygonPoints[0].lat] as [number, number]] : []),
],
}],
getPath: (d: { path: [number, number][] }) => d.path,
getColor: [168, 85, 247, 220],
getWidth: 2,
getDashArray: [8, 4],
dashJustified: true,
widthMinPixels: 2,
})
)
result.push(
new ScatterplotLayer({
id: 'analysis-polygon-points',
data: analysisPolygonPoints.map(p => ({ position: [p.lon, p.lat] as [number, number] })),
getPosition: (d: { position: [number, number] }) => d.position,
getRadius: 5,
getFillColor: [168, 85, 247, 255],
getLineColor: [255, 255, 255, 255],
getLineWidth: 2,
stroked: true,
radiusMinPixels: 5,
radiusMaxPixels: 8,
})
)
}
// --- 오염분석 원 그리기 ---
if (analysisCircleCenter) {
result.push(
new ScatterplotLayer({
id: 'analysis-circle-center',
data: [{ position: [analysisCircleCenter.lon, analysisCircleCenter.lat] as [number, number] }],
getPosition: (d: { position: [number, number] }) => d.position,
getRadius: 6,
getFillColor: [168, 85, 247, 255],
getLineColor: [255, 255, 255, 255],
getLineWidth: 2,
stroked: true,
radiusMinPixels: 6,
radiusMaxPixels: 9,
})
)
if (analysisCircleRadiusM > 0) {
result.push(
new ScatterplotLayer({
id: 'analysis-circle-area',
data: [{ position: [analysisCircleCenter.lon, analysisCircleCenter.lat] as [number, number] }],
getPosition: (d: { position: [number, number] }) => d.position,
getRadius: analysisCircleRadiusM,
radiusUnits: 'meters',
getFillColor: [168, 85, 247, 35],
getLineColor: [168, 85, 247, 200],
getLineWidth: 2,
stroked: true,
filled: true,
lineWidthMinPixels: 2,
})
)
}
}
// --- HNS 대기확산 히트맵 (BitmapLayer, 고정 이미지) ---
if (dispersionHeatmap && dispersionHeatmap.length > 0) {
const maxConc = Math.max(...dispersionHeatmap.map(p => p.concentration));
const minConc = Math.min(...dispersionHeatmap.filter(p => p.concentration > 0.01).map(p => p.concentration));
const filtered = dispersionHeatmap.filter(p => p.concentration > 0.01);
console.log('[MapView] HNS 히트맵:', dispersionHeatmap.length, '→ filtered:', filtered.length, 'maxConc:', maxConc.toFixed(2));
if (filtered.length > 0) {
// 경위도 바운드 계산
let minLon = Infinity, maxLon = -Infinity, minLat = Infinity, maxLat = -Infinity;
for (const p of dispersionHeatmap) {
if (p.lon < minLon) minLon = p.lon;
if (p.lon > maxLon) maxLon = p.lon;
if (p.lat < minLat) minLat = p.lat;
if (p.lat > maxLat) maxLat = p.lat;
}
const padLon = (maxLon - minLon) * 0.02;
const padLat = (maxLat - minLat) * 0.02;
minLon -= padLon; maxLon += padLon;
minLat -= padLat; maxLat += padLat;
// 캔버스에 농도 이미지 렌더링
const W = 1200, H = 960;
const canvas = document.createElement('canvas');
canvas.width = W;
canvas.height = H;
const ctx = canvas.getContext('2d')!;
ctx.clearRect(0, 0, W, H);
// 로그 스케일: 농도 범위를 고르게 분포
const logMin = Math.log(minConc);
const logMax = Math.log(maxConc);
const logRange = logMax - logMin || 1;
const stops: [number, number, number, number][] = [
[34, 197, 94, 220], // green (저농도)
[234, 179, 8, 235], // yellow
[249, 115, 22, 245], // orange
[239, 68, 68, 250], // red (고농도)
[185, 28, 28, 255], // dark red (초고농도)
];
for (const p of filtered) {
// 로그 스케일 정규화 (0~1)
const ratio = Math.max(0, Math.min(1, (Math.log(p.concentration) - logMin) / logRange));
const t = ratio * (stops.length - 1);
const lo = Math.floor(t);
const hi = Math.min(lo + 1, stops.length - 1);
const f = t - lo;
const r = Math.round(stops[lo][0] + (stops[hi][0] - stops[lo][0]) * f);
const g = Math.round(stops[lo][1] + (stops[hi][1] - stops[lo][1]) * f);
const b = Math.round(stops[lo][2] + (stops[hi][2] - stops[lo][2]) * f);
const a = (stops[lo][3] + (stops[hi][3] - stops[lo][3]) * f) / 255;
const px = ((p.lon - minLon) / (maxLon - minLon)) * W;
const py = (1 - (p.lat - minLat) / (maxLat - minLat)) * H;
ctx.fillStyle = `rgba(${r},${g},${b},${a.toFixed(2)})`;
ctx.beginPath();
ctx.arc(px, py, 6, 0, Math.PI * 2);
ctx.fill();
}
result.push(
new BitmapLayer({
id: 'hns-dispersion-bitmap',
image: canvas,
bounds: [minLon, minLat, maxLon, maxLat],
opacity: 1.0,
pickable: false,
}) as unknown as DeckLayer,
);
}
}
// --- HNS 대기확산 구역 (ScatterplotLayer, meters 단위) ---
if (dispersionResult && incidentCoord) {
const zones = dispersionResult.zones.map((zone, idx) => ({
position: [incidentCoord.lon, incidentCoord.lat] as [number, number],
radius: zone.radius,
fillColor: hexToRgba(zone.color, 100),
lineColor: hexToRgba(zone.color, 180),
level: zone.level,
idx,
}))
result.push(
new ScatterplotLayer({
id: 'hns-zones',
data: zones,
getPosition: (d: (typeof zones)[0]) => d.position,
getRadius: (d: (typeof zones)[0]) => d.radius,
getFillColor: (d: (typeof zones)[0]) => d.fillColor,
getLineColor: (d: (typeof zones)[0]) => d.lineColor,
getLineWidth: 2,
stroked: true,
radiusUnits: 'meters' as const,
pickable: true,
autoHighlight: true,
onHover: (info: PickingInfo) => {
if (info.object && info.coordinate) {
const zoneAreas = zones.map(z => ({
level: z.level,
area: Math.PI * z.radius * z.radius / 1e6,
}));
const totalArea = Math.PI * Math.max(...zones.map(z => z.radius)) ** 2 / 1e6;
setPopupInfo({
longitude: info.coordinate[0],
latitude: info.coordinate[1],
content: (
<div className="text-xs leading-relaxed" style={{ minWidth: 180 }}>
<strong className="text-status-orange">{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) {
setPopupInfo(null);
}
},
})
)
}
// --- 역추적 리플레이 ---
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={{ 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,
})
)
}
// --- 입자 중심점 이동 경로 (PathLayer + ScatterplotLayer) ---
const visibleCenters = centerPoints.filter(p => p.time <= currentTime)
if (visibleCenters.length >= 2) {
result.push(
new PathLayer({
id: 'center-path',
data: [{ path: visibleCenters.map(p => [p.lon, p.lat] as [number, number]) }],
getPath: (d: { path: [number, number][] }) => d.path,
getColor: (lightMode ? [0, 60, 150, 230] : [255, 220, 50, 200]) as [number, number, number, number],
getWidth: 2,
widthMinPixels: 2,
widthMaxPixels: 4,
})
)
}
if (visibleCenters.length > 0) {
result.push(
new ScatterplotLayer({
id: 'center-points',
data: visibleCenters,
getPosition: (d: (typeof visibleCenters)[0]) => [d.lon, d.lat],
getRadius: 5,
getFillColor: (lightMode ? [0, 60, 150, 230] : [255, 220, 50, 230]) as [number, number, number, number],
radiusMinPixels: 4,
radiusMaxPixels: 8,
pickable: false,
})
)
}
// --- 시간 표시 라벨 (TextLayer) ---
if (visibleCenters.length > 0 && showTimeLabel) {
const baseTime = simulationStartTime ? new Date(simulationStartTime) : null;
const pad = (n: number) => String(n).padStart(2, '0');
result.push(
new TextLayer({
id: 'time-labels',
data: visibleCenters,
getPosition: (d: (typeof visibleCenters)[0]) => [d.lon, d.lat],
getText: (d: (typeof visibleCenters)[0]) => {
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: (lightMode ? [20, 40, 100, 240] : [255, 220, 50, 220]) as [number, number, number, number],
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],
},
})
)
}
return result
}, [
oilTrajectory, currentTime, selectedModels,
boomLines, isDrawingBoom, drawingPoints,
dispersionResult, dispersionHeatmap, incidentCoord, backtrackReplay,
sensitiveResources, centerPoints, windData,
showWind, showBeached, showTimeLabel, simulationStartTime,
analysisPolygonPoints, analysisCircleCenter, analysisCircleRadiusM, lightMode,
])
// 3D 모드 / 밝은 톤에 따른 지도 스타일 전환
const currentMapStyle = mapToggles.threeD ? SATELLITE_3D_STYLE : lightMode ? LIGHT_STYLE : BASE_STYLE
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) ? 'crosshair' : 'grab' }}
onClick={handleMapClick}
attributionControl={false}
preserveDrawingBuffer={true}
>
{/* 지도 캡처 셋업 */}
{mapCaptureRef && <MapCaptureSetup captureRef={mapCaptureRef} />}
{/* 3D 모드 pitch 제어 */}
<MapPitchController threeD={mapToggles.threeD} />
{/* 사고 지점 변경 시 지도 이동 */}
<MapFlyToIncident lon={flyToIncident?.lon} lat={flyToIncident?.lat} onFlyEnd={onIncidentFlyEnd} />
{/* 외부에서 flyTo 트리거 */}
<FlyToController flyToTarget={flyToTarget} />
{/* 예측 완료 시 궤적 전체 범위로 fitBounds */}
<FitBoundsController fitBoundsTarget={fitBoundsTarget} />
{/* WMS 레이어 */}
{wmsLayers.map(layer => (
<Source
key={layer.id}
id={`wms-${layer.id}`}
type="raster"
tiles={[
`${GEOSERVER_URL}/geoserver/gwc/service/wms?service=WMS&version=1.1.0&request=GetMap&layers=${layer.wmsLayer}&styles=&bbox={bbox-epsg-3857}&width=256&height=256&srs=EPSG:3857&format=image/png&transparent=true`
]}
tileSize={256}
>
<Layer
id={`wms-layer-${layer.id}`}
type="raster"
paint={{
'raster-opacity': wmsOpacity,
'raster-brightness-min': wmsBrightnessMin,
'raster-brightness-max': Math.min(wmsBrightnessMax, 1),
}}
/>
</Source>
))}
{/* deck.gl 오버레이 (인터리브드: 일반 레이어) */}
<DeckGLOverlay layers={deckLayers} />
{/* 해류 파티클 오버레이 */}
{hydrData.length > 0 && showCurrent && (
<HydrParticleOverlay hydrStep={hydrData[currentTime] ?? null} lightMode={lightMode} />
)}
{/* 사고 위치 마커 (MapLibre Marker) */}
{incidentCoord && !isNaN(incidentCoord.lat) && !isNaN(incidentCoord.lon) && !(dispersionHeatmap && dispersionHeatmap.length > 0) && (
<Marker longitude={incidentCoord.lon} latitude={incidentCoord.lat} anchor="bottom">
<div
title={`사고 지점\n${incidentCoord.lat.toFixed(4)}°N, ${incidentCoord.lon.toFixed(4)}°E`}
className="w-6 h-6 bg-primary-cyan 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={() => setPopupInfo(null)}
>
<div className="text-[#333]">{popupInfo.content}</div>
</Popup>
)}
{/* 커스텀 줌 컨트롤 */}
<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>
)}
{/* 기상청 연계 정보 */}
{showOverlays && <WeatherInfoPanel position={currentPosition} />}
{/* 범례 */}
{showOverlays && <MapLegend dispersionResult={dispersionResult} incidentCoord={incidentCoord} oilTrajectory={oilTrajectory} boomLines={boomLines} selectedModels={selectedModels} />}
{/* 좌표 표시 */}
{showOverlays && <CoordinateDisplay
position={incidentCoord ? [incidentCoord.lat, incidentCoord.lon] : currentPosition}
/>}
{/* 타임라인 컨트롤 (외부 제어 모드에서는 숨김 — 하단 플레이어가 대신 담당) */}
{!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-4 left-4 z-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 = [], selectedModels = new Set(['OpenDrift'] as PredictionModel[]) }: MapLegendProps) {
const [minimized, setMinimized] = useState(false)
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 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-text-3 uppercase tracking-wider"></span>
<span className="text-[10px] text-text-3 hover:text-text-1 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-primary-orange"> </h4>
<div className="text-[8px] text-text-3 font-mono">
{incidentCoord.lat.toFixed(4)}°N, {incidentCoord.lon.toFixed(4)}°E
</div>
</div>
</div>
<div className="text-[9px] text-text-2 mb-2 rounded" style={{ background: 'rgba(249,115,22,0.08)', padding: '8px' }}>
<div className="flex justify-between mb-[3px]">
<span className="text-text-3"></span>
<span className="font-semibold text-status-orange">{dispersionResult.substance}</span>
</div>
<div className="flex justify-between mb-[3px]">
<span className="text-text-3"></span>
<span className="font-semibold font-mono">SW {dispersionResult.windDirection}°</span>
</div>
<div className="flex justify-between">
<span className="text-text-3"> </span>
<span className="font-semibold text-primary-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 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-text-3"> ()</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-border 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-text-2 font-korean"></span>
<span className="text-[9px] text-text-3 hover:text-text-1 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-text-2">
<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-text-2">
<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-text-2">
<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-text-2">
<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-text-2">
<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-text-2">
<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-text-2">
<div className="w-[14px] h-[3px] rounded-sm bg-[#22c55e]" />
<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 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 {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
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(--bdL)', borderRadius: '10px',
padding: '12px 18px', zIndex: 50,
minWidth: '340px',
}}
>
<div className="text-sm text-primary-purple 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(--purple), var(--cyan))', 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>
)
}