From ed3aef8e2ae4ca8484a40f00106b19f2e1bbe6ac Mon Sep 17 00:00:00 2001 From: htlee Date: Wed, 18 Mar 2026 13:45:35 +0900 Subject: [PATCH 1/5] =?UTF-8?q?feat(vesselSelect):=20=EC=84=A0=EB=B0=95=20?= =?UTF-8?q?=EA=B7=B8=EB=A3=B9=20=EC=A0=80=EC=9E=A5/=EB=B6=88=EB=9F=AC?= =?UTF-8?q?=EC=98=A4=EA=B8=B0=20+=20=EC=BB=AC=EB=9F=BC=20=EC=A0=95?= =?UTF-8?q?=EB=A0=AC=20+=20=EC=84=A0=ED=83=9D=20=EC=B4=88=EA=B8=B0?= =?UTF-8?q?=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 다중 항적 조회 모달에서 반복 선택을 줄이기 위해 선박 그룹 관리 기능 추가. 계정별 localStorage 영속화(usePersistedState), 최대 10개 그룹, 동명 덮어쓰기. 그리드 헤더 클릭으로 6개 컬럼 asc/desc 정렬, 푸터에 선택 초기화 버튼 추가. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../vesselSelect/hooks/useVesselGroups.ts | 66 +++++++ .../hooks/useVesselSelectModal.ts | 15 +- apps/web/src/features/vesselSelect/index.ts | 4 +- .../src/features/vesselSelect/model/types.ts | 8 + .../web/src/pages/dashboard/DashboardPage.tsx | 2 +- .../widgets/vesselSelect/VesselSelectGrid.tsx | 25 ++- .../vesselSelect/VesselSelectModal.tsx | 171 +++++++++++++++++- 7 files changed, 278 insertions(+), 13 deletions(-) create mode 100644 apps/web/src/features/vesselSelect/hooks/useVesselGroups.ts diff --git a/apps/web/src/features/vesselSelect/hooks/useVesselGroups.ts b/apps/web/src/features/vesselSelect/hooks/useVesselGroups.ts new file mode 100644 index 0000000..da9ea92 --- /dev/null +++ b/apps/web/src/features/vesselSelect/hooks/useVesselGroups.ts @@ -0,0 +1,66 @@ +import { useCallback, useMemo } from 'react'; +import { usePersistedState } from '../../../shared/hooks/usePersistedState'; +import type { VesselGroup } from '../model/types'; +import { MAX_VESSEL_GROUPS } from '../model/types'; + +export interface VesselGroupsState { + groups: VesselGroup[]; + /** 동명 그룹 존재 시 갱신, 신규 시 추가. 10개 초과 시 경고 문자열 반환 */ + saveGroup: (name: string, mmsis: number[]) => string | null; + deleteGroup: (id: string) => void; + applyGroup: (group: VesselGroup) => void; +} + +export function useVesselGroups( + userId: number | null, + setMmsis: (mmsis: Set) => void, +): VesselGroupsState { + const [rawGroups, setRawGroups] = usePersistedState(userId, 'vesselGroups', []); + + const groups = useMemo( + () => [...rawGroups].sort((a, b) => b.updatedAt - a.updatedAt), + [rawGroups], + ); + + const saveGroup = useCallback( + (name: string, mmsis: number[]): string | null => { + const trimmed = name.trim(); + if (!trimmed) return '그룹명을 입력해주세요'; + + let warning: string | null = null; + + setRawGroups((prev) => { + const existing = prev.find((g) => g.name === trimmed); + if (existing) { + return prev.map((g) => + g.id === existing.id ? { ...g, mmsis, updatedAt: Date.now() } : g, + ); + } + if (prev.length >= MAX_VESSEL_GROUPS) { + warning = `최대 ${MAX_VESSEL_GROUPS}개까지 저장 가능합니다`; + return prev; + } + return [...prev, { id: Date.now().toString(36), name: trimmed, mmsis, updatedAt: Date.now() }]; + }); + + return warning; + }, + [setRawGroups], + ); + + const deleteGroup = useCallback( + (id: string) => { + setRawGroups((prev) => prev.filter((g) => g.id !== id)); + }, + [setRawGroups], + ); + + const applyGroup = useCallback( + (group: VesselGroup) => { + setMmsis(new Set(group.mmsis)); + }, + [setMmsis], + ); + + return { groups, saveGroup, deleteGroup, applyGroup }; +} diff --git a/apps/web/src/features/vesselSelect/hooks/useVesselSelectModal.ts b/apps/web/src/features/vesselSelect/hooks/useVesselSelectModal.ts index 6e5e7b5..0428ba0 100644 --- a/apps/web/src/features/vesselSelect/hooks/useVesselSelectModal.ts +++ b/apps/web/src/features/vesselSelect/hooks/useVesselSelectModal.ts @@ -3,6 +3,8 @@ import type { DerivedLegacyVessel } from '../../legacyDashboard/model/types'; import type { MultiTrackQueryContext } from '../../trackReplay/model/track.types'; import { useTrackQueryStore } from '../../trackReplay/stores/trackQueryStore'; import { MAX_VESSEL_SELECT, MAX_QUERY_DAYS } from '../model/types'; +import type { VesselGroup } from '../model/types'; +import { useVesselGroups } from './useVesselGroups'; /** ms → datetime-local input value (KST = UTC+9) */ function toDateTimeLocalKST(ms: number): string { @@ -56,9 +58,14 @@ export interface VesselSelectModalState { setPosition: (pos: { x: number; y: number }) => void; selectionWarning: string | null; + + groups: VesselGroup[]; + saveGroup: (name: string, mmsis: number[]) => string | null; + deleteGroup: (id: string) => void; + applyGroup: (group: VesselGroup) => void; } -export function useVesselSelectModal(): VesselSelectModalState { +export function useVesselSelectModal(userId: number | null = null): VesselSelectModalState { const [isOpen, setIsOpen] = useState(false); const [selectedMmsis, setSelectedMmsis] = useState>(new Set()); const [searchQuery, setSearchQuery] = useState(''); @@ -121,6 +128,8 @@ export function useVesselSelectModal(): VesselSelectModalState { setSelectionWarning(null); }, []); + const { groups, saveGroup, deleteGroup, applyGroup } = useVesselGroups(userId, setMmsis); + const selectAllFiltered = useCallback((filtered: DerivedLegacyVessel[]) => { const capped = filtered.slice(0, MAX_VESSEL_SELECT); setSelectedMmsis(new Set(capped.map((v) => v.mmsi))); @@ -269,5 +278,9 @@ export function useVesselSelectModal(): VesselSelectModalState { position, setPosition, selectionWarning, + groups, + saveGroup, + deleteGroup, + applyGroup, }; } diff --git a/apps/web/src/features/vesselSelect/index.ts b/apps/web/src/features/vesselSelect/index.ts index a309c7d..e9a540b 100644 --- a/apps/web/src/features/vesselSelect/index.ts +++ b/apps/web/src/features/vesselSelect/index.ts @@ -1,3 +1,3 @@ -export type { VesselDescriptor } from './model/types'; -export { MAX_VESSEL_SELECT } from './model/types'; +export type { VesselDescriptor, VesselGroup } from './model/types'; +export { MAX_VESSEL_SELECT, MAX_VESSEL_GROUPS } from './model/types'; export { useVesselSelectModal } from './hooks/useVesselSelectModal'; diff --git a/apps/web/src/features/vesselSelect/model/types.ts b/apps/web/src/features/vesselSelect/model/types.ts index a8f6c7c..27fe660 100644 --- a/apps/web/src/features/vesselSelect/model/types.ts +++ b/apps/web/src/features/vesselSelect/model/types.ts @@ -15,5 +15,13 @@ export interface VesselDescriptor { shipCode?: string; } +export interface VesselGroup { + id: string; + name: string; + mmsis: number[]; + updatedAt: number; +} + export const MAX_VESSEL_SELECT = 20; export const MAX_QUERY_DAYS = 28; +export const MAX_VESSEL_GROUPS = 10; diff --git a/apps/web/src/pages/dashboard/DashboardPage.tsx b/apps/web/src/pages/dashboard/DashboardPage.tsx index a80d705..f22cc60 100644 --- a/apps/web/src/pages/dashboard/DashboardPage.tsx +++ b/apps/web/src/pages/dashboard/DashboardPage.tsx @@ -70,7 +70,7 @@ export function DashboardPage() { const { hasUnread, unreadAnnouncements, acknowledge } = useAnnouncementPopup(uid); // ── Vessel select modal (multi-track) ── - const vesselSelectModal = useVesselSelectModal(); + const vesselSelectModal = useVesselSelectModal(uid); // ── Data fetching ── const { data: zones, error: zonesError } = useZones(); diff --git a/apps/web/src/widgets/vesselSelect/VesselSelectGrid.tsx b/apps/web/src/widgets/vesselSelect/VesselSelectGrid.tsx index f47e3e0..f9c42f5 100644 --- a/apps/web/src/widgets/vesselSelect/VesselSelectGrid.tsx +++ b/apps/web/src/widgets/vesselSelect/VesselSelectGrid.tsx @@ -1,12 +1,16 @@ import { useState, useEffect, useCallback, type CSSProperties } from 'react'; import type { DerivedLegacyVessel } from '../../features/legacyDashboard/model/types'; import { VESSEL_TYPES } from '../../entities/vessel/model/meta'; +import type { SortKey, SortDir } from './VesselSelectModal'; interface VesselSelectGridProps { vessels: DerivedLegacyVessel[]; selectedMmsis: Set; toggleMmsi: (mmsi: number) => void; setMmsis: (mmsis: Set) => void; + sortKey: SortKey | null; + sortDir: SortDir; + onSort: (key: SortKey) => void; } interface DragState { @@ -30,8 +34,15 @@ const STYLE_TH: CSSProperties = { padding: '6px 8px', borderBottom: '1px solid rgba(148,163,184,0.2)', fontWeight: 500, + cursor: 'pointer', + userSelect: 'none', }; +function getSortIndicator(col: SortKey, sortKey: SortKey | null, sortDir: SortDir): string { + if (sortKey !== col) return ' –'; + return sortDir === 'asc' ? ' ▲' : ' ▼'; +} + const STYLE_TH_CHECKBOX: CSSProperties = { ...STYLE_TH, width: 28, @@ -79,7 +90,7 @@ function getDragHighlight(direction: 'check' | 'uncheck'): string { return direction === 'check' ? 'rgba(59,130,246,0.18)' : 'rgba(148,163,184,0.12)'; } -export function VesselSelectGrid({ vessels, selectedMmsis, toggleMmsi, setMmsis }: VesselSelectGridProps) { +export function VesselSelectGrid({ vessels, selectedMmsis, toggleMmsi, setMmsis, sortKey, sortDir, onSort }: VesselSelectGridProps) { const [dragState, setDragState] = useState(null); const handleMouseDown = useCallback( @@ -134,12 +145,12 @@ export function VesselSelectGrid({ vessels, selectedMmsis, toggleMmsi, setMmsis - 업종 - 등록번호 - 선명 - MMSI - 속력 - 상태 + onSort('shipCode')}>업종{getSortIndicator('shipCode', sortKey, sortDir)} + onSort('permitNo')}>등록번호{getSortIndicator('permitNo', sortKey, sortDir)} + onSort('name')}>선명{getSortIndicator('name', sortKey, sortDir)} + onSort('mmsi')}>MMSI{getSortIndicator('mmsi', sortKey, sortDir)} + onSort('sog')}>속력{getSortIndicator('sog', sortKey, sortDir)} + onSort('state')}>상태{getSortIndicator('state', sortKey, sortDir)} diff --git a/apps/web/src/widgets/vesselSelect/VesselSelectModal.tsx b/apps/web/src/widgets/vesselSelect/VesselSelectModal.tsx index d30dc78..1497803 100644 --- a/apps/web/src/widgets/vesselSelect/VesselSelectModal.tsx +++ b/apps/web/src/widgets/vesselSelect/VesselSelectModal.tsx @@ -1,10 +1,14 @@ -import { useMemo, useEffect, useCallback, type CSSProperties } from 'react'; +import { useMemo, useEffect, useCallback, useState, useRef, type CSSProperties } from 'react'; import type { VesselSelectModalState } from '../../features/vesselSelect/hooks/useVesselSelectModal'; import type { DerivedLegacyVessel } from '../../features/legacyDashboard/model/types'; import { VESSEL_TYPE_ORDER, VESSEL_TYPES } from '../../entities/vessel/model/meta'; +import { MAX_VESSEL_GROUPS } from '../../features/vesselSelect/model/types'; import { VesselSelectGrid } from './VesselSelectGrid'; import { ToggleButton, TextInput, Button } from '@wing/ui'; +export type SortKey = 'shipCode' | 'permitNo' | 'name' | 'mmsi' | 'sog' | 'state'; +export type SortDir = 'asc' | 'desc'; + interface VesselSelectModalProps { modal: VesselSelectModalState; vessels: DerivedLegacyVessel[]; @@ -121,6 +125,50 @@ const STYLE_DOT = (color: string): CSSProperties => ({ verticalAlign: 'middle', }); +const STYLE_GROUP_BAR: CSSProperties = { + display: 'flex', + alignItems: 'center', + gap: 6, + padding: '6px 16px', + borderBottom: '1px solid rgba(148,163,184,0.1)', + flexShrink: 0, + overflowX: 'auto', +}; + +const STYLE_GROUP_BTN: CSSProperties = { + display: 'inline-flex', + alignItems: 'center', + gap: 4, + padding: '3px 8px', + fontSize: 11, + borderRadius: 4, + border: '1px solid rgba(148,163,184,0.3)', + background: 'rgba(30,41,59,0.6)', + color: '#cbd5e1', + cursor: 'pointer', + whiteSpace: 'nowrap', + flexShrink: 0, +}; + +const STYLE_GROUP_DELETE: CSSProperties = { + fontSize: 9, + color: '#94a3b8', + cursor: 'pointer', + padding: '0 2px', + lineHeight: 1, +}; + +const STYLE_GROUP_INPUT: CSSProperties = { + fontSize: 11, + padding: '3px 6px', + borderRadius: 4, + border: '1px solid rgba(59,130,246,0.5)', + background: 'rgba(30,41,59,0.8)', + color: '#e2e8f0', + outline: 'none', + width: 100, +}; + const STYLE_SEARCH: CSSProperties = { padding: '6px 16px', borderBottom: '1px solid rgba(148,163,184,0.1)', @@ -201,8 +249,48 @@ export function VesselSelectModal({ modal, vessels }: VesselSelectModalProps) { position, setPosition, selectionWarning, + groups, + saveGroup, + deleteGroup, + applyGroup, } = modal; + // ── 그룹 저장 입력 ── + const [isSavingGroup, setIsSavingGroup] = useState(false); + const [groupNameDraft, setGroupNameDraft] = useState(''); + const [groupWarning, setGroupWarning] = useState(null); + const groupInputRef = useRef(null); + + const handleSaveGroup = useCallback(() => { + if (!groupNameDraft.trim()) { + setIsSavingGroup(false); + return; + } + const warn = saveGroup(groupNameDraft, [...selectedMmsis]); + if (warn) { + setGroupWarning(warn); + } else { + setGroupWarning(null); + } + setIsSavingGroup(false); + setGroupNameDraft(''); + }, [groupNameDraft, saveGroup, selectedMmsis]); + + // ── 정렬 ── + const [sortKey, setSortKey] = useState(null); + const [sortDir, setSortDir] = useState('asc'); + + const handleSort = useCallback((key: SortKey) => { + setSortKey((prev) => { + if (prev === key) { + setSortDir((d) => (d === 'asc' ? 'desc' : 'asc')); + return key; + } + setSortDir('asc'); + return key; + }); + }, []); + // ── 필터 ── const filteredVessels = useMemo(() => { let list = vessels; @@ -222,6 +310,24 @@ export function VesselSelectModal({ modal, vessels }: VesselSelectModalProps) { return list; }, [vessels, shipCodeFilter, stateFilter, onlySailing, searchQuery]); + const sortedVessels = useMemo(() => { + if (!sortKey) return filteredVessels; + const arr = [...filteredVessels]; + arr.sort((a, b) => { + let cmp = 0; + switch (sortKey) { + case 'shipCode': cmp = a.shipCode.localeCompare(b.shipCode); break; + case 'permitNo': cmp = a.permitNo.localeCompare(b.permitNo); break; + case 'name': cmp = a.name.localeCompare(b.name, 'ko'); break; + case 'mmsi': cmp = a.mmsi - b.mmsi; break; + case 'sog': cmp = (a.sog ?? -1) - (b.sog ?? -1); break; + case 'state': cmp = a.state.label.localeCompare(b.state.label, 'ko'); break; + } + return sortDir === 'asc' ? cmp : -cmp; + }); + return arr; + }, [filteredVessels, sortKey, sortDir]); + // ── Escape 닫기 ── const handleKeyDown = useCallback( (e: KeyboardEvent) => { @@ -353,6 +459,56 @@ export function VesselSelectModal({ modal, vessels }: VesselSelectModalProps) { + {/* 그룹 바 */} + {(groups.length > 0 || selectedMmsis.size > 0) && ( +
+ {isSavingGroup ? ( + setGroupNameDraft(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') handleSaveGroup(); + if (e.key === 'Escape') { setIsSavingGroup(false); setGroupNameDraft(''); } + }} + onBlur={handleSaveGroup} + autoFocus + /> + ) : ( + + )} + {groupWarning && {groupWarning}} + {groups.map((g) => ( + + ))} +
+ )} + {/* 검색 */}
setSearchQuery(e.target.value)} /> @@ -360,7 +516,15 @@ export function VesselSelectModal({ modal, vessels }: VesselSelectModalProps) { {/* 그리드 */}
- +
{/* 기간 설정 바 */} @@ -396,6 +560,9 @@ export function VesselSelectModal({ modal, vessels }: VesselSelectModalProps) { 전체 선택 ({filteredVessels.length}척) + {selectionWarning && {selectionWarning}}
선택 {selectedMmsis.size}척 From 94b48945f0c31b6dfc8985ce1bb0d5c54119f07e Mon Sep 17 00:00:00 2001 From: htlee Date: Wed, 18 Mar 2026 13:45:52 +0900 Subject: [PATCH 2/5] =?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 선박 아이콘) */ From 3524b8c634f7e07bbd4b29bc0e54a7eba68581d0 Mon Sep 17 00:00:00 2001 From: htlee Date: Wed, 18 Mar 2026 13:46:05 +0900 Subject: [PATCH 3/5] =?UTF-8?q?chore:=20=ED=8C=80=20=EC=9B=8C=ED=81=AC?= =?UTF-8?q?=ED=94=8C=EB=A1=9C=EC=9A=B0=20v1.6.1=20=EB=8F=99=EA=B8=B0?= =?UTF-8?q?=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 (1M context) --- .claude/settings.json | 34 +++++++++++++++++----------------- .claude/workflow-version.json | 2 +- 2 files changed, 18 insertions(+), 18 deletions(-) diff --git a/.claude/settings.json b/.claude/settings.json index f95864b..219dfcc 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -2,30 +2,30 @@ "$schema": "https://json.schemastore.org/claude-code-settings.json", "permissions": { "allow": [ - "Bash(npm run *)", - "Bash(npm -w *)", - "Bash(npm install *)", - "Bash(npm test *)", - "Bash(npx *)", - "Bash(node *)", - "Bash(git status)", - "Bash(git diff *)", - "Bash(git log *)", + "Bash(curl -s *)", + "Bash(fnm *)", + "Bash(git add *)", "Bash(git branch *)", "Bash(git checkout *)", - "Bash(git add *)", "Bash(git commit *)", - "Bash(git pull *)", - "Bash(git fetch *)", - "Bash(git merge *)", - "Bash(git stash *)", - "Bash(git remote *)", "Bash(git config *)", + "Bash(git diff *)", + "Bash(git fetch *)", + "Bash(git log *)", + "Bash(git merge *)", + "Bash(git pull *)", + "Bash(git remote *)", "Bash(git rev-parse *)", "Bash(git show *)", + "Bash(git stash *)", + "Bash(git status)", "Bash(git tag *)", - "Bash(curl -s *)", - "Bash(fnm *)" + "Bash(node *)", + "Bash(npm -w *)", + "Bash(npm install *)", + "Bash(npm run *)", + "Bash(npm test *)", + "Bash(npx *)" ], "deny": [ "Bash(git push --force*)", diff --git a/.claude/workflow-version.json b/.claude/workflow-version.json index ddbd3cd..8e28c8b 100644 --- a/.claude/workflow-version.json +++ b/.claude/workflow-version.json @@ -1,6 +1,6 @@ { "applied_global_version": "1.6.1", - "applied_date": "2026-03-08", + "applied_date": "2026-03-18", "project_type": "react-ts", "gitea_url": "https://gitea.gc-si.dev", "custom_pre_commit": true From 0d3d4c0ae69256e58fb8cf19e99b18acc0316086 Mon Sep 17 00:00:00 2001 From: htlee Date: Wed, 18 Mar 2026 13:47:51 +0900 Subject: [PATCH 4/5] =?UTF-8?q?docs:=20=EB=A6=B4=EB=A6=AC=EC=A6=88=20?= =?UTF-8?q?=EB=85=B8=ED=8A=B8=20=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/RELEASE-NOTES.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/docs/RELEASE-NOTES.md b/docs/RELEASE-NOTES.md index be2b737..e1643bd 100644 --- a/docs/RELEASE-NOTES.md +++ b/docs/RELEASE-NOTES.md @@ -4,6 +4,15 @@ ## [Unreleased] +### 추가 +- 다중 항적 선박 그룹 저장/불러오기 (계정별 localStorage, 최대 10개) +- 선박 목록 컬럼별 정렬 (업종/등록번호/선명/MMSI/속력/상태) +- 선박 선택 초기화 버튼 +- 모든 선박 우클릭 컨텍스트 메뉴 — 선명 복사, MMSI 복사 + +### 변경 +- 우클릭 항적조회를 대상선박 외 모든 선박으로 컨텍스트 메뉴 확장 (항적조회는 대상선박만 유지) + ## [2026-03-10] ### 추가 From 9caac0a9bd91d0089c73af025c9b0eeeaa0f3cfd Mon Sep 17 00:00:00 2001 From: htlee Date: Wed, 18 Mar 2026 13:56:42 +0900 Subject: [PATCH 5/5] =?UTF-8?q?docs:=20=EB=A6=B4=EB=A6=AC=EC=A6=88=20?= =?UTF-8?q?=EB=85=B8=ED=8A=B8=20=EC=A0=95=EB=A6=AC=20(2026-03-18)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/RELEASE-NOTES.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/RELEASE-NOTES.md b/docs/RELEASE-NOTES.md index e1643bd..f5c4a56 100644 --- a/docs/RELEASE-NOTES.md +++ b/docs/RELEASE-NOTES.md @@ -4,6 +4,8 @@ ## [Unreleased] +## [2026-03-18] + ### 추가 - 다중 항적 선박 그룹 저장/불러오기 (계정별 localStorage, 최대 10개) - 선박 목록 컬럼별 정렬 (업종/등록번호/선명/MMSI/속력/상태)