- 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>
445 lines
15 KiB
JavaScript
445 lines
15 KiB
JavaScript
/**
|
|
* 선박 상세 모달 컴포넌트 (다중 모달 지원, 최대 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 (
|
|
<ul className="shipTypeIco">
|
|
{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 (
|
|
<li
|
|
key={config.key}
|
|
className={isActive ? 'active' : 'inactive'}
|
|
style={{ backgroundColor: isActive ? config.activeColor : config.inactiveColor }}
|
|
>
|
|
{config.key}
|
|
</li>
|
|
);
|
|
})}
|
|
</ul>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 선박 사진 갤러리
|
|
* 이미지가 없으면 기본 이미지(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 (
|
|
<div className="pmGallery">
|
|
{canSlide && (
|
|
<>
|
|
<button type="button" className="navBtn prev" onClick={handlePrev}>
|
|
<span className="blind">이전</span>
|
|
</button>
|
|
<button type="button" className="navBtn next" onClick={handleNext}>
|
|
<span className="blind">다음</span>
|
|
</button>
|
|
</>
|
|
)}
|
|
<div className="galleryView">
|
|
<img
|
|
className="galleryImg"
|
|
src={images[currentIndex]}
|
|
alt="선박 이미지"
|
|
onError={(e) => { e.target.src = defaultShipImg; }}
|
|
/>
|
|
</div>
|
|
{canSlide && (
|
|
<div className="galleryIndicators">
|
|
{images.map((_, i) => (
|
|
<button
|
|
type="button"
|
|
key={i}
|
|
className={`indicator${i === currentIndex ? ' active' : ''}`}
|
|
onClick={() => handleIndicatorClick(i)}
|
|
aria-label={`이미지 ${i + 1}`}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 단일 선박 상세 모달
|
|
* @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 (
|
|
<div
|
|
className="popupMap shipInfo ship-detail-modal"
|
|
style={{
|
|
left: position.x,
|
|
top: position.y,
|
|
transform: 'none',
|
|
}}
|
|
>
|
|
{/* header - 드래그 핸들 */}
|
|
<div className="pmHeader" onMouseDown={handleMouseDown}>
|
|
<div className="rowL">
|
|
<span className="shipTypeIcon">
|
|
<img src={getShipKindIcon(ship.signalKindCode)} alt={kindLabel} />
|
|
</span>
|
|
{ship.nationalCode && (
|
|
<span className="countryFlag">
|
|
<img
|
|
src={getNationalFlagUrl(ship.nationalCode)}
|
|
alt="국기"
|
|
onError={(e) => { e.target.style.display = 'none'; }}
|
|
/>
|
|
</span>
|
|
)}
|
|
<span className="shipName" title={ship.shipName || ''}>{ship.shipName || '-'}</span>
|
|
<span className="shipNum" title={ship.originalTargetId || ''}>{ship.originalTargetId || '-'}</span>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
className="pmClose"
|
|
aria-label="닫기"
|
|
onClick={() => closeDetailModal(id)}
|
|
/>
|
|
</div>
|
|
|
|
{/* gallery */}
|
|
<ShipGallery imageUrlList={ship.imageUrlList} />
|
|
|
|
{/* body */}
|
|
<div className="pmBody">
|
|
<div className="shipAction">
|
|
<div className="rowL">
|
|
<button type="button" className="detailBtn">상세정보</button>
|
|
<SignalFlags ship={ship} />
|
|
</div>
|
|
</div>
|
|
|
|
<ul className="shipStatus">
|
|
<li className="status">
|
|
<div className="statusItem">
|
|
<span className="statusLabel">선박상태</span>
|
|
<span className="statusValue">{isMoving ? '항해' : '정박'}</span>
|
|
</div>
|
|
<div className="statusItem w13r">
|
|
<span className="statusLabel">속도/항로</span>
|
|
<span className="statusValue">{sog.toFixed(1)} kn / {cog.toFixed(1)}°</span>
|
|
</div>
|
|
<div className="statusItem">
|
|
<span className="statusLabel">흘수</span>
|
|
<span className="statusValue">{draught}</span>
|
|
</div>
|
|
</li>
|
|
</ul>
|
|
|
|
<div className="btnWrap">
|
|
<button
|
|
type="button"
|
|
className="trackBtn"
|
|
onClick={handleOpenTrackPanel}
|
|
>
|
|
항적조회
|
|
</button>
|
|
<button type="button" className="trackBtn">항로예측</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* footer */}
|
|
<div className="pmFooter">데이터 수신시간 : {formattedTime}</div>
|
|
|
|
{/* 항적조회 패널 (모달 모드) */}
|
|
{showTrackPanel && (
|
|
<TrackQueryViewer
|
|
modalMode
|
|
isIntegrated={isIntegrated}
|
|
timeRange={timeRange}
|
|
onTimeRangeChange={setTimeRange}
|
|
onQuery={handleTrackQuery}
|
|
isQuerying={isQuerying}
|
|
onClose={handleCloseTrackPanel}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|