gc-wing/apps/web/src/widgets/shipImage/ShipImageModal.tsx
htlee e72e2f14f6 feat(ship-image): 선박 이미지 썸네일 및 갤러리 기능
- AIS 타겟에 shipImagePath/shipImageCount 필드 추가
- 선박 이미지 API 연동 (fetchShipImagesByImo)
- 지도 위 사진 인디케이터 (ScatterplotLayer)
- 호버 툴팁에 썸네일 표시
- 정보 패널 카드 갤러리 (스크롤+화살표)
- 고화질 이미지 모달 (initialIndex 지원)
- Vite 프록시 /shipimg 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 03:45:25 +09:00

144 lines
5.0 KiB
TypeScript
Raw Blame 히스토리

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useCallback, useEffect, useState } from 'react';
import type { ShipImageInfo } from '../../entities/shipImage/model/types';
import { fetchShipImagesByImo, toHighResUrl } from '../../entities/shipImage/api/fetchShipImages';
interface ShipImageModalProps {
/** 갤러리에서 전달받은 전체 이미지 목록 (있으면 API 호출 생략) */
images?: ShipImageInfo[];
/** 시작 인덱스 */
initialIndex?: number;
/** fallback: 첫 번째 이미지 경로 (images 없을 때) */
initialImagePath?: string;
/** 전체 이미지 수 (images 없을 때 표시용) */
totalCount?: number;
/** IMO — images 없을 때 API 호출용 */
imo?: number;
vesselName?: string;
onClose: () => void;
}
const ShipImageModal = ({
images: preloadedImages,
initialIndex = 0,
initialImagePath,
totalCount,
imo,
vesselName,
onClose,
}: ShipImageModalProps) => {
const [index, setIndex] = useState(initialIndex);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(false);
// 전체 이미지 목록: preloaded가 있으면 그것을 사용, 없으면 API로 로드
const [fetchedImages, setFetchedImages] = useState<ShipImageInfo[] | null>(null);
const needsFetch = !preloadedImages && !!imo && imo > 0;
const [fetchingAll, setFetchingAll] = useState(needsFetch);
const allImages = preloadedImages ?? fetchedImages;
const total = allImages ? allImages.length : (totalCount ?? 1);
const hasPrev = index > 0;
const hasNext = index < total - 1;
// 현재 이미지 URL 결정 (모달은 항상 고화질)
const currentImageUrl = (() => {
if (allImages && allImages[index]) return toHighResUrl(allImages[index].path);
if (index === 0 && initialImagePath) return toHighResUrl(initialImagePath);
return null;
})();
// preloaded 없을 때만 API 호출
useEffect(() => {
if (!needsFetch) return;
const ac = new AbortController();
fetchShipImagesByImo(imo!, ac.signal).then((result) => {
if (ac.signal.aborted) return;
setFetchedImages(result.length > 0 ? result : null);
setFetchingAll(false);
});
return () => ac.abort();
}, [needsFetch, imo]);
const goPrev = useCallback(() => {
if (hasPrev) { setIndex((i) => i - 1); setLoading(true); setError(false); }
}, [hasPrev]);
const goNext = useCallback(() => {
if (!hasNext) return;
setIndex((i) => i + 1);
setLoading(true);
setError(false);
}, [hasNext]);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
else if (e.key === 'ArrowLeft') goPrev();
else if (e.key === 'ArrowRight') goNext();
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [onClose, goPrev, goNext]);
// 현재 이미지 메타데이터
const currentMeta = allImages?.[index] ?? null;
return (
<div className="ship-image-modal" onClick={onClose}>
<div className="ship-image-modal__content" onClick={(e) => e.stopPropagation()}>
{/* 헤더 */}
<div className="ship-image-modal__header">
<span className="ship-image-modal__title">
{vesselName && <strong>{vesselName}</strong>}
{total > 1 && <span className="ship-image-modal__counter">{index + 1} / {total}</span>}
</span>
<button className="ship-image-modal__close" onClick={onClose} aria-label="닫기">
</button>
</div>
{/* 이미지 영역 */}
<div className="ship-image-modal__body">
{hasPrev && (
<button className="ship-image-modal__nav ship-image-modal__nav--prev" onClick={goPrev} aria-label="이전">
</button>
)}
<div className="ship-image-modal__img-wrap">
{(loading || fetchingAll) && !error && <div className="ship-image-modal__spinner" />}
{error && <div className="ship-image-modal__error"> </div>}
{currentImageUrl ? (
<img
key={currentImageUrl}
src={currentImageUrl}
alt={vesselName || '선박 사진'}
className="ship-image-modal__img"
style={{ opacity: loading ? 0 : 1 }}
onLoad={() => setLoading(false)}
onError={() => { setLoading(false); setError(true); }}
/>
) : fetchingAll ? null : (
<div className="ship-image-modal__error"> </div>
)}
</div>
{hasNext && (
<button className="ship-image-modal__nav ship-image-modal__nav--next" onClick={goNext} aria-label="다음">
</button>
)}
</div>
{/* 푸터 */}
<div className="ship-image-modal__footer">
{currentMeta?.copyright && <span>{currentMeta.copyright}</span>}
{currentMeta?.date && <span>{currentMeta.date}</span>}
</div>
</div>
</div>
);
};
export default ShipImageModal;