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 { Map, Marker, Popup, Source, Layer, useControl, useMap } from '@vis.gl/react-maplibre'
import { MapboxOverlay } from '@deck.gl/mapbox' import { MapboxOverlay } from '@deck.gl/mapbox'
import { ScatterplotLayer, PathLayer, TextLayer, BitmapLayer } from '@deck.gl/layers' import { ScatterplotLayer, PathLayer, TextLayer, BitmapLayer } from '@deck.gl/layers'
@ -187,6 +187,8 @@ interface MapViewProps {
// 외부 플레이어 제어 (prediction 하단 바에서 제어할 때 사용) // 외부 플레이어 제어 (prediction 하단 바에서 제어할 때 사용)
externalCurrentTime?: number externalCurrentTime?: number
mapCaptureRef?: React.MutableRefObject<(() => string | null) | null> mapCaptureRef?: React.MutableRefObject<(() => string | null) | null>
onIncidentFlyEnd?: () => void
flyToIncident?: { lon: number; lat: number }
} }
// deck.gl 오버레이 컴포넌트 (MapLibre 컨트롤로 등록, interleaved) // deck.gl 오버레이 컴포넌트 (MapLibre 컨트롤로 등록, interleaved)
@ -239,14 +241,17 @@ function MapPitchController({ threeD }: { threeD: boolean }) {
} }
// 사고 지점 변경 시 지도 이동 (Map 내부 컴포넌트) // 사고 지점 변경 시 지도 이동 (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 { current: map } = useMap()
const onFlyEndRef = useRef(onFlyEnd)
useEffect(() => { onFlyEndRef.current = onFlyEnd }, [onFlyEnd])
useEffect(() => { useEffect(() => {
if (!map || lon == null || lat == null) return if (!map || lon == null || lat == null) return
const doFly = () => { 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()) { if (map.loaded()) {
@ -304,6 +309,8 @@ export function MapView({
hydrData = [], hydrData = [],
externalCurrentTime, externalCurrentTime,
mapCaptureRef, mapCaptureRef,
onIncidentFlyEnd,
flyToIncident,
}: MapViewProps) { }: MapViewProps) {
const { mapToggles } = useMapStore() const { mapToggles } = useMapStore()
const isControlled = externalCurrentTime !== undefined const isControlled = externalCurrentTime !== undefined
@ -847,7 +854,7 @@ export function MapView({
{/* 3D 모드 pitch 제어 */} {/* 3D 모드 pitch 제어 */}
<MapPitchController threeD={mapToggles.threeD} /> <MapPitchController threeD={mapToggles.threeD} />
{/* 사고 지점 변경 시 지도 이동 */} {/* 사고 지점 변경 시 지도 이동 */}
<MapFlyToIncident lon={incidentCoord?.lon} lat={incidentCoord?.lat} /> <MapFlyToIncident lon={flyToIncident?.lon} lat={flyToIncident?.lat} onFlyEnd={onIncidentFlyEnd} />
{/* 외부에서 flyTo 트리거 */} {/* 외부에서 flyTo 트리거 */}
<FlyToController flyToTarget={flyToTarget} /> <FlyToController flyToTarget={flyToTarget} />
{/* 예측 완료 시 궤적 전체 범위로 fitBounds */} {/* 예측 완료 시 궤적 전체 범위로 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 { LeftPanel } from './LeftPanel'
import { RightPanel } from './RightPanel' import { RightPanel } from './RightPanel'
import { MapView } from '@common/components/map/MapView' import { MapView } from '@common/components/map/MapView'
@ -104,8 +104,9 @@ export function OilSpillView() {
const { activeSubTab, setActiveSubTab } = useSubMenu('prediction') const { activeSubTab, setActiveSubTab } = useSubMenu('prediction')
const [enabledLayers, setEnabledLayers] = useState<Set<string>>(new Set()) const [enabledLayers, setEnabledLayers] = useState<Set<string>>(new Set())
const [incidentCoord, setIncidentCoord] = useState<{ lon: number; lat: number } | null>(null) const [incidentCoord, setIncidentCoord] = useState<{ lon: number; lat: number } | null>(null)
const [flyToTarget, setFlyToTarget] = useState<{ lng: number; lat: number; zoom?: number } | null>(null) const [flyToCoord, setFlyToCoord] = useState<{ lon: number; lat: number } | undefined>(undefined)
const [fitBoundsTarget, setFitBoundsTarget] = useState<{ north: number; south: number; east: number; west: number } | null>(null) const flyToTarget = null
const fitBoundsTarget = null
const [isSelectingLocation, setIsSelectingLocation] = useState(false) const [isSelectingLocation, setIsSelectingLocation] = useState(false)
const [oilTrajectory, setOilTrajectory] = useState<Array<{ lat: number; lon: number; time: number; particle?: number; model?: PredictionModel }>>([]) const [oilTrajectory, setOilTrajectory] = useState<Array<{ lat: number; lon: number; time: number; particle?: number; model?: PredictionModel }>>([])
const [centerPoints, setCenterPoints] = useState<CenterPoint[]>([]) const [centerPoints, setCenterPoints] = useState<CenterPoint[]>([])
@ -305,6 +306,16 @@ export function OilSpillView() {
return () => clearInterval(interval) return () => clearInterval(interval)
}, [isReplayPlaying, replaySpeed]) }, [isReplayPlaying, replaySpeed])
// flyTo 완료 후 재생 대기 플래그
const pendingPlayRef = useRef(false)
const handleFlyEnd = useCallback(() => {
setFlyToCoord(undefined)
if (pendingPlayRef.current) {
pendingPlayRef.current = false
setIsPlaying(true)
}
}, [])
// 시뮬레이션 폴링 결과 처리 // 시뮬레이션 폴링 결과 처리
useEffect(() => { useEffect(() => {
if (!simStatus) return; if (!simStatus) return;
@ -323,18 +334,9 @@ export function OilSpillView() {
setBoomLines(booms); setBoomLines(booms);
} }
setSensitiveResources(DEMO_SENSITIVE_RESOURCES); setSensitiveResources(DEMO_SENSITIVE_RESOURCES);
// 예측 완료 시 궤적 전체가 보이도록 지도 fitBounds // 새 시뮬레이션 완료 시 flyTo 없으므로 즉시 재생
const particles = simStatus.trajectory; setCurrentStep(0);
if (particles.length > 0) { setIsPlaying(true);
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),
});
}
} }
if (simStatus.status === 'ERROR') { if (simStatus.status === 'ERROR') {
setIsRunningSimulation(false); setIsRunningSimulation(false);
@ -342,12 +344,11 @@ export function OilSpillView() {
} }
}, [simStatus, incidentCoord, algorithmSettings]); }, [simStatus, incidentCoord, algorithmSettings]);
// trajectory 변경 시 플레이어 초기화 및 자동 재생 // trajectory 변경 시 플레이어 스텝 초기화 (재생은 각 경로에서 별도 처리)
useEffect(() => { useEffect(() => {
if (oilTrajectory.length > 0) { if (oilTrajectory.length > 0) {
// eslint-disable-next-line react-hooks/set-state-in-effect // eslint-disable-next-line react-hooks/set-state-in-effect
setCurrentStep(0); setCurrentStep(0);
setIsPlaying(true);
} }
}, [oilTrajectory.length]); }, [oilTrajectory.length]);
@ -383,6 +384,8 @@ export function OilSpillView() {
// 분석 목록에서 사고명 클릭 시 // 분석 목록에서 사고명 클릭 시
const handleSelectAnalysis = async (analysis: Analysis) => { const handleSelectAnalysis = async (analysis: Analysis) => {
setIsPlaying(false)
setCurrentStep(0)
setSelectedAnalysis(analysis) setSelectedAnalysis(analysis)
setCenterPoints([]) setCenterPoints([])
if (analysis.occurredAt) { if (analysis.occurredAt) {
@ -390,7 +393,7 @@ export function OilSpillView() {
} }
if (analysis.lon != null && analysis.lat != null) { if (analysis.lon != null && analysis.lat != null) {
setIncidentCoord({ lon: analysis.lon, lat: analysis.lat }) 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> = { const oilTypeMap: Record<string, string> = {
@ -433,6 +436,12 @@ export function OilSpillView() {
const booms = generateAIBoomLines(trajectory, coord, algorithmSettings) const booms = generateAIBoomLines(trajectory, coord, algorithmSettings)
setBoomLines(booms) setBoomLines(booms)
setSensitiveResources(DEMO_SENSITIVE_RESOURCES) setSensitiveResources(DEMO_SENSITIVE_RESOURCES)
// incidentCoord가 변경된 경우 flyTo 완료 후 재생, 그렇지 않으면 즉시 재생
if (analysis.lon !== incidentCoord?.lon || analysis.lat !== incidentCoord?.lat) {
pendingPlayRef.current = true
} else {
setIsPlaying(true)
}
return return
} }
} catch (err) { } catch (err) {
@ -446,6 +455,12 @@ export function OilSpillView() {
const demoBooms = generateAIBoomLines(demoTrajectory, coord, algorithmSettings) const demoBooms = generateAIBoomLines(demoTrajectory, coord, algorithmSettings)
setBoomLines(demoBooms) setBoomLines(demoBooms)
setSensitiveResources(DEMO_SENSITIVE_RESOURCES) 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) => { const handleMapClick = (lon: number, lat: number) => {
@ -590,6 +605,7 @@ export function OilSpillView() {
<MapView <MapView
enabledLayers={enabledLayers} enabledLayers={enabledLayers}
incidentCoord={incidentCoord ?? undefined} incidentCoord={incidentCoord ?? undefined}
flyToIncident={flyToCoord}
isSelectingLocation={isSelectingLocation || isDrawingBoom} isSelectingLocation={isSelectingLocation || isDrawingBoom}
onMapClick={handleMapClick} onMapClick={handleMapClick}
oilTrajectory={oilTrajectory} oilTrajectory={oilTrajectory}
@ -605,6 +621,7 @@ export function OilSpillView() {
hydrData={hydrData} hydrData={hydrData}
flyToTarget={flyToTarget} flyToTarget={flyToTarget}
fitBoundsTarget={fitBoundsTarget} fitBoundsTarget={fitBoundsTarget}
onIncidentFlyEnd={handleFlyEnd}
externalCurrentTime={oilTrajectory.length > 0 ? currentStep : undefined} externalCurrentTime={oilTrajectory.length > 0 ? currentStep : undefined}
backtrackReplay={isReplayActive && replayShips.length > 0 && incidentCoord ? { backtrackReplay={isReplayActive && replayShips.length > 0 && incidentCoord ? {
isActive: true, isActive: true,

파일 보기

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

파일 보기

@ -5,11 +5,12 @@ config.py
모든 경로, 좌표 범위, 시뮬레이션 상수를 곳에서 관리합니다. 모든 경로, 좌표 범위, 시뮬레이션 상수를 곳에서 관리합니다.
""" """
import os
from pathlib import Path from pathlib import Path
from dataclasses import dataclass from dataclasses import dataclass
from typing import Tuple from typing import Tuple
_BASE_STR = "C:/upload" _BASE_STR = os.getenv("STORAGE_BASE", "C:/upload")
@dataclass(frozen=True) @dataclass(frozen=True)
class StoragePaths: 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