ship-gis/src/components/ship/ShipDetailModal.jsx

326 lines
10 KiB
React
Raw Normal View 히스토리

/**
* 선박 상세 모달 컴포넌트 (다중 모달 지원, 최대 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 {
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()
* @param {string} nationalCode - MID 숫자코드 (: '440', '412')
* @returns {string} 국기 이미지 URL
*/
function getNationalFlagUrl(nationalCode) {
if (!nationalCode) return null;
const baseUrl = import.meta.env.VITE_API_URL || '';
return `${baseUrl}/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);
const isIntegratedShip = ship.targetId && ship.targetId.includes('_');
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);
// 드래그 상태 - 초기 위치는 스토어에서 계산된 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]);
const { ship, id } = modal;
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)}&deg;</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">항적조회</button>
<button type="button" className="trackBtn">항로예측</button>
</div>
</div>
{/* footer */}
<div className="pmFooter">데이터 수신시간 : {formattedTime}</div>
</div>
);
}