(null)
const handleMapClick = useCallback((e: MapLayerMouseEvent) => {
const { lng, lat } = e.lngLat
setCurrentPosition([lat, lng])
if (onMapClick) {
onMapClick(lng, lat)
}
setPopupInfo(null)
}, [onMapClick])
// 애니메이션 재생 로직
useEffect(() => {
if (!isPlaying || oilTrajectory.length === 0) return
const maxTime = Math.max(...oilTrajectory.map(p => p.time))
if (currentTime >= maxTime) {
setIsPlaying(false)
return
}
const interval = setInterval(() => {
setCurrentTime(prev => {
const next = prev + (1 * playbackSpeed)
return next > maxTime ? maxTime : next
})
}, 200)
return () => clearInterval(interval)
}, [isPlaying, currentTime, playbackSpeed, oilTrajectory])
// 시뮬레이션 시작 시 자동으로 애니메이션 재생
useEffect(() => {
if (oilTrajectory.length > 0) {
setCurrentTime(0)
setIsPlaying(true)
}
}, [oilTrajectory.length])
// WMS 레이어 목록
const wmsLayers = useMemo(() => {
return Array.from(enabledLayers)
.map(layerId => {
const layer = layerDatabase.find(l => l.id === layerId)
return layer?.wmsLayer ? { id: layerId, wmsLayer: layer.wmsLayer } : null
})
.filter((l): l is { id: string; wmsLayer: string } => l !== null)
}, [enabledLayers])
// WMS 밝기 값 (MapLibre raster paint)
const wmsBrightnessMax = Math.min(layerBrightness / 50, 2)
const wmsBrightnessMin = wmsBrightnessMax > 1 ? wmsBrightnessMax - 1 : 0
const wmsOpacity = layerOpacity / 100
// deck.gl 레이어 구축
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const deckLayers = useMemo((): any[] => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const result: any[] = []
// --- 유류 확산 입자 (ScatterplotLayer) ---
const visibleParticles = oilTrajectory.filter(p => p.time <= currentTime)
if (visibleParticles.length > 0) {
result.push(
new ScatterplotLayer({
id: 'oil-particles',
data: visibleParticles,
getPosition: (d: (typeof visibleParticles)[0]) => [d.lon, d.lat],
getRadius: 3,
getFillColor: (d: (typeof visibleParticles)[0]) => {
const modelKey = d.model || Array.from(selectedModels)[0] || 'OpenDrift'
return hexToRgba(MODEL_COLORS[modelKey] || '#3b82f6', 180)
},
radiusMinPixels: 2.5,
radiusMaxPixels: 5,
pickable: true,
onClick: (info: PickingInfo) => {
if (info.object) {
const d = info.object as (typeof visibleParticles)[0]
const modelKey = d.model || Array.from(selectedModels)[0] || 'OpenDrift'
setPopupInfo({
longitude: d.lon,
latitude: d.lat,
content: (
{modelKey} 입자 #{(d.particle ?? 0) + 1}
시간: +{d.time}h
위치: {d.lat.toFixed(4)}°, {d.lon.toFixed(4)}°
),
})
}
},
updateTriggers: {
getFillColor: [selectedModels],
},
})
)
}
// --- 오일펜스 라인 (PathLayer) ---
if (boomLines.length > 0) {
result.push(
new PathLayer({
id: 'boom-lines',
data: boomLines,
getPath: (d: BoomLine) => d.coords.map(c => [c.lon, c.lat] as [number, number]),
getColor: (d: BoomLine) => hexToRgba(PRIORITY_COLORS[d.priority] || '#f59e0b', 230),
getWidth: (d: BoomLine) => PRIORITY_WEIGHTS[d.priority] || 2,
getDashArray: (d: BoomLine) => d.status === 'PLANNED' ? [10, 5] : null,
dashJustified: true,
widthMinPixels: 2,
widthMaxPixels: 6,
pickable: true,
onClick: (info: PickingInfo) => {
if (info.object) {
const d = info.object as BoomLine
setPopupInfo({
longitude: info.coordinate?.[0] ?? 0,
latitude: info.coordinate?.[1] ?? 0,
content: (
{d.name}
우선순위: {PRIORITY_LABELS[d.priority] || d.priority}
길이: {d.length.toFixed(0)}m
각도: {d.angle.toFixed(0)}°
차단 효율: {d.efficiency}%
),
})
}
},
})
)
// 오일펜스 끝점 마커
const endpoints: Array<{ position: [number, number]; color: [number, number, number, number] }> = []
boomLines.forEach(line => {
if (line.coords.length >= 2) {
const c = hexToRgba(PRIORITY_COLORS[line.priority] || '#f59e0b', 230)
endpoints.push({ position: [line.coords[0].lon, line.coords[0].lat], color: c })
endpoints.push({ position: [line.coords[line.coords.length - 1].lon, line.coords[line.coords.length - 1].lat], color: c })
}
})
if (endpoints.length > 0) {
result.push(
new ScatterplotLayer({
id: 'boom-endpoints',
data: endpoints,
getPosition: (d: (typeof endpoints)[0]) => d.position,
getRadius: 5,
getFillColor: (d: (typeof endpoints)[0]) => d.color,
getLineColor: [255, 255, 255, 255],
getLineWidth: 2,
stroked: true,
radiusMinPixels: 5,
radiusMaxPixels: 8,
})
)
}
}
// --- 드로잉 미리보기 ---
if (isDrawingBoom && drawingPoints.length > 0) {
result.push(
new PathLayer({
id: 'drawing-preview',
data: [{ path: drawingPoints.map(c => [c.lon, c.lat] as [number, number]) }],
getPath: (d: { path: [number, number][] }) => d.path,
getColor: [245, 158, 11, 200],
getWidth: 3,
getDashArray: [10, 6],
dashJustified: true,
widthMinPixels: 3,
})
)
result.push(
new ScatterplotLayer({
id: 'drawing-points',
data: drawingPoints.map(c => ({ position: [c.lon, c.lat] as [number, number] })),
getPosition: (d: { position: [number, number] }) => d.position,
getRadius: 4,
getFillColor: [245, 158, 11, 255],
getLineColor: [255, 255, 255, 255],
getLineWidth: 2,
stroked: true,
radiusMinPixels: 4,
radiusMaxPixels: 6,
})
)
}
// --- HNS 대기확산 구역 (ScatterplotLayer, meters 단위) ---
if (dispersionResult && incidentCoord) {
const zones = dispersionResult.zones.map((zone, idx) => ({
position: [incidentCoord.lon, incidentCoord.lat] as [number, number],
radius: zone.radius,
fillColor: hexToRgba(zone.color, 100),
lineColor: hexToRgba(zone.color, 180),
level: zone.level,
idx,
}))
result.push(
new ScatterplotLayer({
id: 'hns-zones',
data: zones,
getPosition: (d: (typeof zones)[0]) => d.position,
getRadius: (d: (typeof zones)[0]) => d.radius,
getFillColor: (d: (typeof zones)[0]) => d.fillColor,
getLineColor: (d: (typeof zones)[0]) => d.lineColor,
getLineWidth: 2,
stroked: true,
radiusUnits: 'meters' as const,
pickable: true,
onClick: (info: PickingInfo) => {
if (info.object) {
const d = info.object as (typeof zones)[0]
setPopupInfo({
longitude: incidentCoord.lon,
latitude: incidentCoord.lat,
content: (
{d.level}
물질: {dispersionResult.substance}
농도: {dispersionResult.concentration[d.level]}
반경: {d.radius}m
),
})
}
},
})
)
}
// --- 역추적 리플레이 ---
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: (
{SENSITIVE_ICONS[d.type]}
{d.name}
반경: {d.radiusM}m
도달 예상: {d.arrivalTimeH}h
),
})
}
},
})
)
// 민감자원 중심 마커
result.push(
new ScatterplotLayer({
id: 'sensitive-centers',
data: sensitiveResources,
getPosition: (d: SensitiveResource) => [d.lon, d.lat],
getRadius: 6,
getFillColor: (d: SensitiveResource) => hexToRgba(SENSITIVE_COLORS[d.type] || '#22c55e', 220),
getLineColor: [255, 255, 255, 200],
getLineWidth: 2,
stroked: true,
radiusMinPixels: 6,
radiusMaxPixels: 10,
})
)
// 민감자원 라벨
result.push(
new TextLayer({
id: 'sensitive-labels',
data: sensitiveResources,
getPosition: (d: SensitiveResource) => [d.lon, d.lat],
getText: (d: SensitiveResource) => `${SENSITIVE_ICONS[d.type]} ${d.name} (${d.arrivalTimeH}h)`,
getSize: 12,
getColor: [255, 255, 255, 200],
getPixelOffset: [0, -20],
fontFamily: 'NanumSquare, Malgun Gothic, 맑은 고딕, sans-serif',
fontWeight: 'bold',
characterSet: 'auto',
outlineWidth: 2,
outlineColor: [15, 21, 36, 200],
billboard: true,
sizeUnits: 'pixels' as const,
})
)
}
// --- 해류 화살표 (TextLayer) ---
if (incidentCoord) {
const currentArrows: Array<{ lon: number; lat: number; bearing: number; speed: number }> = []
const gridSize = 5
const spacing = 0.04 // 약 4km 간격
const mainBearing = 200 // SSW 방향 (도)
for (let row = -gridSize; row <= gridSize; row++) {
for (let col = -gridSize; col <= gridSize; col++) {
const lat = incidentCoord.lat + row * spacing
const lon = incidentCoord.lon + col * spacing / Math.cos(incidentCoord.lat * Math.PI / 180)
// 사고 지점에서 멀어질수록 해류 방향 약간 변화
const distFactor = Math.sqrt(row * row + col * col) / gridSize
const localBearing = mainBearing + (col * 3) + (row * 2)
const speed = 0.3 + (1 - distFactor) * 0.2
currentArrows.push({ lon, lat, bearing: localBearing, speed })
}
}
result.push(
new TextLayer({
id: 'current-arrows',
data: currentArrows,
getPosition: (d: (typeof currentArrows)[0]) => [d.lon, d.lat],
getText: () => '➤',
getAngle: (d: (typeof currentArrows)[0]) => -d.bearing + 90,
getSize: 22,
getColor: [6, 182, 212, 100],
characterSet: 'auto',
sizeUnits: 'pixels' as const,
billboard: true,
})
)
}
return result
}, [
oilTrajectory, currentTime, selectedModels,
boomLines, isDrawingBoom, drawingPoints,
dispersionResult, incidentCoord, backtrackReplay,
sensitiveResources,
])
return (
{/* 드로잉 모드 안내 */}
{isDrawingBoom && (
오일펜스 배치 모드 — 지도를 클릭하여 포인트를 추가하세요 ({drawingPoints.length}개 포인트)
)}
{/* 기상청 연계 정보 */}
{/* 범례 */}
{/* 좌표 표시 */}
{/* 타임라인 컨트롤 */}
{oilTrajectory.length > 0 && (
p.time))}
isPlaying={isPlaying}
playbackSpeed={playbackSpeed}
onTimeChange={setCurrentTime}
onPlayPause={() => setIsPlaying(!isPlaying)}
onSpeedChange={setPlaybackSpeed}
/>
)}
{/* 역추적 리플레이 바 */}
{backtrackReplay?.isActive && (
)}
)
}
// 지도 컨트롤 (줌, 위치 초기화)
function MapControls({ center, zoom }: { center: [number, number]; zoom: number }) {
const { current: map } = useMap()
return (
)
}
// 지도 범례
interface MapLegendProps {
dispersionResult?: DispersionResult | null
incidentCoord?: { lon: number; lat: number }
oilTrajectory?: Array<{ lat: number; lon: number; time: number }>
boomLines?: BoomLine[]
selectedModels?: Set
}
function MapLegend({ dispersionResult, incidentCoord, oilTrajectory = [], boomLines = [], selectedModels = new Set(['OpenDrift'] as PredictionModel[]) }: MapLegendProps) {
if (dispersionResult && incidentCoord) {
return (
📍
사고 위치
{incidentCoord.lat.toFixed(4)}°N, {incidentCoord.lon.toFixed(4)}°E
물질
{dispersionResult.substance}
풍향
SW {dispersionResult.windDirection}°
확산 구역
{dispersionResult.zones.length}개
)
}
if (oilTrajectory.length > 0) {
return (
범례
{Array.from(selectedModels).map(model => (
))}
{selectedModels.size === 3 && (
(앙상블 모드)
)}
{boomLines.length > 0 && (
<>
>
)}
)
}
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 (
위도 {Math.abs(lat).toFixed(4)}°{latDirection}
경도 {Math.abs(lng).toFixed(4)}°{lngDirection}
축척 1:50,000
)
}
// 타임라인 컨트롤
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) => {
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 (
⏮
◀
{isPlaying ? '⏸' : '▶'}
▶▶
⏭
{playbackSpeed}×
{timeLabels.map(t => (
{t}h
))}
{timeLabels.map(t => (
))}
{/* eslint-disable-next-line react-hooks/purity */}
+{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
진행률{progressPercent.toFixed(0)}%
속도{playbackSpeed}×
시간{currentTime.toFixed(0)}/{maxTime}h
)
}
// 기상 데이터 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 (
💨
{weather.windSpeed} m/s
풍속 ({weather.windDirection})
🌊
{weather.waveHeight} m
파고
🌡
{weather.waterTemp}°C
수온
🔄
{weather.currentSpeed} m/s
해류 ({weather.currentDirection})
)
}
// 역추적 리플레이 컨트롤 바 (HTML 오버레이)
function BacktrackReplayBar({ replayFrame, totalFrames, ships }: { replayFrame: number; totalFrames: number; ships: ReplayShip[] }) {
const progress = (replayFrame / totalFrames) * 100
return (
)
}