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