feat(vessel): 선박 검색 시 지도에 하이라이트 링 표시

- MapView, IncidentsView에 searchedVesselMmsi 상태 추가
- 검색된 선박 위치에 pulsing 링 애니메이션 Marker 렌더링
- 선박 클릭 시 하이라이트 초기화
- vsb-highlight-ring CSS 애니메이션 추가 (components.css)
This commit is contained in:
jeonghyo.k 2026-04-20 16:13:32 +09:00
부모 559ebd666a
커밋 e8b9b92389
3개의 변경된 파일91개의 추가작업 그리고 3개의 파일을 삭제

파일 보기

@ -1675,4 +1675,38 @@
[data-theme='light'] .vsb-item { [data-theme='light'] .vsb-item {
border-bottom: 1px solid var(--stroke-light); 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;
}
}
} }

파일 보기

@ -399,6 +399,7 @@ export function MapView({
const [vesselHover, setVesselHover] = useState<VesselHoverInfo | null>(null); const [vesselHover, setVesselHover] = useState<VesselHoverInfo | null>(null);
const [selectedVessel, setSelectedVessel] = useState<VesselPosition | null>(null); const [selectedVessel, setSelectedVessel] = useState<VesselPosition | null>(null);
const [detailVessel, setDetailVessel] = useState<VesselPosition | null>(null); const [detailVessel, setDetailVessel] = useState<VesselPosition | null>(null);
const [searchedVesselMmsi, setSearchedVesselMmsi] = useState<string | null>(null);
const [vesselSearchFlyTarget, setVesselSearchFlyTarget] = useState<{ const [vesselSearchFlyTarget, setVesselSearchFlyTarget] = useState<{
lng: number; lng: number;
lat: number; lat: number;
@ -406,6 +407,14 @@ export function MapView({
} | null>(null); } | null>(null);
const currentTime = isControlled ? externalCurrentTime : internalCurrentTime; 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) => { const handleMapCenterChange = useCallback((lat: number, lng: number, zoom: number) => {
setMapCenter([lat, lng]); setMapCenter([lat, lng]);
setMapZoom(zoom); setMapZoom(zoom);
@ -1290,6 +1299,7 @@ export function MapView({
onClick: (vessel) => { onClick: (vessel) => {
setSelectedVessel(vessel); setSelectedVessel(vessel);
setDetailVessel(null); setDetailVessel(null);
setSearchedVesselMmsi(null);
}, },
onHover: (vessel, x, y) => { onHover: (vessel, x, y) => {
setVesselHover(vessel ? { x, y, vessel } : null); setVesselHover(vessel ? { x, y, vessel } : null);
@ -1406,6 +1416,19 @@ export function MapView({
<HydrParticleOverlay hydrStep={hydrData[currentTime] ?? null} /> <HydrParticleOverlay hydrStep={hydrData[currentTime] ?? null} />
)} )}
{/* 선박 검색 하이라이트 링 */}
{searchHighlightVessel && !isDrawingBoom && measureMode === null && drawAnalysisMode === null && (
<Marker
key={searchHighlightVessel.mmsi}
longitude={searchHighlightVessel.lon}
latitude={searchHighlightVessel.lat}
anchor="center"
style={{ pointerEvents: 'none' }}
>
<div className="vsb-highlight-ring" />
</Marker>
)}
{/* 사고 위치 마커 (MapLibre Marker) */} {/* 사고 위치 마커 (MapLibre Marker) */}
{incidentCoord && {incidentCoord &&
!isNaN(incidentCoord.lat) && !isNaN(incidentCoord.lat) &&
@ -1450,7 +1473,10 @@ export function MapView({
{(allVessels ?? vessels).length > 0 && !isDrawingBoom && measureMode === null && drawAnalysisMode === null && ( {(allVessels ?? vessels).length > 0 && !isDrawingBoom && measureMode === null && drawAnalysisMode === null && (
<VesselSearchBar <VesselSearchBar
vessels={allVessels ?? vessels} vessels={allVessels ?? vessels}
onFlyTo={(v) => setVesselSearchFlyTarget({ lng: v.lon, lat: v.lat, zoom: 13 })} onFlyTo={(v) => {
setVesselSearchFlyTarget({ lng: v.lon, lat: v.lat, zoom: 16 });
setSearchedVesselMmsi(v.mmsi);
}}
/> />
)} )}

파일 보기

@ -1,5 +1,5 @@
import { useState, useEffect, useMemo } from 'react'; 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 { useBaseMapStyle } from '@common/hooks/useBaseMapStyle';
import { ScatterplotLayer, PathLayer, TextLayer, GeoJsonLayer } from '@deck.gl/layers'; import { ScatterplotLayer, PathLayer, TextLayer, GeoJsonLayer } from '@deck.gl/layers';
import { PathStyleExtension } from '@deck.gl/extensions'; import { PathStyleExtension } from '@deck.gl/extensions';
@ -133,6 +133,17 @@ export function IncidentsView() {
lat: number; lat: number;
zoom: number; zoom: number;
} | null>(null); } | null>(null);
const [searchedVesselMmsi, setSearchedVesselMmsi] = useState<string | null>(null);
const searchHighlightVessel = useMemo(
() =>
searchedVesselMmsi
? ((allRealVessels.length > 0 ? allRealVessels : realVessels).find(
(v) => v.mmsi === searchedVesselMmsi,
) ?? null)
: null,
[searchedVesselMmsi, allRealVessels, realVessels],
);
const [vesselStatus, setVesselStatus] = useState<VesselCacheStatus | null>(null); const [vesselStatus, setVesselStatus] = useState<VesselCacheStatus | null>(null);
useEffect(() => { useEffect(() => {
@ -624,6 +635,7 @@ export function IncidentsView() {
}); });
setIncidentPopup(null); setIncidentPopup(null);
setDetailVessel(null); setDetailVessel(null);
setSearchedVesselMmsi(null);
}, },
onHover: (vessel, x, y) => { onHover: (vessel, x, y) => {
if (vessel) { if (vessel) {
@ -799,6 +811,19 @@ export function IncidentsView() {
<FlyToController incident={selectedIncident} /> <FlyToController incident={selectedIncident} />
<VesselFlyToController target={vesselSearchFlyTarget} duration={1200} /> <VesselFlyToController target={vesselSearchFlyTarget} duration={1200} />
{/* 선박 검색 하이라이트 링 */}
{searchHighlightVessel && !dischargeMode && measureMode === null && (
<Marker
key={searchHighlightVessel.mmsi}
longitude={searchHighlightVessel.lon}
latitude={searchHighlightVessel.lat}
anchor="center"
style={{ pointerEvents: 'none' }}
>
<div className="vsb-highlight-ring" />
</Marker>
)}
{/* 사고 팝업 */} {/* 사고 팝업 */}
{incidentPopup && ( {incidentPopup && (
<Popup <Popup
@ -823,7 +848,10 @@ export function IncidentsView() {
{(allRealVessels.length > 0 || realVessels.length > 0) && !dischargeMode && measureMode === null && ( {(allRealVessels.length > 0 || realVessels.length > 0) && !dischargeMode && measureMode === null && (
<VesselSearchBar <VesselSearchBar
vessels={allRealVessels.length > 0 ? allRealVessels : realVessels} vessels={allRealVessels.length > 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);
}}
/> />
)} )}