From e72e2f14f654769f8c37760dc0d98763fd951ed3 Mon Sep 17 00:00:00 2001 From: htlee Date: Fri, 20 Feb 2026 03:45:25 +0900 Subject: [PATCH] =?UTF-8?q?feat(ship-image):=20=EC=84=A0=EB=B0=95=20?= =?UTF-8?q?=EC=9D=B4=EB=AF=B8=EC=A7=80=20=EC=8D=B8=EB=84=A4=EC=9D=BC=20?= =?UTF-8?q?=EB=B0=8F=20=EA=B0=A4=EB=9F=AC=EB=A6=AC=20=EA=B8=B0=EB=8A=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AIS 타겟에 shipImagePath/shipImageCount 필드 추가 - 선박 이미지 API 연동 (fetchShipImagesByImo) - 지도 위 사진 인디케이터 (ScatterplotLayer) - 호버 툴팁에 썸네일 표시 - 정보 패널 카드 갤러리 (스크롤+화살표) - 고화질 이미지 모달 (initialIndex 지원) - Vite 프록시 /shipimg 추가 Co-Authored-By: Claude Opus 4.6 --- .../src/app/styles/components/map-panels.css | 203 ++++++++++++++++++ apps/web/src/entities/aisTarget/api/dto.ts | 5 + .../entities/aisTarget/api/fetchPositions.ts | 6 +- .../web/src/entities/aisTarget/model/types.ts | 2 + .../entities/shipImage/api/fetchShipImages.ts | 35 +++ .../web/src/entities/shipImage/model/types.ts | 7 + .../legacyDashboard/dev/mockOverlayData.ts | 12 +- .../src/features/mapToggles/MapToggles.tsx | 2 + .../src/features/shipImage/useShipImageMap.ts | 86 ++++++++ .../web/src/pages/dashboard/DashboardPage.tsx | 56 ++++- .../src/pages/dashboard/useDashboardState.ts | 2 +- apps/web/src/widgets/aisInfo/AisInfoPanel.tsx | 9 +- apps/web/src/widgets/info/VesselInfoPanel.tsx | 12 +- apps/web/src/widgets/map3d/Map3D.tsx | 2 + apps/web/src/widgets/map3d/constants.ts | 2 +- .../src/widgets/map3d/hooks/useDeckLayers.ts | 41 +++- .../widgets/map3d/hooks/useGlobeShipLayers.ts | 13 +- .../widgets/map3d/lib/deckLayerFactories.ts | 47 ++++ apps/web/src/widgets/map3d/lib/shipUtils.ts | 8 +- apps/web/src/widgets/map3d/lib/tooltips.ts | 9 + apps/web/src/widgets/map3d/types.ts | 2 + .../widgets/shipImage/ShipImageGallery.tsx | 130 +++++++++++ .../src/widgets/shipImage/ShipImageModal.tsx | 143 ++++++++++++ apps/web/vite.config.ts | 6 + 24 files changed, 815 insertions(+), 25 deletions(-) create mode 100644 apps/web/src/entities/shipImage/api/fetchShipImages.ts create mode 100644 apps/web/src/entities/shipImage/model/types.ts create mode 100644 apps/web/src/features/shipImage/useShipImageMap.ts create mode 100644 apps/web/src/widgets/shipImage/ShipImageGallery.tsx create mode 100644 apps/web/src/widgets/shipImage/ShipImageModal.tsx diff --git a/apps/web/src/app/styles/components/map-panels.css b/apps/web/src/app/styles/components/map-panels.css index 77cad02..6bbe7f3 100644 --- a/apps/web/src/app/styles/components/map-panels.css +++ b/apps/web/src/app/styles/components/map-panels.css @@ -65,6 +65,209 @@ font-weight: 600; } +/* ── Ship image gallery ── */ +.ship-image-gallery-wrap { + position: relative; + margin: 4px 0 6px; +} + +.ship-image-gallery { + display: flex; + gap: 6px; + overflow-x: auto; + padding: 4px 0; + scrollbar-width: thin; + scroll-snap-type: x proximity; + scroll-behavior: smooth; +} + +.ship-image-gallery__thumb { + flex-shrink: 0; + width: 76px; + height: 56px; + padding: 0; + border: 2px solid transparent; + border-radius: 6px; + overflow: hidden; + cursor: pointer; + background: var(--wing-subtle); + transition: border-color 0.15s; + scroll-snap-align: start; +} + +.ship-image-gallery__thumb:hover { + border-color: var(--accent); +} + +.ship-image-gallery__thumb img { + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} + +.ship-image-gallery__arrow { + position: absolute; + top: 50%; + transform: translateY(-50%); + z-index: 2; + width: 22px; + height: 32px; + border: none; + border-radius: 4px; + background: rgba(0, 0, 0, 0.6); + color: #fff; + font-size: 16px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: background 0.15s; + padding: 0; +} + +.ship-image-gallery__arrow:hover { + background: rgba(0, 0, 0, 0.85); +} + +.ship-image-gallery__arrow--left { left: 0; } +.ship-image-gallery__arrow--right { right: 0; } + +/* ── Ship image modal ── */ +.ship-image-modal { + position: fixed; + inset: 0; + z-index: 1000; + display: flex; + align-items: center; + justify-content: center; + background: rgba(0, 0, 0, 0.8); + backdrop-filter: blur(4px); +} + +.ship-image-modal__content { + position: relative; + max-width: min(92vw, 900px); + max-height: 90vh; + display: flex; + flex-direction: column; + background: var(--wing-glass-dense); + border: 1px solid var(--border); + border-radius: 12px; + overflow: hidden; +} + +.ship-image-modal__header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 14px; + border-bottom: 1px solid var(--wing-subtle); +} + +.ship-image-modal__title { + display: flex; + align-items: center; + gap: 8px; + font-size: 13px; + color: var(--text); +} + +.ship-image-modal__counter { + font-size: 11px; + color: var(--muted); +} + +.ship-image-modal__close { + background: none; + border: none; + color: var(--muted); + font-size: 18px; + cursor: pointer; + padding: 2px 6px; + line-height: 1; +} + +.ship-image-modal__close:hover { + color: var(--text); +} + +.ship-image-modal__body { + position: relative; + display: flex; + align-items: center; + justify-content: center; + flex: 1; + min-height: 0; + padding: 8px; +} + +.ship-image-modal__img-wrap { + position: relative; + display: flex; + align-items: center; + justify-content: center; + min-height: 200px; +} + +.ship-image-modal__img { + max-width: 100%; + max-height: 72vh; + border-radius: 6px; + object-fit: contain; + transition: opacity 0.2s; +} + +.ship-image-modal__spinner { + position: absolute; + width: 32px; + height: 32px; + border: 3px solid rgba(148, 163, 184, 0.28); + border-top-color: var(--accent); + border-radius: 50%; + animation: map-loader-spin 0.7s linear infinite; +} + +.ship-image-modal__error { + font-size: 12px; + color: var(--muted); +} + +.ship-image-modal__nav { + position: absolute; + top: 50%; + transform: translateY(-50%); + background: rgba(0, 0, 0, 0.5); + border: 1px solid rgba(255, 255, 255, 0.15); + color: #fff; + font-size: 28px; + width: 36px; + height: 48px; + border-radius: 6px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: background 0.15s; + z-index: 2; +} + +.ship-image-modal__nav:hover { + background: rgba(0, 0, 0, 0.7); +} + +.ship-image-modal__nav--prev { left: 8px; } +.ship-image-modal__nav--next { right: 8px; } + +.ship-image-modal__footer { + display: flex; + gap: 12px; + padding: 8px 14px; + font-size: 10px; + color: var(--muted); + border-top: 1px solid var(--wing-subtle); +} + .map-loader-overlay { position: absolute; inset: 0; diff --git a/apps/web/src/entities/aisTarget/api/dto.ts b/apps/web/src/entities/aisTarget/api/dto.ts index 0cdc986..286000e 100644 --- a/apps/web/src/entities/aisTarget/api/dto.ts +++ b/apps/web/src/entities/aisTarget/api/dto.ts @@ -17,11 +17,14 @@ export interface ChnPrmShipPositionDto { status: string; signalKindCode: string; messageTimestamp: string; + shipImagePath?: string | null; + shipImageCount?: number; } /** GET /api/v2/vessels/recent-positions 응답 항목 */ export interface RecentVesselPositionDto { mmsi: string; + imo?: number; lon: number; lat: number; sog: number; @@ -31,4 +34,6 @@ export interface RecentVesselPositionDto { shipKindCode: string; nationalCode: string; lastUpdate: string; + shipImagePath?: string | null; + shipImageCount?: number; } diff --git a/apps/web/src/entities/aisTarget/api/fetchPositions.ts b/apps/web/src/entities/aisTarget/api/fetchPositions.ts index 9df5c75..ccfdfc6 100644 --- a/apps/web/src/entities/aisTarget/api/fetchPositions.ts +++ b/apps/web/src/entities/aisTarget/api/fetchPositions.ts @@ -29,13 +29,15 @@ function adaptChnPrmShip(dto: ChnPrmShipPositionDto): AisTarget { source: 'chnprmship', classType: '', signalKindCode: dto.signalKindCode, + shipImagePath: dto.shipImagePath ?? null, + shipImageCount: dto.shipImageCount ?? 0, }; } function adaptRecentVessel(dto: RecentVesselPositionDto): AisTarget { return { mmsi: Number(dto.mmsi), - imo: 0, + imo: dto.imo ?? 0, name: dto.shipNm ?? '', callsign: '', vesselType: dto.shipTy ?? '', @@ -57,6 +59,8 @@ function adaptRecentVessel(dto: RecentVesselPositionDto): AisTarget { classType: '', shipKindCode: dto.shipKindCode, nationalCode: dto.nationalCode, + shipImagePath: dto.shipImagePath ?? null, + shipImageCount: dto.shipImageCount ?? 0, }; } diff --git a/apps/web/src/entities/aisTarget/model/types.ts b/apps/web/src/entities/aisTarget/model/types.ts index 62c2337..53e5cad 100644 --- a/apps/web/src/entities/aisTarget/model/types.ts +++ b/apps/web/src/entities/aisTarget/model/types.ts @@ -23,5 +23,7 @@ export type AisTarget = { signalKindCode?: string; shipKindCode?: string; nationalCode?: string; + shipImagePath?: string | null; + shipImageCount?: number; }; diff --git a/apps/web/src/entities/shipImage/api/fetchShipImages.ts b/apps/web/src/entities/shipImage/api/fetchShipImages.ts new file mode 100644 index 0000000..dd4ea25 --- /dev/null +++ b/apps/web/src/entities/shipImage/api/fetchShipImages.ts @@ -0,0 +1,35 @@ +import type { ShipImageInfo } from '../model/types'; + +const BASE = '/signal-batch'; + +export async function fetchShipImagesByImo( + imo: number, + signal?: AbortSignal, +): Promise { + const res = await fetch(`${BASE}/api/v2/shipimg/${imo}`, { + signal, + headers: { accept: 'application/json' }, + }); + if (!res.ok) return []; + const json: unknown = await res.json(); + return Array.isArray(json) ? json : []; +} + +/** 확장자가 없으면 suffix를 붙여서 정규화 */ +const ensureJpg = (path: string, suffix: '_1.jpg' | '_2.jpg'): string => { + if (/\.jpe?g$/i.test(path)) return path; + return `${path}${suffix}`; +}; + +/** path → 썸네일 URL (_1.jpg) */ +export const toThumbnailUrl = (path: string): string => { + const normalized = ensureJpg(path, '_1.jpg'); + return normalized.startsWith('http') || normalized.startsWith('/') ? normalized : `/shipimg/${normalized}`; +}; + +/** path → 고화질 URL (_2.jpg) */ +export const toHighResUrl = (path: string): string => { + const withExt = ensureJpg(path, '_2.jpg'); + const resolved = withExt.startsWith('http') || withExt.startsWith('/') ? withExt : `/shipimg/${withExt}`; + return resolved.replace(/_1\.jpg$/i, '_2.jpg'); +}; diff --git a/apps/web/src/entities/shipImage/model/types.ts b/apps/web/src/entities/shipImage/model/types.ts new file mode 100644 index 0000000..3565259 --- /dev/null +++ b/apps/web/src/entities/shipImage/model/types.ts @@ -0,0 +1,7 @@ +/** 선박 이미지 메타데이터 — /signal-batch/api/v2/shipimg/{imo} 응답 */ +export interface ShipImageInfo { + picId: number; + path: string; + copyright: string; + date: string; +} diff --git a/apps/web/src/features/legacyDashboard/dev/mockOverlayData.ts b/apps/web/src/features/legacyDashboard/dev/mockOverlayData.ts index d88922a..c83477d 100644 --- a/apps/web/src/features/legacyDashboard/dev/mockOverlayData.ts +++ b/apps/web/src/features/legacyDashboard/dev/mockOverlayData.ts @@ -78,7 +78,7 @@ function makeLegacy( * Group 1 — 정상 쌍끌이 (간격 ~1 NM, 경고 없음) * 위치: 서해남부(zone 3) 125.3°E 34.0°N 부근 */ -const PT_01_AIS = makeAis({ mmsi: 990001, lat: 34.00, lon: 125.30, sog: 3.3, cog: 45, name: 'MOCK VESSEL 1' }); +const PT_01_AIS = makeAis({ mmsi: 990001, lat: 34.00, lon: 125.30, sog: 3.3, cog: 45, name: 'MOCK VESSEL 1', shipImagePath: 'https://picsum.photos/seed/ship990001v1/800/600', shipImageCount: 3 }); const PT_02_AIS = makeAis({ mmsi: 990002, lat: 34.01, lon: 125.32, sog: 3.3, cog: 45, name: 'MOCK VESSEL 2' }); const PT_01_LEG = makeLegacy({ @@ -98,7 +98,7 @@ const PT_02_LEG = makeLegacy({ * Group 2 — 이격 쌍끌이 (간격 ~8 NM → pair_separation alarm) * 위치: 서해남부(zone 3) 125.0°E 34.5°N 부근 */ -const PT_03_AIS = makeAis({ mmsi: 990003, lat: 34.50, lon: 125.00, sog: 3.5, cog: 90, name: 'MOCK VESSEL 3' }); +const PT_03_AIS = makeAis({ mmsi: 990003, lat: 34.50, lon: 125.00, sog: 3.5, cog: 90, name: 'MOCK VESSEL 3', shipImagePath: 'https://picsum.photos/seed/ship990003v1/800/600', shipImageCount: 2 }); const PT_04_AIS = makeAis({ mmsi: 990004, lat: 34.60, lon: 125.12, sog: 3.5, cog: 90, name: 'MOCK VESSEL 4' }); const PT_03_LEG = makeLegacy({ @@ -119,10 +119,10 @@ const PT_04_LEG = makeLegacy({ * 위치: 서해중간(zone 4) 124.8°E 35.2°N 부근 * #11(GN)은 AIS 지연 2시간 → ais_stale alarm 동시 발생 */ -const GN_01_AIS = makeAis({ mmsi: 990005, lat: 35.20, lon: 124.80, sog: 1.0, cog: 180, name: 'MOCK VESSEL 5' }); +const GN_01_AIS = makeAis({ mmsi: 990005, lat: 35.20, lon: 124.80, sog: 1.0, cog: 180, name: 'MOCK VESSEL 5', shipImagePath: 'https://picsum.photos/seed/ship990005v1/800/600', shipImageCount: 1 }); const GN_02_AIS = makeAis({ mmsi: 990006, lat: 35.22, lon: 124.85, sog: 1.2, cog: 170, name: 'MOCK VESSEL 6' }); const GN_03_AIS = makeAis({ mmsi: 990007, lat: 35.18, lon: 124.82, sog: 0.8, cog: 200, name: 'MOCK VESSEL 7' }); -const OT_01_AIS = makeAis({ mmsi: 990008, lat: 35.25, lon: 124.78, sog: 3.5, cog: 160, name: 'MOCK VESSEL 8' }); +const OT_01_AIS = makeAis({ mmsi: 990008, lat: 35.25, lon: 124.78, sog: 3.5, cog: 160, name: 'MOCK VESSEL 8', shipImagePath: 'https://picsum.photos/seed/ship990008v1/800/600', shipImageCount: 4 }); const GN_04_AIS = makeAis({ mmsi: 990011, lat: 35.00, lon: 125.20, sog: 1.5, cog: 190, name: 'MOCK VESSEL 10', messageTimestamp: STALE_TS, receivedDate: STALE_TS, @@ -158,7 +158,7 @@ const GN_04_LEG = makeLegacy({ * Group 4 — 환적 의심 (FC ↔ PS 거리 ~0.15 NM → transshipment alarm) * 위치: 서해남부(zone 3) 125.5°E 34.3°N 부근 */ -const FC_01_AIS = makeAis({ mmsi: 990009, lat: 34.30, lon: 125.50, sog: 1.0, cog: 0, name: 'MOCK CARRIER 1' }); +const FC_01_AIS = makeAis({ mmsi: 990009, lat: 34.30, lon: 125.50, sog: 1.0, cog: 0, name: 'MOCK CARRIER 1', shipImagePath: 'https://picsum.photos/seed/ship990009v1/800/600', shipImageCount: 2 }); const PS_01_AIS = makeAis({ mmsi: 990010, lat: 34.302, lon: 125.502, sog: 0.5, cog: 10, name: 'MOCK VESSEL 9' }); const FC_01_LEG = makeLegacy({ @@ -177,7 +177,7 @@ const PS_01_LEG = makeLegacy({ * PT는 zone 2,3만 허가. zone 4(서해중간)에 위치하면 이탈 판정. * 위치: 서해중간(zone 4) 125.0°E 36.5°N 부근 */ -const PT_05_AIS = makeAis({ mmsi: 990012, lat: 36.50, lon: 125.00, sog: 3.3, cog: 270, name: 'MOCK VESSEL 11' }); +const PT_05_AIS = makeAis({ mmsi: 990012, lat: 36.50, lon: 125.00, sog: 3.3, cog: 270, name: 'MOCK VESSEL 11', shipImagePath: 'https://picsum.photos/seed/ship990012v1/800/600', shipImageCount: 1 }); const PT_05_LEG = makeLegacy({ permitNo: 'MOCK-P012', shipCode: 'PT', mmsiList: [990012], diff --git a/apps/web/src/features/mapToggles/MapToggles.tsx b/apps/web/src/features/mapToggles/MapToggles.tsx index 1ee204b..c5ef342 100644 --- a/apps/web/src/features/mapToggles/MapToggles.tsx +++ b/apps/web/src/features/mapToggles/MapToggles.tsx @@ -9,6 +9,7 @@ export type MapToggleState = { predictVectors: boolean; shipLabels: boolean; subcables: boolean; + shipPhotos: boolean; }; type Props = { @@ -26,6 +27,7 @@ export function MapToggles({ value, onToggle }: Props) { { id: "predictVectors", label: "예측 벡터" }, { id: "shipLabels", label: "선박명 표시" }, { id: "subcables", label: "해저케이블" }, + { id: "shipPhotos", label: "선박 사진" }, ]; return ( diff --git a/apps/web/src/features/shipImage/useShipImageMap.ts b/apps/web/src/features/shipImage/useShipImageMap.ts new file mode 100644 index 0000000..780992b --- /dev/null +++ b/apps/web/src/features/shipImage/useShipImageMap.ts @@ -0,0 +1,86 @@ +import { useEffect, useMemo, useRef, useState } from 'react'; +import type { AisTarget } from '../../entities/aisTarget/model/types'; +import type { ShipImageInfo } from '../../entities/shipImage/model/types'; +import { fetchShipImagesByImo } from '../../entities/shipImage/api/fetchShipImages'; + +const BATCH_SIZE = 5; + +/** + * 대상선박(chnprmship)의 IMO별 이미지 메타데이터를 일괄 조회. + * IMO 단위 캐시로 중복 요청 방지, storeRef + rev 패턴으로 프로그레시브 갱신. + */ +export function useShipImageMap(opts: { + targets: AisTarget[]; + enabled?: boolean; +}): Map { + const { targets, enabled = true } = opts; + + // IMO → images 캐시 (컴포넌트 수명 동안 유지) + const cacheRef = useRef>(new Map()); + // mmsi → images 결과 (렌더링용) + const storeRef = useRef>(new Map()); + const [rev, setRev] = useState(0); + + // 고유 { mmsi, imo } 쌍 추출 (imo > 0만) + const entries = useMemo(() => { + const seen = new Set(); + const result: Array<{ mmsi: number; imo: number }> = []; + for (const t of targets) { + if (t.imo > 0 && !seen.has(t.mmsi)) { + seen.add(t.mmsi); + result.push({ mmsi: t.mmsi, imo: t.imo }); + } + } + return result; + }, [targets]); + + useEffect(() => { + if (!enabled || entries.length === 0) return; + + const ac = new AbortController(); + let cancelled = false; + + (async () => { + // 캐시에 없는 IMO만 추출 + const uncachedImos = new Set(); + for (const e of entries) { + if (!cacheRef.current.has(e.imo)) uncachedImos.add(e.imo); + } + + // batch fetch (BATCH_SIZE 병렬) + const imoArr = Array.from(uncachedImos); + for (let i = 0; i < imoArr.length; i += BATCH_SIZE) { + if (cancelled) return; + const batch = imoArr.slice(i, i + BATCH_SIZE); + const results = await Promise.allSettled( + batch.map((imo) => fetchShipImagesByImo(imo, ac.signal)), + ); + for (let j = 0; j < batch.length; j++) { + const r = results[j]; + const images = r.status === 'fulfilled' ? r.value : []; + cacheRef.current.set(batch[j], images); + } + } + + if (cancelled) return; + + // mmsi → images 매핑 재구성 + const next = new Map(); + for (const e of entries) { + const imgs = cacheRef.current.get(e.imo); + if (imgs && imgs.length > 0) next.set(e.mmsi, imgs); + } + storeRef.current = next; + setRev((v) => v + 1); + })(); + + return () => { + cancelled = true; + ac.abort(); + }; + }, [entries, enabled]); + + // rev를 의존성으로 두어 storeRef 갱신 시 새 참조 반환 + // eslint-disable-next-line react-hooks/exhaustive-deps + return useMemo(() => storeRef.current, [rev]); +} diff --git a/apps/web/src/pages/dashboard/DashboardPage.tsx b/apps/web/src/pages/dashboard/DashboardPage.tsx index 860b7b5..10fff16 100644 --- a/apps/web/src/pages/dashboard/DashboardPage.tsx +++ b/apps/web/src/pages/dashboard/DashboardPage.tsx @@ -18,6 +18,8 @@ import { Topbar } from "../../widgets/topbar/Topbar"; import { VesselInfoPanel } from "../../widgets/info/VesselInfoPanel"; import { SubcableInfoPanel } from "../../widgets/subcableInfo/SubcableInfoPanel"; import { DepthLegend } from "../../widgets/legend/DepthLegend"; +import type { ShipImageInfo } from "../../entities/shipImage/model/types"; +import ShipImageModal from "../../widgets/shipImage/ShipImageModal"; import { queryTrackByMmsi } from "../../features/trackReplay/services/trackQueryService"; import { useTrackQueryStore } from "../../features/trackReplay/stores/trackQueryStore"; import { GlobalTrackReplayPanel } from "../../widgets/trackReplay/GlobalTrackReplayPanel"; @@ -238,6 +240,44 @@ export function DashboardPage() { [highlightedMmsiSet, availableTargetMmsiSet], ); + // 모달 상태 + const [imageModal, setImageModal] = useState<{ + images?: ShipImageInfo[]; + initialIndex?: number; + initialImagePath?: string; + totalCount?: number; + imo?: number; + vesselName?: string; + } | null>(null); + + const handleOpenImageModal = useCallback((mmsi: number) => { + const target = targetsInScope.find((t) => t.mmsi === mmsi); + if (!target?.shipImagePath) return; + const vessel = legacyVesselsAll.find((v) => v.mmsi === mmsi); + const vesselName = vessel?.name || target.name || ''; + setImageModal({ + initialImagePath: target.shipImagePath, + totalCount: target.shipImageCount ?? 1, + imo: target.imo > 0 ? target.imo : undefined, + vesselName, + }); + }, [targetsInScope, legacyVesselsAll]); + + const handlePanelOpenImageModal = useCallback((index: number, images?: ShipImageInfo[]) => { + if (!selectedMmsi) return; + const target = targetsInScope.find((t) => t.mmsi === selectedMmsi); + const vessel = legacyVesselsAll.find((v) => v.mmsi === selectedMmsi); + const vesselName = vessel?.name || target?.name || ''; + setImageModal({ + images, + initialIndex: index, + initialImagePath: target?.shipImagePath ?? undefined, + totalCount: target?.shipImageCount ?? 1, + imo: target && target.imo > 0 ? target.imo : undefined, + vesselName, + }); + }, [selectedMmsi, targetsInScope, legacyVesselsAll]); + const fishingCount = legacyVesselsAll.filter((v) => v.state.isFishing).length; const transitCount = legacyVesselsAll.filter((v) => v.state.isTransit).length; @@ -364,6 +404,7 @@ export function DashboardPage() { onOpenTrackMenu={handleOpenTrackMenu} onMapReady={handleMapReady} alarmMmsiMap={alarmMmsiMap} + onClickShipPhoto={handleOpenImageModal} /> {selectedLegacyVessel ? ( - setSelectedMmsi(null)} onSelectMmsi={setSelectedMmsi} /> + setSelectedMmsi(null)} onSelectMmsi={setSelectedMmsi} imo={selectedTarget && selectedTarget.imo > 0 ? selectedTarget.imo : undefined} shipImagePath={selectedTarget?.shipImagePath} shipImageCount={selectedTarget?.shipImageCount} onOpenImageModal={handlePanelOpenImageModal} /> ) : selectedTarget ? ( - setSelectedMmsi(null)} /> + setSelectedMmsi(null)} onOpenImageModal={handlePanelOpenImageModal} /> ) : null} + {imageModal && ( + setImageModal(null)} + /> + )} {selectedCableId && subcableData?.details.get(selectedCableId) ? ( (uid, 'mapStyleSettings', DEFAULT_MAP_STYLE_SETTINGS); const [overlays, setOverlays] = usePersistedState(uid, 'overlays', { pairLines: true, pairRange: true, fcLines: true, zones: true, - fleetCircles: true, predictVectors: true, shipLabels: true, subcables: false, + fleetCircles: true, predictVectors: true, shipLabels: true, subcables: false, shipPhotos: true, }); const [settings, setSettings] = usePersistedState(uid, 'map3dSettings', { showShips: true, showDensity: false, showSeamark: false, diff --git a/apps/web/src/widgets/aisInfo/AisInfoPanel.tsx b/apps/web/src/widgets/aisInfo/AisInfoPanel.tsx index 7df8139..67b0fa3 100644 --- a/apps/web/src/widgets/aisInfo/AisInfoPanel.tsx +++ b/apps/web/src/widgets/aisInfo/AisInfoPanel.tsx @@ -1,14 +1,17 @@ import type { AisTarget } from "../../entities/aisTarget/model/types"; import type { LegacyVesselInfo } from "../../entities/legacyVessel/model/types"; +import type { ShipImageInfo } from "../../entities/shipImage/model/types"; import { fmtIsoFull } from "../../shared/lib/datetime"; +import ShipImageGallery from "../shipImage/ShipImageGallery"; type Props = { target: AisTarget; legacy?: LegacyVesselInfo | null; onClose: () => void; + onOpenImageModal?: (index: number, images?: ShipImageInfo[]) => void; }; -export function AisInfoPanel({ target: t, legacy, onClose }: Props) { +export function AisInfoPanel({ target: t, legacy, onClose, onOpenImageModal }: Props) { const name = (t.name || "").trim() || "(no name)"; return (
@@ -25,6 +28,10 @@ export function AisInfoPanel({ target: t, legacy, onClose }: Props) {
+ {t.shipImagePath && ( + 0 ? t.imo : undefined} initialImagePath={t.shipImagePath} totalCount={t.shipImageCount} onOpenModal={onOpenImageModal} /> + )} + {legacy ? (
CN Permit Match
diff --git a/apps/web/src/widgets/info/VesselInfoPanel.tsx b/apps/web/src/widgets/info/VesselInfoPanel.tsx index 5920a9d..dbfa620 100644 --- a/apps/web/src/widgets/info/VesselInfoPanel.tsx +++ b/apps/web/src/widgets/info/VesselInfoPanel.tsx @@ -1,18 +1,24 @@ import { ZONE_META } from "../../entities/zone/model/meta"; import { SPEED_MAX, VESSEL_TYPES } from "../../entities/vessel/model/meta"; +import type { ShipImageInfo } from "../../entities/shipImage/model/types"; import type { DerivedLegacyVessel } from "../../features/legacyDashboard/model/types"; import { fmtIsoFull } from "../../shared/lib/datetime"; import { haversineNm } from "../../shared/lib/geo/haversineNm"; import { OVERLAY_RGB, rgbToHex } from "../../shared/lib/map/palette"; +import ShipImageGallery from "../shipImage/ShipImageGallery"; type Props = { vessel: DerivedLegacyVessel; allVessels: DerivedLegacyVessel[]; onClose: () => void; onSelectMmsi?: (mmsi: number) => void; + imo?: number; + shipImagePath?: string | null; + shipImageCount?: number; + onOpenImageModal?: (index: number, images?: ShipImageInfo[]) => void; }; -export function VesselInfoPanel({ vessel: v, allVessels, onClose, onSelectMmsi }: Props) { +export function VesselInfoPanel({ vessel: v, allVessels, onClose, onSelectMmsi, imo, shipImagePath, shipImageCount, onOpenImageModal }: Props) { const t = VESSEL_TYPES[v.shipCode]; const ownerLabel = v.ownerCn || v.ownerRoman || v.ownerKey || "-"; const primary = t.speedProfile.filter((s) => s.primary); @@ -44,6 +50,10 @@ export function VesselInfoPanel({ vessel: v, allVessels, onClose, onSelectMmsi }
+ {shipImagePath && ( + + )} +
속도 diff --git a/apps/web/src/widgets/map3d/Map3D.tsx b/apps/web/src/widgets/map3d/Map3D.tsx index 8cf47f5..be52cc6 100644 --- a/apps/web/src/widgets/map3d/Map3D.tsx +++ b/apps/web/src/widgets/map3d/Map3D.tsx @@ -82,6 +82,7 @@ export function Map3D({ onOpenTrackMenu, onMapReady, alarmMmsiMap, + onClickShipPhoto, }: Props) { // ── Shared refs ────────────────────────────────────────────────────── const containerRef = useRef(null); @@ -610,6 +611,7 @@ export function Map3D({ toFleetMmsiList, touchDeckHoverState, hasAuxiliarySelectModifier, onDeckSelectOrHighlight, onSelectMmsi: onMapSelectMmsi, onToggleHighlightMmsi, ensureMercatorOverlay, alarmMmsiMap, + onClickShipPhoto, }, ); diff --git a/apps/web/src/widgets/map3d/constants.ts b/apps/web/src/widgets/map3d/constants.ts index c29a7bc..583ffbb 100644 --- a/apps/web/src/widgets/map3d/constants.ts +++ b/apps/web/src/widgets/map3d/constants.ts @@ -43,7 +43,7 @@ export const HALO_OUTLINE_COLOR: [number, number, number, number] = [210, 225, 2 export const HALO_OUTLINE_COLOR_SELECTED: [number, number, number, number] = [14, 234, 255, 230]; export const HALO_OUTLINE_COLOR_HIGHLIGHTED: [number, number, number, number] = [245, 158, 11, 210]; export const GLOBE_OUTLINE_PERMITTED = 'rgba(210,225,240,0.62)'; -export const GLOBE_OUTLINE_OTHER = 'rgba(160,175,195,0.35)'; +export const GLOBE_OUTLINE_OTHER = 'rgba(160,175,195,0.55)'; // ── Flat map icon sizes ── diff --git a/apps/web/src/widgets/map3d/hooks/useDeckLayers.ts b/apps/web/src/widgets/map3d/hooks/useDeckLayers.ts index 46d952c..9e58b05 100644 --- a/apps/web/src/widgets/map3d/hooks/useDeckLayers.ts +++ b/apps/web/src/widgets/map3d/hooks/useDeckLayers.ts @@ -19,7 +19,6 @@ import { } from '../lib/tooltips'; import { sanitizeDeckLayerList } from '../lib/mapCore'; import { buildMercatorDeckLayers, buildGlobeDeckLayers } from '../lib/deckLayerFactories'; - // NOTE: // Globe mode now relies on MapLibre native overlays (useGlobeOverlays/useGlobeShips). // Keep Deck custom-layer rendering disabled in globe to avoid projection-space artifacts. @@ -69,6 +68,7 @@ export function useDeckLayers( onToggleHighlightMmsi?: (mmsi: number) => void; ensureMercatorOverlay: () => MapboxOverlay | null; alarmMmsiMap?: Map; + onClickShipPhoto?: (mmsi: number) => void; }, ) { const { @@ -82,6 +82,7 @@ export function useDeckLayers( toFleetMmsiList, touchDeckHoverState, hasAuxiliarySelectModifier, onDeckSelectOrHighlight, onSelectMmsi, onToggleHighlightMmsi, ensureMercatorOverlay, alarmMmsiMap, + onClickShipPhoto, } = opts; const legacyTargets = useMemo(() => { @@ -106,6 +107,10 @@ export function useDeckLayers( return shipData.filter((t) => alarmMmsiMap.has(t.mmsi)); }, [shipData, alarmMmsiMap]); + const shipPhotoTargets = useMemo(() => { + return shipData.filter((t) => !!t.shipImagePath); + }, [shipData]); + const mercatorLayersRef = useRef([]); const alarmRafRef = useRef(0); @@ -161,6 +166,8 @@ export function useDeckLayers( alarmMmsiMap, alarmPulseRadius: 8, alarmPulseHoverRadius: 12, + shipPhotoTargets, + onClickShipPhoto, }); const normalizedBaseLayers = sanitizeDeckLayerList(layers); @@ -264,6 +271,9 @@ export function useDeckLayers( hasAuxiliarySelectModifier, alarmTargets, alarmMmsiMap, + shipPhotoTargets, + onClickShipPhoto, + overlays.shipPhotos, ]); // Mercator alarm pulse breathing animation (rAF) @@ -337,8 +347,32 @@ export function useDeckLayers( if (!deckTarget) return; if (!ENABLE_GLOBE_DECK_OVERLAYS) { + // Ship photo indicator: 사진 유무 표시 (ScatterplotLayer) + const photoLayers: unknown[] = []; + if (settings.showShips && overlays.shipPhotos && shipPhotoTargets.length > 0) { + photoLayers.push( + new ScatterplotLayer({ + id: 'ship-photo-indicator', + data: shipPhotoTargets, + pickable: true, + billboard: false, + filled: true, + stroked: true, + radiusUnits: 'pixels', + getRadius: 5, + getFillColor: [0, 188, 212, 180], + getLineColor: [255, 255, 255, 200], + lineWidthUnits: 'pixels', + getLineWidth: 1, + getPosition: (d) => [d.lon, d.lat] as [number, number], + onClick: (info: PickingInfo) => { + if (info.object) onClickShipPhoto?.((info.object as AisTarget).mmsi); + }, + }), + ); + } try { - deckTarget.setProps({ layers: [], getTooltip: undefined, onClick: undefined } as never); + deckTarget.setProps({ layers: sanitizeDeckLayerList(photoLayers), getTooltip: undefined, onClick: undefined } as never); } catch { // ignore } @@ -405,5 +439,8 @@ export function useDeckLayers( toFleetMmsiList, touchDeckHoverState, legacyHits, + shipPhotoTargets, + onClickShipPhoto, + overlays.shipPhotos, ]); } diff --git a/apps/web/src/widgets/map3d/hooks/useGlobeShipLayers.ts b/apps/web/src/widgets/map3d/hooks/useGlobeShipLayers.ts index 970642c..165e19e 100644 --- a/apps/web/src/widgets/map3d/hooks/useGlobeShipLayers.ts +++ b/apps/web/src/widgets/map3d/hooks/useGlobeShipLayers.ts @@ -298,7 +298,8 @@ export function useGlobeShipLayers( 'case', ['==', ['feature-state', 'selected'], 1], 0.38, ['==', ['feature-state', 'highlighted'], 1], 0.34, - 0.16, + ['==', ['get', 'permitted'], 1], 0.16, + 0.25, ] as never, }, } as unknown as LayerSpecification, @@ -332,7 +333,7 @@ export function useGlobeShipLayers( ['==', ['feature-state', 'selected'], 1], 3.4, ['==', ['feature-state', 'highlighted'], 1], 2.7, ['==', ['get', 'permitted'], 1], 1.8, - 0.7, + 1.2, ] as never, 'circle-stroke-opacity': 0.85, }, @@ -433,13 +434,13 @@ export function useGlobeShipLayers( ['linear'], ['zoom'], 6.5, - 0.16, + 0.28, 8, - 0.34, + 0.45, 11, - 0.54, + 0.65, 14, - 0.68, + 0.78, ] as never, }, } as unknown as LayerSpecification, diff --git a/apps/web/src/widgets/map3d/lib/deckLayerFactories.ts b/apps/web/src/widgets/map3d/lib/deckLayerFactories.ts index 52a2d72..8340b10 100644 --- a/apps/web/src/widgets/map3d/lib/deckLayerFactories.ts +++ b/apps/web/src/widgets/map3d/lib/deckLayerFactories.ts @@ -85,6 +85,8 @@ export interface MercatorDeckLayerContext extends DeckHoverCallbacks, DeckSelect alarmMmsiMap?: Map; alarmPulseRadius?: number; alarmPulseHoverRadius?: number; + shipPhotoTargets?: AisTarget[]; + onClickShipPhoto?: (mmsi: number) => void; } export function buildMercatorDeckLayers(ctx: MercatorDeckLayerContext): unknown[] { @@ -316,6 +318,26 @@ export function buildMercatorDeckLayers(ctx: MercatorDeckLayerContext): unknown[ }; if (shipOtherData.length > 0) { + layers.push( + new ScatterplotLayer({ + id: 'ships-other-halo', + data: shipOtherData, + pickable: false, + billboard: false, + parameters: overlayParams, + getPosition: (d) => [d.lon, d.lat] as [number, number], + radiusUnits: 'pixels', + getRadius: 10, + getFillColor: (d) => getShipColor(d, null, null, EMPTY_MMSI_SET).slice(0, 3).concat(40) as unknown as [number, number, number, number], + getLineColor: (d) => { + const c = getShipColor(d, null, null, EMPTY_MMSI_SET); + return [c[0], c[1], c[2], 100] as [number, number, number, number]; + }, + stroked: true, + lineWidthUnits: 'pixels', + getLineWidth: 1, + }), + ); layers.push( new IconLayer({ id: 'ships-other', @@ -529,6 +551,31 @@ export function buildMercatorDeckLayers(ctx: MercatorDeckLayerContext): unknown[ ); } + /* ─ ship photo indicator (사진 유무 표시) ─ */ + const photoTargets = ctx.shipPhotoTargets ?? []; + if (ctx.showShips && ctx.overlays.shipPhotos && photoTargets.length > 0) { + layers.push( + new ScatterplotLayer({ + id: 'ship-photo-indicator', + data: photoTargets, + pickable: true, + billboard: false, + filled: true, + stroked: true, + radiusUnits: 'pixels', + getRadius: 5, + getFillColor: [0, 188, 212, 180], + getLineColor: [255, 255, 255, 200], + lineWidthUnits: 'pixels', + getLineWidth: 1, + getPosition: (d) => [d.lon, d.lat] as [number, number], + onClick: (info: PickingInfo) => { + if (info.object) ctx.onClickShipPhoto?.((info.object as AisTarget).mmsi); + }, + }), + ); + } + return layers; } diff --git a/apps/web/src/widgets/map3d/lib/shipUtils.ts b/apps/web/src/widgets/map3d/lib/shipUtils.ts index 80b79e6..eaab8bb 100644 --- a/apps/web/src/widgets/map3d/lib/shipUtils.ts +++ b/apps/web/src/widgets/map3d/lib/shipUtils.ts @@ -87,10 +87,10 @@ export function getShipColor( if (rgb) return [rgb[0], rgb[1], rgb[2], 235]; return [245, 158, 11, 235]; } - if (!isFiniteNumber(t.sog)) return [MAP_DEFAULT_SHIP_RGB[0], MAP_DEFAULT_SHIP_RGB[1], MAP_DEFAULT_SHIP_RGB[2], 130]; - if (t.sog >= 10) return [148, 163, 184, 185]; - if (t.sog >= 1) return [MAP_DEFAULT_SHIP_RGB[0], MAP_DEFAULT_SHIP_RGB[1], MAP_DEFAULT_SHIP_RGB[2], 175]; - return [71, 85, 105, 165]; + if (!isFiniteNumber(t.sog)) return [MAP_DEFAULT_SHIP_RGB[0], MAP_DEFAULT_SHIP_RGB[1], MAP_DEFAULT_SHIP_RGB[2], 175]; + if (t.sog >= 10) return [148, 163, 184, 215]; + if (t.sog >= 1) return [MAP_DEFAULT_SHIP_RGB[0], MAP_DEFAULT_SHIP_RGB[1], MAP_DEFAULT_SHIP_RGB[2], 210]; + return [71, 85, 105, 200]; } export function buildGlobeShipFeature( diff --git a/apps/web/src/widgets/map3d/lib/tooltips.ts b/apps/web/src/widgets/map3d/lib/tooltips.ts index 340b2f3..399ff87 100644 --- a/apps/web/src/widgets/map3d/lib/tooltips.ts +++ b/apps/web/src/widgets/map3d/lib/tooltips.ts @@ -1,5 +1,6 @@ import type { AisTarget } from '../../../entities/aisTarget/model/types'; import type { LegacyVesselInfo } from '../../../entities/legacyVessel/model/types'; +import { toThumbnailUrl } from '../../../entities/shipImage/api/fetchShipImages'; import { fmtIsoFull } from '../../../shared/lib/datetime'; import { isFiniteNumber, toSafeNumber } from './setUtils'; @@ -50,8 +51,16 @@ export function getShipTooltipHtml({
` : ''; + const imgPath = t?.shipImagePath; + const photoHtml = imgPath + ? `
+ +
` + : ''; + return { html: `
+ ${photoHtml}
${name}
MMSI: ${mmsi}${vesselType ? ` · ${vesselType}` : ''}
SOG: ${sog ?? '?'} kt · COG: ${cog ?? '?'}°
diff --git a/apps/web/src/widgets/map3d/types.ts b/apps/web/src/widgets/map3d/types.ts index afb24d1..f374532 100644 --- a/apps/web/src/widgets/map3d/types.ts +++ b/apps/web/src/widgets/map3d/types.ts @@ -71,6 +71,8 @@ export interface Map3DProps { onOpenTrackMenu?: (info: { x: number; y: number; mmsi: number; vesselName: string }) => void; /** MMSI → 가장 높은 우선순위 경고 종류. filteredAlarms 기반. */ alarmMmsiMap?: Map; + /** 사진 있는 선박 클릭 시 콜백 (사진 표시자 or 선박 아이콘) */ + onClickShipPhoto?: (mmsi: number) => void; } export type DashSeg = { diff --git a/apps/web/src/widgets/shipImage/ShipImageGallery.tsx b/apps/web/src/widgets/shipImage/ShipImageGallery.tsx new file mode 100644 index 0000000..474a57a --- /dev/null +++ b/apps/web/src/widgets/shipImage/ShipImageGallery.tsx @@ -0,0 +1,130 @@ +import { useEffect, useRef, useState } from 'react'; +import type { ShipImageInfo } from '../../entities/shipImage/model/types'; +import { fetchShipImagesByImo, toThumbnailUrl } from '../../entities/shipImage/api/fetchShipImages'; + +interface ShipImageGalleryProps { + imo?: number; + initialImagePath?: string | null; + totalCount?: number; + onOpenModal?: (index: number, images?: ShipImageInfo[]) => void; +} + +const SCROLL_STEP = 86; // 80px thumb + 6px gap + +const ShipImageGallery = ({ imo, initialImagePath, totalCount, onOpenModal }: ShipImageGalleryProps) => { + const [images, setImages] = useState(null); + const scrollRef = useRef(null); + const [canScrollLeft, setCanScrollLeft] = useState(false); + const [canScrollRight, setCanScrollRight] = useState(false); + + useEffect(() => { + if (!imo || imo <= 0) return; + const ac = new AbortController(); + fetchShipImagesByImo(imo, ac.signal).then((result) => { + if (ac.signal.aborted) return; + if (result.length > 0) setImages(result); + }); + return () => ac.abort(); + }, [imo]); + + const updateScrollButtons = () => { + const el = scrollRef.current; + if (!el) return; + setCanScrollLeft(el.scrollLeft > 0); + setCanScrollRight(el.scrollLeft + el.clientWidth < el.scrollWidth - 1); + }; + + useEffect(() => { + const el = scrollRef.current; + if (!el || !images) return; + const raf = requestAnimationFrame(updateScrollButtons); + el.addEventListener('scroll', updateScrollButtons, { passive: true }); + return () => { + cancelAnimationFrame(raf); + el.removeEventListener('scroll', updateScrollButtons); + }; + }, [images]); + + const handleScroll = (dir: 'left' | 'right') => { + const el = scrollRef.current; + if (!el) return; + el.scrollBy({ left: dir === 'left' ? -SCROLL_STEP : SCROLL_STEP, behavior: 'smooth' }); + }; + + // 전체 이미지 로드 완료 + if (images && images.length > 0) { + const showArrows = images.length > 3; + return ( +
+ {showArrows && canScrollLeft && ( + + )} +
+ {images.map((img, i) => ( + + ))} +
+ {showArrows && canScrollRight && ( + + )} +
+ ); + } + + // fallback: 단일 이미지 (API 로딩 중이거나 imo 없음) + if (!initialImagePath) return null; + const count = totalCount ?? 1; + + return ( +
+ +
+ ); +}; + +export default ShipImageGallery; diff --git a/apps/web/src/widgets/shipImage/ShipImageModal.tsx b/apps/web/src/widgets/shipImage/ShipImageModal.tsx new file mode 100644 index 0000000..a45325d --- /dev/null +++ b/apps/web/src/widgets/shipImage/ShipImageModal.tsx @@ -0,0 +1,143 @@ +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(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 ( +
+
e.stopPropagation()}> + {/* 헤더 */} +
+ + {vesselName && {vesselName}} + {total > 1 && {index + 1} / {total}} + + +
+ + {/* 이미지 영역 */} +
+ {hasPrev && ( + + )} + +
+ {(loading || fetchingAll) && !error &&
} + {error &&
이미지를 불러올 수 없습니다
} + {currentImageUrl ? ( + {vesselName setLoading(false)} + onError={() => { setLoading(false); setError(true); }} + /> + ) : fetchingAll ? null : ( +
이미지를 불러올 수 없습니다
+ )} +
+ + {hasNext && ( + + )} +
+ + {/* 푸터 */} +
+ {currentMeta?.copyright && {currentMeta.copyright}} + {currentMeta?.date && {currentMeta.date}} +
+
+
+ ); +}; + +export default ShipImageModal; diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index b885814..9d3c13f 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -40,6 +40,12 @@ export default defineConfig(({ mode }) => { changeOrigin: true, secure: false, }, + // 선박 이미지 정적 파일 (nginx alias /pgdata/shipimg/) + "/shipimg": { + target: signalBatchTarget, + changeOrigin: true, + secure: false, + }, }, }, };