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);
// ── Track context menu ──
const [trackContextMenu, setTrackContextMenu] = useState<{ x: number; y: number; mmsi: number; vesselName: string } | null>(null);
const handleOpenTrackMenu = useCallback((info: { x: number; y: number; mmsi: number; vesselName: string }) => setTrackContextMenu(info), []);
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; isPermitted: boolean }) => setTrackContextMenu(info), []);
const handleCloseTrackMenu = useCallback(() => setTrackContextMenu(null), []);
// ── Projection loading ──

파일 보기

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

파일 보기

@ -1,11 +1,12 @@
import { useEffect, useRef } from 'react';
import { useEffect, useRef, useState, type CSSProperties } from 'react';
interface Props {
x: number;
y: number;
mmsi: number;
vesselName: string;
onRequestTrack: (mmsi: number, minutes: number) => void;
isPermitted: boolean;
onRequestTrack?: (mmsi: number, minutes: number) => void;
onClose: () => void;
}
@ -20,12 +21,40 @@ const TRACK_OPTIONS = [
const MENU_WIDTH = 180;
const MENU_PAD = 8;
export function VesselContextMenu({ x, y, mmsi, vesselName, onRequestTrack, onClose }: Props) {
const ref = useRef<HTMLDivElement>(null);
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 - (TRACK_OPTIONS.length * 30 + 56) - MENU_PAD;
const maxTop = window.innerHeight - estimatedHeight - MENU_PAD;
const top = Math.min(y, maxTop);
useEffect(() => {
@ -47,8 +76,18 @@ export function VesselContextMenu({ x, y, mmsi, vesselName, onRequestTrack, onCl
};
}, [onClose]);
const handleSelect = (minutes: number) => {
onRequestTrack(mmsi, minutes);
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();
};
@ -92,44 +131,56 @@ export function VesselContextMenu({ x, y, mmsi, vesselName, onRequestTrack, onCl
{vesselName}
</div>
{/* 항적조회 항목 */}
<div
style={{
padding: '4px 12px 2px',
fontSize: 11,
fontWeight: 600,
color: 'rgba(255,255,255,0.6)',
}}
{/* 선명 복사 */}
<button
type="button"
style={STYLE_ITEM}
onClick={() => handleCopy(vesselName, 'name')}
onMouseEnter={handleHover}
onMouseLeave={handleLeave}
>
</div>
{copiedField === 'name' ? '복사됨' : '선명 복사'}
</button>
{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>
))}
{/* 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>
);
}

파일 보기

@ -66,10 +66,10 @@ export interface Map3DProps {
onViewStateChange?: (view: MapViewState) => void;
onGlobeShipsReady?: (ready: boolean) => void;
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;
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 기반. */
alarmMmsiMap?: Map<number, LegacyAlarmKind>;
/** 사진 있는 선박 클릭 시 콜백 (사진 표시자 or 선박 아이콘) */