/**
* 선박 상세 모달 컴포넌트 (다중 모달 지원, 최대 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 (
{SIGNAL_FLAG_CONFIGS.map((config) => {
let isActive = false;
let isVisible = false;
if (useIntegratedMode) {
const val = ship[config.dataKey];
if (val === '1') { isVisible = true; isActive = true; }
else if (val === '0') { isVisible = true; }
} else {
if (config.signalSourceCode === ship.signalSourceCode) {
isVisible = true;
isActive = true;
}
}
if (!isVisible) return null;
return (
-
{config.key}
);
})}
);
}
/**
* 선박 사진 갤러리
* 이미지가 없으면 기본 이미지(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 - 드래그 핸들 */}
{ship.nationalCode && (
{ e.target.style.display = 'none'; }}
/>
)}
{ship.shipName || '-'}
{ship.originalTargetId || '-'}
{/* gallery */}
{/* body */}
{/* footer */}
데이터 수신시간 : {formattedTime}
{/* 항적조회 패널 (모달 모드) */}
{showTrackPanel && (
)}
);
}