feat(prediction): flyTo 완료 후 자동 재생 + OpenDrift Docker 설정 추가

- MapView: flyToIncident/onIncidentFlyEnd props 추가, moveend 이벤트 후 콜백 호출
- OilSpillView: 사고 지점 변경 시 flyTo 완료 후 재생(pendingPlayRef), 동일 지점은 즉시 재생
- opendrift/config.py: STORAGE_BASE 환경변수로 스토리지 경로 설정
- opendrift/dockerfile, .dockerignore 추가
- opendrift/createKmaImage.py 제거

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
jeonghyo.k 2026-03-09 18:46:15 +09:00
부모 88eb6b121a
커밋 b601edd741
6개의 변경된 파일71개의 추가작업 그리고 106개의 파일을 삭제

파일 보기

@ -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 제어 */}
<MapPitchController threeD={mapToggles.threeD} />
{/* 사고 지점 변경 시 지도 이동 */}
<MapFlyToIncident lon={incidentCoord?.lon} lat={incidentCoord?.lat} />
<MapFlyToIncident lon={flyToIncident?.lon} lat={flyToIncident?.lat} onFlyEnd={onIncidentFlyEnd} />
{/* 외부에서 flyTo 트리거 */}
<FlyToController flyToTarget={flyToTarget} />
{/* 예측 완료 시 궤적 전체 범위로 fitBounds */}

파일 보기

@ -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<Set<string>>(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<Array<{ lat: number; lon: number; time: number; particle?: number; model?: PredictionModel }>>([])
const [centerPoints, setCenterPoints] = useState<CenterPoint[]>([])
@ -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<string, string> = {
@ -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() {
<MapView
enabledLayers={enabledLayers}
incidentCoord={incidentCoord ?? undefined}
flyToIncident={flyToCoord}
isSelectingLocation={isSelectingLocation || isDrawingBoom}
onMapClick={handleMapClick}
oilTrajectory={oilTrajectory}
@ -605,6 +621,7 @@ export function OilSpillView() {
hydrData={hydrData}
flyToTarget={flyToTarget}
fitBoundsTarget={fitBoundsTarget}
onIncidentFlyEnd={handleFlyEnd}
externalCurrentTime={oilTrajectory.length > 0 ? currentStep : undefined}
backtrackReplay={isReplayActive && replayShips.length > 0 && incidentCoord ? {
isActive: true,

파일 보기

@ -0,0 +1,2 @@
__pycache__/
result/

파일 보기

@ -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:

파일 보기

@ -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

파일 보기

@ -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