From 3a224ea64953758590c25b078ce52e37c08c874c Mon Sep 17 00:00:00 2001 From: "jeonghyo.k" Date: Fri, 27 Mar 2026 17:34:28 +0900 Subject: [PATCH] =?UTF-8?q?feat(prediction):=20=EC=97=AD=EC=B6=94=EC=A0=81?= =?UTF-8?q?=20=EB=A6=AC=ED=94=8C=EB=A0=88=EC=9D=B4=20=EC=97=AD=EB=B0=A9?= =?UTF-8?q?=ED=96=A5=20=EC=98=88=EC=B8=A1=20=ED=8C=8C=ED=8B=B0=ED=81=B4=20?= =?UTF-8?q?=ED=91=9C=EC=8B=9C=20=EB=B0=8F=20=ED=94=8C=EB=A0=88=EC=9D=B4?= =?UTF-8?q?=EC=96=B4=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Python 역방향 시뮬레이션 결과(backwardParticles)를 rsltData에 저장 - 리플레이 중 역방향 파티클을 보라색(#a855f7) ScatterplotLayer로 표시 - 전체 파티클 경로 외각을 컨벡스 헐 PolygonLayer로 표시 - 재생 완료 후 재생 버튼 클릭 시 처음부터 재시작 (↺ 아이콘) - 재생 바 드래그 시크 기능 추가 (onMouseDown + document mousemove/mouseup) --- .../prediction/backtrackAnalysisService.ts | 60 +++++++++------- .../components/map/BacktrackReplayBar.tsx | 66 ++++++++++++++--- .../components/map/BacktrackReplayOverlay.tsx | 72 ++++++++++++++++++- .../src/common/components/map/MapView.tsx | 4 +- frontend/src/common/types/backtrack.ts | 3 + .../prediction/components/OilSpillView.tsx | 7 +- 6 files changed, 170 insertions(+), 42 deletions(-) diff --git a/backend/src/prediction/backtrackAnalysisService.ts b/backend/src/prediction/backtrackAnalysisService.ts index c291163..d7027d2 100644 --- a/backend/src/prediction/backtrackAnalysisService.ts +++ b/backend/src/prediction/backtrackAnalysisService.ts @@ -3,6 +3,7 @@ import { getBacktrack } from './predictionService.js'; const PYTHON_API_URL = process.env.PYTHON_API_URL ?? 'http://localhost:5003'; const VESSEL_TRACK_API_URL = process.env.VESSEL_TRACK_API_URL ?? 'https://guide.gc-si.dev/signal-batch'; +const VESSEL_TRACK_COOKIE = process.env.VESSEL_TRACK_COOKIE ?? ''; // 유종 코드(DB) → OpenDrift 유종 코드 매핑 const OIL_TYPE_MAP: Record = { @@ -13,8 +14,6 @@ const OIL_TYPE_MAP: Record = { 'KEROSENE': 'FUEL OIL NO.1 (KEROSENE)', 'GASOLINE': 'GENERIC GASOLINE', }; -const POLL_INTERVAL_MS = 3000; -const POLL_TIMEOUT_MS = 30 * 60 * 1000; // AIS 선박유형 코드 → 위험도 점수 매핑 // AIS VESSEL_TP: 80-89=유조선류, 70-79=카고, 30-39=어선 @@ -133,25 +132,6 @@ function toTimeLabel(d: Date): string { return `${String(kst.getUTCHours()).padStart(2, '0')}:${String(kst.getUTCMinutes()).padStart(2, '0')}`; } -// Python 결과 폴링 (DONE까지 대기) -async function pollUntilDone(jobId: string): Promise { - const deadline = Date.now() + POLL_TIMEOUT_MS; - while (Date.now() < deadline) { - await new Promise(resolve => setTimeout(resolve, POLL_INTERVAL_MS)); - try { - const res = await fetch(`${PYTHON_API_URL}/status/${jobId}`, { - signal: AbortSignal.timeout(5000), - }); - if (!res.ok) continue; - const data = await res.json() as { status: string; result?: PythonTimeStep[]; error?: string }; - if (data.status === 'DONE' && data.result) return data.result; - if (data.status === 'ERROR') throw new Error(data.error ?? 'Python 분석 오류'); - } catch (e) { - if (e instanceof Error && e.message !== 'fetch failed') throw e; - } - } - throw new Error('역추적 분석 시간 초과 (30분)'); -} // 파티클 스텝별 탐색 영역 계산 function computeParticleSteps( @@ -286,16 +266,29 @@ async function fetchVesselTracks( polygons: [polygon], }; - console.log(body); + console.log('[backtrack] VESSEL_TRACK_API 호출', { + url: `${VESSEL_TRACK_API_URL}/api/v2/tracks/area-search`, + startTime: body.startTime, + endTime: body.endTime, + srchRadiusNm, + polygons: body.polygons.length, + hasCookie: !!VESSEL_TRACK_COOKIE, + }); const res = await fetch(`${VESSEL_TRACK_API_URL}/api/v2/tracks/area-search`, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: { + 'Content-Type': 'application/json', + ...(VESSEL_TRACK_COOKIE ? { 'Cookie': VESSEL_TRACK_COOKIE } : {}), + }, body: JSON.stringify(body), }); + console.log('[backtrack] VESSEL_TRACK_API 응답', { status: res.status, ok: res.ok }); if (!res.ok) throw new Error(`선박 항적 API 오류: ${res.status}`); - return res.json() as Promise; + const data = await res.json() as VesselTrackApiResponse; + console.log('[backtrack] VESSEL_TRACK_API 데이터', { totalVessels: data.summary?.totalVessels ?? 0, tracks: data.tracks?.length ?? 0 }); + return data; } // 스코어링 + 순위 (선박 항적 API 응답 기반) @@ -473,6 +466,7 @@ export async function runBacktrackAnalysis(backtrackSn: number): Promise { } // Python 역방향 시뮬레이션 실행 (파티클 시각화용) + console.log('[backtrack] Python 역방향 시뮬레이션 요청', { lat: bt.lat, lon: bt.lon, anlysHours, matVol, matTy, spillTime: spillTime.toISOString() }); let rawResult: PythonTimeStep[]; try { const pythonRes = await fetch(`${PYTHON_API_URL}/run-model-backward`, { @@ -490,8 +484,9 @@ export async function runBacktrackAnalysis(backtrackSn: number): Promise { signal: AbortSignal.timeout(30000), }); if (!pythonRes.ok) throw new Error(`Python 서버 오류: ${pythonRes.status}`); - const { job_id } = await pythonRes.json() as { job_id: string }; - rawResult = await pollUntilDone(job_id); + const pythonData = await pythonRes.json() as { success: boolean; result: PythonTimeStep[] }; + rawResult = pythonData.result ?? []; + console.log('[backtrack] Python 역방향 시뮬레이션 완료 — steps:', rawResult.length); } catch (pyErr) { // Python 미연동 시 폴백: 빈 파티클 스텝 생성 console.warn('[backtrack] Python 미연동 — 폴백 모드 사용:', pyErr); @@ -515,16 +510,19 @@ export async function runBacktrackAnalysis(backtrackSn: number): Promise { // 선박 항적 API 호출 const srchRadius = bt.srchRadiusNm ?? 10; + console.log('[backtrack] 선박 항적 API 호출 시작', { srchRadius, analysisStart: analysisStart.toISOString(), spillTime: spillTime.toISOString() }); const apiResponse = await fetchVesselTracks( bt.lat, bt.lon, srchRadius, analysisStart, spillTime, ); const totalVessels = apiResponse.summary.totalVessels; + console.log('[backtrack] 선박 점수 계산 시작 — totalVessels:', totalVessels); const ranked = scoreAndRankVesselsFromApi( apiResponse.tracks, apiResponse.hitDetails, bt.lat, bt.lon, srchRadius, anlysHours, ); + console.log('[backtrack] 선박 점수 계산 완료 — ranked:', ranked.length, '/', totalVessels, '| top:', ranked[0]?.name, ranked[0]?.probability); const replayShips = buildReplayShipsFromApi(ranked); const collisionEvent = findCollisionEventFromApi(ranked, analysisStart, spillTime); @@ -536,7 +534,15 @@ export async function runBacktrackAnalysis(backtrackSn: number): Promise { // vessels에서 내부 필드 제거 const vessels = ranked.map(({ _rawScore: _r, _track: _t, _minDistIdx: _m, ...v }) => v); - const rsltData = { vessels, replayShips, collisionEvent, timeRange }; + // rawResult 샘플링 (최대 24 스텝, 파티클은 [lon, lat] 형식으로 저장) + const MAX_BACKWARD_STEPS = 24; + const sampleRate = Math.max(1, Math.ceil(rawResult.length / MAX_BACKWARD_STEPS)); + const backwardParticles = rawResult + .filter((_, i) => i % sampleRate === 0) + .slice(0, MAX_BACKWARD_STEPS) + .map(step => step.particles.map(p => [p.lon, p.lat] as [number, number])); + + const rsltData = { vessels, replayShips, collisionEvent, timeRange, backwardParticles }; await wingPool.query( `UPDATE wing.BACKTRACK diff --git a/frontend/src/common/components/map/BacktrackReplayBar.tsx b/frontend/src/common/components/map/BacktrackReplayBar.tsx index 8bbaf4f..a1c7e2d 100755 --- a/frontend/src/common/components/map/BacktrackReplayBar.tsx +++ b/frontend/src/common/components/map/BacktrackReplayBar.tsx @@ -1,3 +1,4 @@ +import { useRef, useEffect } from 'react' import type { ReplayShip, CollisionEvent } from '@common/types/backtrack' interface BacktrackReplayBarProps { @@ -12,6 +13,7 @@ interface BacktrackReplayBarProps { replayShips: ReplayShip[] collisionEvent: CollisionEvent | null replayTimeRange?: { start: string; end: string } + hasBackwardParticles?: boolean } export function BacktrackReplayBar({ @@ -26,8 +28,51 @@ export function BacktrackReplayBar({ replayShips, collisionEvent, replayTimeRange, + hasBackwardParticles, }: BacktrackReplayBarProps) { const progress = (replayFrame / totalFrames) * 100 + const isFinished = replayFrame >= totalFrames + + // 드래그 시크 + const barRef = useRef(null) + const isDraggingRef = useRef(false) + const onSeekRef = useRef(onSeek) + const totalFramesRef = useRef(totalFrames) + + useEffect(() => { onSeekRef.current = onSeek }, [onSeek]) + useEffect(() => { totalFramesRef.current = totalFrames }, [totalFrames]) + + useEffect(() => { + const onMouseMove = (e: MouseEvent) => { + if (!isDraggingRef.current || !barRef.current) return + const rect = barRef.current.getBoundingClientRect() + const pct = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)) + onSeekRef.current(Math.round(pct * totalFramesRef.current)) + } + const onMouseUp = () => { isDraggingRef.current = false } + document.addEventListener('mousemove', onMouseMove) + document.addEventListener('mouseup', onMouseUp) + return () => { + document.removeEventListener('mousemove', onMouseMove) + document.removeEventListener('mouseup', onMouseUp) + } + }, []) + + const handleBarMouseDown = (e: React.MouseEvent) => { + isDraggingRef.current = true + if (!barRef.current) return + const rect = barRef.current.getBoundingClientRect() + const pct = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)) + onSeek(Math.round(pct * totalFrames)) + } + + // 재생 완료 후 재생 버튼 클릭 → 처음부터 재시작 + const handlePlayClick = () => { + if (!isPlaying && isFinished) { + onSeek(0) + } + onTogglePlay() + } // 타임 계산 let startLabel: string @@ -64,12 +109,6 @@ export function BacktrackReplayBar({ currentTimeLabel = `${dayLabel} ${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')} KST` } - const handleSeekClick = (e: React.MouseEvent) => { - const rect = e.currentTarget.getBoundingClientRect() - const pct = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)) - onSeek(Math.round(pct * totalFrames)) - } - return (
{/* Play/Pause */} {/* Timeline */}
{/* Progress bar */}
{/* Legend row */} -
+
{replayShips.map((ship) => (
@@ -206,6 +246,12 @@ export function BacktrackReplayBar({
))} + {hasBackwardParticles && ( +
+
+ 역방향 예측 +
+ )}
) diff --git a/frontend/src/common/components/map/BacktrackReplayOverlay.tsx b/frontend/src/common/components/map/BacktrackReplayOverlay.tsx index 42bc289..c80f47c 100755 --- a/frontend/src/common/components/map/BacktrackReplayOverlay.tsx +++ b/frontend/src/common/components/map/BacktrackReplayOverlay.tsx @@ -1,7 +1,29 @@ -import { ScatterplotLayer, PathLayer } from '@deck.gl/layers' -import type { ReplayShip, CollisionEvent, ReplayPathPoint } from '@common/types/backtrack' +import { ScatterplotLayer, PathLayer, PolygonLayer } from '@deck.gl/layers' +import type { ReplayShip, CollisionEvent, ReplayPathPoint, BackwardParticleStep } from '@common/types/backtrack' import { hexToRgba } from './mapUtils' +// Andrew's monotone chain — 전체 파티클 경로의 외각 폴리곤 계산 +function convexHull(points: [number, number][]): [number, number][] { + if (points.length < 3) return points + const cross = (O: [number, number], A: [number, number], B: [number, number]) => + (A[0] - O[0]) * (B[1] - O[1]) - (A[1] - O[1]) * (B[0] - O[0]) + const sorted = [...points].sort((a, b) => a[0] !== b[0] ? a[0] - b[0] : a[1] - b[1]) + const lower: [number, number][] = [] + for (const p of sorted) { + while (lower.length >= 2 && cross(lower[lower.length - 2], lower[lower.length - 1], p) <= 0) lower.pop() + lower.push(p) + } + const upper: [number, number][] = [] + for (let i = sorted.length - 1; i >= 0; i--) { + const p = sorted[i] + while (upper.length >= 2 && cross(upper[upper.length - 2], upper[upper.length - 1], p) <= 0) upper.pop() + upper.push(p) + } + lower.pop() + upper.pop() + return [...lower, ...upper] +} + function getInterpolatedPosition( path: ReplayPathPoint[], frame: number, @@ -24,11 +46,14 @@ interface BacktrackReplayParams { replayFrame: number totalFrames: number incidentCoord: { lat: number; lon: number } + backwardParticles?: BackwardParticleStep[] } +const BACKWARD_COLOR: [number, number, number, number] = [168, 85, 247, 160] + // eslint-disable-next-line @typescript-eslint/no-explicit-any export function createBacktrackLayers(params: BacktrackReplayParams): any[] { - const { replayShips, collisionEvent, replayFrame, totalFrames, incidentCoord } = params + const { replayShips, collisionEvent, replayFrame, totalFrames, incidentCoord, backwardParticles } = params // eslint-disable-next-line @typescript-eslint/no-explicit-any const layers: any[] = [] const progress = replayFrame / totalFrames @@ -145,5 +170,46 @@ export function createBacktrackLayers(params: BacktrackReplayParams): any[] { } } + // 역방향 예측 파티클 + 전체 경로 외각 폴리곤 + if (backwardParticles && backwardParticles.length > 0) { + // 전체 스텝의 모든 파티클을 합쳐 외각 폴리곤 계산 (정적 — 항상 표시) + const allPoints = backwardParticles.flat() + if (allPoints.length >= 3) { + const hull = convexHull(allPoints) + if (hull.length >= 3) { + layers.push( + new PolygonLayer({ + id: 'bt-backward-hull', + data: [{ polygon: hull }], + getPolygon: (d: { polygon: [number, number][] }) => d.polygon, + getFillColor: [168, 85, 247, 18], + getLineColor: [168, 85, 247, 110], + getLineWidth: 1, + stroked: true, + filled: true, + lineWidthMinPixels: 1, + }) + ) + } + } + + // 현재 프레임 파티클 + const stepIndex = Math.round((1 - progress) * (backwardParticles.length - 1)) + const particles = backwardParticles[stepIndex] ?? [] + if (particles.length > 0) { + layers.push( + new ScatterplotLayer({ + id: 'bt-backward-particles', + data: particles, + getPosition: (d: [number, number]) => d, + getRadius: 3, + getFillColor: BACKWARD_COLOR, + radiusMinPixels: 2.5, + radiusMaxPixels: 5, + }) + ) + } + } + return layers } diff --git a/frontend/src/common/components/map/MapView.tsx b/frontend/src/common/components/map/MapView.tsx index c7d29f2..a287784 100755 --- a/frontend/src/common/components/map/MapView.tsx +++ b/frontend/src/common/components/map/MapView.tsx @@ -11,7 +11,7 @@ import type { PredictionModel, SensitiveResource } from '@tabs/prediction/compon import type { HydrDataStep, SensitiveResourceFeatureCollection } from '@tabs/prediction/services/predictionApi' import HydrParticleOverlay from './HydrParticleOverlay' import type { BoomLine, BoomLineCoord } from '@common/types/boomLine' -import type { ReplayShip, CollisionEvent } from '@common/types/backtrack' +import type { ReplayShip, CollisionEvent, BackwardParticleStep } from '@common/types/backtrack' import { createBacktrackLayers } from './BacktrackReplayOverlay' import { buildMeasureLayers } from './measureLayers' import { MeasureOverlay } from './MeasureOverlay' @@ -358,6 +358,7 @@ interface MapViewProps { replayFrame: number totalFrames: number incidentCoord: { lat: number; lon: number } + backwardParticles?: BackwardParticleStep[] } sensitiveResources?: SensitiveResource[] sensitiveResourceGeojson?: SensitiveResourceFeatureCollection | null @@ -1100,6 +1101,7 @@ export function MapView({ replayFrame: backtrackReplay.replayFrame, totalFrames: backtrackReplay.totalFrames, incidentCoord: backtrackReplay.incidentCoord, + backwardParticles: backtrackReplay.backwardParticles, })) } diff --git a/frontend/src/common/types/backtrack.ts b/frontend/src/common/types/backtrack.ts index cc7a39e..269cc10 100755 --- a/frontend/src/common/types/backtrack.ts +++ b/frontend/src/common/types/backtrack.ts @@ -56,3 +56,6 @@ export interface BacktrackTimeRange { start: string // ISO 문자열 end: string // ISO 문자열 } + +// 역방향 예측 파티클 스텝 — 각 스텝은 [lon, lat] 쌍의 배열 +export type BackwardParticleStep = [number, number][] diff --git a/frontend/src/tabs/prediction/components/OilSpillView.tsx b/frontend/src/tabs/prediction/components/OilSpillView.tsx index d1ecddc..da199bf 100755 --- a/frontend/src/tabs/prediction/components/OilSpillView.tsx +++ b/frontend/src/tabs/prediction/components/OilSpillView.tsx @@ -12,7 +12,7 @@ import { useSubMenu, navigateToTab, setReportGenCategory, setOilReportPayload, t import { fetchWeatherSnapshotForCoord } from '@tabs/weather/services/weatherUtils' import { useWeatherSnapshotStore } from '@common/store/weatherSnapshotStore' import type { BoomLine, AlgorithmSettings, ContainmentResult, BoomLineCoord } from '@common/types/boomLine' -import type { BacktrackPhase, BacktrackVessel, BacktrackConditions, BacktrackInputConditions, ReplayShip, CollisionEvent } from '@common/types/backtrack' +import type { BacktrackPhase, BacktrackVessel, BacktrackConditions, BacktrackInputConditions, ReplayShip, CollisionEvent, BackwardParticleStep } from '@common/types/backtrack' import { TOTAL_REPLAY_FRAMES } from '@common/types/backtrack' import { fetchBacktrackByAcdnt, createBacktrack, fetchPredictionDetail, fetchAnalysisTrajectory, fetchSensitiveResources, fetchSensitiveResourcesGeojson } from '../services/predictionApi' import type { CenterPoint, HydrDataStep, ImageAnalyzeResult, OilParticle, PredictionDetail, RunModelSyncResponse, SimulationSummary, SensitiveResourceCategory, SensitiveResourceFeatureCollection, WindPoint } from '../services/predictionApi' @@ -194,6 +194,7 @@ export function OilSpillView() { const [replayShips, setReplayShips] = useState([]) const [collisionEvent, setCollisionEvent] = useState(null) const [replayTimeRange, setReplayTimeRange] = useState<{ start: string; end: string } | null>(null) + const [backwardParticles, setBackwardParticles] = useState([]) // 재계산 상태 const [recalcModalOpen, setRecalcModalOpen] = useState(false) @@ -250,6 +251,7 @@ export function OilSpillView() { if (rslt['timeRange']) { setReplayTimeRange(rslt['timeRange'] as { start: string; end: string }) } + setBackwardParticles(Array.isArray(rslt['backwardParticles']) ? rslt['backwardParticles'] as BackwardParticleStep[] : []) setBacktrackConditions({ estimatedSpillTime: bt.estSpilDtm ? new Date(bt.estSpilDtm).toLocaleString('ko-KR', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }) : '', analysisRange: bt.anlysRange || '±12시간', @@ -308,6 +310,7 @@ export function OilSpillView() { if (Array.isArray(rslt['replayShips'])) setReplayShips(rslt['replayShips'] as ReplayShip[]) if (rslt['collisionEvent']) setCollisionEvent(rslt['collisionEvent'] as CollisionEvent) if (rslt['timeRange']) setReplayTimeRange(rslt['timeRange'] as { start: string; end: string }) + setBackwardParticles(Array.isArray(rslt['backwardParticles']) ? rslt['backwardParticles'] as BackwardParticleStep[] : []) setBacktrackConditions(prev => ({ ...prev, totalVessels: bt.totalVessels || 0 })) setBacktrackPhase('results') } else { @@ -1051,6 +1054,7 @@ export function OilSpillView() { replayFrame, totalFrames: TOTAL_REPLAY_FRAMES, incidentCoord, + backwardParticles, } : undefined} showCurrent={displayControls.showCurrent} showWind={displayControls.showWind} @@ -1241,6 +1245,7 @@ export function OilSpillView() { replayShips={replayShips} collisionEvent={collisionEvent} replayTimeRange={replayTimeRange ?? undefined} + hasBackwardParticles={backwardParticles.length > 0} /> )}