From e8b9b923898548f161207067e157c6da8642b2f9 Mon Sep 17 00:00:00 2001 From: "jeonghyo.k" Date: Mon, 20 Apr 2026 16:13:32 +0900 Subject: [PATCH] =?UTF-8?q?feat(vessel):=20=EC=84=A0=EB=B0=95=20=EA=B2=80?= =?UTF-8?q?=EC=83=89=20=EC=8B=9C=20=EC=A7=80=EB=8F=84=EC=97=90=20=ED=95=98?= =?UTF-8?q?=EC=9D=B4=EB=9D=BC=EC=9D=B4=ED=8A=B8=20=EB=A7=81=20=ED=91=9C?= =?UTF-8?q?=EC=8B=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - MapView, IncidentsView에 searchedVesselMmsi 상태 추가 - 검색된 선박 위치에 pulsing 링 애니메이션 Marker 렌더링 - 선박 클릭 시 하이라이트 초기화 - vsb-highlight-ring CSS 애니메이션 추가 (components.css) --- frontend/src/common/styles/components.css | 34 +++++++++++++++++++ .../src/components/common/map/MapView.tsx | 28 ++++++++++++++- .../incidents/components/IncidentsView.tsx | 32 +++++++++++++++-- 3 files changed, 91 insertions(+), 3 deletions(-) diff --git a/frontend/src/common/styles/components.css b/frontend/src/common/styles/components.css index 240358f..6204334 100644 --- a/frontend/src/common/styles/components.css +++ b/frontend/src/common/styles/components.css @@ -1675,4 +1675,38 @@ [data-theme='light'] .vsb-item { border-bottom: 1px solid var(--stroke-light); } + + /* 선박 검색 하이라이트 링 */ + .vsb-highlight-ring { + position: relative; + width: 48px; + height: 48px; + border-radius: 50%; + pointer-events: none; + } + + .vsb-highlight-ring::before, + .vsb-highlight-ring::after { + content: ''; + position: absolute; + inset: 0; + border-radius: 50%; + border: 2.5px solid #38bdf8; + animation: vsb-ring-pulse 2s ease-out infinite; + } + + .vsb-highlight-ring::after { + animation-delay: 1s; + } + + @keyframes vsb-ring-pulse { + 0% { + transform: scale(0.5); + opacity: 1; + } + 100% { + transform: scale(2.8); + opacity: 0; + } + } } diff --git a/frontend/src/components/common/map/MapView.tsx b/frontend/src/components/common/map/MapView.tsx index 5ce5a2e..f3e343e 100644 --- a/frontend/src/components/common/map/MapView.tsx +++ b/frontend/src/components/common/map/MapView.tsx @@ -399,6 +399,7 @@ export function MapView({ const [vesselHover, setVesselHover] = useState(null); const [selectedVessel, setSelectedVessel] = useState(null); const [detailVessel, setDetailVessel] = useState(null); + const [searchedVesselMmsi, setSearchedVesselMmsi] = useState(null); const [vesselSearchFlyTarget, setVesselSearchFlyTarget] = useState<{ lng: number; lat: number; @@ -406,6 +407,14 @@ export function MapView({ } | null>(null); const currentTime = isControlled ? externalCurrentTime : internalCurrentTime; + const searchHighlightVessel = useMemo( + () => + searchedVesselMmsi + ? ((allVessels ?? vessels).find((v) => v.mmsi === searchedVesselMmsi) ?? null) + : null, + [searchedVesselMmsi, allVessels, vessels], + ); + const handleMapCenterChange = useCallback((lat: number, lng: number, zoom: number) => { setMapCenter([lat, lng]); setMapZoom(zoom); @@ -1290,6 +1299,7 @@ export function MapView({ onClick: (vessel) => { setSelectedVessel(vessel); setDetailVessel(null); + setSearchedVesselMmsi(null); }, onHover: (vessel, x, y) => { setVesselHover(vessel ? { x, y, vessel } : null); @@ -1406,6 +1416,19 @@ export function MapView({ )} + {/* 선박 검색 하이라이트 링 */} + {searchHighlightVessel && !isDrawingBoom && measureMode === null && drawAnalysisMode === null && ( + +
+ + )} + {/* 사고 위치 마커 (MapLibre Marker) */} {incidentCoord && !isNaN(incidentCoord.lat) && @@ -1450,7 +1473,10 @@ export function MapView({ {(allVessels ?? vessels).length > 0 && !isDrawingBoom && measureMode === null && drawAnalysisMode === null && ( setVesselSearchFlyTarget({ lng: v.lon, lat: v.lat, zoom: 13 })} + onFlyTo={(v) => { + setVesselSearchFlyTarget({ lng: v.lon, lat: v.lat, zoom: 16 }); + setSearchedVesselMmsi(v.mmsi); + }} /> )} diff --git a/frontend/src/components/incidents/components/IncidentsView.tsx b/frontend/src/components/incidents/components/IncidentsView.tsx index a600dbc..eddc8ca 100644 --- a/frontend/src/components/incidents/components/IncidentsView.tsx +++ b/frontend/src/components/incidents/components/IncidentsView.tsx @@ -1,5 +1,5 @@ import { useState, useEffect, useMemo } from 'react'; -import { Map as MapLibreMap, Popup } from '@vis.gl/react-maplibre'; +import { Map as MapLibreMap, Marker, Popup } from '@vis.gl/react-maplibre'; import { useBaseMapStyle } from '@common/hooks/useBaseMapStyle'; import { ScatterplotLayer, PathLayer, TextLayer, GeoJsonLayer } from '@deck.gl/layers'; import { PathStyleExtension } from '@deck.gl/extensions'; @@ -133,6 +133,17 @@ export function IncidentsView() { lat: number; zoom: number; } | null>(null); + const [searchedVesselMmsi, setSearchedVesselMmsi] = useState(null); + + const searchHighlightVessel = useMemo( + () => + searchedVesselMmsi + ? ((allRealVessels.length > 0 ? allRealVessels : realVessels).find( + (v) => v.mmsi === searchedVesselMmsi, + ) ?? null) + : null, + [searchedVesselMmsi, allRealVessels, realVessels], + ); const [vesselStatus, setVesselStatus] = useState(null); useEffect(() => { @@ -624,6 +635,7 @@ export function IncidentsView() { }); setIncidentPopup(null); setDetailVessel(null); + setSearchedVesselMmsi(null); }, onHover: (vessel, x, y) => { if (vessel) { @@ -799,6 +811,19 @@ export function IncidentsView() { + {/* 선박 검색 하이라이트 링 */} + {searchHighlightVessel && !dischargeMode && measureMode === null && ( + +
+ + )} + {/* 사고 팝업 */} {incidentPopup && ( 0 || realVessels.length > 0) && !dischargeMode && measureMode === null && ( 0 ? allRealVessels : realVessels} - onFlyTo={(v) => setVesselSearchFlyTarget({ lng: v.lon, lat: v.lat, zoom: 13 })} + onFlyTo={(v) => { + setVesselSearchFlyTarget({ lng: v.lon, lat: v.lat, zoom: 13 }); + setSearchedVesselMmsi(v.mmsi); + }} /> )}