From a511e797d3128f29f6322cfa12062915a08b0fc0 Mon Sep 17 00:00:00 2001 From: htlee Date: Fri, 20 Feb 2026 10:27:55 +0900 Subject: [PATCH] =?UTF-8?q?feat(ship-image):=20=EB=AA=A8=EB=8B=AC=20?= =?UTF-8?q?=EA=B3=A0=EC=A0=95=20=ED=81=AC=EA=B8=B0=20+=20=EB=A7=81=20?= =?UTF-8?q?=EC=BA=90=EB=9F=AC=EC=85=80=20+=20=EC=A7=80=EB=8F=84=20?= =?UTF-8?q?=ED=81=B4=EB=A6=AD=20=EC=9E=90=EB=8F=99=20=EB=AA=A8=EB=8B=AC=20?= =?UTF-8?q?+=20shipPhotos=20=ED=86=A0=EA=B8=80=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/app/styles/components/map-panels.css | 11 +- .../src/features/mapToggles/MapToggles.tsx | 2 - .../web/src/pages/dashboard/DashboardPage.tsx | 8 +- .../src/pages/dashboard/useDashboardState.ts | 2 +- .../src/widgets/map3d/hooks/useDeckLayers.ts | 1 - .../widgets/map3d/hooks/useGlobeShipLayers.ts | 3 +- .../widgets/map3d/lib/deckLayerFactories.ts | 2 +- .../src/widgets/shipImage/ShipImageModal.tsx | 44 ++++-- .../widgets/shipImage/ThumbnailCarousel.tsx | 149 ++++++++++++++++++ 9 files changed, 197 insertions(+), 25 deletions(-) create mode 100644 apps/web/src/widgets/shipImage/ThumbnailCarousel.tsx diff --git a/apps/web/src/app/styles/components/map-panels.css b/apps/web/src/app/styles/components/map-panels.css index 6bbe7f3..5c51fd3 100644 --- a/apps/web/src/app/styles/components/map-panels.css +++ b/apps/web/src/app/styles/components/map-panels.css @@ -147,8 +147,8 @@ .ship-image-modal__content { position: relative; - max-width: min(92vw, 900px); - max-height: 90vh; + width: min(92vw, 900px); + height: min(90vh, 680px); display: flex; flex-direction: column; background: var(--wing-glass-dense); @@ -207,12 +207,13 @@ display: flex; align-items: center; justify-content: center; - min-height: 200px; + width: 100%; + height: 100%; } .ship-image-modal__img { max-width: 100%; - max-height: 72vh; + max-height: 100%; border-radius: 6px; object-fit: contain; transition: opacity 0.2s; @@ -259,6 +260,8 @@ .ship-image-modal__nav--prev { left: 8px; } .ship-image-modal__nav--next { right: 8px; } + + .ship-image-modal__footer { display: flex; gap: 12px; diff --git a/apps/web/src/features/mapToggles/MapToggles.tsx b/apps/web/src/features/mapToggles/MapToggles.tsx index c5ef342..1ee204b 100644 --- a/apps/web/src/features/mapToggles/MapToggles.tsx +++ b/apps/web/src/features/mapToggles/MapToggles.tsx @@ -9,7 +9,6 @@ export type MapToggleState = { predictVectors: boolean; shipLabels: boolean; subcables: boolean; - shipPhotos: boolean; }; type Props = { @@ -27,7 +26,6 @@ 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/pages/dashboard/DashboardPage.tsx b/apps/web/src/pages/dashboard/DashboardPage.tsx index 10fff16..9cfd2b5 100644 --- a/apps/web/src/pages/dashboard/DashboardPage.tsx +++ b/apps/web/src/pages/dashboard/DashboardPage.tsx @@ -263,6 +263,12 @@ export function DashboardPage() { }); }, [targetsInScope, legacyVesselsAll]); + // 지도에서 선박 클릭 시: 선택 + 사진이 있으면 자동으로 모달 표시 + const handleMapSelectMmsi = useCallback((mmsi: number | null) => { + setSelectedMmsi(mmsi); + if (mmsi) handleOpenImageModal(mmsi); + }, [setSelectedMmsi, handleOpenImageModal]); + const handlePanelOpenImageModal = useCallback((index: number, images?: ShipImageInfo[]) => { if (!selectedMmsi) return; const target = targetsInScope.find((t) => t.mmsi === selectedMmsi); @@ -368,7 +374,7 @@ export function DashboardPage() { baseMap={baseMap} projection={projection} overlays={overlays} - onSelectMmsi={setSelectedMmsi} + onSelectMmsi={handleMapSelectMmsi} onToggleHighlightMmsi={toggleHighlightedMmsi} onViewBboxChange={setViewBbox} legacyHits={legacyHits} diff --git a/apps/web/src/pages/dashboard/useDashboardState.ts b/apps/web/src/pages/dashboard/useDashboardState.ts index 72aba8d..a8ce54d 100644 --- a/apps/web/src/pages/dashboard/useDashboardState.ts +++ b/apps/web/src/pages/dashboard/useDashboardState.ts @@ -47,7 +47,7 @@ export function useDashboardState(uid: number | null) { const [mapStyleSettings, setMapStyleSettings] = usePersistedState(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, shipPhotos: true, + fleetCircles: true, predictVectors: true, shipLabels: true, subcables: false, }); const [settings, setSettings] = usePersistedState(uid, 'map3dSettings', { showShips: true, showDensity: false, showSeamark: false, diff --git a/apps/web/src/widgets/map3d/hooks/useDeckLayers.ts b/apps/web/src/widgets/map3d/hooks/useDeckLayers.ts index 4d007ca..f8b1aca 100644 --- a/apps/web/src/widgets/map3d/hooks/useDeckLayers.ts +++ b/apps/web/src/widgets/map3d/hooks/useDeckLayers.ts @@ -273,7 +273,6 @@ export function useDeckLayers( alarmMmsiMap, shipPhotoTargets, onClickShipPhoto, - overlays.shipPhotos, ]); // Mercator alarm pulse breathing animation (rAF) diff --git a/apps/web/src/widgets/map3d/hooks/useGlobeShipLayers.ts b/apps/web/src/widgets/map3d/hooks/useGlobeShipLayers.ts index 0c2f1b0..1a5a267 100644 --- a/apps/web/src/widgets/map3d/hooks/useGlobeShipLayers.ts +++ b/apps/web/src/widgets/map3d/hooks/useGlobeShipLayers.ts @@ -199,7 +199,7 @@ export function useGlobeShipLayers( // → style._changed 방지 → 불필요한 symbol placement 재계산 방지 → 라벨 사라짐 방지 const visibility: 'visible' | 'none' = projection === 'globe' ? 'visible' : 'none'; const labelVisibility: 'visible' | 'none' = projection === 'globe' && overlays.shipLabels ? 'visible' : 'none'; - const photoVisibility: 'visible' | 'none' = projection === 'globe' && overlays.shipPhotos ? 'visible' : 'none'; + const photoVisibility: 'visible' | 'none' = projection === 'globe' ? 'visible' : 'none'; if (map.getLayer(symbolId) || map.getLayer(symbolLiteId)) { const changed = map.getLayoutProperty(symbolId, 'visibility') !== visibility || @@ -643,7 +643,6 @@ export function useGlobeShipLayers( projection, settings.showShips, overlays.shipLabels, - overlays.shipPhotos, globeShipGeoJson, alarmGeoJson, mapSyncEpoch, diff --git a/apps/web/src/widgets/map3d/lib/deckLayerFactories.ts b/apps/web/src/widgets/map3d/lib/deckLayerFactories.ts index 8340b10..e72a68b 100644 --- a/apps/web/src/widgets/map3d/lib/deckLayerFactories.ts +++ b/apps/web/src/widgets/map3d/lib/deckLayerFactories.ts @@ -553,7 +553,7 @@ export function buildMercatorDeckLayers(ctx: MercatorDeckLayerContext): unknown[ /* ─ ship photo indicator (사진 유무 표시) ─ */ const photoTargets = ctx.shipPhotoTargets ?? []; - if (ctx.showShips && ctx.overlays.shipPhotos && photoTargets.length > 0) { + if (ctx.showShips && photoTargets.length > 0) { layers.push( new ScatterplotLayer({ id: 'ship-photo-indicator', diff --git a/apps/web/src/widgets/shipImage/ShipImageModal.tsx b/apps/web/src/widgets/shipImage/ShipImageModal.tsx index a45325d..6dba513 100644 --- a/apps/web/src/widgets/shipImage/ShipImageModal.tsx +++ b/apps/web/src/widgets/shipImage/ShipImageModal.tsx @@ -1,6 +1,7 @@ -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import type { ShipImageInfo } from '../../entities/shipImage/model/types'; -import { fetchShipImagesByImo, toHighResUrl } from '../../entities/shipImage/api/fetchShipImages'; +import { fetchShipImagesByImo, toHighResUrl, toThumbnailUrl } from '../../entities/shipImage/api/fetchShipImages'; +import { ThumbnailCarousel, type CarouselItem } from './ThumbnailCarousel'; interface ShipImageModalProps { /** 갤러리에서 전달받은 전체 이미지 목록 (있으면 API 호출 생략) */ @@ -37,8 +38,6 @@ const ShipImageModal = ({ const allImages = preloadedImages ?? fetchedImages; const total = allImages ? allImages.length : (totalCount ?? 1); - const hasPrev = index > 0; - const hasNext = index < total - 1; // 현재 이미지 URL 결정 (모달은 항상 고화질) const currentImageUrl = (() => { @@ -59,16 +58,26 @@ const ShipImageModal = ({ 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); + if (total <= 1) return; + setIndex((i) => (i - 1 + total) % total); setLoading(true); setError(false); - }, [hasNext]); + }, [total]); + + const goNext = useCallback(() => { + if (total <= 1) return; + setIndex((i) => (i + 1) % total); + setLoading(true); + setError(false); + }, [total]); + + const goTo = useCallback((target: number) => { + setIndex(target); + setLoading(true); + setError(false); + }, []); useEffect(() => { const onKey = (e: KeyboardEvent) => { @@ -80,6 +89,12 @@ const ShipImageModal = ({ return () => window.removeEventListener('keydown', onKey); }, [onClose, goPrev, goNext]); + // 캐러셀 아이템 + const carouselItems = useMemo(() => { + if (!allImages) return []; + return allImages.map((img, i) => ({ key: i, src: toThumbnailUrl(img.path) })); + }, [allImages]); + // 현재 이미지 메타데이터 const currentMeta = allImages?.[index] ?? null; @@ -99,7 +114,7 @@ const ShipImageModal = ({ {/* 이미지 영역 */}
- {hasPrev && ( + {total > 1 && ( @@ -123,13 +138,16 @@ const ShipImageModal = ({ )}
- {hasNext && ( + {total > 1 && ( )} + {/* 캐러셀 썸네일 */} + + {/* 푸터 */}
{currentMeta?.copyright && {currentMeta.copyright}} diff --git a/apps/web/src/widgets/shipImage/ThumbnailCarousel.tsx b/apps/web/src/widgets/shipImage/ThumbnailCarousel.tsx new file mode 100644 index 0000000..5e6abbc --- /dev/null +++ b/apps/web/src/widgets/shipImage/ThumbnailCarousel.tsx @@ -0,0 +1,149 @@ +import { useCallback, useEffect, useRef, useMemo } from 'react'; + +export interface CarouselItem { + key: string | number; + src: string; +} + +interface ThumbnailCarouselProps { + items: CarouselItem[]; + activeIndex: number; + onSelect: (index: number) => void; + className?: string; +} + +/** 한 번에 표시할 최대 슬롯 수 */ +const MAX_VISIBLE = 5; +/** 드래그 시 인덱스 1칸 이동에 필요한 px */ +const DRAG_STEP = 50; +/** 휠 누적 임계값 */ +const WHEEL_THRESHOLD = 40; + +/** 슬롯 크기/투명도 — 중앙에서의 거리 기반 */ +const SLOT_STYLES: Record = { + 0: { w: 64, h: 48, opacity: 1 }, + 1: { w: 52, h: 40, opacity: 0.7 }, + 2: { w: 44, h: 34, opacity: 0.5 }, +}; + +/** 링 형태 슬롯 계산: activeIndex 중앙, 좌우 wrap-around */ +function ringSlots(active: number, total: number): { idx: number; pos: number }[] { + if (total <= 0) return []; + if (total === 1) return [{ idx: 0, pos: 0 }]; + // 2장: 중앙 + 우측 + if (total === 2) return [{ idx: active, pos: 0 }, { idx: (active + 1) % 2, pos: 1 }]; + + const half = Math.min(Math.floor(MAX_VISIBLE / 2), Math.floor(total / 2)); + const slots: { idx: number; pos: number }[] = []; + const seen = new Set(); + for (let offset = -half; offset <= half; offset++) { + const idx = ((active + offset) % total + total) % total; + if (seen.has(idx)) continue; + seen.add(idx); + slots.push({ idx, pos: offset }); + } + return slots; +} + +/** + * 링 형태 무한 썸네일 캐러셀. + * - 5개 고정 슬롯 (중앙 = 활성, 좌우 2장씩) + * - 모듈러 인덱싱으로 끝↔처음 무한 순환 + * - 마우스 드래그 + 휠로 연속 탐색 (주르륵) + * - Tailwind CSS 기반, 재사용 가능 + */ +export function ThumbnailCarousel({ items, activeIndex, onSelect, className }: ThumbnailCarouselProps) { + const total = items.length; + const containerRef = useRef(null); + const activeRef = useRef(activeIndex); + useEffect(() => { activeRef.current = activeIndex; }, [activeIndex]); + const dragRef = useRef({ active: false, startX: 0, startIndex: 0, lastSteps: 0 }); + + const slots = useMemo(() => ringSlots(activeIndex, total), [activeIndex, total]); + + // ── 드래그: 연속 인덱스 이동 ── + const onMouseDown = useCallback((e: React.MouseEvent) => { + dragRef.current = { active: true, startX: e.pageX, startIndex: activeRef.current, lastSteps: 0 }; + if (containerRef.current) containerRef.current.style.cursor = 'grabbing'; + }, []); + + const onMouseMove = useCallback((e: React.MouseEvent) => { + if (!dragRef.current.active) return; + e.preventDefault(); + const dx = e.pageX - dragRef.current.startX; + const steps = Math.round(-dx / DRAG_STEP); + if (steps !== dragRef.current.lastSteps) { + dragRef.current.lastSteps = steps; + onSelect(((dragRef.current.startIndex + steps) % total + total) % total); + } + }, [total, onSelect]); + + const onMouseUp = useCallback(() => { + dragRef.current.active = false; + if (containerRef.current) containerRef.current.style.cursor = ''; + }, []); + + // ── 휠: 누적 기반 인덱스 이동 ── + useEffect(() => { + const c = containerRef.current; + if (!c) return; + let acc = 0; + const onWheel = (e: WheelEvent) => { + e.preventDefault(); + acc += Math.abs(e.deltaX) > Math.abs(e.deltaY) ? e.deltaX : e.deltaY; + if (Math.abs(acc) >= WHEEL_THRESHOLD) { + const dir = acc > 0 ? 1 : -1; + acc = 0; + const cur = activeRef.current; + onSelect(((cur + dir) % total + total) % total); + } + }; + c.addEventListener('wheel', onWheel, { passive: false }); + return () => c.removeEventListener('wheel', onWheel); + }, [total, onSelect]); + + if (total <= 1) return null; + + return ( +
+ {slots.map(({ idx, pos }) => { + const { w, h, opacity } = SLOT_STYLES[Math.abs(pos)] ?? SLOT_STYLES[2]; + const isActive = pos === 0; + return ( + + ); + })} +
+ ); +}