import { DISPLAY_TZ } from '../../../shared/lib/datetime'; import { haversineNm } from '../../../shared/lib/geo/haversineNm'; import type { MultiTrackQueryContext, ProcessedTrack, TrackQueryContext } from '../model/track.types'; const BOM = '\uFEFF'; function escCsv(value: string | number | undefined | null): string { if (value == null) return ''; const s = String(value); if (s.includes(',') || s.includes('"') || s.includes('\n')) { return `"${s.replace(/"/g, '""')}"`; } return s; } function fmtTimestamp(ms: number): string { if (!Number.isFinite(ms) || ms <= 0) return ''; return new Date(ms).toLocaleString('sv-SE', { timeZone: DISPLAY_TZ }); } /** 두 포인트 간 거리(NM)·시간차(h)로 속력(knots) 계산 */ function calcSpeedKnots(track: ProcessedTrack, index: number): number { if (index <= 0) return 0; const [lon1, lat1] = track.geometry[index - 1]; const [lon2, lat2] = track.geometry[index]; const dtMs = track.timestampsMs[index] - track.timestampsMs[index - 1]; if (dtMs <= 0) return 0; const distNm = haversineNm(lat1, lon1, lat2, lon2); const hours = dtMs / 3_600_000; return Math.round((distNm / hours) * 100) / 100; } /** 포인트별 1행: mmsi, longitude, latitude, timestamp, speedKnots */ export function buildDynamicCsv(tracks: ProcessedTrack[], ctx: TrackQueryContext | null, multiCtx?: MultiTrackQueryContext | null): string { const header = ['mmsi', 'longitude', 'latitude', 'timestamp', 'timestampMs', 'speedKnots']; const rows: string[] = [header.join(',')]; const mmsi = ctx?.mmsi ?? ''; for (const track of tracks) { const trackMmsi = multiCtx ? track.targetId : (mmsi || track.targetId); for (let i = 0; i < track.geometry.length; i++) { rows.push( [ escCsv(trackMmsi), escCsv(track.geometry[i][0]), escCsv(track.geometry[i][1]), escCsv(fmtTimestamp(track.timestampsMs[i])), escCsv(track.timestampsMs[i]), escCsv(calcSpeedKnots(track, i)), ].join(','), ); } } return BOM + rows.join('\n'); } /** 선박별 1행: mmsi, 선명, 업종, 소유주, 선단, 허가번호 등 전체 메타 */ export function buildStaticCsv(tracks: ProcessedTrack[], ctx: TrackQueryContext | null, multiCtx?: MultiTrackQueryContext | null): string { const header = [ 'mmsi', 'shipName', 'vesselType', 'ownerCn', 'ownerRoman', 'permitNo', 'pairPermitNo', 'ton', 'callSign', 'workSeaArea', 'nationalCode', 'totalDistanceNm', 'avgSpeed', 'maxSpeed', 'pointCount', 'startTime', 'endTime', 'chnPrmShipName', 'chnPrmVesselType', 'chnPrmCallsign', 'chnPrmImo', ]; const rows: string[] = [header.join(',')]; // Build per-mmsi lookup for multi-vessel mode const multiVesselMap = multiCtx ? new Map(multiCtx.vessels.map((v) => [String(v.mmsi), v])) : null; for (const track of tracks) { const firstTs = track.timestampsMs[0] ?? 0; const lastTs = track.timestampsMs[track.timestampsMs.length - 1] ?? 0; const info = track.chnPrmShipInfo; const mv = multiVesselMap?.get(track.targetId); rows.push( [ escCsv(mv?.mmsi ?? ctx?.mmsi ?? track.targetId), escCsv(track.shipName), escCsv(mv?.vesselType ?? ctx?.vesselType ?? ''), escCsv(mv?.ownerCn ?? ctx?.ownerCn), escCsv(mv?.ownerRoman ?? ctx?.ownerRoman), escCsv(mv?.permitNo ?? ctx?.permitNo), escCsv(mv?.pairPermitNo ?? ctx?.pairPermitNo), escCsv(mv?.ton ?? ctx?.ton), escCsv(mv?.callSign ?? ctx?.callSign), escCsv(mv?.workSeaArea ?? ctx?.workSeaArea), escCsv(track.nationalCode), escCsv(track.stats.totalDistanceNm), escCsv(track.stats.avgSpeed), escCsv(track.stats.maxSpeed), escCsv(track.stats.pointCount), escCsv(fmtTimestamp(firstTs)), escCsv(fmtTimestamp(lastTs)), escCsv(info?.name), escCsv(info?.vesselType), escCsv(info?.callsign), escCsv(info?.imo), ].join(','), ); } return BOM + rows.join('\n'); } function downloadCsv(csvContent: string, filename: string): void { const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = filename; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); } export function exportTrackCsv(tracks: ProcessedTrack[], ctx: TrackQueryContext | null, multiCtx?: MultiTrackQueryContext | null): void { const now = new Date(); const ts = now.toISOString().replace(/[:.]/g, '-').slice(0, 19); downloadCsv(buildDynamicCsv(tracks, ctx, multiCtx), `track-points-${ts}.csv`); setTimeout(() => { downloadCsv(buildStaticCsv(tracks, ctx, multiCtx), `track-vessel-${ts}.csv`); }, 100); }