/** * 선박 상세 모달 컴포넌트 (다중 모달 지원, 최대 3개) * 퍼블리시 ShipComponent.jsx의 popupMap shipInfo 구조 활용 * 참조: mda-react-front/src/components/popup/ShipDetailModal.tsx * * - 헤더 드래그로 위치 이동 가능 * - 선박 사진 갤러리 (없으면 기본 이미지) * - 새 모달은 직전 모달의 현재 위치(드래그 반영) 기준 우측 140px 오프셋으로 생성 */ import { useRef, useState, useCallback, useEffect } from 'react'; import useShipStore from '../../stores/shipStore'; import { useTrackQueryStore } from '../../tracking/stores/trackQueryStore'; import { fetchVesselTracksV2, convertToProcessedTracks, buildVesselListForQuery, isIntegratedTargetId, } from '../../tracking/services/trackQueryApi'; import { TrackQueryViewer } from '../../tracking/components/TrackQueryViewer'; import { SHIP_KIND_LABELS, SIGNAL_FLAG_CONFIGS, SPEED_THRESHOLD, SIGNAL_KIND_CODE_FISHING, SIGNAL_KIND_CODE_KCGV, SIGNAL_KIND_CODE_PASSENGER, SIGNAL_KIND_CODE_CARGO, SIGNAL_KIND_CODE_TANKER, SIGNAL_KIND_CODE_GOV, } from '../../types/constants'; import defaultShipImg from '../../assets/img/default-ship.png'; import fishingIcon from '../../assets/img/shipDetail/detailKindIcon/fishing.svg'; import kcgvIcon from '../../assets/img/shipDetail/detailKindIcon/kcgv.svg'; import passengerIcon from '../../assets/img/shipDetail/detailKindIcon/passenger.svg'; import cargoIcon from '../../assets/img/shipDetail/detailKindIcon/cargo.svg'; import tankerIcon from '../../assets/img/shipDetail/detailKindIcon/tanker.svg'; import govIcon from '../../assets/img/shipDetail/detailKindIcon/gov.svg'; import etcIcon from '../../assets/img/shipDetail/detailKindIcon/etc.svg'; import './ShipDetailModal.scss'; /** 선종코드 → 아이콘 매핑 */ const SHIP_KIND_ICONS = { [SIGNAL_KIND_CODE_FISHING]: fishingIcon, [SIGNAL_KIND_CODE_KCGV]: kcgvIcon, [SIGNAL_KIND_CODE_PASSENGER]: passengerIcon, [SIGNAL_KIND_CODE_CARGO]: cargoIcon, [SIGNAL_KIND_CODE_TANKER]: tankerIcon, [SIGNAL_KIND_CODE_GOV]: govIcon, }; /** 선종 아이콘 URL 반환 */ function getShipKindIcon(signalKindCode) { return SHIP_KIND_ICONS[signalKindCode] || etcIcon; } /** * 국기 아이콘 URL 반환 (서버 API) * 참조: mda-react-front/src/services/filterCheck.ts - filterNationFlag() * 개발 환경에서는 Vite 프록시를 통해 API 서버로 전달됨 * @param {string} nationalCode - MID 숫자코드 (예: '440', '412') * @returns {string} 국기 이미지 URL */ function getNationalFlagUrl(nationalCode) { if (!nationalCode) return null; // 상대 경로 사용 (Vite 프록시가 /ship/image를 API 서버로 전달) return `/ship/image/small/${nationalCode}.svg`; } /** * receivedTime 문자열을 YYYY-MM-DD HH:mm:ss 형식으로 변환 * 입력 예: '20241123112300' 또는 '2024-11-23 11:23:00' 또는 '2024-11-23T11:23:00' * @param {string} raw * @returns {string} */ function formatDateTime(raw) { if (!raw) return '-'; // 이미 YYYY-MM-DD HH:mm:ss 형태면 그대로 반환 if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(raw)) { return raw; } // 숫자만 추출 (구분자 제거) const digits = raw.replace(/\D/g, ''); if (digits.length >= 14) { const y = digits.slice(0, 4); const M = digits.slice(4, 6); const d = digits.slice(6, 8); const h = digits.slice(8, 10); const m = digits.slice(10, 12); const s = digits.slice(12, 14); return `${y}-${M}-${d} ${h}:${m}:${s}`; } // 파싱 불가하면 원본 반환 return raw; } /** * AVETDR 신호 플래그 표시 */ function SignalFlags({ ship }) { const isIntegrate = useShipStore((s) => s.isIntegrate); // 통합선박 판별: 언더스코어 또는 integrate 플래그 const isIntegratedShip = ship.targetId && (ship.targetId.includes('_') || ship.integrate); const useIntegratedMode = isIntegrate && isIntegratedShip; return ( ); } /** * 선박 사진 갤러리 * 이미지가 없으면 기본 이미지(default-ship.png) 표시 */ function ShipGallery({ imageUrlList }) { const [currentIndex, setCurrentIndex] = useState(0); const hasImages = imageUrlList && imageUrlList.length > 0; const images = hasImages ? imageUrlList : [defaultShipImg]; const total = images.length; const canSlide = total > 1; const handlePrev = useCallback(() => { setCurrentIndex((prev) => (prev === 0 ? total - 1 : prev - 1)); }, [total]); const handleNext = useCallback(() => { setCurrentIndex((prev) => (prev === total - 1 ? 0 : prev + 1)); }, [total]); const handleIndicatorClick = useCallback((index) => { setCurrentIndex(index); }, []); return (
{canSlide && ( <> )}
선박 이미지 { e.target.src = defaultShipImg; }} />
{canSlide && (
{images.map((_, i) => (
)}
); } /** * 단일 선박 상세 모달 * @param {Object} props.modal - { ship, id, initialPos } */ export default function ShipDetailModal({ modal }) { const closeDetailModal = useShipStore((s) => s.closeDetailModal); const updateModalPos = useShipStore((s) => s.updateModalPos); const isIntegrateMode = useShipStore((s) => s.isIntegrate); // 항적조회 패널 상태 const [showTrackPanel, setShowTrackPanel] = useState(false); const [isQuerying, setIsQuerying] = useState(false); const [timeRange, setTimeRange] = useState(() => { const now = new Date(); const from = new Date(now.getTime() - 3 * 24 * 60 * 60 * 1000); // 3일 전 const pad = (n) => String(n).padStart(2, '0'); const toLocal = (d) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`; return { fromDate: toLocal(from), toDate: toLocal(now) }; }); // 드래그 상태 - 초기 위치는 스토어에서 계산된 initialPos 사용 const [position, setPosition] = useState(() => ({ ...modal.initialPos })); const posRef = useRef(modal.initialPos); const dragging = useRef(false); const dragStart = useRef({ x: 0, y: 0 }); // 드래그 핸들러 const handleMouseDown = useCallback((e) => { dragging.current = true; dragStart.current = { x: e.clientX - position.x, y: e.clientY - position.y, }; e.preventDefault(); }, [position]); useEffect(() => { const handleMouseMove = (e) => { if (!dragging.current) return; const newPos = { x: e.clientX - dragStart.current.x, y: e.clientY - dragStart.current.y, }; posRef.current = newPos; setPosition(newPos); }; const handleMouseUp = () => { if (dragging.current) { dragging.current = false; // 드래그 종료 시 스토어에 현재 위치 보고 (ref에서 읽어서 render 중 setState 회피) updateModalPos(modal.id, posRef.current); } }; window.addEventListener('mousemove', handleMouseMove); window.addEventListener('mouseup', handleMouseUp); return () => { window.removeEventListener('mousemove', handleMouseMove); window.removeEventListener('mouseup', handleMouseUp); }; }, [modal.id, updateModalPos]); // KST 기준 로컬 ISO 문자열 생성 (toISOString()은 UTC 기준이므로 사용하지 않음) const toKstISOString = useCallback((date) => { const pad = (n, len = 2) => String(n).padStart(len, '0'); return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`; }, []); // 항적 조회 실행 (공용) const executeTrackQuery = useCallback(async (fromDate, toDate) => { const { ship } = modal; const startTime = new Date(fromDate); const endTime = new Date(toDate); if (isNaN(startTime.getTime()) || isNaN(endTime.getTime())) return; if (startTime >= endTime) return; const isIntegrated = isIntegratedTargetId(ship.targetId); // 모달 항적조회: 통합모드 ON이면 전체 장비 조회, OFF면 단일 장비 조회 // isIntegration API 파라미터는 항상 '0' (개별 항적 반환) const queryResult = buildVesselListForQuery(ship, 'modal', isIntegrateMode); if (!queryResult.canQuery) { useTrackQueryStore.getState().setError(queryResult.errorMessage || '조회 불가'); return; } setIsQuerying(true); const store = useTrackQueryStore.getState(); try { const rawTracks = await fetchVesselTracksV2({ startTime: toKstISOString(startTime), endTime: toKstISOString(endTime), vessels: queryResult.vessels, isIntegration: '0', }); const processed = convertToProcessedTracks(rawTracks); if (processed.length === 0) { store.setError('항적 데이터가 없습니다.'); } else { store.setTracks(processed, startTime.getTime()); } } catch (e) { console.error('[ShipDetailModal] 항적 조회 실패:', e); store.setError('항적 조회 실패'); } setIsQuerying(false); }, [modal, toKstISOString, isIntegrateMode]); // 항적조회 패널 열기 + 즉시 3일 조회 const handleOpenTrackPanel = useCallback(async () => { // 이전 항적 데이터 초기화 useTrackQueryStore.getState().reset(); useTrackQueryStore.getState().setModalMode(true, modal.id); setShowTrackPanel(true); // 즉시 3일 항적 조회 const now = new Date(); const from = new Date(now.getTime() - 3 * 24 * 60 * 60 * 1000); const pad = (n) => String(n).padStart(2, '0'); const toLocal = (d) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`; const newTimeRange = { fromDate: toLocal(from), toDate: toLocal(now) }; setTimeRange(newTimeRange); await executeTrackQuery(from, now); }, [modal.id, executeTrackQuery]); // 항적조회 패널 닫기 const handleCloseTrackPanel = useCallback(() => { setShowTrackPanel(false); useTrackQueryStore.getState().reset(); }, []); // 시간 폼에서 재조회 const handleTrackQuery = useCallback(async () => { await executeTrackQuery(timeRange.fromDate, timeRange.toDate); }, [timeRange, executeTrackQuery]); const { ship, id } = modal; const isIntegrated = isIntegratedTargetId(ship.targetId); const kindLabel = SHIP_KIND_LABELS[ship.signalKindCode] || '기타'; const sog = Number(ship.sog) || 0; const cog = Number(ship.cog) || 0; const isMoving = sog > SPEED_THRESHOLD; const draught = ship.draught ? `${(Number(ship.draught) / 10).toFixed(1)}m` : '-'; const formattedTime = formatDateTime(ship.receivedTime); return (
{/* header - 드래그 핸들 */}
{kindLabel} {ship.nationalCode && ( 국기 { e.target.style.display = 'none'; }} /> )} {ship.shipName || '-'} {ship.originalTargetId || '-'}
{/* gallery */} {/* body */}
  • 선박상태 {isMoving ? '항해' : '정박'}
    속도/항로 {sog.toFixed(1)} kn / {cog.toFixed(1)}°
    흘수 {draught}
{/* footer */}
데이터 수신시간 : {formattedTime}
{/* 항적조회 패널 (모달 모드) */} {showTrackPanel && ( )}
); }