wing-ops/frontend/src/components/common/map/MapView.tsx

1876 lines
66 KiB
TypeScript
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, useMap } from '@vis.gl/react-maplibre';
import {
ScatterplotLayer,
PathLayer,
TextLayer,
BitmapLayer,
PolygonLayer,
GeoJsonLayer,
} from '@deck.gl/layers';
import type { PickingInfo, Layer as DeckLayer } from '@deck.gl/core';
import type { MapLayerMouseEvent } from 'maplibre-gl';
import 'maplibre-gl/dist/maplibre-gl.css';
import { layerDatabase } from '@common/services/layerService';
import type { PredictionModel } from '@/types/prediction/PredictionType';
import type { SensitiveResource } from '@interfaces/prediction/PredictionInterface';
import type {
HydrDataStep,
SensitiveResourceFeatureCollection,
} from '@interfaces/prediction/PredictionInterface';
import HydrParticleOverlay from './HydrParticleOverlay';
import { TimelineControl } from './TimelineControl';
import type { BoomLine, BoomLineCoord } from '@/types/boomLine';
import type { ReplayShip, CollisionEvent, BackwardParticleStep } from '@/types/backtrack';
import { createBacktrackLayers } from './BacktrackReplayOverlay';
import { buildMeasureLayers } from './measureLayers';
import { MeasureOverlay } from './MeasureOverlay';
import { useMeasureTool } from '@common/hooks/useMeasureTool';
import { hexToRgba } from './mapUtils';
import { S57EncOverlay } from './S57EncOverlay';
import { SrOverlay } from './SrOverlay';
import { DeckGLOverlay } from './DeckGLOverlay';
import { FlyToController } from './FlyToController';
import { useMapStore } from '@common/store/mapStore';
import { useBaseMapStyle } from '@common/hooks/useBaseMapStyle';
import { buildVesselLayers } from './VesselLayer';
import { MapBoundsTracker } from './MapBoundsTracker';
import {
VesselHoverTooltip,
VesselPopupPanel,
VesselDetailModal,
type VesselHoverInfo,
} from './VesselInteraction';
import type { VesselPosition, MapBounds } from '@/types/vessel';
/* eslint-disable react-refresh/only-export-components */
const GEOSERVER_URL = import.meta.env.VITE_GEOSERVER_URL || 'http://localhost:8080';
// 인천 송도 국제도시
const DEFAULT_CENTER: [number, number] = [37.39, 126.64];
const DEFAULT_ZOOM = 10;
// 모델별 색상 매핑
const MODEL_COLORS: Record<PredictionModel, string> = {
KOSPS: '#06b6d4',
POSEIDON: '#ef4444',
OpenDrift: '#3b82f6',
};
// 오일펜스 우선순위별 색상/두께
const PRIORITY_COLORS: Record<string, string> = {
CRITICAL: '#ef4444',
HIGH: '#f97316',
MEDIUM: '#eab308',
};
const PRIORITY_WEIGHTS: Record<string, number> = {
CRITICAL: 4,
HIGH: 3,
MEDIUM: 2,
};
const PRIORITY_LABELS: Record<string, string> = {
CRITICAL: '긴급',
HIGH: '중요',
MEDIUM: '보통',
};
function hslToRgb(h: number, s: number, l: number): [number, number, number] {
const a = s * Math.min(l, 1 - l);
const f = (n: number) => {
const k = (n + h * 12) % 12;
return l - a * Math.max(-1, Math.min(k - 3, 9 - k, 1));
};
return [Math.round(f(0) * 255), Math.round(f(8) * 255), Math.round(f(4) * 255)];
}
function categoryToRgb(category: string): [number, number, number] {
let hash = 0;
for (let i = 0; i < category.length; i++) {
hash = (hash * 31 + category.charCodeAt(i)) >>> 0;
}
const hue = (hash * 137) % 360;
return hslToRgb(hue / 360, 0.65, 0.55);
}
const SENSITIVE_COLORS: Record<string, string> = {
aquaculture: '#22c55e',
beach: '#0ea5e9',
ecology: '#eab308',
intake: '#a855f7',
};
const SENSITIVE_ICONS: Record<string, string> = {
aquaculture: '🐟',
beach: '🏖',
ecology: '🦅',
intake: '🚰',
};
interface DispersionZone {
level: string;
color: string;
radius: number;
angle: number;
}
interface DispersionContour {
level: string;
threshold: number;
color: string;
segments: Array<[[number, number], [number, number]]>;
}
interface DispersionResult {
zones: DispersionZone[];
timestamp: string;
windDirection: number;
substance: string;
concentration: Record<string, string>;
contours?: DispersionContour[];
}
interface MapViewProps {
center?: [number, number];
zoom?: number;
enabledLayers?: Set<string>;
incidentCoord?: { lon: number; lat: number };
isSelectingLocation?: boolean;
onMapClick?: (lon: number, lat: number) => void;
oilTrajectory?: Array<{
lat: number;
lon: number;
time: number;
particle?: number;
model?: string;
stranded?: 0 | 1;
}>;
selectedModels?: Set<PredictionModel>;
dispersionResult?: DispersionResult | null;
dispersionHeatmap?: Array<{ lon: number; lat: number; concentration: number }>;
boomLines?: BoomLine[];
showBoomLines?: boolean;
isDrawingBoom?: boolean;
drawingPoints?: BoomLineCoord[];
layerOpacity?: number;
layerBrightness?: number;
layerColors?: Record<string, string>;
backtrackReplay?: {
isActive: boolean;
ships: ReplayShip[];
collisionEvent: CollisionEvent | null;
replayFrame: number;
totalFrames: number;
incidentCoord: { lat: number; lon: number };
backwardParticles?: BackwardParticleStep[];
};
sensitiveResources?: SensitiveResource[];
sensitiveResourceGeojson?: SensitiveResourceFeatureCollection | null;
flyToTarget?: { lng: number; lat: number; zoom?: number } | null;
fitBoundsTarget?: { north: number; south: number; east: number; west: number } | null;
centerPoints?: Array<{ lat: number; lon: number; time: number; model?: string }>;
windData?: Array<Array<{ lat: number; lon: number; wind_speed: number; wind_direction: number }>>;
hydrData?: (HydrDataStep | null)[];
// 외부 플레이어 제어 (prediction 하단 바에서 제어할 때 사용)
externalCurrentTime?: number;
mapCaptureRef?: React.MutableRefObject<(() => Promise<string | null>) | null>;
onIncidentFlyEnd?: () => void;
flyToIncident?: { lon: number; lat: number };
showCurrent?: boolean;
showWind?: boolean;
showBeached?: boolean;
showTimeLabel?: boolean;
simulationStartTime?: string;
drawAnalysisMode?: 'polygon' | 'circle' | null;
analysisPolygonPoints?: Array<{ lat: number; lon: number }>;
analysisCircleCenter?: { lat: number; lon: number } | null;
analysisCircleRadiusM?: number;
/** false로 설정 시 WeatherInfoPanel, MapLegend, CoordinateDisplay 숨김 (기본: true) */
showOverlays?: boolean;
/** 선박 신호 목록 (실시간 표출) */
vessels?: VesselPosition[];
/** 지도 뷰포트 bounds 변경 콜백 (선박 신호 필터링에 사용) */
onBoundsChange?: (bounds: MapBounds) => void;
}
// DeckGLOverlay, FlyToController → @components/common/map/DeckGLOverlay, FlyToController 에서 import
// MapBoundsTracker는 './MapBoundsTracker' 모듈로 추출됨 (IncidentsView 등 BaseMap 직접 사용처에서도 재사용)
// fitBounds 트리거 컴포넌트 (Map 내부에서 useMap() 사용)
function FitBoundsController({
fitBoundsTarget,
}: {
fitBoundsTarget?: { north: number; south: number; east: number; west: number } | null;
}) {
const { current: map } = useMap();
useEffect(() => {
if (!map || !fitBoundsTarget) return;
map.fitBounds(
[
[fitBoundsTarget.west, fitBoundsTarget.south],
[fitBoundsTarget.east, fitBoundsTarget.north],
],
{ padding: 80, duration: 1200, maxZoom: 12 },
);
}, [fitBoundsTarget, map]);
return null;
}
// Map 중앙 좌표 + 줌 추적 컴포넌트 (Map 내부에서 useMap() 사용)
function MapCenterTracker({
onCenterChange,
}: {
onCenterChange: (lat: number, lng: number, zoom: number) => void;
}) {
const { current: map } = useMap();
useEffect(() => {
if (!map) return;
const update = () => {
const center = map.getCenter();
const zoom = map.getZoom();
onCenterChange(center.lat, center.lng, zoom);
};
update();
map.on('moveend', update);
return () => {
map.off('moveend', update);
};
}, [map, onCenterChange]);
return null;
}
// 3D 모드 pitch/bearing 제어 컴포넌트 (Map 내부에서 useMap() 사용)
function MapPitchController({ threeD }: { threeD: boolean }) {
const { current: map } = useMap();
useEffect(() => {
if (!map) return;
map.easeTo(
threeD ? { pitch: 45, bearing: -17, duration: 800 } : { pitch: 0, bearing: 0, duration: 800 },
);
}, [threeD, map]);
return null;
}
// 사고 지점 변경 시 지도 이동 (Map 내부 컴포넌트)
function MapFlyToIncident({
coord,
onFlyEnd,
}: {
coord?: { lon: number; lat: number };
onFlyEnd?: () => void;
}) {
const { current: map } = useMap();
const onFlyEndRef = useRef(onFlyEnd);
useEffect(() => {
onFlyEndRef.current = onFlyEnd;
}, [onFlyEnd]);
useEffect(() => {
if (!map || !coord) return;
const { lon, lat } = coord;
const doFly = () => {
map.flyTo({ center: [lon, lat], zoom: 11, duration: 1200 });
map.once('moveend', () => onFlyEndRef.current?.());
};
if (map.loaded()) {
doFly();
} else {
map.once('load', doFly);
}
}, [coord, map]); // 객체 참조 추적: 같은 좌표라도 새 객체면 effect 재실행
return null;
}
// 지도 캡처 지원 (map.once('render') 후 캡처로 빈 캔버스 문제 방지)
function MapCaptureSetup({
captureRef,
}: {
captureRef: React.MutableRefObject<(() => Promise<string | null>) | null>;
}) {
const { current: map } = useMap();
useEffect(() => {
if (!map) return;
captureRef.current = () =>
new Promise<string | null>((resolve) => {
map.once('render', () => {
try {
// WebGL 캔버스는 alpha=0 투명 배경이므로 불투명 배경과 합성 후 추출
// 최대 1200px로 리사이즈 + JPEG 압축으로 전송 크기 절감
const src = map.getCanvas();
const maxW = 1200;
const scale = src.width > maxW ? maxW / src.width : 1;
const composite = document.createElement('canvas');
composite.width = Math.round(src.width * scale);
composite.height = Math.round(src.height * scale);
const ctx = composite.getContext('2d')!;
ctx.fillStyle = '#0f1117';
ctx.fillRect(0, 0, composite.width, composite.height);
ctx.drawImage(src, 0, 0, composite.width, composite.height);
resolve(composite.toDataURL('image/jpeg', 0.82));
} catch {
resolve(null);
}
});
map.triggerRepaint();
});
}, [map, captureRef]);
return null;
}
// 팝업 정보
interface PopupInfo {
longitude: number;
latitude: number;
content: React.ReactNode;
}
export function MapView({
center = DEFAULT_CENTER,
zoom = DEFAULT_ZOOM,
enabledLayers = new Set(),
incidentCoord,
isSelectingLocation = false,
onMapClick,
oilTrajectory = [],
selectedModels = new Set(['OpenDrift'] as PredictionModel[]),
dispersionResult = null,
dispersionHeatmap = [],
boomLines = [],
showBoomLines = true,
isDrawingBoom = false,
drawingPoints = [],
layerOpacity = 50,
layerBrightness = 50,
layerColors,
backtrackReplay,
sensitiveResources = [],
sensitiveResourceGeojson,
flyToTarget,
fitBoundsTarget,
centerPoints = [],
windData = [],
hydrData = [],
externalCurrentTime,
mapCaptureRef,
onIncidentFlyEnd,
flyToIncident,
showCurrent = true,
showWind = true,
showBeached = false,
showTimeLabel = false,
simulationStartTime,
drawAnalysisMode = null,
analysisPolygonPoints = [],
analysisCircleCenter,
analysisCircleRadiusM = 0,
showOverlays = true,
vessels = [],
onBoundsChange,
}: MapViewProps) {
const lightMode = true;
const { mapToggles, measureMode, measureInProgress, measurements } = useMapStore();
const { handleMeasureClick } = useMeasureTool();
const isControlled = externalCurrentTime !== undefined;
const [currentPosition, setCurrentPosition] = useState<[number, number]>(DEFAULT_CENTER);
const [mapCenter, setMapCenter] = useState<[number, number]>(DEFAULT_CENTER);
const [mapZoom, setMapZoom] = useState<number>(DEFAULT_ZOOM);
const [internalCurrentTime, setInternalCurrentTime] = useState(0);
const [isPlaying, setIsPlaying] = useState(false);
const [playbackSpeed, setPlaybackSpeed] = useState(1);
const [popupInfo, setPopupInfo] = useState<PopupInfo | null>(null);
// deck.gl 레이어 클릭 시 MapLibre 맵 클릭 핸들러 차단용 플래그 (민감자원 등)
const deckClickHandledRef = useRef(false);
// 클릭으로 열린 팝업(닫기 전까지 유지) 추적 — 호버 핸들러가 닫지 않도록 방지
const persistentPopupRef = useRef(false);
// 현재 호버 중인 민감자원 feature properties (handleMapClick에서 팝업 생성에 사용)
const hoveredSensitiveRef = useRef<Record<string, unknown> | null>(null);
// 선박 호버/클릭 상호작용 상태
const [vesselHover, setVesselHover] = useState<VesselHoverInfo | null>(null);
const [selectedVessel, setSelectedVessel] = useState<VesselPosition | null>(null);
const [detailVessel, setDetailVessel] = useState<VesselPosition | null>(null);
const currentTime = isControlled ? externalCurrentTime : internalCurrentTime;
const handleMapCenterChange = useCallback((lat: number, lng: number, zoom: number) => {
setMapCenter([lat, lng]);
setMapZoom(zoom);
}, []);
const handleMapClick = useCallback(
(e: MapLayerMouseEvent) => {
const { lng, lat } = e.lngLat;
setCurrentPosition([lat, lng]);
// deck.gl 다른 레이어 onClick이 처리한 클릭 — 팝업 유지
if (deckClickHandledRef.current) {
deckClickHandledRef.current = false;
return;
}
// 민감자원 hover 중이면 팝업 표시
if (hoveredSensitiveRef.current) {
const props = hoveredSensitiveRef.current;
const { category, ...rest } = props;
const entries = Object.entries(rest).filter(
([k, v]) => k !== 'srId' && v !== null && v !== undefined && v !== '',
);
persistentPopupRef.current = true;
setPopupInfo({
longitude: lng,
latitude: lat,
content: (
<div
className="text-caption font-korean"
style={{ minWidth: '180px', maxWidth: '260px' }}
>
<div className="font-semibold mb-1.5 pb-1 border-b border-[rgba(0,0,0,0.12)]">
{String(category ?? '민감자원')}
</div>
{entries.length > 0 ? (
<div className="space-y-0.5">
{entries.map(([key, val]) => (
<div key={key} className="flex gap-2 justify-between">
<span className="text-caption text-[#888] shrink-0">{key}</span>
<span className="text-caption text-[#333] font-medium text-right break-all">
{typeof val === 'object' ? JSON.stringify(val) : String(val)}
</span>
</div>
))}
</div>
) : (
<p className="text-caption text-[#999]"> </p>
)}
</div>
),
});
return;
}
if (measureMode !== null) {
handleMeasureClick(lng, lat);
return;
}
if (onMapClick) {
onMapClick(lng, lat);
}
setPopupInfo(null);
},
[onMapClick, measureMode, handleMeasureClick],
);
// 애니메이션 재생 로직 (외부 제어 모드에서는 비활성)
useEffect(() => {
if (isControlled || !isPlaying || oilTrajectory.length === 0) return;
const maxTime = Math.max(...oilTrajectory.map((p) => p.time));
if (internalCurrentTime >= maxTime) {
setIsPlaying(false);
return;
}
const interval = setInterval(() => {
setInternalCurrentTime((prev) => {
const next = prev + 1 * playbackSpeed;
return next > maxTime ? maxTime : next;
});
}, 200);
return () => clearInterval(interval);
}, [isControlled, isPlaying, internalCurrentTime, playbackSpeed, oilTrajectory]);
// 시뮬레이션 시작 시 자동으로 애니메이션 재생 (외부 제어 모드에서는 비활성)
useEffect(() => {
if (isControlled) return;
if (oilTrajectory.length > 0) {
setInternalCurrentTime(0);
setIsPlaying(true);
}
}, [isControlled, oilTrajectory.length]);
// WMS 레이어 목록
const wmsLayers = useMemo(() => {
return Array.from(enabledLayers)
.map((layerId) => {
const layer = layerDatabase.find((l) => l.id === layerId);
return layer?.wmsLayer ? { id: layerId, wmsLayer: layer.wmsLayer } : null;
})
.filter((l): l is { id: string; wmsLayer: string } => l !== null);
}, [enabledLayers]);
// WMS 밝기 값 (MapLibre raster paint)
const wmsBrightnessMax = Math.min(layerBrightness / 50, 2);
const wmsBrightnessMin = wmsBrightnessMax > 1 ? wmsBrightnessMax - 1 : 0;
const wmsOpacity = layerOpacity / 100;
// HNS 대기확산 히트맵 이미지 (Canvas 렌더링 + PNG 변환은 고비용이므로 별도 메모이제이션)
const heatmapImage = useMemo(() => {
if (!dispersionHeatmap || dispersionHeatmap.length === 0) return null;
const maxConc = Math.max(...dispersionHeatmap.map((p) => p.concentration));
const minConc = Math.min(
...dispersionHeatmap.filter((p) => p.concentration > 0.001).map((p) => p.concentration),
);
const filtered = dispersionHeatmap.filter((p) => p.concentration > 0.001);
if (filtered.length === 0) return null;
// 경위도 바운드 계산
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, 12, 0, Math.PI * 2);
ctx.fill();
}
// Canvas → data URL 변환 (interleaved MapboxOverlay에서 HTMLCanvasElement 직접 전달 시 렌더링 안 되는 문제 회피)
const imageUrl = canvas.toDataURL('image/png');
return {
imageUrl,
bounds: [minLon, minLat, maxLon, maxLat] as [number, number, number, number],
};
}, [dispersionHeatmap]);
// deck.gl 레이어 구축
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const deckLayers = useMemo((): any[] => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const result: any[] = [];
// --- 유류 확산 입자 (ScatterplotLayer) ---
const visibleParticles = oilTrajectory.filter((p) => p.time <= currentTime);
const activeStep =
visibleParticles.length > 0 ? Math.max(...visibleParticles.map((p) => p.time)) : -1;
if (visibleParticles.length > 0) {
result.push(
new ScatterplotLayer({
id: 'oil-particles',
data: visibleParticles,
getPosition: (d: (typeof visibleParticles)[0]) => [d.lon, d.lat],
getRadius: 3,
getFillColor: (d: (typeof visibleParticles)[0]) => {
const modelKey = (d.model ||
Array.from(selectedModels)[0] ||
'OpenDrift') as PredictionModel;
// 1순위: stranded 입자 → showBeached=true 시 모델 색, false 시 회색
if (d.stranded === 1)
return showBeached
? hexToRgba(MODEL_COLORS[modelKey] || '#3b82f6', 180)
: ([130, 130, 130, 70] as [number, number, number, number]);
// 2순위: 현재 활성 스텝 → 모델 기본 색상
if (d.time === activeStep) return hexToRgba(MODEL_COLORS[modelKey] || '#3b82f6', 180);
// 3순위: 과거 스텝 → 회색 + 투명
return [130, 130, 130, 70] as [number, number, number, number];
},
radiusMinPixels: 2.5,
radiusMaxPixels: 5,
pickable: true,
onClick: (info: PickingInfo) => {
if (info.object) {
const d = info.object as (typeof visibleParticles)[0];
const modelKey = d.model || Array.from(selectedModels)[0] || 'OpenDrift';
setPopupInfo({
longitude: d.lon,
latitude: d.lat,
content: (
<div className="text-caption">
<strong>
{modelKey} #{(d.particle ?? 0) + 1}
</strong>
{d.stranded === 1 && <span className="text-red-400"> ( )</span>}
<br />
: +{d.time}h
<br />
: {d.lat.toFixed(4)}°, {d.lon.toFixed(4)}°
</div>
),
});
}
},
updateTriggers: {
getFillColor: [selectedModels, currentTime, showBeached],
},
}),
);
}
// --- 육지부착 hollow ring (stranded 모양 구분) ---
const strandedParticles = showBeached ? visibleParticles.filter((p) => p.stranded === 1) : [];
if (strandedParticles.length > 0) {
result.push(
new ScatterplotLayer({
id: 'oil-stranded-ring',
data: strandedParticles,
getPosition: (d: (typeof strandedParticles)[0]) => [d.lon, d.lat],
stroked: true,
filled: false,
getLineColor: (d: (typeof strandedParticles)[0]) => {
const modelKey = (d.model ||
Array.from(selectedModels)[0] ||
'OpenDrift') as PredictionModel;
return hexToRgba(MODEL_COLORS[modelKey] || '#3b82f6', 255);
},
lineWidthMinPixels: 2,
getRadius: 4,
radiusMinPixels: 5,
radiusMaxPixels: 8,
updateTriggers: {
getLineColor: [selectedModels],
},
}),
);
}
// --- 오일펜스 라인 (PathLayer) ---
if (showBoomLines && boomLines.length > 0) {
result.push(
new PathLayer({
id: 'boom-lines',
data: boomLines,
getPath: (d: BoomLine) => d.coords.map((c) => [c.lon, c.lat] as [number, number]),
getColor: (d: BoomLine) => hexToRgba(PRIORITY_COLORS[d.priority] || '#f59e0b', 230),
getWidth: (d: BoomLine) => PRIORITY_WEIGHTS[d.priority] || 2,
getDashArray: (d: BoomLine) => (d.status === 'PLANNED' ? [10, 5] : [0, 0]),
dashJustified: true,
widthMinPixels: 2,
widthMaxPixels: 6,
pickable: true,
onClick: (info: PickingInfo) => {
if (info.object) {
const d = info.object as BoomLine;
setPopupInfo({
longitude: info.coordinate?.[0] ?? 0,
latitude: info.coordinate?.[1] ?? 0,
content: (
<div className="text-caption" 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 (heatmapImage) {
result.push(
new BitmapLayer({
id: 'hns-dispersion-bitmap',
image: heatmapImage.imageUrl,
bounds: heatmapImage.bounds,
opacity: 1.0,
pickable: false,
}) as unknown as DeckLayer,
);
}
// --- HNS 대기확산 구역 (ScatterplotLayer, meters 단위) ---
if (dispersionResult && incidentCoord) {
// contour가 있으면 동심원 fill은 희미하게(contour가 실제 경계 표시), 없으면 진하게
const hasContours = !!(dispersionResult.contours && dispersionResult.contours.length > 0);
const zoneFillAlpha = hasContours ? 40 : 100;
const zoneLineAlpha = hasContours ? 80 : 180;
const zones = dispersionResult.zones.map((zone, idx) => ({
position: [incidentCoord.lon, incidentCoord.lat] as [number, number],
radius: zone.radius,
fillColor: hexToRgba(zone.color, zoneFillAlpha),
lineColor: hexToRgba(zone.color, zoneLineAlpha),
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-caption leading-relaxed" style={{ minWidth: 180 }}>
<strong className="text-color-warning">
{dispersionResult.substance}
</strong>
<table style={{ width: '100%', marginTop: 4, borderCollapse: 'collapse' }}>
<tbody>
{zoneAreas.map((z) => (
<tr key={z.level} style={{ borderBottom: '1px solid rgba(0,0,0,0.08)' }}>
<td style={{ padding: '2px 0', fontSize: 10 }}>{z.level}</td>
<td
style={{
padding: '2px 0',
fontSize: 10,
textAlign: 'right',
fontFamily: 'monospace',
}}
>
{z.area.toFixed(3)} km²
</td>
</tr>
))}
<tr>
<td style={{ padding: '3px 0 0', fontSize: 10, fontWeight: 700 }}>
</td>
<td
style={{
padding: '3px 0 0',
fontSize: 10,
fontWeight: 700,
textAlign: 'right',
fontFamily: 'monospace',
}}
>
{totalArea.toFixed(3)} km²
</td>
</tr>
</tbody>
</table>
</div>
),
});
} else if (!info.object) {
// 클릭으로 열린 팝업(어장 등)이 있으면 호버로 닫지 않음
if (!persistentPopupRef.current) {
setPopupInfo(null);
}
}
},
}),
);
// --- HNS AEGL 등농도선 (PathLayer) ---
if (dispersionResult.contours) {
dispersionResult.contours.forEach((contour, cIdx) => {
if (contour.segments.length === 0) return;
const color = hexToRgba(contour.color, 230);
result.push(
new PathLayer({
id: `hns-contour-${cIdx}-${contour.level}`,
data: contour.segments,
getPath: (d: [[number, number], [number, number]]) => d,
getColor: color,
getWidth: 3,
widthUnits: 'pixels' as const,
capRounded: true,
jointRounded: true,
pickable: false,
}) as unknown as DeckLayer,
);
});
}
}
// --- 역추적 리플레이 ---
if (backtrackReplay?.isActive) {
result.push(
...createBacktrackLayers({
replayShips: backtrackReplay.ships,
collisionEvent: backtrackReplay.collisionEvent,
replayFrame: backtrackReplay.replayFrame,
totalFrames: backtrackReplay.totalFrames,
incidentCoord: backtrackReplay.incidentCoord,
backwardParticles: backtrackReplay.backwardParticles,
}),
);
}
// --- 민감자원 영역 (ScatterplotLayer) ---
if (sensitiveResources.length > 0) {
result.push(
new ScatterplotLayer({
id: 'sensitive-zones',
data: sensitiveResources,
getPosition: (d: SensitiveResource) => [d.lon, d.lat],
getRadius: (d: SensitiveResource) => d.radiusM,
getFillColor: (d: SensitiveResource) =>
hexToRgba(SENSITIVE_COLORS[d.type] || '#22c55e', 40),
getLineColor: (d: SensitiveResource) =>
hexToRgba(SENSITIVE_COLORS[d.type] || '#22c55e', 150),
getLineWidth: 2,
stroked: true,
radiusUnits: 'meters' as const,
pickable: true,
onClick: (info: PickingInfo) => {
if (info.object) {
const d = info.object as SensitiveResource;
setPopupInfo({
longitude: d.lon,
latitude: d.lat,
content: (
<div className="text-caption" 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-caption text-[#666]">
: {d.radiusM}m<br />
:{' '}
<strong style={{ color: d.arrivalTimeH <= 6 ? '#ef4444' : '#f97316' }}>
{d.arrivalTimeH}h
</strong>
</div>
</div>
),
});
}
},
}),
);
// 민감자원 중심 마커
result.push(
new ScatterplotLayer({
id: 'sensitive-centers',
data: sensitiveResources,
getPosition: (d: SensitiveResource) => [d.lon, d.lat],
getRadius: 6,
getFillColor: (d: SensitiveResource) =>
hexToRgba(SENSITIVE_COLORS[d.type] || '#22c55e', 220),
getLineColor: [255, 255, 255, 200],
getLineWidth: 2,
stroked: true,
radiusMinPixels: 6,
radiusMaxPixels: 10,
}),
);
// 민감자원 라벨
result.push(
new TextLayer({
id: 'sensitive-labels',
data: sensitiveResources,
getPosition: (d: SensitiveResource) => [d.lon, d.lat],
getText: (d: SensitiveResource) =>
`${SENSITIVE_ICONS[d.type]} ${d.name} (${d.arrivalTimeH}h)`,
getSize: 12,
getColor: (lightMode ? [20, 40, 100, 240] : [255, 255, 255, 200]) as [
number,
number,
number,
number,
],
getPixelOffset: [0, -20],
fontFamily: 'NanumSquare, Malgun Gothic, 맑은 고딕, sans-serif',
fontWeight: 'bold',
characterSet: 'auto',
outlineWidth: 2,
outlineColor: [15, 21, 36, 200],
billboard: true,
sizeUnits: 'pixels' as const,
}),
);
}
// --- 민감자원 GeoJSON 레이어 ---
if (sensitiveResourceGeojson && sensitiveResourceGeojson.features.length > 0) {
result.push(
new GeoJsonLayer({
id: 'sensitive-resource-geojson',
// eslint-disable-next-line @typescript-eslint/no-explicit-any
data: sensitiveResourceGeojson as any,
pickable: true,
stroked: true,
filled: true,
pointRadiusMinPixels: 10,
pointRadiusMaxPixels: 20,
lineWidthMinPixels: 1,
getLineWidth: 1.5,
getFillColor: (f: { properties: { category?: string } | null }) => {
const cat = f.properties?.category ?? '';
const [r, g, b] = categoryToRgb(cat);
return [r, g, b, 80] as [number, number, number, number];
},
getLineColor: (f: { properties: { category?: string } | null }) => {
const cat = f.properties?.category ?? '';
const [r, g, b] = categoryToRgb(cat);
return [r, g, b, 210] as [number, number, number, number];
},
onHover: (info: PickingInfo) => {
if (info.object) {
hoveredSensitiveRef.current =
(info.object as { properties: Record<string, unknown> | null }).properties ?? {};
} else {
hoveredSensitiveRef.current = null;
}
},
}) as unknown as DeckLayer,
);
}
// --- 입자 중심점 이동 경로 (모델별 PathLayer + ScatterplotLayer) ---
const visibleCenters = centerPoints.filter((p) => p.time <= currentTime);
if (visibleCenters.length > 0) {
// 모델별 그룹핑 (Record 사용 — Map 컴포넌트와 이름 충돌 회피)
const modelGroups: Record<string, typeof visibleCenters> = {};
visibleCenters.forEach((p) => {
const key = p.model || 'OpenDrift';
if (!modelGroups[key]) modelGroups[key] = [];
modelGroups[key].push(p);
});
Object.entries(modelGroups).forEach(([model, points]) => {
const modelColor = hexToRgba(MODEL_COLORS[model as PredictionModel] || '#06b6d4', 210);
if (points.length >= 2) {
result.push(
new PathLayer({
id: `center-path-${model}`,
data: [
{
path: points.map(
(p: { lon: number; lat: number }) => [p.lon, p.lat] as [number, number],
),
},
],
getPath: (d: { path: [number, number][] }) => d.path,
getColor: modelColor,
getWidth: 2,
widthMinPixels: 2,
widthMaxPixels: 4,
}),
);
}
result.push(
new ScatterplotLayer({
id: `center-points-${model}`,
data: points,
getPosition: (d: { lon: number; lat: number }) => [d.lon, d.lat],
getRadius: 5,
getFillColor: modelColor,
radiusMinPixels: 4,
radiusMaxPixels: 8,
pickable: false,
}),
);
if (showTimeLabel) {
const baseTime = simulationStartTime ? new Date(simulationStartTime) : null;
const pad = (n: number) => String(n).padStart(2, '0');
result.push(
new TextLayer({
id: `time-labels-${model}`,
data: points,
getPosition: (d: { lon: number; lat: number }) => [d.lon, d.lat],
getText: (d: { time: number }) => {
if (baseTime) {
const dt = new Date(baseTime.getTime() + d.time * 3600 * 1000);
return `${dt.getFullYear()}-${pad(dt.getMonth() + 1)}-${pad(dt.getDate())} ${pad(dt.getHours())}:${pad(dt.getMinutes())}`;
}
return `+${d.time}h`;
},
getSize: 12,
getColor: hexToRgba(MODEL_COLORS[model as PredictionModel] || '#06b6d4', 240),
getPixelOffset: [0, 16] as [number, number],
fontWeight: 'bold',
outlineWidth: 2,
outlineColor: (lightMode ? [255, 255, 255, 180] : [15, 21, 36, 200]) as [
number,
number,
number,
number,
],
billboard: true,
sizeUnits: 'pixels' as const,
updateTriggers: {
getText: [simulationStartTime, currentTime],
},
}),
);
}
});
}
// --- 바람 화살표 (TextLayer) ---
if (incidentCoord && windData.length > 0 && showWind) {
type ArrowPoint = { lon: number; lat: number; bearing: number; speed: number };
const activeWindStep = windData[currentTime] ?? windData[0] ?? [];
const currentArrows: ArrowPoint[] = activeWindStep
.filter((d) => d.wind_speed != null && d.wind_direction != null)
.map((d) => ({
lon: d.lon,
lat: d.lat,
bearing: d.wind_direction,
speed: d.wind_speed,
}));
result.push(
new TextLayer({
id: 'current-arrows',
data: currentArrows,
getPosition: (d: ArrowPoint) => [d.lon, d.lat],
getText: () => '➤',
getAngle: (d: ArrowPoint) => -d.bearing + 90,
getSize: 22,
getColor: (d: ArrowPoint): [number, number, number, number] => {
const s = d.speed;
if (s < 3) return [6, 182, 212, 130]; // cyan-500: calm
if (s < 7) return [34, 197, 94, 150]; // green-500: light
if (s < 12) return [234, 179, 8, 170]; // yellow-500: moderate
if (s < 17) return [249, 115, 22, 190]; // orange-500: fresh
return [239, 68, 68, 210]; // red-500: strong
},
characterSet: 'auto',
sizeUnits: 'pixels' as const,
billboard: true,
updateTriggers: {
getColor: [currentTime, windData],
getAngle: [currentTime, windData],
},
}),
);
}
// 거리/면적 측정 레이어
result.push(...buildMeasureLayers(measureInProgress, measureMode, measurements));
// 선박 신호 레이어
result.push(
...buildVesselLayers(
vessels,
{
onClick: (vessel) => {
setSelectedVessel(vessel);
setDetailVessel(null);
},
onHover: (vessel, x, y) => {
setVesselHover(vessel ? { x, y, vessel } : null);
},
},
mapZoom,
),
);
return result.filter(Boolean);
}, [
oilTrajectory,
currentTime,
selectedModels,
boomLines,
showBoomLines,
isDrawingBoom,
drawingPoints,
dispersionResult,
heatmapImage,
incidentCoord,
backtrackReplay,
sensitiveResources,
sensitiveResourceGeojson,
centerPoints,
windData,
showWind,
showBeached,
showTimeLabel,
simulationStartTime,
analysisPolygonPoints,
analysisCircleCenter,
analysisCircleRadiusM,
lightMode,
vessels,
mapZoom,
]);
// 3D 모드 / 테마에 따른 지도 스타일 전환
const currentMapStyle = useBaseMapStyle();
return (
<div className="w-full h-full relative">
<Map
initialViewState={{
longitude: center[1],
latitude: center[0],
zoom: zoom,
}}
mapStyle={currentMapStyle}
style={{
width: '100%',
height: '100%',
cursor:
isSelectingLocation || drawAnalysisMode !== null || measureMode !== null
? 'crosshair'
: 'grab',
}}
onClick={handleMapClick}
attributionControl={false}
{...({ preserveDrawingBuffer: true } as Record<string, unknown>)}
>
{/* 지도 캡처 셋업 */}
{mapCaptureRef && <MapCaptureSetup captureRef={mapCaptureRef} />}
{/* 지도 중앙 좌표 + 줌 추적 */}
<MapCenterTracker onCenterChange={handleMapCenterChange} />
{/* 3D 모드 pitch 제어 */}
<MapPitchController threeD={mapToggles['threeD'] ?? false} />
{/* 사고 지점 변경 시 지도 이동 */}
<MapFlyToIncident coord={flyToIncident} onFlyEnd={onIncidentFlyEnd} />
{/* 외부에서 flyTo 트리거 */}
<FlyToController target={flyToTarget} duration={1200} />
{/* 예측 완료 시 궤적 전체 범위로 fitBounds */}
<FitBoundsController fitBoundsTarget={fitBoundsTarget} />
{/* 선박 신호 뷰포트 bounds 추적 */}
<MapBoundsTracker onBoundsChange={onBoundsChange} />
{/* S-57 전자해도 오버레이 (공식 style.json 기반) */}
<S57EncOverlay visible={mapToggles['s57'] ?? false} />
{/* SR 민감자원 벡터타일 오버레이 */}
<SrOverlay enabledLayers={enabledLayers} opacity={layerOpacity} layerColors={layerColors} />
{/* WMS 레이어 */}
{wmsLayers.map((layer) => (
<Source
key={layer.id}
id={`wms-${layer.id}`}
type="raster"
tiles={[
`${GEOSERVER_URL}/geoserver/gwc/service/wms?service=WMS&version=1.1.0&request=GetMap&layers=${layer.wmsLayer}&styles=&bbox={bbox-epsg-3857}&width=256&height=256&srs=EPSG:3857&format=image/png&transparent=true`,
]}
tileSize={256}
>
<Layer
id={`wms-layer-${layer.id}`}
type="raster"
paint={{
'raster-opacity': wmsOpacity,
'raster-brightness-min': wmsBrightnessMin,
'raster-brightness-max': Math.min(wmsBrightnessMax, 1),
}}
/>
</Source>
))}
{/* deck.gl 오버레이 (인터리브드: 일반 레이어) */}
<DeckGLOverlay layers={deckLayers} />
{/* 해류 파티클 오버레이 */}
{hydrData.length > 0 && showCurrent && (
<HydrParticleOverlay hydrStep={hydrData[currentTime] ?? null} />
)}
{/* 사고 위치 마커 (MapLibre Marker) */}
{incidentCoord &&
!isNaN(incidentCoord.lat) &&
!isNaN(incidentCoord.lon) &&
!(dispersionHeatmap && dispersionHeatmap.length > 0) && (
<Marker longitude={incidentCoord.lon} latitude={incidentCoord.lat} anchor="bottom">
<div
title={`사고 지점\n${incidentCoord.lat.toFixed(4)}°N, ${incidentCoord.lon.toFixed(4)}°E`}
className="w-6 h-6 bg-color-accent border-2 border-white"
style={{
borderRadius: '50% 50% 50% 0',
transform: 'rotate(-45deg)',
boxShadow: '0 2px 8px rgba(6,182,212,0.5)',
}}
/>
</Marker>
)}
{/* deck.gl 객체 클릭 팝업 */}
{popupInfo && (
<Popup
longitude={popupInfo.longitude}
latitude={popupInfo.latitude}
anchor="bottom"
onClose={() => {
persistentPopupRef.current = false;
setPopupInfo(null);
}}
>
<div className="text-[#333]">{popupInfo.content}</div>
</Popup>
)}
{/* 측정 결과 지우기 버튼 */}
<MeasureOverlay />
{/* 커스텀 줌 컨트롤 */}
<MapControls center={center} zoom={zoom} />
</Map>
{/* 드로잉 모드 안내 */}
{isDrawingBoom && (
<div className="boom-drawing-indicator">
({drawingPoints.length} )
</div>
)}
{drawAnalysisMode === 'polygon' && (
<div
className="boom-drawing-indicator"
style={{ background: 'rgba(168,85,247,0.15)', borderColor: 'rgba(168,85,247,0.4)' }}
>
({analysisPolygonPoints.length})
</div>
)}
{drawAnalysisMode === 'circle' && (
<div
className="boom-drawing-indicator"
style={{ background: 'rgba(168,85,247,0.15)', borderColor: 'rgba(168,85,247,0.4)' }}
>
{!analysisCircleCenter ? '원 분석 모드 — 중심점을 클릭하세요' : '반경 지점을 클릭하세요'}
</div>
)}
{measureMode === 'distance' && (
<div className="boom-drawing-indicator">
{measureInProgress.length === 0 ? '시작점을 클릭하세요' : '끝점을 클릭하세요'}
</div>
)}
{measureMode === 'area' && (
<div className="boom-drawing-indicator">
({measureInProgress.length})
{measureInProgress.length >= 3 && ' → 첫 번째 점 근처를 클릭하면 닫힙니다'}
</div>
)}
{/* 기상청 연계 정보 */}
{showOverlays && <WeatherInfoPanel position={currentPosition} />}
{/* 범례 */}
{showOverlays && (
<MapLegend
dispersionResult={dispersionResult}
incidentCoord={incidentCoord}
oilTrajectory={oilTrajectory}
boomLines={boomLines}
selectedModels={selectedModels}
/>
)}
{/* 좌표 표시 */}
{showOverlays && <CoordinateDisplay position={mapCenter} zoom={mapZoom} />}
{/* 타임라인 컨트롤 (외부 제어 모드에서는 숨김 — 하단 플레이어가 대신 담당) */}
{!isControlled && oilTrajectory.length > 0 && (
<TimelineControl
currentTime={currentTime}
maxTime={Math.max(...oilTrajectory.map((p) => p.time))}
isPlaying={isPlaying}
playbackSpeed={playbackSpeed}
onTimeChange={setInternalCurrentTime}
onPlayPause={() => setIsPlaying(!isPlaying)}
onSpeedChange={setPlaybackSpeed}
simulationStartTime={simulationStartTime}
/>
)}
{/* 역추적 리플레이 바 */}
{backtrackReplay?.isActive && (
<BacktrackReplayBar
replayFrame={backtrackReplay.replayFrame}
totalFrames={backtrackReplay.totalFrames}
ships={backtrackReplay.ships}
/>
)}
{/* 선박 호버 툴팁 */}
{vesselHover && !selectedVessel && <VesselHoverTooltip hover={vesselHover} />}
{/* 선박 클릭 팝업 */}
{selectedVessel && !detailVessel && (
<VesselPopupPanel
vessel={selectedVessel}
onClose={() => setSelectedVessel(null)}
onDetail={() => {
setDetailVessel(selectedVessel);
setSelectedVessel(null);
}}
/>
)}
{/* 선박 상세 모달 */}
{detailVessel && (
<VesselDetailModal vessel={detailVessel} onClose={() => setDetailVessel(null)} />
)}
</div>
);
}
// 지도 컨트롤 (줌, 위치 초기화)
function MapControls({ center, zoom }: { center: [number, number]; zoom: number }) {
const { current: map } = useMap();
return (
<div className="absolute top-[80px] left-[10px] z-10">
<div className="flex flex-col gap-1">
<button
onClick={() => map?.zoomIn()}
className="w-[28px] h-[28px] bg-[color-mix(in_srgb,var(--bg-elevated)_85%,transparent)] backdrop-blur-sm border border-stroke rounded-sm text-fg-sub flex items-center justify-center hover:bg-bg-surface-hover hover:text-fg transition-all text-caption"
>
+
</button>
<button
onClick={() => map?.zoomOut()}
className="w-[28px] h-[28px] bg-[color-mix(in_srgb,var(--bg-elevated)_85%,transparent)] backdrop-blur-sm border border-stroke rounded-sm text-fg-sub flex items-center justify-center hover:bg-bg-surface-hover hover:text-fg transition-all text-caption"
>
</button>
<button
onClick={() => map?.flyTo({ center: [center[1], center[0]], zoom, duration: 1000 })}
className="w-[28px] h-[28px] bg-[color-mix(in_srgb,var(--bg-elevated)_85%,transparent)] backdrop-blur-sm border border-stroke rounded-sm text-fg-sub flex items-center justify-center hover:text-fg transition-all text-caption"
>
&#x1F3AF;
</button>
</div>
</div>
);
}
// 지도 범례
interface MapLegendProps {
dispersionResult?: DispersionResult | null;
incidentCoord?: { lon: number; lat: number };
oilTrajectory?: Array<{ lat: number; lon: number; time: number }>;
boomLines?: BoomLine[];
selectedModels?: Set<PredictionModel>;
}
function MapLegend({
dispersionResult,
incidentCoord,
oilTrajectory = [],
selectedModels = new Set(['OpenDrift'] as PredictionModel[]),
}: MapLegendProps) {
const [minimized, setMinimized] = useState(true);
if (dispersionResult && incidentCoord) {
return (
<div className="absolute top-4 right-4 bg-[rgba(18,25,41,0.95)] backdrop-blur-xl border border-stroke rounded-lg min-w-[200px] z-[20]">
{/* 헤더 + 최소화 버튼 */}
<div
className="flex items-center justify-between px-3.5 pt-3 pb-1 cursor-pointer"
onClick={() => setMinimized(!minimized)}
>
<span className="text-caption font-bold text-fg-disabled uppercase tracking-wider">
</span>
<span className="text-caption text-fg-disabled hover:text-fg transition-colors">
{minimized ? '▶' : '▼'}
</span>
</div>
{!minimized && (
<div className="px-3.5 pb-3.5">
<div className="flex items-center gap-1.5 mb-2.5">
<div className="text-base">📍</div>
<div>
<h4 className="text-label-2 font-bold text-color-warning"> </h4>
<div className="text-caption text-fg-disabled font-mono">
{incidentCoord.lat.toFixed(4)}°N, {incidentCoord.lon.toFixed(4)}°E
</div>
</div>
</div>
<div
className="text-caption text-fg-sub mb-2 rounded"
style={{ background: 'rgba(249,115,22,0.08)', padding: '8px' }}
>
<div className="flex justify-between mb-[3px]">
<span className="text-fg-disabled"></span>
<span className="font-semibold text-color-warning">
{dispersionResult.substance}
</span>
</div>
<div className="flex justify-between mb-[3px]">
<span className="text-fg-disabled"></span>
<span className="font-semibold font-mono">
SW {dispersionResult.windDirection}°
</span>
</div>
<div className="flex justify-between">
<span className="text-fg-disabled"> </span>
<span className="font-semibold text-color-accent">
{dispersionResult.zones.length}
</span>
</div>
</div>
<div>
<h5 className="text-caption font-bold text-fg-disabled mb-2"> </h5>
<div className="flex flex-col gap-1.5">
<div className="flex items-center gap-2 text-caption text-fg-sub">
<div
className="w-3 h-3 rounded-full"
style={{ background: 'rgba(239,68,68,0.7)' }}
/>
<span> (AEGL-3)</span>
</div>
<div className="flex items-center gap-2 text-caption text-fg-sub">
<div
className="w-3 h-3 rounded-full"
style={{ background: 'rgba(249,115,22,0.7)' }}
/>
<span> (AEGL-2)</span>
</div>
<div className="flex items-center gap-2 text-caption text-fg-sub">
<div
className="w-3 h-3 rounded-full"
style={{ background: 'rgba(234,179,8,0.7)' }}
/>
<span> (AEGL-1)</span>
</div>
</div>
</div>
<div
className="flex items-center gap-1.5 mt-2 rounded"
style={{ padding: '6px', background: 'rgba(168,85,247,0.08)' }}
>
<div className="text-caption">🧭</div>
<span className="text-caption text-fg-disabled"> ()</span>
</div>
</div>
)}
</div>
);
}
if (oilTrajectory.length > 0) {
return (
<div
className="absolute top-4 right-4 bg-[rgba(18,25,41,0.92)] backdrop-blur-xl border border-stroke rounded-md z-[20]"
style={{ minWidth: 155 }}
>
{/* 헤더 + 접기/펼치기 */}
<div
className="flex items-center justify-between px-3 py-2 cursor-pointer select-none"
onClick={() => setMinimized(!minimized)}
>
<span className="text-caption font-bold text-fg-sub font-korean"></span>
<span className="text-caption text-fg-disabled hover:text-fg transition-colors ml-3">
{minimized ? '▶' : '▼'}
</span>
</div>
{!minimized && (
<div className="px-3 pb-2.5 flex flex-col gap-[5px]">
{/* 모델별 색상 */}
{Array.from(selectedModels).map((model) => (
<div key={model} className="flex items-center gap-2 text-caption text-fg-sub">
<div
className="w-[14px] h-[3px] rounded-sm"
style={{ background: MODEL_COLORS[model] }}
/>
<span className="font-korean">{model}</span>
</div>
))}
{/* 앙상블 */}
<div className="flex items-center gap-2 text-caption text-fg-sub">
<div className="w-[14px] h-[3px] rounded-sm" style={{ background: '#a855f7' }} />
<span className="font-korean"></span>
</div>
{/* 오일펜스 라인 */}
<div className="h-px bg-border my-0.5" />
<div className="flex items-center gap-2 text-caption text-fg-sub">
<div className="flex gap-px">
<div className="w-[4px] h-[4px] rounded-full bg-[#f97316]" />
<div className="w-[4px] h-[4px] rounded-full bg-[#f97316]" />
<div className="w-[4px] h-[4px] rounded-full bg-[#f97316]" />
</div>
<span className="font-korean"> </span>
</div>
{/* 도달시간별 선종 */}
<div className="h-px bg-border my-0.5" />
<div className="flex items-center gap-2 text-caption text-fg-sub">
<div className="w-[14px] h-[3px] rounded-sm bg-[#ef4444]" />
<span className="font-korean"> (&lt;6h)</span>
</div>
<div className="flex items-center gap-2 text-caption text-fg-sub">
<div className="w-[14px] h-[3px] rounded-sm bg-[#f97316]" />
<span className="font-korean"> (6~12h)</span>
</div>
<div className="flex items-center gap-2 text-caption text-fg-sub">
<div className="w-[14px] h-[3px] rounded-sm bg-[#eab308]" />
<span className="font-korean"> (12~24h)</span>
</div>
<div className="flex items-center gap-2 text-caption text-fg-sub">
<div className="w-[14px] h-[3px] rounded-sm bg-[#22c55e]" />
<span className="font-korean"></span>
</div>
</div>
)}
</div>
);
}
return null;
}
// 좌표 표시
function CoordinateDisplay({ position, zoom }: { position: [number, number]; zoom: number }) {
const [lat, lng] = position;
const latDirection = lat >= 0 ? 'N' : 'S';
const lngDirection = lng >= 0 ? 'E' : 'W';
// MapLibre 줌 → 축척 변환 (96 DPI 기준)
const metersPerPixel =
(40075016.686 * Math.cos((lat * Math.PI) / 180)) / (256 * Math.pow(2, zoom));
const scaleRatio = Math.round(metersPerPixel * (96 / 0.0254));
const scaleLabel =
scaleRatio >= 1000000
? `1:${(scaleRatio / 1000000).toFixed(1)}M`
: `1:${scaleRatio.toLocaleString()}`;
return (
<div className="cod">
<span>
{' '}
<span className="cov">
{Math.abs(lat).toFixed(4)}°{latDirection}
</span>
</span>
<span>
{' '}
<span className="cov">
{Math.abs(lng).toFixed(4)}°{lngDirection}
</span>
</span>
<span>
<span className="cov">{scaleLabel}</span>
</span>
</div>
);
}
// 기상 데이터 Mock
function getWeatherData(position: [number, number]) {
const [lat, lng] = position;
const latSeed = Math.abs(lat * 100) % 10;
const lngSeed = Math.abs(lng * 100) % 10;
const directions = ['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW'];
return {
windSpeed: Number((5 + latSeed).toFixed(1)),
windDirection: directions[Math.floor(lngSeed * 0.8)],
waveHeight: Number((1 + latSeed * 0.2).toFixed(1)),
waterTemp: Number((8 + (lngSeed - 5) * 0.5).toFixed(1)),
currentSpeed: Number((0.3 + lngSeed * 0.05).toFixed(2)),
currentDirection: directions[Math.floor(latSeed * 0.8)],
};
}
function WeatherInfoPanel({ position }: { position: [number, number] }) {
const weather = getWeatherData(position);
return (
<div className="wip">
<div className="wii">
<div className="wii-icon">💨</div>
<div className="wii-value">{weather.windSpeed} m/s</div>
<div className="wii-label"> ({weather.windDirection})</div>
</div>
<div className="wii">
<div className="wii-icon">🌊</div>
<div className="wii-value">{weather.waveHeight} m</div>
<div className="wii-label"></div>
</div>
<div className="wii">
<div className="wii-icon">🌡</div>
<div className="wii-value">{weather.waterTemp}°C</div>
<div className="wii-label"></div>
</div>
<div className="wii">
<div className="wii-icon">🔄</div>
<div className="wii-value">{weather.currentSpeed} m/s</div>
<div className="wii-label"> ({weather.currentDirection})</div>
</div>
</div>
);
}
// 역추적 리플레이 컨트롤 바 (HTML 오버레이)
function BacktrackReplayBar({
replayFrame,
totalFrames,
ships,
}: {
replayFrame: number;
totalFrames: number;
ships: ReplayShip[];
}) {
const progress = (replayFrame / totalFrames) * 100;
return (
<div
className="absolute flex items-center gap-4"
style={{
bottom: 80,
left: '50%',
transform: 'translateX(-50%)',
background: 'rgba(10,14,26,0.92)',
backdropFilter: 'blur(12px)',
border: '1px solid var(--stroke-light)',
borderRadius: '10px',
padding: '12px 18px',
zIndex: 50,
minWidth: '340px',
}}
>
<div className="text-body-2 text-color-tertiary font-mono font-bold">
{progress.toFixed(0)}%
</div>
<div className="flex-1 h-1 bg-border relative rounded-[2px]">
<div
className="h-full rounded-[2px]"
style={{
width: `${progress}%`,
background: 'linear-gradient(90deg, var(--color-tertiary), var(--color-accent))',
transition: 'width 0.05s',
}}
/>
</div>
<div className="flex gap-1.5">
{ships.map((s) => (
<div
key={s.vesselName}
className="w-2 h-2 rounded-full border border-white/30"
style={{ background: s.color }}
title={s.vesselName}
/>
))}
</div>
</div>
);
}