- 패널에 시작/종료 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>
165 lines
4.8 KiB
TypeScript
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);
|
|
}
|