- 대상 선박 멀티 선택 모달 (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>
148 lines
4.8 KiB
TypeScript
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);
|
|
}
|