- tracking 패키지 TS→JS 변환 (stores, services, components, hooks, utils) - 모달 항적조회 + 우클릭 항적조회 - 라이브 연결선 (PathStyleExtension dash + 1초 인터벌) - TrackQueryModal, TrackQueryViewer, GlobalTrackQueryViewer - 항적 레이어 (trackLayer.js) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
252 lines
8.7 KiB
JavaScript
252 lines
8.7 KiB
JavaScript
/**
|
|
* 선박 우클릭 컨텍스트 메뉴
|
|
* - 단일 선박 우클릭: 해당 선박 메뉴
|
|
* - Ctrl+Drag 선택 후 우클릭: 선택된 선박 전체 메뉴
|
|
*/
|
|
import { useState, useEffect, useRef, useCallback } from 'react';
|
|
import useShipStore from '../../stores/shipStore';
|
|
import { useTrackQueryStore } from '../../tracking/stores/trackQueryStore';
|
|
import useTrackingModeStore, { RADIUS_OPTIONS, isPatrolShip } from '../../stores/trackingModeStore';
|
|
import {
|
|
fetchVesselTracksV2,
|
|
convertToProcessedTracks,
|
|
buildVesselListForQuery,
|
|
deduplicateVessels,
|
|
} from '../../tracking/services/trackQueryApi';
|
|
import './ShipContextMenu.scss';
|
|
|
|
/** KST 기준 로컬 ISO 문자열 생성 (toISOString()은 UTC이므로 사용하지 않음) */
|
|
function toKstISOString(date) {
|
|
const pad = (n) => String(n).padStart(2, '0');
|
|
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
|
|
}
|
|
|
|
const MENU_ITEMS = [
|
|
{ key: 'track', label: '항적조회' },
|
|
// TODO: 임시 배포용 - 미구현 기능 숨김
|
|
// { key: 'analysis', label: '항적분석' },
|
|
// { key: 'detail', label: '상세정보' },
|
|
{ key: 'radius', label: '반경설정', hasSubmenu: true },
|
|
];
|
|
|
|
export default function ShipContextMenu() {
|
|
const contextMenu = useShipStore((s) => s.contextMenu);
|
|
const closeContextMenu = useShipStore((s) => s.closeContextMenu);
|
|
const setRadius = useTrackingModeStore((s) => s.setRadius);
|
|
const selectTrackedShip = useTrackingModeStore((s) => s.selectTrackedShip);
|
|
const currentRadius = useTrackingModeStore((s) => s.radiusNM);
|
|
const menuRef = useRef(null);
|
|
const [hoveredItem, setHoveredItem] = useState(null);
|
|
|
|
// 외부 클릭 시 닫기
|
|
useEffect(() => {
|
|
if (!contextMenu) return;
|
|
|
|
const handleClick = (e) => {
|
|
if (menuRef.current && !menuRef.current.contains(e.target)) {
|
|
closeContextMenu();
|
|
}
|
|
};
|
|
|
|
document.addEventListener('mousedown', handleClick);
|
|
return () => document.removeEventListener('mousedown', handleClick);
|
|
}, [contextMenu, closeContextMenu]);
|
|
|
|
// 반경 선택 핸들러
|
|
const handleRadiusSelect = useCallback((radius) => {
|
|
if (!contextMenu) return;
|
|
const { ships } = contextMenu;
|
|
|
|
// 단일 경비함정인 경우 해당 함정을 추적 대상으로 설정
|
|
if (ships.length === 1) {
|
|
const ship = ships[0];
|
|
setRadius(radius);
|
|
selectTrackedShip(ship.featureId, ship);
|
|
}
|
|
closeContextMenu();
|
|
}, [contextMenu, setRadius, selectTrackedShip, closeContextMenu]);
|
|
|
|
// 메뉴 항목 클릭
|
|
const handleAction = useCallback(async (key) => {
|
|
if (!contextMenu) return;
|
|
const { ships } = contextMenu;
|
|
|
|
// 반경설정은 서브메뉴에서 처리
|
|
if (key === 'radius') return;
|
|
|
|
closeContextMenu();
|
|
|
|
switch (key) {
|
|
case 'track': {
|
|
const store = useTrackQueryStore.getState();
|
|
store.reset();
|
|
|
|
const endTime = new Date();
|
|
const startTime = new Date(endTime.getTime() - 3 * 24 * 60 * 60 * 1000); // 3일
|
|
|
|
const { isIntegrate, features } = useShipStore.getState();
|
|
|
|
const allVessels = [];
|
|
const errors = [];
|
|
ships.forEach(ship => {
|
|
const result = buildVesselListForQuery(ship, 'rightClick', isIntegrate, features);
|
|
if (result.canQuery) allVessels.push(...result.vessels);
|
|
else if (result.errorMessage) errors.push(result.errorMessage);
|
|
});
|
|
|
|
// (sigSrcCd, targetId) 중복 제거
|
|
const uniqueVessels = deduplicateVessels(allVessels);
|
|
|
|
if (uniqueVessels.length === 0) {
|
|
store.setError(errors[0] || '조회 가능한 선박이 없습니다.');
|
|
return;
|
|
}
|
|
|
|
store.setModalMode(false, null);
|
|
store.setLoading(true);
|
|
try {
|
|
const rawTracks = await fetchVesselTracksV2({
|
|
startTime: toKstISOString(startTime),
|
|
endTime: toKstISOString(endTime),
|
|
vessels: uniqueVessels,
|
|
isIntegration: isIntegrate ? '1' : '0',
|
|
});
|
|
const processed = convertToProcessedTracks(rawTracks);
|
|
if (processed.length === 0) {
|
|
store.setError('항적 데이터가 없습니다.');
|
|
} else {
|
|
store.setTracks(processed, startTime.getTime());
|
|
}
|
|
} catch (e) {
|
|
console.error('[ShipContextMenu] 항적 조회 실패:', e);
|
|
store.setError('항적 조회 실패');
|
|
}
|
|
store.setLoading(false);
|
|
break;
|
|
}
|
|
case 'analysis': {
|
|
// 항적분석: 동일한 조회 후 showPlayback 활성화
|
|
const store = useTrackQueryStore.getState();
|
|
store.reset();
|
|
|
|
const endTime = new Date();
|
|
const startTime = new Date(endTime.getTime() - 3 * 24 * 60 * 60 * 1000); // 3일
|
|
|
|
const { isIntegrate, features } = useShipStore.getState();
|
|
|
|
const allVessels = [];
|
|
ships.forEach(ship => {
|
|
const result = buildVesselListForQuery(ship, 'rightClick', isIntegrate, features);
|
|
if (result.canQuery) allVessels.push(...result.vessels);
|
|
});
|
|
|
|
// (sigSrcCd, targetId) 중복 제거
|
|
const uniqueVessels = deduplicateVessels(allVessels);
|
|
|
|
if (uniqueVessels.length === 0) return;
|
|
|
|
store.setModalMode(false, null);
|
|
store.setLoading(true);
|
|
try {
|
|
const rawTracks = await fetchVesselTracksV2({
|
|
startTime: toKstISOString(startTime),
|
|
endTime: toKstISOString(endTime),
|
|
vessels: uniqueVessels,
|
|
isIntegration: isIntegrate ? '1' : '0',
|
|
});
|
|
const processed = convertToProcessedTracks(rawTracks);
|
|
if (processed.length > 0) {
|
|
store.setTracks(processed, startTime.getTime());
|
|
// showPlayback 활성화 (재생 컨트롤 표시)
|
|
useTrackQueryStore.setState({ showPlayback: true });
|
|
}
|
|
} catch (e) {
|
|
console.error('[ShipContextMenu] 항적분석 조회 실패:', e);
|
|
}
|
|
store.setLoading(false);
|
|
break;
|
|
}
|
|
case 'detail':
|
|
if (ships.length === 1) {
|
|
useShipStore.getState().openDetailModal(ships[0]);
|
|
}
|
|
break;
|
|
default:
|
|
console.log(`[ContextMenu] action=${key}, ships=`, ships.map((s) => ({
|
|
featureId: s.featureId,
|
|
shipName: s.shipName,
|
|
targetId: s.targetId,
|
|
})));
|
|
}
|
|
}, [contextMenu, closeContextMenu]);
|
|
|
|
if (!contextMenu) return null;
|
|
|
|
const { x, y, ships } = contextMenu;
|
|
|
|
// 단일 경비함정인지 확인 (반경설정 메뉴 표시 조건)
|
|
const isSinglePatrolShip = ships.length === 1 && isPatrolShip(ships[0].originalTargetId);
|
|
|
|
// 표시할 메뉴 항목 필터링
|
|
const visibleMenuItems = MENU_ITEMS.filter((item) => {
|
|
if (item.key === 'radius') return isSinglePatrolShip;
|
|
return true;
|
|
});
|
|
|
|
// 화면 밖 넘침 방지
|
|
const menuWidth = 160;
|
|
const menuHeight = visibleMenuItems.length * 36 + 40; // 항목 + 헤더
|
|
const adjustedX = x + menuWidth > window.innerWidth ? x - menuWidth : x;
|
|
const adjustedY = y + menuHeight > window.innerHeight ? y - menuHeight : y;
|
|
|
|
// 서브메뉴 위치 (오른쪽 또는 왼쪽)
|
|
const submenuOnLeft = adjustedX + menuWidth + 120 > window.innerWidth;
|
|
|
|
const title = ships.length === 1
|
|
? (ships[0].shipName || ships[0].featureId)
|
|
: `${ships.length}척 선택`;
|
|
|
|
return (
|
|
<div
|
|
ref={menuRef}
|
|
className="ship-context-menu"
|
|
style={{ left: adjustedX, top: adjustedY }}
|
|
>
|
|
<div className="ship-context-menu__header">{title}</div>
|
|
{visibleMenuItems.map((item, index) => (
|
|
<div
|
|
key={item.key}
|
|
className={`ship-context-menu__item ${item.hasSubmenu ? 'has-submenu' : ''}`}
|
|
onClick={() => handleAction(item.key)}
|
|
onMouseEnter={() => setHoveredItem(item.key)}
|
|
onMouseLeave={() => setHoveredItem(null)}
|
|
>
|
|
{item.label}
|
|
{item.hasSubmenu && <span className="submenu-arrow">▶</span>}
|
|
|
|
{/* 반경설정 서브메뉴 */}
|
|
{item.key === 'radius' && hoveredItem === 'radius' && (
|
|
<div
|
|
className={`ship-context-menu__submenu ${submenuOnLeft ? 'left' : 'right'}`}
|
|
style={{ top: 0 }}
|
|
>
|
|
{RADIUS_OPTIONS.map((radius) => (
|
|
<div
|
|
key={radius}
|
|
className={`ship-context-menu__item ${currentRadius === radius ? 'active' : ''}`}
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
handleRadiusSelect(radius);
|
|
}}
|
|
>
|
|
{radius} NM
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|