signal-batch/frontend/src/features/vessel-map/utils/interpolation.ts
htlee ed0f3056b1 feat: Ship-GIS 기능 이관 — 최근위치/선박항적/뷰포트 리플레이
dark(ship-gis) 프로젝트의 맵 기반 3대 기능을 API 탐색기에 이관.
Feature 폴더 모듈화 구조로 타 프로젝트 재활용 가능하게 구성.

Phase 1: vessel-map 공유 모듈 (Deck.gl 9 + Zustand 5 + STOMP)
Phase 2: 최근 위치 (30초 폴링 + IconLayer + 선종 필터 + 팝업)
Phase 3: 선박 항적 (MMSI 조회 + PathLayer + 타임라인 보간)
Phase 4: 뷰포트 리플레이 (STOMP WebSocket 청크 + TripsLayer 애니메이션)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 15:19:21 +09:00

122 lines
3.4 KiB
TypeScript
Raw Blame 히스토리

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import type { TrackSegment, InterpolatedPosition } from '../types'
/**
* 이진 탐색으로 currentTime이 속하는 구간 [lo, hi] 인덱스 반환
* O(log n) — 프레임당 선박 수 × O(log n)
*/
function findTimeIndex(timestampsMs: number[], currentTime: number): [number, number] {
let lo = 0
let hi = timestampsMs.length - 1
while (lo < hi - 1) {
const mid = (lo + hi) >> 1
if (timestampsMs[mid] <= currentTime) lo = mid
else hi = mid
}
return [lo, hi]
}
/** 두 점 사이 방위각 계산 (degrees, 0-360) */
function calculateHeading(
lon1: number, lat1: number,
lon2: number, lat2: number,
): number {
const dLon = ((lon2 - lon1) * Math.PI) / 180
const lat1Rad = (lat1 * Math.PI) / 180
const lat2Rad = (lat2 * Math.PI) / 180
const y = Math.sin(dLon) * Math.cos(lat2Rad)
const x =
Math.cos(lat1Rad) * Math.sin(lat2Rad) -
Math.sin(lat1Rad) * Math.cos(lat2Rad) * Math.cos(dLon)
let heading = (Math.atan2(y, x) * 180) / Math.PI
if (heading < 0) heading += 360
return heading
}
/**
* 트랙 배열에서 currentTime 기준 보간된 위치 목록 생성
* disabledIds에 포함된 vesselId는 제외
*/
export function getPositionsAtTime(
tracks: TrackSegment[],
currentTime: number,
disabledIds?: Set<string>,
): InterpolatedPosition[] {
const positions: InterpolatedPosition[] = []
for (const track of tracks) {
if (disabledIds?.has(track.vesselId)) continue
const { timestampsMs, geometry, speeds } = track
if (timestampsMs.length === 0) continue
// 경계값 처리
if (currentTime <= timestampsMs[0]) {
positions.push({
vesselId: track.vesselId,
lon: geometry[0][0],
lat: geometry[0][1],
heading: 0,
speed: speeds[0] || 0,
shipName: track.shipName || '',
shipKindCode: track.shipKindCode || '',
})
continue
}
if (currentTime >= timestampsMs[timestampsMs.length - 1]) {
const last = geometry.length - 1
positions.push({
vesselId: track.vesselId,
lon: geometry[last][0],
lat: geometry[last][1],
heading: 0,
speed: speeds[last] || 0,
shipName: track.shipName || '',
shipKindCode: track.shipKindCode || '',
})
continue
}
// 이진 탐색 + 선형 보간
const [lo, hi] = findTimeIndex(timestampsMs, currentTime)
const t1 = timestampsMs[lo]
const t2 = timestampsMs[hi]
const ratio = t2 === t1 ? 0 : (currentTime - t1) / (t2 - t1)
const p1 = geometry[lo]
const p2 = geometry[hi]
const lon = p1[0] + (p2[0] - p1[0]) * ratio
const lat = p1[1] + (p2[1] - p1[1]) * ratio
const heading = calculateHeading(p1[0], p1[1], p2[0], p2[1])
const speed = speeds[lo] + (speeds[hi] - speeds[lo]) * ratio
positions.push({
vesselId: track.vesselId,
lon,
lat,
heading,
speed,
shipName: track.shipName || '',
shipKindCode: track.shipKindCode || '',
})
}
return positions
}
/** 트랙 배열의 전체 시간 범위 [min, max] (ms) */
export function getTimeRange(tracks: TrackSegment[]): [number, number] {
let min = Infinity
let max = -Infinity
for (const t of tracks) {
if (t.timestampsMs.length === 0) continue
const first = t.timestampsMs[0]
const last = t.timestampsMs[t.timestampsMs.length - 1]
if (first < min) min = first
if (last > max) max = last
}
return [min, max]
}