gc-wing/apps/web/src/entities/vesselTrack/lib/buildTrackGeoJson.ts
htlee 69775c90a2 feat(map): 항적조회 + SVG 캐시 + fitBounds
- 대상선박 우클릭 컨텍스트 메뉴로 항적 조회 (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>
2026-02-16 18:19:01 +09:00

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