release: 2026-03-27.3 (5건 커밋) #138

병합
jhkang develop 에서 main 로 5 commits 를 머지했습니다 2026-03-27 17:46:46 +09:00
7개의 변경된 파일176개의 추가작업 그리고 42개의 파일을 삭제
Showing only changes of commit 9bae76f1d4 - Show all commits

파일 보기

@ -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<string, string> = {
@ -13,8 +14,6 @@ const OIL_TYPE_MAP: Record<string, string> = {
'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<PythonTimeStep[]> {
const deadline = Date.now() + POLL_TIMEOUT_MS;
while (Date.now() < deadline) {
await new Promise<void>(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<VesselTrackApiResponse>;
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<void> {
}
// 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<void> {
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<void> {
// 선박 항적 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<void> {
// 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

파일 보기

@ -4,6 +4,12 @@
## [Unreleased]
### 추가
- 역추적: 리플레이에 Python 역방향 시뮬레이션 파티클 표시 (보라색 ScatterplotLayer)
- 역추적: 전체 파티클 이동 경로 외각 폴리곤(컨벡스 헐) 표시
- 역추적: 리플레이 바 — 재생 완료 후 재시작 기능 (↺ 아이콘)
- 역추적: 리플레이 바 — 드래그 시크 기능 추가
## [2026-03-27.2]
### 수정

파일 보기

@ -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<HTMLDivElement>(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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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 (
<div
className="absolute flex flex-col"
@ -126,7 +165,7 @@ export function BacktrackReplayBar({
<div className="flex items-center gap-3">
{/* Play/Pause */}
<button
onClick={onTogglePlay}
onClick={handlePlayClick}
className="shrink-0 w-9 h-9 rounded-full flex items-center justify-center text-sm cursor-pointer"
style={{
background: isPlaying ? 'var(--purple)' : 'rgba(168,85,247,0.15)',
@ -134,15 +173,16 @@ export function BacktrackReplayBar({
color: isPlaying ? '#fff' : 'var(--purple)',
}}
>
{isPlaying ? '⏸' : '▶'}
{isPlaying ? '⏸' : isFinished ? '↺' : '▶'}
</button>
{/* Timeline */}
<div className="flex-1 flex flex-col gap-1">
{/* Progress bar */}
<div
ref={barRef}
className="relative h-5 flex items-center cursor-pointer"
onClick={handleSeekClick}
onMouseDown={handleBarMouseDown}
>
<div
className="w-full h-1 bg-border relative overflow-visible"
@ -197,7 +237,7 @@ export function BacktrackReplayBar({
</div>
{/* Legend row */}
<div className="flex items-center gap-[14px] pt-1 border-t border-border">
<div className="flex items-center gap-[14px] pt-1 border-t border-border flex-wrap">
{replayShips.map((ship) => (
<div key={ship.vesselName} className="flex items-center gap-1.5">
<div className="w-4 h-[3px]" style={{ background: ship.color, borderRadius: '1px' }} />
@ -206,6 +246,12 @@ export function BacktrackReplayBar({
</span>
</div>
))}
{hasBackwardParticles && (
<div className="flex items-center gap-1.5">
<div className="w-3 h-3 rounded-full" style={{ background: '#a855f7', opacity: 0.8 }} />
<span className="text-[9px] text-text-2 font-mono"> </span>
</div>
)}
</div>
</div>
)

파일 보기

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

파일 보기

@ -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,
}))
}

파일 보기

@ -56,3 +56,6 @@ export interface BacktrackTimeRange {
start: string // ISO 문자열
end: string // ISO 문자열
}
// 역방향 예측 파티클 스텝 — 각 스텝은 [lon, lat] 쌍의 배열
export type BackwardParticleStep = [number, number][]

파일 보기

@ -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<ReplayShip[]>([])
const [collisionEvent, setCollisionEvent] = useState<CollisionEvent | null>(null)
const [replayTimeRange, setReplayTimeRange] = useState<{ start: string; end: string } | null>(null)
const [backwardParticles, setBackwardParticles] = useState<BackwardParticleStep[]>([])
// 재계산 상태
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}
/>
)}
</>