feat(map): 모든 선박 우클릭 컨텍스트 메뉴 — 선명/MMSI 복사
기존 대상선박 전용 우클릭 메뉴를 모든 선박 아이콘으로 확장. 선명 복사, MMSI 복사 항목을 상단에 추가하고, 항적조회는 대상선박(isPermitted)에만 조건부 표시. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
부모
ed3aef8e2a
커밋
94b48945f0
@ -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 선박 아이콘) */
|
||||
|
||||
불러오는 중...
Reference in New Issue
Block a user