feat(map): 모든 선박 우클릭 컨텍스트 메뉴 — 선명/MMSI 복사

기존 대상선박 전용 우클릭 메뉴를 모든 선박 아이콘으로 확장.
선명 복사, MMSI 복사 항목을 상단에 추가하고,
항적조회는 대상선박(isPermitted)에만 조건부 표시.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
htlee 2026-03-18 13:45:52 +09:00
부모 ed3aef8e2a
커밋 94b48945f0
4개의 변경된 파일105개의 추가작업 그리고 52개의 파일을 삭제

파일 보기

@ -79,8 +79,8 @@ export function useDashboardState(uid: number | null) {
const [selectedCableId, setSelectedCableId] = useState<string | null>(null); const [selectedCableId, setSelectedCableId] = useState<string | null>(null);
// ── Track context menu ── // ── Track context menu ──
const [trackContextMenu, setTrackContextMenu] = useState<{ x: number; y: number; mmsi: number; vesselName: string } | null>(null); const [trackContextMenu, setTrackContextMenu] = useState<{ x: number; y: number; mmsi: number; vesselName: string; isPermitted: boolean } | null>(null);
const handleOpenTrackMenu = useCallback((info: { x: number; y: number; mmsi: number; vesselName: string }) => setTrackContextMenu(info), []); const handleOpenTrackMenu = useCallback((info: { x: number; y: number; mmsi: number; vesselName: string; isPermitted: boolean }) => setTrackContextMenu(info), []);
const handleCloseTrackMenu = useCallback(() => setTrackContextMenu(null), []); const handleCloseTrackMenu = useCallback(() => setTrackContextMenu(null), []);
// ── Projection loading ── // ── Projection loading ──

파일 보기

@ -708,11 +708,12 @@ export function Map3D({
if (hovered.length > 0) mmsi = hovered[0]; if (hovered.length > 0) mmsi = hovered[0];
} }
if (mmsi == null || !legacyHits?.has(mmsi)) return; if (mmsi == null) return;
const target = shipByMmsi.get(mmsi); const target = shipByMmsi.get(mmsi);
const vesselName = (target?.name || '').trim() || `MMSI ${mmsi}`; const vesselName = (target?.name || '').trim() || `MMSI ${mmsi}`;
onOpenTrackMenu({ x: e.clientX, y: e.clientY, mmsi, vesselName }); const isPermitted = legacyHits?.has(mmsi) ?? false;
onOpenTrackMenu({ x: e.clientX, y: e.clientY, mmsi, vesselName, isPermitted });
}; };
container.addEventListener('contextmenu', onContextMenu); container.addEventListener('contextmenu', onContextMenu);
return () => container.removeEventListener('contextmenu', onContextMenu); return () => container.removeEventListener('contextmenu', onContextMenu);
@ -734,13 +735,14 @@ export function Map3D({
return ( return (
<> <>
<div ref={containerRef} style={{ width: '100%', height: '100%' }} /> <div ref={containerRef} style={{ width: '100%', height: '100%' }} />
{trackContextMenu && onRequestTrack && onCloseTrackMenu && ( {trackContextMenu && onCloseTrackMenu && (
<VesselContextMenu <VesselContextMenu
x={trackContextMenu.x} x={trackContextMenu.x}
y={trackContextMenu.y} y={trackContextMenu.y}
mmsi={trackContextMenu.mmsi} mmsi={trackContextMenu.mmsi}
vesselName={trackContextMenu.vesselName} vesselName={trackContextMenu.vesselName}
onRequestTrack={onRequestTrack} isPermitted={trackContextMenu.isPermitted}
onRequestTrack={onRequestTrack ?? undefined}
onClose={onCloseTrackMenu} onClose={onCloseTrackMenu}
/> />
)} )}

파일 보기

@ -1,11 +1,12 @@
import { useEffect, useRef } from 'react'; import { useEffect, useRef, useState, type CSSProperties } from 'react';
interface Props { interface Props {
x: number; x: number;
y: number; y: number;
mmsi: number; mmsi: number;
vesselName: string; vesselName: string;
onRequestTrack: (mmsi: number, minutes: number) => void; isPermitted: boolean;
onRequestTrack?: (mmsi: number, minutes: number) => void;
onClose: () => void; onClose: () => void;
} }
@ -20,12 +21,40 @@ const TRACK_OPTIONS = [
const MENU_WIDTH = 180; const MENU_WIDTH = 180;
const MENU_PAD = 8; const MENU_PAD = 8;
export function VesselContextMenu({ x, y, mmsi, vesselName, onRequestTrack, onClose }: Props) { const STYLE_ITEM: CSSProperties = {
const ref = useRef<HTMLDivElement>(null); 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 left = Math.min(x, window.innerWidth - MENU_WIDTH - MENU_PAD);
const maxTop = window.innerHeight - (TRACK_OPTIONS.length * 30 + 56) - MENU_PAD; const maxTop = window.innerHeight - estimatedHeight - MENU_PAD;
const top = Math.min(y, maxTop); const top = Math.min(y, maxTop);
useEffect(() => { useEffect(() => {
@ -47,8 +76,18 @@ export function VesselContextMenu({ x, y, mmsi, vesselName, onRequestTrack, onCl
}; };
}, [onClose]); }, [onClose]);
const handleSelect = (minutes: number) => { const handleCopy = async (text: string, field: 'name' | 'mmsi') => {
onRequestTrack(mmsi, minutes); try {
await navigator.clipboard.writeText(text);
setCopiedField(field);
setTimeout(() => setCopiedField(null), 1200);
} catch {
// clipboard API 불가 시 무시
}
};
const handleSelectTrack = (minutes: number) => {
onRequestTrack?.(mmsi, minutes);
onClose(); onClose();
}; };
@ -92,44 +131,56 @@ export function VesselContextMenu({ x, y, mmsi, vesselName, onRequestTrack, onCl
{vesselName} {vesselName}
</div> </div>
{/* 항적조회 항목 */} {/* 선명 복사 */}
<div <button
style={{ type="button"
padding: '4px 12px 2px', style={STYLE_ITEM}
fontSize: 11, onClick={() => handleCopy(vesselName, 'name')}
fontWeight: 600, onMouseEnter={handleHover}
color: 'rgba(255,255,255,0.6)', onMouseLeave={handleLeave}
}}
> >
{copiedField === 'name' ? '복사됨' : '선명 복사'}
</div> </button>
{TRACK_OPTIONS.map((opt) => ( {/* MMSI 복사 */}
<button <button
key={opt.minutes} type="button"
onClick={() => handleSelect(opt.minutes)} style={STYLE_ITEM}
style={{ onClick={() => handleCopy(String(mmsi), 'mmsi')}
display: 'block', onMouseEnter={handleHover}
width: '100%', onMouseLeave={handleLeave}
padding: '5px 12px 5px 24px', >
background: 'none', {copiedField === 'mmsi' ? '복사됨' : 'MMSI 복사'}
border: 'none', </button>
color: '#e2e2e2',
fontSize: 12, {/* 항적조회 (대상선박만) */}
textAlign: 'left', {isPermitted && onRequestTrack && (
cursor: 'pointer', <>
lineHeight: 1.4, <div style={STYLE_SEPARATOR} />
}} <div
onMouseEnter={(e) => { style={{
(e.target as HTMLElement).style.background = 'rgba(59,130,246,0.18)'; padding: '4px 12px 2px',
}} fontSize: 11,
onMouseLeave={(e) => { fontWeight: 600,
(e.target as HTMLElement).style.background = 'none'; color: 'rgba(255,255,255,0.6)',
}} }}
> >
{opt.label}
</button> </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> </div>
); );
} }

파일 보기

@ -66,10 +66,10 @@ export interface Map3DProps {
onViewStateChange?: (view: MapViewState) => void; onViewStateChange?: (view: MapViewState) => void;
onGlobeShipsReady?: (ready: boolean) => void; onGlobeShipsReady?: (ready: boolean) => void;
activeTrack?: ActiveTrack | null; activeTrack?: ActiveTrack | null;
trackContextMenu?: { x: number; y: number; mmsi: number; vesselName: string } | null; trackContextMenu?: { x: number; y: number; mmsi: number; vesselName: string; isPermitted: boolean } | null;
onRequestTrack?: (mmsi: number, minutes: number) => void; onRequestTrack?: (mmsi: number, minutes: number) => void;
onCloseTrackMenu?: () => void; onCloseTrackMenu?: () => void;
onOpenTrackMenu?: (info: { x: number; y: number; mmsi: number; vesselName: string }) => void; onOpenTrackMenu?: (info: { x: number; y: number; mmsi: number; vesselName: string; isPermitted: boolean }) => void;
/** MMSI → 가장 높은 우선순위 경고 종류. filteredAlarms 기반. */ /** MMSI → 가장 높은 우선순위 경고 종류. filteredAlarms 기반. */
alarmMmsiMap?: Map<number, LegacyAlarmKind>; alarmMmsiMap?: Map<number, LegacyAlarmKind>;
/** 사진 있는 선박 클릭 시 콜백 (사진 표시자 or 선박 아이콘) */ /** 사진 있는 선박 클릭 시 콜백 (사진 표시자 or 선박 아이콘) */