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);
|
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 선박 아이콘) */
|
||||||
|
|||||||
불러오는 중...
Reference in New Issue
Block a user