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

Reviewed-on: #39
This commit is contained in:
htlee 2026-02-20 10:31:05 +09:00
커밋 44dd74b59b
9개의 변경된 파일197개의 추가작업 그리고 25개의 파일을 삭제

파일 보기

@ -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>}

파일 보기

@ -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>
);
}