기존 대상선박 전용 우클릭 메뉴를 모든 선박 아이콘으로 확장. 선명 복사, MMSI 복사 항목을 상단에 추가하고, 항적조회는 대상선박(isPermitted)에만 조건부 표시. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
187 lines
5.0 KiB
TypeScript
187 lines
5.0 KiB
TypeScript
import { useEffect, useRef, useState, type CSSProperties } from 'react';
|
|
|
|
interface Props {
|
|
x: number;
|
|
y: number;
|
|
mmsi: number;
|
|
vesselName: string;
|
|
isPermitted: boolean;
|
|
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;
|
|
|
|
const STYLE_ITEM: CSSProperties = {
|
|
display: 'block',
|
|
width: '100%',
|
|
padding: '5px 12px 5px 24px',
|
|
background: 'none',
|
|
border: 'none',
|
|
color: '#e2e2e2',
|
|
fontSize: 12,
|
|
textAlign: 'left',
|
|
cursor: 'pointer',
|
|
lineHeight: 1.4,
|
|
};
|
|
|
|
const STYLE_SEPARATOR: CSSProperties = {
|
|
height: 1,
|
|
background: 'rgba(255,255,255,0.08)',
|
|
margin: '3px 0',
|
|
};
|
|
|
|
function handleHover(e: React.MouseEvent) {
|
|
(e.target as HTMLElement).style.background = 'rgba(59,130,246,0.18)';
|
|
}
|
|
|
|
function handleLeave(e: React.MouseEvent) {
|
|
(e.target as HTMLElement).style.background = 'none';
|
|
}
|
|
|
|
export function VesselContextMenu({ x, y, mmsi, vesselName, isPermitted, onRequestTrack, onClose }: Props) {
|
|
const ref = useRef<HTMLDivElement>(null);
|
|
const [copiedField, setCopiedField] = useState<'name' | 'mmsi' | null>(null);
|
|
|
|
const estimatedHeight = (isPermitted && onRequestTrack ? TRACK_OPTIONS.length * 30 + 56 : 0) + 90;
|
|
const left = Math.min(x, window.innerWidth - MENU_WIDTH - MENU_PAD);
|
|
const maxTop = window.innerHeight - estimatedHeight - 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 handleCopy = async (text: string, field: 'name' | 'mmsi') => {
|
|
try {
|
|
await navigator.clipboard.writeText(text);
|
|
setCopiedField(field);
|
|
setTimeout(() => setCopiedField(null), 1200);
|
|
} catch {
|
|
// clipboard API 불가 시 무시
|
|
}
|
|
};
|
|
|
|
const handleSelectTrack = (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>
|
|
|
|
{/* 선명 복사 */}
|
|
<button
|
|
type="button"
|
|
style={STYLE_ITEM}
|
|
onClick={() => handleCopy(vesselName, 'name')}
|
|
onMouseEnter={handleHover}
|
|
onMouseLeave={handleLeave}
|
|
>
|
|
{copiedField === 'name' ? '복사됨' : '선명 복사'}
|
|
</button>
|
|
|
|
{/* MMSI 복사 */}
|
|
<button
|
|
type="button"
|
|
style={STYLE_ITEM}
|
|
onClick={() => handleCopy(String(mmsi), 'mmsi')}
|
|
onMouseEnter={handleHover}
|
|
onMouseLeave={handleLeave}
|
|
>
|
|
{copiedField === 'mmsi' ? '복사됨' : 'MMSI 복사'}
|
|
</button>
|
|
|
|
{/* 항적조회 (대상선박만) */}
|
|
{isPermitted && onRequestTrack && (
|
|
<>
|
|
<div style={STYLE_SEPARATOR} />
|
|
<div
|
|
style={{
|
|
padding: '4px 12px 2px',
|
|
fontSize: 11,
|
|
fontWeight: 600,
|
|
color: 'rgba(255,255,255,0.6)',
|
|
}}
|
|
>
|
|
항적조회
|
|
</div>
|
|
{TRACK_OPTIONS.map((opt) => (
|
|
<button
|
|
type="button"
|
|
key={opt.minutes}
|
|
onClick={() => handleSelectTrack(opt.minutes)}
|
|
style={STYLE_ITEM}
|
|
onMouseEnter={handleHover}
|
|
onMouseLeave={handleLeave}
|
|
>
|
|
{opt.label}
|
|
</button>
|
|
))}
|
|
</>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|