gc-wing/apps/web/src/features/trackReplay/services/trackQueryService.ts
htlee 85dc7146be feat(trackReplay): 항적 기간 조정/재조회 + CSV 내보내기
- 패널에 시작/종료 datetime-local 입력 + 재조회 버튼 추가
- TrackQueryContext에 legacy 메타데이터(업종/소유주/허가번호 등) 포함
- CSV 다운로드: points(포인트별 lon/lat/timestamp/speedKnots) + vessel(선박 메타)
- speed는 haversine 거리/시간 기반 계산값(knots)
- trackQueryService에 queryTrackByDateRange 추가
- 패널 체크박스 정리: 가상선박/반복 제거

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 03:00:41 +09:00

165 lines
4.8 KiB
TypeScript

import type { ProcessedTrack } from '../model/track.types';
type QueryTrackByMmsiParams = {
mmsi: number;
minutes: number;
shipNameHint?: string;
shipKindCodeHint?: string;
nationalCodeHint?: string;
isPermitted?: boolean;
};
export type QueryTrackByDateRangeParams = {
mmsi: number;
startTimeIso: string;
endTimeIso: string;
shipNameHint?: string;
shipKindCodeHint?: string;
nationalCodeHint?: string;
isPermitted?: boolean;
};
type V2TrackResponse = {
vesselId?: string;
targetId?: string;
sigSrcCd?: string;
shipName?: string;
shipKindCode?: string;
nationalCode?: string;
geometry?: [number, number][];
timestamps?: Array<string | number>;
speeds?: number[];
totalDistance?: number;
avgSpeed?: number;
maxSpeed?: number;
pointCount?: number;
chnPrmShipInfo?: {
name?: string;
vesselType?: string;
callsign?: string;
imo?: number;
};
};
function normalizeTimestampMs(value: string | number): number {
if (typeof value === 'number') return value < 1e12 ? value * 1000 : value;
if (/^\d{10,}$/.test(value)) {
const asNum = Number(value);
return asNum < 1e12 ? asNum * 1000 : asNum;
}
const parsed = new Date(value).getTime();
return Number.isFinite(parsed) ? parsed : 0;
}
function toFiniteNumber(value: unknown): number | null {
if (typeof value === 'number') return Number.isFinite(value) ? value : null;
if (typeof value === 'string') {
const parsed = Number(value.trim());
return Number.isFinite(parsed) ? parsed : null;
}
return null;
}
function convertV2Tracks(rows: V2TrackResponse[]): ProcessedTrack[] {
const out: ProcessedTrack[] = [];
for (const row of rows) {
if (!row.geometry || row.geometry.length === 0) continue;
const timestamps = Array.isArray(row.timestamps) ? row.timestamps : [];
const timestampsMs = timestamps.map((ts) => normalizeTimestampMs(ts));
const sortedIndices = timestampsMs
.map((_, idx) => idx)
.sort((a, b) => timestampsMs[a] - timestampsMs[b]);
const geometry: [number, number][] = [];
const sortedTimes: number[] = [];
const speeds: number[] = [];
for (const idx of sortedIndices) {
const coord = row.geometry?.[idx];
if (!Array.isArray(coord) || coord.length !== 2) continue;
const nLon = toFiniteNumber(coord[0]);
const nLat = toFiniteNumber(coord[1]);
if (nLon == null || nLat == null) continue;
geometry.push([nLon, nLat]);
sortedTimes.push(timestampsMs[idx]);
speeds.push(toFiniteNumber(row.speeds?.[idx]) ?? 0);
}
if (geometry.length === 0) continue;
const targetId = row.targetId || row.vesselId || '';
const sigSrcCd = row.sigSrcCd || '000001';
const chnName = row.chnPrmShipInfo?.name?.trim();
out.push({
vesselId: row.vesselId || `${sigSrcCd}_${targetId}`,
targetId,
sigSrcCd,
shipName: chnName || (row.shipName || '').trim() || targetId,
shipKindCode: row.shipKindCode || '000027',
nationalCode: row.nationalCode || '',
geometry,
timestampsMs: sortedTimes,
speeds,
stats: {
totalDistanceNm: row.totalDistance || 0,
avgSpeed: row.avgSpeed || 0,
maxSpeed: row.maxSpeed || 0,
pointCount: row.pointCount || geometry.length,
},
chnPrmShipInfo: row.chnPrmShipInfo ? { ...row.chnPrmShipInfo } : undefined,
});
}
return out;
}
async function fetchV2Tracks(
startTimeIso: string,
endTimeIso: string,
mmsi: number,
isPermitted: boolean,
): Promise<ProcessedTrack[]> {
const base = (import.meta.env.VITE_TRACK_V2_BASE_URL || '/signal-batch').trim();
const requestBody = {
startTime: startTimeIso,
endTime: endTimeIso,
vessels: [String(mmsi)],
includeChnPrmShip: isPermitted,
};
const endpoint = `${base.replace(/\/$/, '')}/api/v2/tracks/vessels`;
const res = await fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json', accept: 'application/json' },
body: JSON.stringify(requestBody),
});
if (!res.ok) {
throw new Error(`Track API ${res.status}`);
}
const json = (await res.json()) as unknown;
const rows = Array.isArray(json)
? (json as V2TrackResponse[])
: Array.isArray((json as { data?: unknown }).data)
? ((json as { data: V2TrackResponse[] }).data)
: [];
return convertV2Tracks(rows);
}
export async function queryTrackByMmsi(params: QueryTrackByMmsiParams): Promise<ProcessedTrack[]> {
const end = new Date();
const start = new Date(end.getTime() - params.minutes * 60_000);
return fetchV2Tracks(start.toISOString(), end.toISOString(), params.mmsi, params.isPermitted ?? false);
}
export async function queryTrackByDateRange(params: QueryTrackByDateRangeParams): Promise<ProcessedTrack[]> {
return fetchV2Tracks(params.startTimeIso, params.endTimeIso, params.mmsi, params.isPermitted ?? false);
}