diff --git a/frontend/src/common/components/map/MapView.tsx b/frontend/src/common/components/map/MapView.tsx
index ed23781..13b2dcc 100755
--- a/frontend/src/common/components/map/MapView.tsx
+++ b/frontend/src/common/components/map/MapView.tsx
@@ -1,4 +1,4 @@
-import { useState, useMemo, useEffect, useCallback } from 'react'
+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 } from '@deck.gl/layers'
@@ -187,6 +187,8 @@ interface MapViewProps {
// 외부 플레이어 제어 (prediction 하단 바에서 제어할 때 사용)
externalCurrentTime?: number
mapCaptureRef?: React.MutableRefObject<(() => string | null) | null>
+ onIncidentFlyEnd?: () => void
+ flyToIncident?: { lon: number; lat: number }
}
// deck.gl 오버레이 컴포넌트 (MapLibre 컨트롤로 등록, interleaved)
@@ -239,14 +241,17 @@ function MapPitchController({ threeD }: { threeD: boolean }) {
}
// 사고 지점 변경 시 지도 이동 (Map 내부 컴포넌트)
-function MapFlyToIncident({ lon, lat }: { lon?: number; lat?: number }) {
+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: 12, duration: 1200 })
+ map.flyTo({ center: [lon, lat], zoom: 11, duration: 1200 })
+ map.once('moveend', () => onFlyEndRef.current?.())
}
if (map.loaded()) {
@@ -304,6 +309,8 @@ export function MapView({
hydrData = [],
externalCurrentTime,
mapCaptureRef,
+ onIncidentFlyEnd,
+ flyToIncident,
}: MapViewProps) {
const { mapToggles } = useMapStore()
const isControlled = externalCurrentTime !== undefined
@@ -847,7 +854,7 @@ export function MapView({
{/* 3D 모드 pitch 제어 */}
{/* 사고 지점 변경 시 지도 이동 */}
-
+
{/* 외부에서 flyTo 트리거 */}
{/* 예측 완료 시 궤적 전체 범위로 fitBounds */}
diff --git a/frontend/src/tabs/prediction/components/OilSpillView.tsx b/frontend/src/tabs/prediction/components/OilSpillView.tsx
index e81fe19..08133b5 100755
--- a/frontend/src/tabs/prediction/components/OilSpillView.tsx
+++ b/frontend/src/tabs/prediction/components/OilSpillView.tsx
@@ -1,4 +1,4 @@
-import { useState, useEffect, useCallback, useMemo } from 'react'
+import { useState, useEffect, useCallback, useMemo, useRef } from 'react'
import { LeftPanel } from './LeftPanel'
import { RightPanel } from './RightPanel'
import { MapView } from '@common/components/map/MapView'
@@ -104,8 +104,9 @@ export function OilSpillView() {
const { activeSubTab, setActiveSubTab } = useSubMenu('prediction')
const [enabledLayers, setEnabledLayers] = useState>(new Set())
const [incidentCoord, setIncidentCoord] = useState<{ lon: number; lat: number } | null>(null)
- const [flyToTarget, setFlyToTarget] = useState<{ lng: number; lat: number; zoom?: number } | null>(null)
- const [fitBoundsTarget, setFitBoundsTarget] = useState<{ north: number; south: number; east: number; west: number } | null>(null)
+ const [flyToCoord, setFlyToCoord] = useState<{ lon: number; lat: number } | undefined>(undefined)
+ const flyToTarget = null
+ const fitBoundsTarget = null
const [isSelectingLocation, setIsSelectingLocation] = useState(false)
const [oilTrajectory, setOilTrajectory] = useState>([])
const [centerPoints, setCenterPoints] = useState([])
@@ -305,6 +306,16 @@ export function OilSpillView() {
return () => clearInterval(interval)
}, [isReplayPlaying, replaySpeed])
+ // flyTo 완료 후 재생 대기 플래그
+ const pendingPlayRef = useRef(false)
+ const handleFlyEnd = useCallback(() => {
+ setFlyToCoord(undefined)
+ if (pendingPlayRef.current) {
+ pendingPlayRef.current = false
+ setIsPlaying(true)
+ }
+ }, [])
+
// 시뮬레이션 폴링 결과 처리
useEffect(() => {
if (!simStatus) return;
@@ -323,18 +334,9 @@ export function OilSpillView() {
setBoomLines(booms);
}
setSensitiveResources(DEMO_SENSITIVE_RESOURCES);
- // 예측 완료 시 궤적 전체가 보이도록 지도 fitBounds
- const particles = simStatus.trajectory;
- if (particles.length > 0) {
- const lats = particles.map(p => p.lat);
- const lons = particles.map(p => p.lon);
- setFitBoundsTarget({
- north: Math.max(...lats),
- south: Math.min(...lats),
- east: Math.max(...lons),
- west: Math.min(...lons),
- });
- }
+ // 새 시뮬레이션 완료 시 flyTo 없으므로 즉시 재생
+ setCurrentStep(0);
+ setIsPlaying(true);
}
if (simStatus.status === 'ERROR') {
setIsRunningSimulation(false);
@@ -342,12 +344,11 @@ export function OilSpillView() {
}
}, [simStatus, incidentCoord, algorithmSettings]);
- // trajectory 변경 시 플레이어 초기화 및 자동 재생
+ // trajectory 변경 시 플레이어 스텝 초기화 (재생은 각 경로에서 별도 처리)
useEffect(() => {
if (oilTrajectory.length > 0) {
// eslint-disable-next-line react-hooks/set-state-in-effect
setCurrentStep(0);
- setIsPlaying(true);
}
}, [oilTrajectory.length]);
@@ -383,6 +384,8 @@ export function OilSpillView() {
// 분석 목록에서 사고명 클릭 시
const handleSelectAnalysis = async (analysis: Analysis) => {
+ setIsPlaying(false)
+ setCurrentStep(0)
setSelectedAnalysis(analysis)
setCenterPoints([])
if (analysis.occurredAt) {
@@ -390,7 +393,7 @@ export function OilSpillView() {
}
if (analysis.lon != null && analysis.lat != null) {
setIncidentCoord({ lon: analysis.lon, lat: analysis.lat })
- setFlyToTarget({ lng: analysis.lon, lat: analysis.lat, zoom: 11 })
+ setFlyToCoord({ lon: analysis.lon, lat: analysis.lat })
}
// 유종 매핑
const oilTypeMap: Record = {
@@ -433,6 +436,12 @@ export function OilSpillView() {
const booms = generateAIBoomLines(trajectory, coord, algorithmSettings)
setBoomLines(booms)
setSensitiveResources(DEMO_SENSITIVE_RESOURCES)
+ // incidentCoord가 변경된 경우 flyTo 완료 후 재생, 그렇지 않으면 즉시 재생
+ if (analysis.lon !== incidentCoord?.lon || analysis.lat !== incidentCoord?.lat) {
+ pendingPlayRef.current = true
+ } else {
+ setIsPlaying(true)
+ }
return
}
} catch (err) {
@@ -446,6 +455,12 @@ export function OilSpillView() {
const demoBooms = generateAIBoomLines(demoTrajectory, coord, algorithmSettings)
setBoomLines(demoBooms)
setSensitiveResources(DEMO_SENSITIVE_RESOURCES)
+ // incidentCoord가 변경된 경우 flyTo 완료 후 재생, 그렇지 않으면 즉시 재생
+ if (analysis.lon !== incidentCoord?.lon || analysis.lat !== incidentCoord?.lat) {
+ pendingPlayRef.current = true
+ } else {
+ setIsPlaying(true)
+ }
}
const handleMapClick = (lon: number, lat: number) => {
@@ -590,6 +605,7 @@ export function OilSpillView() {
0 ? currentStep : undefined}
backtrackReplay={isReplayActive && replayShips.length > 0 && incidentCoord ? {
isActive: true,
diff --git a/prediction/opendrift/.dockerignore b/prediction/opendrift/.dockerignore
new file mode 100644
index 0000000..0ba08e2
--- /dev/null
+++ b/prediction/opendrift/.dockerignore
@@ -0,0 +1,2 @@
+__pycache__/
+result/
\ No newline at end of file
diff --git a/prediction/opendrift/config.py b/prediction/opendrift/config.py
index 1a3cec5..1cd9ec2 100644
--- a/prediction/opendrift/config.py
+++ b/prediction/opendrift/config.py
@@ -5,11 +5,12 @@ config.py
모든 경로, 좌표 범위, 시뮬레이션 상수를 한 곳에서 관리합니다.
"""
+import os
from pathlib import Path
from dataclasses import dataclass
from typing import Tuple
-_BASE_STR = "C:/upload"
+_BASE_STR = os.getenv("STORAGE_BASE", "C:/upload")
@dataclass(frozen=True)
class StoragePaths:
diff --git a/prediction/opendrift/createKmaImage.py b/prediction/opendrift/createKmaImage.py
deleted file mode 100644
index 4440591..0000000
--- a/prediction/opendrift/createKmaImage.py
+++ /dev/null
@@ -1,83 +0,0 @@
-from PIL import Image
-import base64
-from io import BytesIO
-
-def crop_and_encode_geographic_kma_image(
- image_path: str,
- type: str
-) -> str:
- """
- 지정된 PNG 이미지에서 특정 위경도 중심을 기준으로
- 주변 crop_radius_km 영역을 잘라내고 Base64 문자열로 인코딩합니다.
-
- :param image_path: 입력 PNG 파일 경로.
- :param image_bounds: 이미지 전체가 나타내는 영역의 (min_lon, min_lat, max_lon, max_lat)
- 좌표. (경도 최소, 위도 최소, 경도 최대, 위도 최대)
- :param center_point: 자르기 영역의 중심이 될 (lon, lat) 좌표.
- :param crop_radius_km: 중심에서 상하좌우로 자를 거리 (km).
- :return: 잘린 이미지의 PNG Base64 문자열.
- """
-
- if type == 'wind':
- full_image_bounds = (78.0993797274984161,12.1585396012363489, 173.8566763130032484,61.1726557651764793)
- elif type == 'hydr':
- full_image_bounds = (101.57732, 12.21703, 155.62642, 57.32893)
- image_bounds = (121.8399658203125, 32.2400016784668, 131.679931640625, 42.79999923706055)
-
- TARGET_WIDTH = 50
- TARGET_HEIGHT = 90
-
- # 1. 이미지 로드
- try:
- img = Image.open(image_path)
- except FileNotFoundError:
- return f"Error: File not found at {image_path}"
- except Exception as e:
- return f"Error opening image: {e}"
-
- width, height = img.size
-
- full_min_lon, full_min_lat, full_max_lon, full_max_lat = full_image_bounds
- full_deg_lat_span = full_max_lat - full_min_lat
- full_deg_lon_span = full_max_lon - full_min_lon
-
- min_lon, min_lat, max_lon, max_lat = image_bounds
-
- def lon_to_pixel_x(lon):
- return int(width * (lon - full_min_lon) / full_deg_lon_span)
-
- def lat_to_pixel_y(lat):
- # Y축은 위도에 반비례 (큰 위도가 작은 Y 픽셀)
- return int(height * (full_max_lat - lat) / full_deg_lat_span)
-
- # 자를 영역의 픽셀 좌표 계산
- pixel_x_min = max(0, lon_to_pixel_x(min_lon))
- pixel_y_min = max(0, lat_to_pixel_y(max_lat)) # 위도 최대가 y_min (상단)
- pixel_x_max = min(width, lon_to_pixel_x(max_lon))
- pixel_y_max = min(height, lat_to_pixel_y(min_lat)) # 위도 최소가 y_max (하단)
-
- # PIL의 crop 함수는 (left, top, right, bottom) 순서의 픽셀 좌표를 사용
- crop_box = (pixel_x_min, pixel_y_min, pixel_x_max, pixel_y_max)
-
- # 4. 이미지 자르기
- if pixel_x_min >= pixel_x_max or pixel_y_min >= pixel_y_max:
- return "Error: Crop area is outside the image bounds or zero size."
-
- cropped_img = img.crop(crop_box)
-
- if cropped_img.size != (TARGET_WIDTH, TARGET_HEIGHT):
- cropped_img = cropped_img.resize(
- (TARGET_WIDTH, TARGET_HEIGHT),
- Image.LANCZOS
- )
-
- # 5. Base64 문자열로 인코딩
- buffer = BytesIO()
- cropped_img.save(buffer, format="PNG")
- base64_encoded_data = base64.b64encode(buffer.getvalue()).decode("utf-8")
-
- # Base64 문자열 앞에 MIME 타입 정보 추가
- # base64_string = f"data:image/png;base64,{base64_encoded_data}"
-
- return base64_encoded_data
-
diff --git a/prediction/opendrift/dockerfile b/prediction/opendrift/dockerfile
new file mode 100644
index 0000000..ec4c377
--- /dev/null
+++ b/prediction/opendrift/dockerfile
@@ -0,0 +1,21 @@
+FROM opendrift/opendrift:latest
+
+WORKDIR /app
+
+# gunicorn 설치 추가
+RUN pip install fastapi uvicorn gunicorn
+# 결과 데이터 저장 폴더 추가
+RUN mkdir -p /app/result
+
+COPY . .
+
+EXPOSE 5003
+
+# gunicorn으로 실행
+CMD ["gunicorn", "api:app", "-w", "4", "-k", "uvicorn.workers.UvicornWorker", "--bind", "0.0.0.0:5003"]
+
+## 빌드 명령어
+# docker build -t opendrift-api .
+
+## 실행 명령어 (로컬 데이터 폴더를 컨테이너의 /storage로 마운트)
+# docker run -d -p 5003:5003 -v /devdata/services/prediction/data:/storage -e STORAGE_BASE=/storage --name opendrift-api opendrift-api