160 lines
4.7 KiB
TypeScript
160 lines
4.7 KiB
TypeScript
import type { TrackPoint } from '../../../entities/vesselTrack/model/types';
|
|
import type { ProcessedTrack, TrackStats } from '../model/track.types';
|
|
|
|
const DEFAULT_SHIP_KIND = '000027';
|
|
const DEFAULT_SIGNAL_SOURCE = '000001';
|
|
const EPSILON_DISTANCE = 1e-10;
|
|
|
|
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;
|
|
}
|
|
|
|
export function normalizeTrackTimestampMs(value: string | number | undefined | null): number {
|
|
if (typeof value === 'number') {
|
|
return value < 1e12 ? value * 1000 : value;
|
|
}
|
|
|
|
if (typeof value === 'string' && value.trim().length > 0) {
|
|
if (/^\d{10,}$/.test(value)) {
|
|
const asNum = Number(value);
|
|
return asNum < 1e12 ? asNum * 1000 : asNum;
|
|
}
|
|
|
|
const parsed = new Date(value).getTime();
|
|
if (Number.isFinite(parsed)) return parsed;
|
|
}
|
|
|
|
return Date.now();
|
|
}
|
|
|
|
function calculateStats(points: TrackPoint[]): TrackStats {
|
|
let maxSpeed = 0;
|
|
let speedSum = 0;
|
|
|
|
for (const point of points) {
|
|
const speed = Number.isFinite(point.sog) ? point.sog : 0;
|
|
maxSpeed = Math.max(maxSpeed, speed);
|
|
speedSum += speed;
|
|
}
|
|
|
|
return {
|
|
totalDistanceNm: 0,
|
|
avgSpeed: points.length > 0 ? speedSum / points.length : 0,
|
|
maxSpeed,
|
|
pointCount: points.length,
|
|
};
|
|
}
|
|
|
|
export function convertLegacyTrackPointsToProcessedTrack(
|
|
mmsi: number,
|
|
points: TrackPoint[],
|
|
hints?: {
|
|
shipName?: string;
|
|
shipKindCode?: string;
|
|
nationalCode?: string;
|
|
sigSrcCd?: string;
|
|
},
|
|
): ProcessedTrack | null {
|
|
const sorted = [...points].sort(
|
|
(a, b) => normalizeTrackTimestampMs(a.messageTimestamp) - normalizeTrackTimestampMs(b.messageTimestamp),
|
|
);
|
|
|
|
if (sorted.length === 0) return null;
|
|
|
|
const first = sorted[0];
|
|
const normalizedPoints = sorted
|
|
.map((point) => {
|
|
const lon = toFiniteNumber(point.lon);
|
|
const lat = toFiniteNumber(point.lat);
|
|
if (lon == null || lat == null) return null;
|
|
|
|
const ts = normalizeTrackTimestampMs(point.messageTimestamp);
|
|
const speed = toFiniteNumber(point.sog) ?? 0;
|
|
return {
|
|
point,
|
|
lon,
|
|
lat,
|
|
ts,
|
|
speed,
|
|
};
|
|
})
|
|
.filter((entry): entry is NonNullable<typeof entry> => entry != null);
|
|
|
|
if (normalizedPoints.length === 0) return null;
|
|
|
|
const geometry: [number, number][] = [];
|
|
const timestampsMs: number[] = [];
|
|
const speeds: number[] = [];
|
|
const statsPoints: TrackPoint[] = [];
|
|
|
|
for (const entry of normalizedPoints) {
|
|
const lastCoord = geometry[geometry.length - 1];
|
|
const isDuplicateCoord =
|
|
lastCoord != null &&
|
|
Math.abs(lastCoord[0] - entry.lon) <= EPSILON_DISTANCE &&
|
|
Math.abs(lastCoord[1] - entry.lat) <= EPSILON_DISTANCE;
|
|
const lastTs = timestampsMs[timestampsMs.length - 1];
|
|
|
|
// Drop exact duplicate samples to avoid zero-length/duplicate segments.
|
|
if (isDuplicateCoord && lastTs === entry.ts) continue;
|
|
|
|
geometry.push([entry.lon, entry.lat]);
|
|
timestampsMs.push(entry.ts);
|
|
speeds.push(entry.speed);
|
|
statsPoints.push(entry.point);
|
|
}
|
|
|
|
if (geometry.length === 0) return null;
|
|
|
|
const stats = calculateStats(statsPoints);
|
|
|
|
return {
|
|
vesselId: `${hints?.sigSrcCd || DEFAULT_SIGNAL_SOURCE}_${mmsi}`,
|
|
targetId: String(mmsi),
|
|
sigSrcCd: hints?.sigSrcCd || DEFAULT_SIGNAL_SOURCE,
|
|
shipName: (hints?.shipName || first.name || '').trim() || `MMSI ${mmsi}`,
|
|
shipKindCode: hints?.shipKindCode || DEFAULT_SHIP_KIND,
|
|
nationalCode: hints?.nationalCode || '',
|
|
geometry,
|
|
timestampsMs,
|
|
speeds,
|
|
stats,
|
|
};
|
|
}
|
|
|
|
export function getTracksTimeRange(tracks: ProcessedTrack[]): { start: number; end: number } | null {
|
|
if (tracks.length === 0) return null;
|
|
|
|
let min = Number.POSITIVE_INFINITY;
|
|
let max = Number.NEGATIVE_INFINITY;
|
|
|
|
for (const track of tracks) {
|
|
if (track.timestampsMs.length === 0) continue;
|
|
min = Math.min(min, track.timestampsMs[0]);
|
|
max = Math.max(max, track.timestampsMs[track.timestampsMs.length - 1]);
|
|
}
|
|
|
|
if (!Number.isFinite(min) || !Number.isFinite(max) || min > max) return null;
|
|
return { start: min, end: max };
|
|
}
|
|
|
|
export function getShipKindColor(shipKindCode: string): [number, number, number, number] {
|
|
const colors: Record<string, [number, number, number, number]> = {
|
|
'000020': [25, 116, 25, 180],
|
|
'000021': [0, 41, 255, 180],
|
|
'000022': [176, 42, 42, 180],
|
|
'000023': [255, 139, 54, 180],
|
|
'000024': [255, 0, 0, 180],
|
|
'000025': [92, 30, 224, 180],
|
|
'000027': [255, 135, 207, 180],
|
|
'000028': [232, 95, 27, 180],
|
|
};
|
|
|
|
return colors[shipKindCode] || colors['000027'];
|
|
}
|