- 대상선박 우클릭 컨텍스트 메뉴로 항적 조회 (6h~5d) - Mercator: PathLayer(고정) + TripsLayer(애니메이션) + ScatterplotLayer(포인트) - Globe: MapLibre 네이티브 line + arrow + circle 레이어 - rAF 직접 overlay 조작으로 React 재렌더링 방지 - SVG 아이콘 data URL 캐시로 네트워크 재요청 방지 - 항적 조회 시 자동 fitBounds (전체 항적 뷰포트 맞춤) - API 프록시 /api/ais-target/:mmsi/track 엔드포인트 추가 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
116 lines
3.3 KiB
TypeScript
116 lines
3.3 KiB
TypeScript
import { haversineNm } from '../../../shared/lib/geo/haversineNm';
|
|
import type { ActiveTrack, NormalizedTrip } from '../model/types';
|
|
|
|
/** 시간순 정렬 후 TripsLayer용 정규화 데이터 생성 */
|
|
export function normalizeTrip(
|
|
track: ActiveTrack,
|
|
color: [number, number, number],
|
|
): NormalizedTrip {
|
|
const sorted = [...track.points].sort(
|
|
(a, b) => new Date(a.messageTimestamp).getTime() - new Date(b.messageTimestamp).getTime(),
|
|
);
|
|
|
|
if (sorted.length === 0) {
|
|
return { path: [], timestamps: [], mmsi: track.mmsi, name: '', color };
|
|
}
|
|
|
|
const baseEpoch = new Date(sorted[0].messageTimestamp).getTime();
|
|
const path: [number, number][] = [];
|
|
const timestamps: number[] = [];
|
|
|
|
for (const pt of sorted) {
|
|
path.push([pt.lon, pt.lat]);
|
|
// 32-bit float 정밀도를 보장하기 위해 첫 포인트 기준 초 단위 오프셋
|
|
timestamps.push((new Date(pt.messageTimestamp).getTime() - baseEpoch) / 1000);
|
|
}
|
|
|
|
return {
|
|
path,
|
|
timestamps,
|
|
mmsi: track.mmsi,
|
|
name: sorted[0].name || `MMSI ${track.mmsi}`,
|
|
color,
|
|
};
|
|
}
|
|
|
|
/** Globe 전용 — LineString GeoJSON */
|
|
export function buildTrackLineGeoJson(
|
|
track: ActiveTrack,
|
|
): GeoJSON.FeatureCollection<GeoJSON.LineString> {
|
|
const sorted = [...track.points].sort(
|
|
(a, b) => new Date(a.messageTimestamp).getTime() - new Date(b.messageTimestamp).getTime(),
|
|
);
|
|
|
|
if (sorted.length < 2) {
|
|
return { type: 'FeatureCollection', features: [] };
|
|
}
|
|
|
|
let totalDistanceNm = 0;
|
|
const coordinates: [number, number][] = [];
|
|
for (let i = 0; i < sorted.length; i++) {
|
|
const pt = sorted[i];
|
|
coordinates.push([pt.lon, pt.lat]);
|
|
if (i > 0) {
|
|
const prev = sorted[i - 1];
|
|
totalDistanceNm += haversineNm(prev.lat, prev.lon, pt.lat, pt.lon);
|
|
}
|
|
}
|
|
|
|
return {
|
|
type: 'FeatureCollection',
|
|
features: [
|
|
{
|
|
type: 'Feature',
|
|
properties: {
|
|
mmsi: track.mmsi,
|
|
name: sorted[0].name || `MMSI ${track.mmsi}`,
|
|
pointCount: sorted.length,
|
|
minutes: track.minutes,
|
|
totalDistanceNm: Math.round(totalDistanceNm * 100) / 100,
|
|
},
|
|
geometry: { type: 'LineString', coordinates },
|
|
},
|
|
],
|
|
};
|
|
}
|
|
|
|
/** Globe+Mercator 공용 — Point GeoJSON */
|
|
export function buildTrackPointsGeoJson(
|
|
track: ActiveTrack,
|
|
): GeoJSON.FeatureCollection<GeoJSON.Point> {
|
|
const sorted = [...track.points].sort(
|
|
(a, b) => new Date(a.messageTimestamp).getTime() - new Date(b.messageTimestamp).getTime(),
|
|
);
|
|
|
|
return {
|
|
type: 'FeatureCollection',
|
|
features: sorted.map((pt, index) => ({
|
|
type: 'Feature' as const,
|
|
properties: {
|
|
mmsi: pt.mmsi,
|
|
name: pt.name,
|
|
sog: pt.sog,
|
|
cog: pt.cog,
|
|
heading: pt.heading,
|
|
status: pt.status,
|
|
messageTimestamp: pt.messageTimestamp,
|
|
index,
|
|
},
|
|
geometry: { type: 'Point' as const, coordinates: [pt.lon, pt.lat] },
|
|
})),
|
|
};
|
|
}
|
|
|
|
export function getTrackTimeRange(trip: NormalizedTrip): {
|
|
minTime: number;
|
|
maxTime: number;
|
|
durationSec: number;
|
|
} {
|
|
if (trip.timestamps.length === 0) {
|
|
return { minTime: 0, maxTime: 0, durationSec: 0 };
|
|
}
|
|
const minTime = trip.timestamps[0];
|
|
const maxTime = trip.timestamps[trip.timestamps.length - 1];
|
|
return { minTime, maxTime, durationSec: maxTime - minTime };
|
|
}
|