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>
122 lines
3.4 KiB
TypeScript
122 lines
3.4 KiB
TypeScript
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]
|
||
}
|