import type { HistoryFrame, SubFrame } from '../components/korea/fleetClusterTypes'; import type { CorrelationVesselTrack, GearCorrelationItem } from '../services/vesselAnalysis'; import { buildInterpPolygon } from '../components/korea/fleetClusterUtils'; export interface TripsLayerDatum { id: string; path: [number, number][]; // [lon, lat][] timestamps: number[]; // relative ms from startTime (TripsLayer requirement) color: [number, number, number, number]; } export interface MemberPosition { mmsi: string; name: string; lon: number; lat: number; cog: number; role: string; isParent: boolean; isGear: boolean; stale: boolean; } export interface CenterTrailSegment { path: [number, number][]; isInterpolated: boolean; } /** * Walk all frames and collect per-MMSI tracks for TripsLayer rendering. * timestamps are relative ms from startTime (deck.gl TripsLayer requirement). */ export function buildMemberTripsData(frames: HistoryFrame[], startTime: number): TripsLayerDatum[] { const memberMap = new Map(); for (const frame of frames) { const t = new Date(frame.snapshotTime).getTime() - startTime; for (const member of frame.members) { const entry = memberMap.get(member.mmsi) ?? { path: [], timestamps: [] }; entry.path.push([member.lon, member.lat]); entry.timestamps.push(t); memberMap.set(member.mmsi, entry); } } const result: TripsLayerDatum[] = []; for (const [mmsi, data] of memberMap) { if (data.path.length >= 2) { result.push({ id: mmsi, path: data.path, timestamps: data.timestamps, color: [200, 200, 200, 180], }); } } return result; } /** * Convert correlation vessel tracks to TripsLayer format. * timestamps are relative ms from startTime (deck.gl TripsLayer requirement). */ export function buildCorrelationTripsData( tracks: CorrelationVesselTrack[], startTime: number, ): TripsLayerDatum[] { const result: TripsLayerDatum[] = []; for (const vt of tracks) { if (vt.track.length >= 2) { result.push({ id: vt.mmsi, path: vt.track.map(pt => [pt.lon, pt.lat]), timestamps: vt.track.map(pt => pt.ts - startTime), color: [96, 165, 250, 150], }); } } return result; } /** * Split center trail into real/interpolated segments and collect real-data dot positions. * Consecutive frames with the same _longGap flag form one segment. */ export function buildCenterTrailData( frames: HistoryFrame[], ): { segments: CenterTrailSegment[]; dots: [number, number][] } { const segments: CenterTrailSegment[] = []; const dots: [number, number][] = []; if (frames.length === 0) return { segments, dots }; let segStart = 0; for (let i = 1; i <= frames.length; i++) { const curInterp = i < frames.length ? !!frames[i]._longGap : null; const startInterp = !!frames[segStart]._longGap; if (i < frames.length && curInterp === startInterp) continue; const from = segStart > 0 ? segStart - 1 : segStart; const seg = frames.slice(from, i); if (seg.length >= 2) { segments.push({ path: seg.map(s => [s.centerLon, s.centerLat]), isInterpolated: startInterp, }); } segStart = i; } for (const frame of frames) { if (!frame._longGap && !frame._interp) { dots.push([frame.centerLon, frame.centerLat]); } } return { segments, dots }; } /** * Map real (non-interpolated) frames to normalized [0, 1] positions * along the timeline, for progress bar gap indicators. */ export function buildSnapshotRanges( frames: HistoryFrame[], startTime: number, endTime: number, ): number[] { const duration = endTime - startTime; if (duration <= 0) return []; return frames .filter(h => !h._interp) .map(h => (new Date(h.snapshotTime).getTime() - startTime) / duration); } /** * Cursor-based frame index lookup. * Uses forward linear scan from cursorHint during normal playback (O(1–2)), * falls back to binary search when time goes backward or hint is invalid. * Returns { index: -1 } when the closest frame is more than 30 minutes away. */ export function findFrameAtTime( frameTimes: number[], timeMs: number, cursorHint: number, ): { index: number; cursor: number } { if (frameTimes.length === 0) return { index: -1, cursor: 0 }; // Forward linear scan from cursor if (cursorHint >= 0 && cursorHint < frameTimes.length) { if (frameTimes[cursorHint] <= timeMs) { let i = cursorHint; while (i < frameTimes.length - 1 && frameTimes[i + 1] <= timeMs) { i++; } return { index: i, cursor: i }; } // Time went backward — fall through to binary search } // Binary search fallback let lo = 0; let hi = frameTimes.length - 1; while (lo < hi) { const mid = (lo + hi + 1) >> 1; if (frameTimes[mid] <= timeMs) { lo = mid; } else { hi = mid - 1; } } if (Math.abs(frameTimes[lo] - timeMs) > 1_800_000) { return { index: -1, cursor: lo }; } return { index: lo, cursor: lo }; } /** * Interpolate member positions between frameIdx and frameIdx+1 at timeMs. * Returns stale=true for frames marked as _longGap or _interp. */ export function interpolateMemberPositions( frames: HistoryFrame[], frameIdx: number, timeMs: number, ): MemberPosition[] { if (frameIdx < 0 || frameIdx >= frames.length) return []; const frame = frames[frameIdx]; const isStale = !!frame._longGap || !!frame._interp; const toPosition = ( m: { mmsi: string; name: string; lon: number; lat: number; cog: number; role: string; isParent: boolean }, lon: number, lat: number, cog: number, ): MemberPosition => ({ mmsi: m.mmsi, name: m.name, lon, lat, cog, role: m.role, isParent: m.isParent, isGear: m.role === 'GEAR' || !m.isParent, stale: isStale, }); // No next frame — return current positions as-is if (frameIdx >= frames.length - 1) { return frame.members.map(m => toPosition(m, m.lon, m.lat, m.cog)); } const nextFrame = frames[frameIdx + 1]; const t0 = new Date(frame.snapshotTime).getTime(); const t1 = new Date(nextFrame.snapshotTime).getTime(); const ratio = t1 > t0 ? Math.max(0, Math.min(1, (timeMs - t0) / (t1 - t0))) : 0; const nextMap = new Map(nextFrame.members.map(m => [m.mmsi, m])); return frame.members.map(m => { const nm = nextMap.get(m.mmsi); if (!nm) { return toPosition(m, m.lon, m.lat, m.cog); } return toPosition( m, m.lon + (nm.lon - m.lon) * ratio, m.lat + (nm.lat - m.lat) * ratio, nm.cog, ); }); } /** * interpolateMemberPositions와 동일한 보간 로직이지만, * 특정 subClusterId에 속한 멤버만 스코프한다. * subClusterId에 해당하는 SubFrame이 없으면 빈 배열을 반환한다. */ export function interpolateSubFrameMembers( frames: HistoryFrame[], frameIdx: number, timeMs: number, subClusterId: number, ): MemberPosition[] { if (frameIdx < 0 || frameIdx >= frames.length) return []; const frame = frames[frameIdx]; const subFrame: SubFrame | undefined = frame.subFrames.find(sf => sf.subClusterId === subClusterId); if (!subFrame) return []; const isStale = !!frame._longGap || !!frame._interp; const toPosition = ( m: { mmsi: string; name: string; lon: number; lat: number; cog: number; role: string; isParent: boolean }, lon: number, lat: number, cog: number, ): MemberPosition => ({ mmsi: m.mmsi, name: m.name, lon, lat, cog, role: m.role, isParent: m.isParent, isGear: m.role === 'GEAR' || !m.isParent, stale: isStale, }); // 다음 프레임 없음 — 현재 subFrame 위치 그대로 반환 if (frameIdx >= frames.length - 1) { return subFrame.members.map(m => toPosition(m, m.lon, m.lat, m.cog)); } const nextFrame = frames[frameIdx + 1]; const nextSubFrame: SubFrame | undefined = nextFrame.subFrames.find( sf => sf.subClusterId === subClusterId, ); // 다음 프레임에 해당 subClusterId 없음 — 현재 위치 그대로 반환 if (!nextSubFrame) { return subFrame.members.map(m => toPosition(m, m.lon, m.lat, m.cog)); } const t0 = new Date(frame.snapshotTime).getTime(); const t1 = new Date(nextFrame.snapshotTime).getTime(); const ratio = t1 > t0 ? Math.max(0, Math.min(1, (timeMs - t0) / (t1 - t0))) : 0; const nextMap = new Map(nextSubFrame.members.map(m => [m.mmsi, m])); return subFrame.members.map(m => { const nm = nextMap.get(m.mmsi); if (!nm) { return toPosition(m, m.lon, m.lat, m.cog); } return toPosition( m, m.lon + (nm.lon - m.lon) * ratio, m.lat + (nm.lat - m.lat) * ratio, nm.cog, ); }); } /** * 모델별 오퍼레이셔널 폴리곤 중심 경로 사전 계산. * 각 프레임마다 멤버 위치 + 연관 선박 위치(트랙 보간)로 폴리곤을 만들고 중심점을 기록. */ export interface ModelCenterTrail { modelName: string; subClusterId: number; // 서브클러스터별 독립 trail path: [number, number][]; // [lon, lat][] timestamps: number[]; // relative ms } /** 트랙 맵에서 특정 시점의 보간 위치 조회 */ function _interpTrackPos( track: { ts: number[]; path: [number, number][] }, t: number, ): [number, number] { if (t <= track.ts[0]) return track.path[0]; if (t >= track.ts[track.ts.length - 1]) return track.path[track.path.length - 1]; let lo = 0, hi = track.ts.length - 1; while (lo < hi - 1) { const mid = (lo + hi) >> 1; if (track.ts[mid] <= t) lo = mid; else hi = mid; } const ratio = track.ts[hi] !== track.ts[lo] ? (t - track.ts[lo]) / (track.ts[hi] - track.ts[lo]) : 0; return [ track.path[lo][0] + (track.path[hi][0] - track.path[lo][0]) * ratio, track.path[lo][1] + (track.path[hi][1] - track.path[lo][1]) * ratio, ]; } export function buildModelCenterTrails( frames: HistoryFrame[], corrTracks: CorrelationVesselTrack[], corrByModel: Map, enabledVessels: Set, startTime: number, ): ModelCenterTrail[] { const trackMap = new Map(); for (const vt of corrTracks) { if (vt.track.length < 1) continue; trackMap.set(vt.mmsi, { ts: vt.track.map(p => p.ts), path: vt.track.map(p => [p.lon, p.lat]), }); } const results: ModelCenterTrail[] = []; for (const [mn, items] of corrByModel) { const enabledItems = items.filter(c => enabledVessels.has(c.targetMmsi)); if (enabledItems.length === 0) continue; // subClusterId별 연관 선박 그룹핑 const subItemsMap = new Map(); for (const c of enabledItems) { const sid = c.subClusterId ?? 0; const list = subItemsMap.get(sid) ?? []; list.push(c); subItemsMap.set(sid, list); } // 서브클러스터별 독립 trail 생성 for (const [sid, subItems] of subItemsMap) { const path: [number, number][] = []; const timestamps: number[] = []; for (const frame of frames) { const t = new Date(frame.snapshotTime).getTime(); const relT = t - startTime; // 해당 서브클러스터의 멤버 위치 const sf = frame.subFrames?.find(s => s.subClusterId === sid); const basePts: [number, number][] = sf ? sf.members.map(m => [m.lon, m.lat]) : frame.members.map(m => [m.lon, m.lat]); // fallback const allPts: [number, number][] = [...basePts]; // 연관 선박 위치 (트랙 보간) for (const c of subItems) { const track = trackMap.get(c.targetMmsi); if (!track || track.path.length === 0) continue; allPts.push(_interpTrackPos(track, t)); } const poly = buildInterpPolygon(allPts); if (!poly) continue; const ring = poly.coordinates[0]; let cx = 0, cy = 0; for (const pt of ring) { cx += pt[0]; cy += pt[1]; } cx /= ring.length; cy /= ring.length; path.push([cx, cy]); timestamps.push(relT); } if (path.length >= 2) { results.push({ modelName: mn, subClusterId: sid, path, timestamps }); } } } return results; }