gc-wing/apps/web/src/widgets/map3d/lib/tooltips.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

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