- 대상선박 우클릭 컨텍스트 메뉴로 항적 조회 (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>
222 lines
7.1 KiB
TypeScript
222 lines
7.1 KiB
TypeScript
import type { AisTarget } from '../../../entities/aisTarget/model/types';
|
|
import type { LegacyVesselInfo } from '../../../entities/legacyVessel/model/types';
|
|
import { fmtIsoFull } from '../../../shared/lib/datetime';
|
|
import { isFiniteNumber, toSafeNumber } from './setUtils';
|
|
|
|
export function formatNm(value: number | null | undefined) {
|
|
if (!isFiniteNumber(value)) return '-';
|
|
return `${value.toFixed(2)} NM`;
|
|
}
|
|
|
|
export function getLegacyTag(legacyHits: Map<number, LegacyVesselInfo> | null | undefined, mmsi: number) {
|
|
const legacy = legacyHits?.get(mmsi);
|
|
if (!legacy) return null;
|
|
return `${legacy.permitNo} (${legacy.shipCode})`;
|
|
}
|
|
|
|
export function getTargetName(
|
|
mmsi: number,
|
|
targetByMmsi: Map<number, AisTarget>,
|
|
legacyHits: Map<number, LegacyVesselInfo> | null | undefined,
|
|
) {
|
|
const legacy = legacyHits?.get(mmsi);
|
|
const target = targetByMmsi.get(mmsi);
|
|
return (
|
|
(target?.name || '').trim() || legacy?.shipNameCn || legacy?.shipNameRoman || `MMSI ${mmsi}`
|
|
);
|
|
}
|
|
|
|
export function getShipTooltipHtml({
|
|
mmsi,
|
|
targetByMmsi,
|
|
legacyHits,
|
|
}: {
|
|
mmsi: number;
|
|
targetByMmsi: Map<number, AisTarget>;
|
|
legacyHits: Map<number, LegacyVesselInfo> | null | undefined;
|
|
}) {
|
|
const legacy = legacyHits?.get(mmsi);
|
|
const t = targetByMmsi.get(mmsi);
|
|
const name = getTargetName(mmsi, targetByMmsi, legacyHits);
|
|
const sog = isFiniteNumber(t?.sog) ? t.sog : null;
|
|
const cog = isFiniteNumber(t?.cog) ? t.cog : null;
|
|
const msg = t?.messageTimestamp ?? null;
|
|
const vesselType = t?.vesselType || '';
|
|
|
|
const legacyHtml = legacy
|
|
? `<div style="margin-top: 6px; padding-top: 6px; border-top: 1px solid rgba(255,255,255,.08)">
|
|
<div><b>CN Permit</b> · <b>${legacy.shipCode}</b> · ${legacy.permitNo}</div>
|
|
<div>유효범위: ${legacy.workSeaArea || '-'}</div>
|
|
</div>`
|
|
: '';
|
|
|
|
return {
|
|
html: `<div style="font-family: system-ui; font-size: 12px; white-space: nowrap;">
|
|
<div style="font-weight: 700; margin-bottom: 4px;">${name}</div>
|
|
<div>MMSI: <b>${mmsi}</b>${vesselType ? ` · ${vesselType}` : ''}</div>
|
|
<div>SOG: <b>${sog ?? '?'}</b> kt · COG: <b>${cog ?? '?'}</b>°</div>
|
|
${msg ? `<div style="opacity:.7; font-size: 11px; margin-top: 4px;">${fmtIsoFull(msg)}</div>` : ''}
|
|
${legacyHtml}
|
|
</div>`,
|
|
};
|
|
}
|
|
|
|
export function getPairLinkTooltipHtml({
|
|
warn,
|
|
distanceNm,
|
|
aMmsi,
|
|
bMmsi,
|
|
legacyHits,
|
|
targetByMmsi,
|
|
}: {
|
|
warn: boolean;
|
|
distanceNm: number | null | undefined;
|
|
aMmsi: number;
|
|
bMmsi: number;
|
|
legacyHits: Map<number, LegacyVesselInfo> | null | undefined;
|
|
targetByMmsi: Map<number, AisTarget>;
|
|
}) {
|
|
const d = formatNm(distanceNm);
|
|
const a = getTargetName(aMmsi, targetByMmsi, legacyHits);
|
|
const b = getTargetName(bMmsi, targetByMmsi, legacyHits);
|
|
const aTag = getLegacyTag(legacyHits, aMmsi);
|
|
const bTag = getLegacyTag(legacyHits, bMmsi);
|
|
return {
|
|
html: `<div style="font-family: system-ui; font-size: 12px;">
|
|
<div style="font-weight: 700; margin-bottom: 4px;">쌍 연결</div>
|
|
<div>${aTag ?? `MMSI ${aMmsi}`}</div>
|
|
<div style="opacity:.85;">↔ ${bTag ?? `MMSI ${bMmsi}`}</div>
|
|
<div style="margin-top: 4px;">거리: <b>${d}</b> · 상태: <b>${warn ? '주의' : '정상'}</b></div>
|
|
<div style="opacity:.6; margin-top: 3px;">${a} / ${b}</div>
|
|
</div>`,
|
|
};
|
|
}
|
|
|
|
export function getFcLinkTooltipHtml({
|
|
suspicious,
|
|
distanceNm,
|
|
fcMmsi,
|
|
otherMmsi,
|
|
legacyHits,
|
|
targetByMmsi,
|
|
}: {
|
|
suspicious: boolean;
|
|
distanceNm: number | null | undefined;
|
|
fcMmsi: number;
|
|
otherMmsi: number;
|
|
legacyHits: Map<number, LegacyVesselInfo> | null | undefined;
|
|
targetByMmsi: Map<number, AisTarget>;
|
|
}) {
|
|
const d = formatNm(distanceNm);
|
|
const a = getTargetName(fcMmsi, targetByMmsi, legacyHits);
|
|
const b = getTargetName(otherMmsi, targetByMmsi, legacyHits);
|
|
const aTag = getLegacyTag(legacyHits, fcMmsi);
|
|
const bTag = getLegacyTag(legacyHits, otherMmsi);
|
|
return {
|
|
html: `<div style="font-family: system-ui; font-size: 12px;">
|
|
<div style="font-weight: 700; margin-bottom: 4px;">환적 연결</div>
|
|
<div>${aTag ?? `MMSI ${fcMmsi}`}</div>
|
|
<div style="opacity:.85;">→ ${bTag ?? `MMSI ${otherMmsi}`}</div>
|
|
<div style="margin-top: 4px;">거리: <b>${d}</b> · 상태: <b>${suspicious ? '의심' : '일반'}</b></div>
|
|
<div style="opacity:.6; margin-top: 3px;">${a} / ${b}</div>
|
|
</div>`,
|
|
};
|
|
}
|
|
|
|
export function getRangeTooltipHtml({
|
|
warn,
|
|
distanceNm,
|
|
aMmsi,
|
|
bMmsi,
|
|
legacyHits,
|
|
}: {
|
|
warn: boolean;
|
|
distanceNm: number | null | undefined;
|
|
aMmsi: number;
|
|
bMmsi: number;
|
|
legacyHits: Map<number, LegacyVesselInfo> | null | undefined;
|
|
}) {
|
|
const d = formatNm(distanceNm);
|
|
const aTag = getLegacyTag(legacyHits, aMmsi);
|
|
const bTag = getLegacyTag(legacyHits, bMmsi);
|
|
const radiusNm = toSafeNumber(distanceNm);
|
|
return {
|
|
html: `<div style="font-family: system-ui; font-size: 12px;">
|
|
<div style="font-weight: 700; margin-bottom: 4px;">쌍 연결범위</div>
|
|
<div>${aTag ?? `MMSI ${aMmsi}`}</div>
|
|
<div style="opacity:.85;">↔ ${bTag ?? `MMSI ${bMmsi}`}</div>
|
|
<div style="margin-top: 4px;">범위: <b>${d}</b> · 반경: <b>${formatNm(radiusNm == null ? null : radiusNm / 2)}</b> · 상태: <b>${warn ? '주의' : '정상'}</b></div>
|
|
</div>`,
|
|
};
|
|
}
|
|
|
|
export function getFleetCircleTooltipHtml({
|
|
ownerKey,
|
|
ownerLabel,
|
|
count,
|
|
}: {
|
|
ownerKey: string;
|
|
ownerLabel?: string;
|
|
count: number;
|
|
}) {
|
|
const displayOwner = ownerLabel && ownerLabel.trim() ? ownerLabel : ownerKey;
|
|
return {
|
|
html: `<div style="font-family: system-ui; font-size: 12px;">
|
|
<div style="font-weight: 700; margin-bottom: 4px;">선단 범위</div>
|
|
<div>소유주: ${displayOwner || '-'}</div>
|
|
<div>선박 수: <b>${count}</b></div>
|
|
</div>`,
|
|
};
|
|
}
|
|
|
|
function fmtMinutesKr(minutes: number): string {
|
|
if (minutes < 60) return `${minutes}분`;
|
|
if (minutes < 1440) return `${Math.round(minutes / 60)}시간`;
|
|
return `${Math.round(minutes / 1440)}일`;
|
|
}
|
|
|
|
export function getTrackLineTooltipHtml({
|
|
name,
|
|
pointCount,
|
|
minutes,
|
|
totalDistanceNm,
|
|
}: {
|
|
name: string;
|
|
pointCount: number;
|
|
minutes: number;
|
|
totalDistanceNm: number;
|
|
}) {
|
|
return {
|
|
html: `<div style="font-family: system-ui; font-size: 12px;">
|
|
<div style="font-weight: 700; margin-bottom: 4px;">항적 · ${name}</div>
|
|
<div>기간: <b>${fmtMinutesKr(minutes)}</b> · 포인트: <b>${pointCount}</b></div>
|
|
<div>총 거리: <b>${totalDistanceNm.toFixed(1)} NM</b></div>
|
|
</div>`,
|
|
};
|
|
}
|
|
|
|
export function getTrackPointTooltipHtml({
|
|
name,
|
|
sog,
|
|
cog,
|
|
heading,
|
|
status,
|
|
messageTimestamp,
|
|
}: {
|
|
name: string;
|
|
sog: number;
|
|
cog: number;
|
|
heading: number;
|
|
status: string;
|
|
messageTimestamp: string;
|
|
}) {
|
|
return {
|
|
html: `<div style="font-family: system-ui; font-size: 12px; white-space: nowrap;">
|
|
<div style="font-weight: 700; margin-bottom: 4px;">${name}</div>
|
|
<div>SOG: <b>${isFiniteNumber(sog) ? sog : '?'}</b> kt · COG: <b>${isFiniteNumber(cog) ? cog : '?'}</b>°</div>
|
|
<div>Heading: <b>${isFiniteNumber(heading) ? heading : '?'}</b>° · 상태: ${status || '-'}</div>
|
|
<div style="opacity:.7; font-size: 11px; margin-top: 4px;">${fmtIsoFull(messageTimestamp)}</div>
|
|
</div>`,
|
|
};
|
|
}
|