gc-wing/apps/web/src/features/trackReplay/lib/adapters.ts

160 lines
4.7 KiB
TypeScript
Raw Normal View 히스토리

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'];
}