gc-wing/apps/web/src/features/trackReplay/lib/csvExport.ts
htlee baf827657e feat(vesselSelect): 다중 선박 항적 조회 + 경고 링 개선
- 대상 선박 멀티 선택 모달 (features/vesselSelect, widgets/vesselSelect)
  · 업종/상태 필터 분리 + 그룹별 전체 on/off
  · 드래그 선택 (클릭+드래그로 범위 체크/언체크)
  · 기간 프리셋 7/14/21/28일, 최대 조회 28일 제한(초과 시 자동 조정)
  · MAX_VESSEL_SELECT=20, MAX_QUERY_DAYS=28
- trackReplay 확장: beginMultiQuery, queryMultiTrack, 다중 CSV 내보내기
- GlobalTrackReplayPanel: 기간 편집/재조회, 선박 목록 on/off 토글
- 경고 브리딩 효과: filled circle → stroked ring
  · Globe: zoom-interpolated offset 기반 반경
  · Mercator: ScatterplotLayer → IconLayer + SVG ring (깜빡임 해결)
- hideLiveShips 조회 시 기본 체크
- Topbar "다중항적" 버튼 강조 스타일
- 공지사항 id:2 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 12:54:20 +09:00

148 lines
4.8 KiB
TypeScript

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);
}