From 94b48945f0c31b6dfc8985ce1bb0d5c54119f07e Mon Sep 17 00:00:00 2001 From: htlee Date: Wed, 18 Mar 2026 13:45:52 +0900 Subject: [PATCH] =?UTF-8?q?feat(map):=20=EB=AA=A8=EB=93=A0=20=EC=84=A0?= =?UTF-8?q?=EB=B0=95=20=EC=9A=B0=ED=81=B4=EB=A6=AD=20=EC=BB=A8=ED=85=8D?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EB=A9=94=EB=89=B4=20=E2=80=94=20=EC=84=A0?= =?UTF-8?q?=EB=AA=85/MMSI=20=EB=B3=B5=EC=82=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 기존 대상선박 전용 우클릭 메뉴를 모든 선박 아이콘으로 확장. 선명 복사, MMSI 복사 항목을 상단에 추가하고, 항적조회는 대상선박(isPermitted)에만 조건부 표시. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/pages/dashboard/useDashboardState.ts | 4 +- apps/web/src/widgets/map3d/Map3D.tsx | 10 +- .../map3d/components/VesselContextMenu.tsx | 139 ++++++++++++------ apps/web/src/widgets/map3d/types.ts | 4 +- 4 files changed, 105 insertions(+), 52 deletions(-) diff --git a/apps/web/src/pages/dashboard/useDashboardState.ts b/apps/web/src/pages/dashboard/useDashboardState.ts index 2da1242..3b1809f 100644 --- a/apps/web/src/pages/dashboard/useDashboardState.ts +++ b/apps/web/src/pages/dashboard/useDashboardState.ts @@ -79,8 +79,8 @@ export function useDashboardState(uid: number | null) { const [selectedCableId, setSelectedCableId] = useState(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 ── diff --git a/apps/web/src/widgets/map3d/Map3D.tsx b/apps/web/src/widgets/map3d/Map3D.tsx index 4d889a1..a7c4182 100644 --- a/apps/web/src/widgets/map3d/Map3D.tsx +++ b/apps/web/src/widgets/map3d/Map3D.tsx @@ -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 ( <>
- {trackContextMenu && onRequestTrack && onCloseTrackMenu && ( + {trackContextMenu && onCloseTrackMenu && ( )} diff --git a/apps/web/src/widgets/map3d/components/VesselContextMenu.tsx b/apps/web/src/widgets/map3d/components/VesselContextMenu.tsx index 5cc0591..c3878c6 100644 --- a/apps/web/src/widgets/map3d/components/VesselContextMenu.tsx +++ b/apps/web/src/widgets/map3d/components/VesselContextMenu.tsx @@ -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(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(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}
- {/* 항적조회 항목 */} -
handleCopy(vesselName, 'name')} + onMouseEnter={handleHover} + onMouseLeave={handleLeave} > - 항적조회 -
+ {copiedField === 'name' ? '복사됨' : '선명 복사'} + - {TRACK_OPTIONS.map((opt) => ( - - ))} + {/* MMSI 복사 */} + + + {/* 항적조회 (대상선박만) */} + {isPermitted && onRequestTrack && ( + <> +
+
+ 항적조회 +
+ {TRACK_OPTIONS.map((opt) => ( + + ))} + + )}
); } diff --git a/apps/web/src/widgets/map3d/types.ts b/apps/web/src/widgets/map3d/types.ts index 417154d..eb5769f 100644 --- a/apps/web/src/widgets/map3d/types.ts +++ b/apps/web/src/widgets/map3d/types.ts @@ -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; /** 사진 있는 선박 클릭 시 콜백 (사진 표시자 or 선박 아이콘) */