release: 2026-03-11.2 (12건 커밋) #85
@ -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,
|
||||
|
||||
2
prediction/opendrift/.dockerignore
Normal file
2
prediction/opendrift/.dockerignore
Normal file
@ -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
|
||||
|
||||
21
prediction/opendrift/dockerfile
Normal file
21
prediction/opendrift/dockerfile
Normal file
@ -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
|
||||
불러오는 중...
Reference in New Issue
Block a user