Merge pull request 'release: 2026-02-20 (2건 커밋)' (#39) from develop into main
All checks were successful
Build and Deploy Wing / build-and-deploy (push) Successful in 29s
All checks were successful
Build and Deploy Wing / build-and-deploy (push) Successful in 29s
Reviewed-on: #39
This commit is contained in:
커밋
44dd74b59b
@ -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;
|
||||
|
||||
@ -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 (
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -47,7 +47,7 @@ export function useDashboardState(uid: number | null) {
|
||||
const [mapStyleSettings, setMapStyleSettings] = usePersistedState<MapStyleSettings>(uid, 'mapStyleSettings', DEFAULT_MAP_STYLE_SETTINGS);
|
||||
const [overlays, setOverlays] = usePersistedState<MapToggleState>(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<Map3DSettings>(uid, 'map3dSettings', {
|
||||
showShips: true, showDensity: false, showSeamark: false,
|
||||
|
||||
@ -273,7 +273,6 @@ export function useDeckLayers(
|
||||
alarmMmsiMap,
|
||||
shipPhotoTargets,
|
||||
onClickShipPhoto,
|
||||
overlays.shipPhotos,
|
||||
]);
|
||||
|
||||
// Mercator alarm pulse breathing animation (rAF)
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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<AisTarget>({
|
||||
id: 'ship-photo-indicator',
|
||||
|
||||
@ -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<CarouselItem[]>(() => {
|
||||
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 = ({
|
||||
|
||||
{/* 이미지 영역 */}
|
||||
<div className="ship-image-modal__body">
|
||||
{hasPrev && (
|
||||
{total > 1 && (
|
||||
<button className="ship-image-modal__nav ship-image-modal__nav--prev" onClick={goPrev} aria-label="이전">
|
||||
‹
|
||||
</button>
|
||||
@ -123,13 +138,16 @@ const ShipImageModal = ({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{hasNext && (
|
||||
{total > 1 && (
|
||||
<button className="ship-image-modal__nav ship-image-modal__nav--next" onClick={goNext} aria-label="다음">
|
||||
›
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 캐러셀 썸네일 */}
|
||||
<ThumbnailCarousel items={carouselItems} activeIndex={index} onSelect={goTo} />
|
||||
|
||||
{/* 푸터 */}
|
||||
<div className="ship-image-modal__footer">
|
||||
{currentMeta?.copyright && <span>{currentMeta.copyright}</span>}
|
||||
|
||||
149
apps/web/src/widgets/shipImage/ThumbnailCarousel.tsx
Normal file
149
apps/web/src/widgets/shipImage/ThumbnailCarousel.tsx
Normal file
@ -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<number, { w: number; h: number; opacity: number }> = {
|
||||
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<number>();
|
||||
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<HTMLDivElement>(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 (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={[
|
||||
'flex items-center justify-center gap-2 py-2.5',
|
||||
'select-none cursor-grab',
|
||||
'border-t border-[var(--wing-subtle)]',
|
||||
className,
|
||||
].filter(Boolean).join(' ')}
|
||||
onMouseDown={onMouseDown}
|
||||
onMouseMove={onMouseMove}
|
||||
onMouseUp={onMouseUp}
|
||||
onMouseLeave={onMouseUp}
|
||||
>
|
||||
{slots.map(({ idx, pos }) => {
|
||||
const { w, h, opacity } = SLOT_STYLES[Math.abs(pos)] ?? SLOT_STYLES[2];
|
||||
const isActive = pos === 0;
|
||||
return (
|
||||
<button
|
||||
key={idx}
|
||||
className={[
|
||||
'shrink-0 rounded-md overflow-hidden border-2 p-0',
|
||||
'transition-all duration-200 ease-out',
|
||||
'bg-[var(--wing-subtle)]',
|
||||
isActive
|
||||
? 'border-[var(--accent)]'
|
||||
: 'border-transparent hover:opacity-100 hover:border-[var(--accent)]',
|
||||
].join(' ')}
|
||||
style={{ width: w, height: h, opacity }}
|
||||
onClick={() => onSelect(idx)}
|
||||
>
|
||||
<img
|
||||
src={items[idx].src}
|
||||
alt=""
|
||||
className="block w-full h-full object-cover pointer-events-none"
|
||||
draggable={false}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
불러오는 중...
Reference in New Issue
Block a user