Merge pull request 'release: 2026-03-27.3 (5건 커밋)' (#138) from develop into main
All checks were successful
Build and Deploy Wing-Demo / build-and-deploy (push) Successful in 33s
All checks were successful
Build and Deploy Wing-Demo / build-and-deploy (push) Successful in 33s
This commit is contained in:
커밋
d71c43ae5a
@ -3,6 +3,7 @@ import { getBacktrack } from './predictionService.js';
|
|||||||
|
|
||||||
const PYTHON_API_URL = process.env.PYTHON_API_URL ?? 'http://localhost:5003';
|
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_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 유종 코드 매핑
|
// 유종 코드(DB) → OpenDrift 유종 코드 매핑
|
||||||
const OIL_TYPE_MAP: Record<string, string> = {
|
const OIL_TYPE_MAP: Record<string, string> = {
|
||||||
@ -13,8 +14,6 @@ const OIL_TYPE_MAP: Record<string, string> = {
|
|||||||
'KEROSENE': 'FUEL OIL NO.1 (KEROSENE)',
|
'KEROSENE': 'FUEL OIL NO.1 (KEROSENE)',
|
||||||
'GASOLINE': 'GENERIC GASOLINE',
|
'GASOLINE': 'GENERIC GASOLINE',
|
||||||
};
|
};
|
||||||
const POLL_INTERVAL_MS = 3000;
|
|
||||||
const POLL_TIMEOUT_MS = 30 * 60 * 1000;
|
|
||||||
|
|
||||||
// AIS 선박유형 코드 → 위험도 점수 매핑
|
// AIS 선박유형 코드 → 위험도 점수 매핑
|
||||||
// AIS VESSEL_TP: 80-89=유조선류, 70-79=카고, 30-39=어선
|
// 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')}`;
|
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(
|
function computeParticleSteps(
|
||||||
@ -286,16 +266,29 @@ async function fetchVesselTracks(
|
|||||||
polygons: [polygon],
|
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`, {
|
const res = await fetch(`${VESSEL_TRACK_API_URL}/api/v2/tracks/area-search`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...(VESSEL_TRACK_COOKIE ? { 'Cookie': VESSEL_TRACK_COOKIE } : {}),
|
||||||
|
},
|
||||||
body: JSON.stringify(body),
|
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}`);
|
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 응답 기반)
|
// 스코어링 + 순위 (선박 항적 API 응답 기반)
|
||||||
@ -473,6 +466,7 @@ export async function runBacktrackAnalysis(backtrackSn: number): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Python 역방향 시뮬레이션 실행 (파티클 시각화용)
|
// Python 역방향 시뮬레이션 실행 (파티클 시각화용)
|
||||||
|
console.log('[backtrack] Python 역방향 시뮬레이션 요청', { lat: bt.lat, lon: bt.lon, anlysHours, matVol, matTy, spillTime: spillTime.toISOString() });
|
||||||
let rawResult: PythonTimeStep[];
|
let rawResult: PythonTimeStep[];
|
||||||
try {
|
try {
|
||||||
const pythonRes = await fetch(`${PYTHON_API_URL}/run-model-backward`, {
|
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),
|
signal: AbortSignal.timeout(30000),
|
||||||
});
|
});
|
||||||
if (!pythonRes.ok) throw new Error(`Python 서버 오류: ${pythonRes.status}`);
|
if (!pythonRes.ok) throw new Error(`Python 서버 오류: ${pythonRes.status}`);
|
||||||
const { job_id } = await pythonRes.json() as { job_id: string };
|
const pythonData = await pythonRes.json() as { success: boolean; result: PythonTimeStep[] };
|
||||||
rawResult = await pollUntilDone(job_id);
|
rawResult = pythonData.result ?? [];
|
||||||
|
console.log('[backtrack] Python 역방향 시뮬레이션 완료 — steps:', rawResult.length);
|
||||||
} catch (pyErr) {
|
} catch (pyErr) {
|
||||||
// Python 미연동 시 폴백: 빈 파티클 스텝 생성
|
// Python 미연동 시 폴백: 빈 파티클 스텝 생성
|
||||||
console.warn('[backtrack] Python 미연동 — 폴백 모드 사용:', pyErr);
|
console.warn('[backtrack] Python 미연동 — 폴백 모드 사용:', pyErr);
|
||||||
@ -515,16 +510,19 @@ export async function runBacktrackAnalysis(backtrackSn: number): Promise<void> {
|
|||||||
|
|
||||||
// 선박 항적 API 호출
|
// 선박 항적 API 호출
|
||||||
const srchRadius = bt.srchRadiusNm ?? 10;
|
const srchRadius = bt.srchRadiusNm ?? 10;
|
||||||
|
console.log('[backtrack] 선박 항적 API 호출 시작', { srchRadius, analysisStart: analysisStart.toISOString(), spillTime: spillTime.toISOString() });
|
||||||
const apiResponse = await fetchVesselTracks(
|
const apiResponse = await fetchVesselTracks(
|
||||||
bt.lat, bt.lon, srchRadius, analysisStart, spillTime,
|
bt.lat, bt.lon, srchRadius, analysisStart, spillTime,
|
||||||
);
|
);
|
||||||
|
|
||||||
const totalVessels = apiResponse.summary.totalVessels;
|
const totalVessels = apiResponse.summary.totalVessels;
|
||||||
|
console.log('[backtrack] 선박 점수 계산 시작 — totalVessels:', totalVessels);
|
||||||
const ranked = scoreAndRankVesselsFromApi(
|
const ranked = scoreAndRankVesselsFromApi(
|
||||||
apiResponse.tracks,
|
apiResponse.tracks,
|
||||||
apiResponse.hitDetails,
|
apiResponse.hitDetails,
|
||||||
bt.lat, bt.lon, srchRadius, anlysHours,
|
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 replayShips = buildReplayShipsFromApi(ranked);
|
||||||
const collisionEvent = findCollisionEventFromApi(ranked, analysisStart, spillTime);
|
const collisionEvent = findCollisionEventFromApi(ranked, analysisStart, spillTime);
|
||||||
|
|
||||||
@ -536,7 +534,15 @@ export async function runBacktrackAnalysis(backtrackSn: number): Promise<void> {
|
|||||||
// vessels에서 내부 필드 제거
|
// vessels에서 내부 필드 제거
|
||||||
const vessels = ranked.map(({ _rawScore: _r, _track: _t, _minDistIdx: _m, ...v }) => v);
|
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(
|
await wingPool.query(
|
||||||
`UPDATE wing.BACKTRACK
|
`UPDATE wing.BACKTRACK
|
||||||
|
|||||||
@ -4,6 +4,14 @@
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [2026-03-27.3]
|
||||||
|
|
||||||
|
### 추가
|
||||||
|
- 역추적: 리플레이에 Python 역방향 시뮬레이션 파티클 표시 (보라색 ScatterplotLayer)
|
||||||
|
- 역추적: 전체 파티클 이동 경로 외각 폴리곤(컨벡스 헐) 표시
|
||||||
|
- 역추적: 리플레이 바 — 재생 완료 후 재시작 기능 (↺ 아이콘)
|
||||||
|
- 역추적: 리플레이 바 — 드래그 시크 기능 추가
|
||||||
|
|
||||||
## [2026-03-27.2]
|
## [2026-03-27.2]
|
||||||
|
|
||||||
### 수정
|
### 수정
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
import { useRef, useEffect } from 'react'
|
||||||
import type { ReplayShip, CollisionEvent } from '@common/types/backtrack'
|
import type { ReplayShip, CollisionEvent } from '@common/types/backtrack'
|
||||||
|
|
||||||
interface BacktrackReplayBarProps {
|
interface BacktrackReplayBarProps {
|
||||||
@ -12,6 +13,7 @@ interface BacktrackReplayBarProps {
|
|||||||
replayShips: ReplayShip[]
|
replayShips: ReplayShip[]
|
||||||
collisionEvent: CollisionEvent | null
|
collisionEvent: CollisionEvent | null
|
||||||
replayTimeRange?: { start: string; end: string }
|
replayTimeRange?: { start: string; end: string }
|
||||||
|
hasBackwardParticles?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export function BacktrackReplayBar({
|
export function BacktrackReplayBar({
|
||||||
@ -26,8 +28,51 @@ export function BacktrackReplayBar({
|
|||||||
replayShips,
|
replayShips,
|
||||||
collisionEvent,
|
collisionEvent,
|
||||||
replayTimeRange,
|
replayTimeRange,
|
||||||
|
hasBackwardParticles,
|
||||||
}: BacktrackReplayBarProps) {
|
}: BacktrackReplayBarProps) {
|
||||||
const progress = (replayFrame / totalFrames) * 100
|
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
|
let startLabel: string
|
||||||
@ -64,12 +109,6 @@ export function BacktrackReplayBar({
|
|||||||
currentTimeLabel = `${dayLabel} ${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')} KST`
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
className="absolute flex flex-col"
|
className="absolute flex flex-col"
|
||||||
@ -126,7 +165,7 @@ export function BacktrackReplayBar({
|
|||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
{/* Play/Pause */}
|
{/* Play/Pause */}
|
||||||
<button
|
<button
|
||||||
onClick={onTogglePlay}
|
onClick={handlePlayClick}
|
||||||
className="shrink-0 w-9 h-9 rounded-full flex items-center justify-center text-sm cursor-pointer"
|
className="shrink-0 w-9 h-9 rounded-full flex items-center justify-center text-sm cursor-pointer"
|
||||||
style={{
|
style={{
|
||||||
background: isPlaying ? 'var(--purple)' : 'rgba(168,85,247,0.15)',
|
background: isPlaying ? 'var(--purple)' : 'rgba(168,85,247,0.15)',
|
||||||
@ -134,15 +173,16 @@ export function BacktrackReplayBar({
|
|||||||
color: isPlaying ? '#fff' : 'var(--purple)',
|
color: isPlaying ? '#fff' : 'var(--purple)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{isPlaying ? '⏸' : '▶'}
|
{isPlaying ? '⏸' : isFinished ? '↺' : '▶'}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Timeline */}
|
{/* Timeline */}
|
||||||
<div className="flex-1 flex flex-col gap-1">
|
<div className="flex-1 flex flex-col gap-1">
|
||||||
{/* Progress bar */}
|
{/* Progress bar */}
|
||||||
<div
|
<div
|
||||||
|
ref={barRef}
|
||||||
className="relative h-5 flex items-center cursor-pointer"
|
className="relative h-5 flex items-center cursor-pointer"
|
||||||
onClick={handleSeekClick}
|
onMouseDown={handleBarMouseDown}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="w-full h-1 bg-border relative overflow-visible"
|
className="w-full h-1 bg-border relative overflow-visible"
|
||||||
@ -197,7 +237,7 @@ export function BacktrackReplayBar({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Legend row */}
|
{/* 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) => (
|
{replayShips.map((ship) => (
|
||||||
<div key={ship.vesselName} className="flex items-center gap-1.5">
|
<div key={ship.vesselName} className="flex items-center gap-1.5">
|
||||||
<div className="w-4 h-[3px]" style={{ background: ship.color, borderRadius: '1px' }} />
|
<div className="w-4 h-[3px]" style={{ background: ship.color, borderRadius: '1px' }} />
|
||||||
@ -206,6 +246,12 @@ export function BacktrackReplayBar({
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -1,7 +1,29 @@
|
|||||||
import { ScatterplotLayer, PathLayer } from '@deck.gl/layers'
|
import { ScatterplotLayer, PathLayer, PolygonLayer } from '@deck.gl/layers'
|
||||||
import type { ReplayShip, CollisionEvent, ReplayPathPoint } from '@common/types/backtrack'
|
import type { ReplayShip, CollisionEvent, ReplayPathPoint, BackwardParticleStep } from '@common/types/backtrack'
|
||||||
import { hexToRgba } from './mapUtils'
|
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(
|
function getInterpolatedPosition(
|
||||||
path: ReplayPathPoint[],
|
path: ReplayPathPoint[],
|
||||||
frame: number,
|
frame: number,
|
||||||
@ -24,11 +46,14 @@ interface BacktrackReplayParams {
|
|||||||
replayFrame: number
|
replayFrame: number
|
||||||
totalFrames: number
|
totalFrames: number
|
||||||
incidentCoord: { lat: number; lon: 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
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
export function createBacktrackLayers(params: BacktrackReplayParams): 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
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
const layers: any[] = []
|
const layers: any[] = []
|
||||||
const progress = replayFrame / totalFrames
|
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
|
return layers
|
||||||
}
|
}
|
||||||
|
|||||||
@ -11,7 +11,7 @@ import type { PredictionModel, SensitiveResource } from '@tabs/prediction/compon
|
|||||||
import type { HydrDataStep, SensitiveResourceFeatureCollection } from '@tabs/prediction/services/predictionApi'
|
import type { HydrDataStep, SensitiveResourceFeatureCollection } from '@tabs/prediction/services/predictionApi'
|
||||||
import HydrParticleOverlay from './HydrParticleOverlay'
|
import HydrParticleOverlay from './HydrParticleOverlay'
|
||||||
import type { BoomLine, BoomLineCoord } from '@common/types/boomLine'
|
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 { createBacktrackLayers } from './BacktrackReplayOverlay'
|
||||||
import { buildMeasureLayers } from './measureLayers'
|
import { buildMeasureLayers } from './measureLayers'
|
||||||
import { MeasureOverlay } from './MeasureOverlay'
|
import { MeasureOverlay } from './MeasureOverlay'
|
||||||
@ -358,6 +358,7 @@ interface MapViewProps {
|
|||||||
replayFrame: number
|
replayFrame: number
|
||||||
totalFrames: number
|
totalFrames: number
|
||||||
incidentCoord: { lat: number; lon: number }
|
incidentCoord: { lat: number; lon: number }
|
||||||
|
backwardParticles?: BackwardParticleStep[]
|
||||||
}
|
}
|
||||||
sensitiveResources?: SensitiveResource[]
|
sensitiveResources?: SensitiveResource[]
|
||||||
sensitiveResourceGeojson?: SensitiveResourceFeatureCollection | null
|
sensitiveResourceGeojson?: SensitiveResourceFeatureCollection | null
|
||||||
@ -1100,6 +1101,7 @@ export function MapView({
|
|||||||
replayFrame: backtrackReplay.replayFrame,
|
replayFrame: backtrackReplay.replayFrame,
|
||||||
totalFrames: backtrackReplay.totalFrames,
|
totalFrames: backtrackReplay.totalFrames,
|
||||||
incidentCoord: backtrackReplay.incidentCoord,
|
incidentCoord: backtrackReplay.incidentCoord,
|
||||||
|
backwardParticles: backtrackReplay.backwardParticles,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -56,3 +56,6 @@ export interface BacktrackTimeRange {
|
|||||||
start: string // ISO 문자열
|
start: string // ISO 문자열
|
||||||
end: 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 { fetchWeatherSnapshotForCoord } from '@tabs/weather/services/weatherUtils'
|
||||||
import { useWeatherSnapshotStore } from '@common/store/weatherSnapshotStore'
|
import { useWeatherSnapshotStore } from '@common/store/weatherSnapshotStore'
|
||||||
import type { BoomLine, AlgorithmSettings, ContainmentResult, BoomLineCoord } from '@common/types/boomLine'
|
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 { TOTAL_REPLAY_FRAMES } from '@common/types/backtrack'
|
||||||
import { fetchBacktrackByAcdnt, createBacktrack, fetchPredictionDetail, fetchAnalysisTrajectory, fetchSensitiveResources, fetchSensitiveResourcesGeojson } from '../services/predictionApi'
|
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'
|
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 [replayShips, setReplayShips] = useState<ReplayShip[]>([])
|
||||||
const [collisionEvent, setCollisionEvent] = useState<CollisionEvent | null>(null)
|
const [collisionEvent, setCollisionEvent] = useState<CollisionEvent | null>(null)
|
||||||
const [replayTimeRange, setReplayTimeRange] = useState<{ start: string; end: string } | null>(null)
|
const [replayTimeRange, setReplayTimeRange] = useState<{ start: string; end: string } | null>(null)
|
||||||
|
const [backwardParticles, setBackwardParticles] = useState<BackwardParticleStep[]>([])
|
||||||
|
|
||||||
// 재계산 상태
|
// 재계산 상태
|
||||||
const [recalcModalOpen, setRecalcModalOpen] = useState(false)
|
const [recalcModalOpen, setRecalcModalOpen] = useState(false)
|
||||||
@ -250,6 +251,7 @@ export function OilSpillView() {
|
|||||||
if (rslt['timeRange']) {
|
if (rslt['timeRange']) {
|
||||||
setReplayTimeRange(rslt['timeRange'] as { start: string; end: string })
|
setReplayTimeRange(rslt['timeRange'] as { start: string; end: string })
|
||||||
}
|
}
|
||||||
|
setBackwardParticles(Array.isArray(rslt['backwardParticles']) ? rslt['backwardParticles'] as BackwardParticleStep[] : [])
|
||||||
setBacktrackConditions({
|
setBacktrackConditions({
|
||||||
estimatedSpillTime: bt.estSpilDtm ? new Date(bt.estSpilDtm).toLocaleString('ko-KR', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }) : '',
|
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시간',
|
analysisRange: bt.anlysRange || '±12시간',
|
||||||
@ -308,6 +310,7 @@ export function OilSpillView() {
|
|||||||
if (Array.isArray(rslt['replayShips'])) setReplayShips(rslt['replayShips'] as ReplayShip[])
|
if (Array.isArray(rslt['replayShips'])) setReplayShips(rslt['replayShips'] as ReplayShip[])
|
||||||
if (rslt['collisionEvent']) setCollisionEvent(rslt['collisionEvent'] as CollisionEvent)
|
if (rslt['collisionEvent']) setCollisionEvent(rslt['collisionEvent'] as CollisionEvent)
|
||||||
if (rslt['timeRange']) setReplayTimeRange(rslt['timeRange'] as { start: string; end: string })
|
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 }))
|
setBacktrackConditions(prev => ({ ...prev, totalVessels: bt.totalVessels || 0 }))
|
||||||
setBacktrackPhase('results')
|
setBacktrackPhase('results')
|
||||||
} else {
|
} else {
|
||||||
@ -1051,6 +1054,7 @@ export function OilSpillView() {
|
|||||||
replayFrame,
|
replayFrame,
|
||||||
totalFrames: TOTAL_REPLAY_FRAMES,
|
totalFrames: TOTAL_REPLAY_FRAMES,
|
||||||
incidentCoord,
|
incidentCoord,
|
||||||
|
backwardParticles,
|
||||||
} : undefined}
|
} : undefined}
|
||||||
showCurrent={displayControls.showCurrent}
|
showCurrent={displayControls.showCurrent}
|
||||||
showWind={displayControls.showWind}
|
showWind={displayControls.showWind}
|
||||||
@ -1241,6 +1245,7 @@ export function OilSpillView() {
|
|||||||
replayShips={replayShips}
|
replayShips={replayShips}
|
||||||
collisionEvent={collisionEvent}
|
collisionEvent={collisionEvent}
|
||||||
replayTimeRange={replayTimeRange ?? undefined}
|
replayTimeRange={replayTimeRange ?? undefined}
|
||||||
|
hasBackwardParticles={backwardParticles.length > 0}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
불러오는 중...
Reference in New Issue
Block a user