gc-wing/apps/web/src/widgets/map3d/components/VesselContextMenu.tsx
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

136 lines
3.6 KiB
TypeScript

import { useEffect, useRef } from 'react';
interface Props {
x: number;
y: number;
mmsi: number;
vesselName: string;
onRequestTrack: (mmsi: number, minutes: number) => void;
onClose: () => void;
}
const TRACK_OPTIONS = [
{ label: '6시간', minutes: 360 },
{ label: '12시간', minutes: 720 },
{ label: '1일', minutes: 1440 },
{ label: '3일', minutes: 4320 },
{ label: '5일', minutes: 7200 },
] as const;
const MENU_WIDTH = 180;
const MENU_PAD = 8;
export function VesselContextMenu({ x, y, mmsi, vesselName, onRequestTrack, onClose }: Props) {
const ref = useRef<HTMLDivElement>(null);
// 화면 밖 보정
const left = Math.min(x, window.innerWidth - MENU_WIDTH - MENU_PAD);
const maxTop = window.innerHeight - (TRACK_OPTIONS.length * 30 + 56) - MENU_PAD;
const top = Math.min(y, maxTop);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
const onClick = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) onClose();
};
const onScroll = () => onClose();
window.addEventListener('keydown', onKey);
window.addEventListener('mousedown', onClick, true);
window.addEventListener('scroll', onScroll, true);
return () => {
window.removeEventListener('keydown', onKey);
window.removeEventListener('mousedown', onClick, true);
window.removeEventListener('scroll', onScroll, true);
};
}, [onClose]);
const handleSelect = (minutes: number) => {
onRequestTrack(mmsi, minutes);
onClose();
};
return (
<div
ref={ref}
style={{
position: 'fixed',
left,
top,
zIndex: 9999,
minWidth: MENU_WIDTH,
background: 'rgba(24, 24, 32, 0.96)',
border: '1px solid rgba(255,255,255,0.12)',
borderRadius: 8,
boxShadow: '0 8px 24px rgba(0,0,0,0.5)',
padding: '4px 0',
fontFamily: 'system-ui, sans-serif',
fontSize: 12,
color: '#e2e2e2',
backdropFilter: 'blur(8px)',
}}
>
{/* Header */}
<div
style={{
padding: '6px 12px 4px',
fontSize: 10,
fontWeight: 700,
color: 'rgba(255,255,255,0.45)',
letterSpacing: 0.3,
borderBottom: '1px solid rgba(255,255,255,0.06)',
marginBottom: 2,
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
maxWidth: MENU_WIDTH - 24,
}}
title={`${vesselName} (${mmsi})`}
>
{vesselName}
</div>
{/* 항적조회 항목 */}
<div
style={{
padding: '4px 12px 2px',
fontSize: 11,
fontWeight: 600,
color: 'rgba(255,255,255,0.6)',
}}
>
</div>
{TRACK_OPTIONS.map((opt) => (
<button
key={opt.minutes}
onClick={() => handleSelect(opt.minutes)}
style={{
display: 'block',
width: '100%',
padding: '5px 12px 5px 24px',
background: 'none',
border: 'none',
color: '#e2e2e2',
fontSize: 12,
textAlign: 'left',
cursor: 'pointer',
lineHeight: 1.4,
}}
onMouseEnter={(e) => {
(e.target as HTMLElement).style.background = 'rgba(59,130,246,0.18)';
}}
onMouseLeave={(e) => {
(e.target as HTMLElement).style.background = 'none';
}}
>
{opt.label}
</button>
))}
</div>
);
}