Merge pull request 'feat(vessel): ���� �˻� ���� ���� (��ü ij�� Ȯ�롤���̶���Ʈ �������� �̵�����)' (#194) from feature/vessel-search-on-map into develop

This commit is contained in:
jhkang 2026-04-20 16:18:44 +09:00
커밋 b5c1f88706
13개의 변경된 파일408개의 추가작업 그리고 19개의 파일을 삭제

파일 보기

@ -1,12 +1,13 @@
import { Router } from 'express'; import { Router } from 'express';
import { getVesselsInBounds, getCacheStatus } from './vesselService.js'; import { requireAuth } from '../auth/authMiddleware.js';
import { getVesselsInBounds, getAllVessels, getCacheStatus } from './vesselService.js';
import type { BoundingBox } from './vesselTypes.js'; import type { BoundingBox } from './vesselTypes.js';
const vesselRouter = Router(); const vesselRouter = Router();
// POST /api/vessels/in-area // POST /api/vessels/in-area
// 현재 뷰포트 bbox 안의 선박 목록 반환 (메모리 캐시에서 필터링) // 현재 뷰포트 bbox 안의 선박 목록 반환 (메모리 캐시에서 필터링)
vesselRouter.post('/in-area', (req, res) => { vesselRouter.post('/in-area', requireAuth, (req, res) => {
const { bounds } = req.body as { bounds?: BoundingBox }; const { bounds } = req.body as { bounds?: BoundingBox };
if ( if (
@ -24,8 +25,14 @@ vesselRouter.post('/in-area', (req, res) => {
res.json(vessels); res.json(vessels);
}); });
// GET /api/vessels/all — 캐시된 전체 선박 목록 반환 (검색용)
vesselRouter.get('/all', requireAuth, (_req, res) => {
const vessels = getAllVessels();
res.json(vessels);
});
// GET /api/vessels/status — 캐시 상태 확인 (디버그용) // GET /api/vessels/status — 캐시 상태 확인 (디버그용)
vesselRouter.get('/status', (_req, res) => { vesselRouter.get('/status', requireAuth, (_req, res) => {
const status = getCacheStatus(); const status = getCacheStatus();
res.json(status); res.json(status);
}); });

파일 보기

@ -42,6 +42,10 @@ export function getVesselsInBounds(bounds: BoundingBox): VesselPosition[] {
return result; return result;
} }
export function getAllVessels(): VesselPosition[] {
return Array.from(cachedVessels.values());
}
export function getCacheStatus(): { export function getCacheStatus(): {
count: number; count: number;
bangjeCount: number; bangjeCount: number;

파일 보기

@ -5,6 +5,8 @@
## [Unreleased] ## [Unreleased]
### 추가 ### 추가
- 선박: 선박 검색 시 지도에 하이라이트 링 애니메이션 표시 (MapView, IncidentsView)
- 선박: 선박 검색 범위를 전체 캐시 대상으로 확대
- HNS: 정보 레이어 패널 통합 (레이어 표시/불투명도/밝기/색상 제어) - HNS: 정보 레이어 패널 통합 (레이어 표시/불투명도/밝기/색상 제어)
- HNS: 분석 생성 시 유출량·단위·예측 시간·알고리즘·기준 모델 파라미터 전달 - HNS: 분석 생성 시 유출량·단위·예측 시간·알고리즘·기준 모델 파라미터 전달
@ -13,6 +15,7 @@
- 기상 탭: 지도 오버레이 컨트롤 위치 우측 상단으로 조정 - 기상 탭: 지도 오버레이 컨트롤 위치 우측 상단으로 조정
### 수정 ### 수정
- 선박: 라우터 전체에 requireAuth 미들웨어 추가
- 기상정보 탭 로딩 지연 개선: KHOA API 관측소 요청을 병렬 처리로 전환 및 API 키 미설정 시 즉시 fallback 처리 - 기상정보 탭 로딩 지연 개선: KHOA API 관측소 요청을 병렬 처리로 전환 및 API 키 미설정 시 즉시 fallback 처리
### 기타 ### 기타

파일 보기

@ -14,16 +14,21 @@ import type { VesselPosition, MapBounds } from '@/types/vessel';
* *
* (VITE_VESSEL_SIGNAL_MODE=polling): * (VITE_VESSEL_SIGNAL_MODE=polling):
* - 60 REST API(/api/vessels/in-area) bbox * - 60 REST API(/api/vessels/in-area) bbox
* - 3 /api/vessels/all
* *
* (VITE_VESSEL_SIGNAL_MODE=websocket): * (VITE_VESSEL_SIGNAL_MODE=websocket):
* - WebSocket (VITE_VESSEL_WS_URL) * - WebSocket (VITE_VESSEL_WS_URL)
* - bbox로 * - bbox로
* *
* @param mapBounds MapView의 onBoundsChange로 bbox * @param mapBounds MapView의 onBoundsChange로 bbox
* @returns * @returns { vessels: 뷰포트 , allVessels: 전체 () }
*/ */
export function useVesselSignals(mapBounds: MapBounds | null): VesselPosition[] { export function useVesselSignals(mapBounds: MapBounds | null): {
vessels: VesselPosition[];
allVessels: VesselPosition[];
} {
const [vessels, setVessels] = useState<VesselPosition[]>([]); const [vessels, setVessels] = useState<VesselPosition[]>([]);
const [allVessels, setAllVessels] = useState<VesselPosition[]>([]);
const boundsRef = useRef<MapBounds | null>(mapBounds); const boundsRef = useRef<MapBounds | null>(mapBounds);
const clientRef = useRef<VesselSignalClient | null>(null); const clientRef = useRef<VesselSignalClient | null>(null);
@ -55,11 +60,12 @@ export function useVesselSignals(mapBounds: MapBounds | null): VesselPosition[]
: initial; : initial;
// WS 첫 메시지가 먼저 도착해 이미 채워졌다면 덮어쓰지 않음 // WS 첫 메시지가 먼저 도착해 이미 채워졌다면 덮어쓰지 않음
setVessels((prev) => (prev.length === 0 ? filtered : prev)); setVessels((prev) => (prev.length === 0 ? filtered : prev));
setAllVessels((prev) => (prev.length === 0 ? initial : prev));
}) })
.catch((e) => console.warn('[useVesselSignals] 초기 스냅샷 실패', e)); .catch((e) => console.warn('[useVesselSignals] 초기 스냅샷 실패', e));
} }
client.start(setVessels, getViewportBounds); client.start(setVessels, getViewportBounds, setAllVessels);
return () => { return () => {
client.stop(); client.stop();
clientRef.current = null; clientRef.current = null;
@ -75,5 +81,5 @@ export function useVesselSignals(mapBounds: MapBounds | null): VesselPosition[]
} }
}, [mapBounds]); }, [mapBounds]);
return vessels; return { vessels, allVessels };
} }

파일 보기

@ -6,6 +6,11 @@ export async function getVesselsInArea(bounds: MapBounds): Promise<VesselPositio
return res.data; return res.data;
} }
export async function getAllVessels(): Promise<VesselPosition[]> {
const res = await api.get<VesselPosition[]>('/vessels/all');
return res.data;
}
/** /**
* / 1 API. * / 1 API.
* REST 10 . * REST 10 .

파일 보기

@ -1,10 +1,11 @@
import type { VesselPosition, MapBounds } from '@/types/vessel'; import type { VesselPosition, MapBounds } from '@/types/vessel';
import { getVesselsInArea } from './vesselApi'; import { getVesselsInArea, getAllVessels } from './vesselApi';
export interface VesselSignalClient { export interface VesselSignalClient {
start( start(
onVessels: (vessels: VesselPosition[]) => void, onVessels: (vessels: VesselPosition[]) => void,
getViewportBounds: () => MapBounds | null, getViewportBounds: () => MapBounds | null,
onAllVessels?: (vessels: VesselPosition[]) => void,
): void; ): void;
stop(): void; stop(): void;
/** /**
@ -16,8 +17,10 @@ export interface VesselSignalClient {
// 개발환경: setInterval(60s) → 백엔드 REST API 호출 // 개발환경: setInterval(60s) → 백엔드 REST API 호출
class PollingVesselClient implements VesselSignalClient { class PollingVesselClient implements VesselSignalClient {
private intervalId: ReturnType<typeof setInterval> | null = null; private intervalId: ReturnType<typeof setInterval> | undefined = undefined;
private allIntervalId: ReturnType<typeof setInterval> | undefined = undefined;
private onVessels: ((vessels: VesselPosition[]) => void) | null = null; private onVessels: ((vessels: VesselPosition[]) => void) | null = null;
private onAllVessels: ((vessels: VesselPosition[]) => void) | undefined = undefined;
private getViewportBounds: (() => MapBounds | null) | null = null; private getViewportBounds: (() => MapBounds | null) | null = null;
private async poll(): Promise<void> { private async poll(): Promise<void> {
@ -31,24 +34,38 @@ class PollingVesselClient implements VesselSignalClient {
} }
} }
private async pollAll(): Promise<void> {
if (!this.onAllVessels) return;
try {
const vessels = await getAllVessels();
this.onAllVessels(vessels);
} catch {
// 무시
}
}
start( start(
onVessels: (vessels: VesselPosition[]) => void, onVessels: (vessels: VesselPosition[]) => void,
getViewportBounds: () => MapBounds | null, getViewportBounds: () => MapBounds | null,
onAllVessels?: (vessels: VesselPosition[]) => void,
): void { ): void {
this.onVessels = onVessels; this.onVessels = onVessels;
this.onAllVessels = onAllVessels;
this.getViewportBounds = getViewportBounds; this.getViewportBounds = getViewportBounds;
// 즉시 1회 실행 후 60초 간격으로 반복
this.poll(); this.poll();
this.pollAll();
this.intervalId = setInterval(() => this.poll(), 60_000); this.intervalId = setInterval(() => this.poll(), 60_000);
this.allIntervalId = setInterval(() => this.pollAll(), 3 * 60_000);
} }
stop(): void { stop(): void {
if (this.intervalId !== null) { clearInterval(this.intervalId);
clearInterval(this.intervalId); clearInterval(this.allIntervalId);
this.intervalId = null; this.intervalId = undefined;
} this.allIntervalId = undefined;
this.onVessels = null; this.onVessels = null;
this.onAllVessels = undefined;
this.getViewportBounds = null; this.getViewportBounds = null;
} }
@ -69,12 +86,16 @@ class DirectWebSocketVesselClient implements VesselSignalClient {
start( start(
onVessels: (vessels: VesselPosition[]) => void, onVessels: (vessels: VesselPosition[]) => void,
getViewportBounds: () => MapBounds | null, getViewportBounds: () => MapBounds | null,
onAllVessels?: (vessels: VesselPosition[]) => void,
): void { ): void {
this.ws = new WebSocket(this.wsUrl); this.ws = new WebSocket(this.wsUrl);
this.ws.onmessage = (event) => { this.ws.onmessage = (event) => {
try { try {
const allVessels = JSON.parse(event.data as string) as VesselPosition[]; const allVessels = JSON.parse(event.data as string) as VesselPosition[];
onAllVessels?.(allVessels);
const bounds = getViewportBounds(); const bounds = getViewportBounds();
if (!bounds) { if (!bounds) {

파일 보기

@ -1544,4 +1544,169 @@
[data-theme='light'] .combo-list { [data-theme='light'] .combo-list {
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.1); box-shadow: 0 8px 24px rgba(0, 0, 0, 0.1);
} }
/* ── VesselSearchBar ── */
.vsb-wrap {
position: absolute;
top: 12px;
left: 50%;
transform: translateX(-50%);
width: 320px;
z-index: 30;
}
.vsb-input-wrap {
position: relative;
display: flex;
align-items: center;
}
.vsb-icon {
position: absolute;
left: 9px;
width: 14px;
height: 14px;
color: var(--fg-disabled);
pointer-events: none;
flex-shrink: 0;
}
.vsb-input {
padding-left: 30px !important;
background: rgba(18, 20, 24, 0.88) !important;
backdrop-filter: blur(8px);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
}
.vsb-list {
position: absolute;
top: calc(100% + 4px);
left: 0;
right: 0;
background: var(--bg-surface);
border: 1px solid var(--stroke-light);
border-radius: 6px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
z-index: 200;
max-height: 208px;
overflow-y: auto;
scrollbar-width: thin;
scrollbar-color: var(--stroke-light) transparent;
animation: comboIn 0.15s ease;
}
.vsb-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 8px 12px;
border-bottom: 1px solid rgba(30, 42, 66, 0.5);
transition: background 0.12s;
}
.vsb-item:last-child {
border-bottom: none;
}
.vsb-item:hover {
background: rgba(6, 182, 212, 0.08);
}
.vsb-info {
min-width: 0;
flex: 1;
}
.vsb-name {
font-size: 0.8125rem;
font-family: var(--font-korean);
color: var(--fg-default);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.vsb-meta {
font-size: 0.6875rem;
font-family: var(--font-korean);
color: var(--fg-sub);
margin-top: 2px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.vsb-btn {
flex-shrink: 0;
padding: 3px 8px;
font-size: 0.6875rem;
font-family: var(--font-korean);
font-weight: 600;
color: #fff;
background: linear-gradient(135deg, var(--color-accent), var(--color-info));
border: none;
border-radius: 4px;
cursor: pointer;
transition: box-shadow 0.15s;
white-space: nowrap;
}
.vsb-btn:hover {
box-shadow: 0 0 12px rgba(6, 182, 212, 0.4);
}
.vsb-empty {
padding: 12px;
text-align: center;
font-size: 0.75rem;
font-family: var(--font-korean);
color: var(--fg-disabled);
}
[data-theme='light'] .vsb-input {
background: rgba(255, 255, 255, 0.92) !important;
}
[data-theme='light'] .vsb-list {
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.1);
}
[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;
}
}
} }

파일 보기

@ -41,6 +41,7 @@ import {
VesselDetailModal, VesselDetailModal,
type VesselHoverInfo, type VesselHoverInfo,
} from './VesselInteraction'; } from './VesselInteraction';
import { VesselSearchBar } from './VesselSearchBar';
import type { VesselPosition, MapBounds } from '@/types/vessel'; import type { VesselPosition, MapBounds } from '@/types/vessel';
/* eslint-disable react-refresh/only-export-components */ /* eslint-disable react-refresh/only-export-components */
@ -187,6 +188,8 @@ interface MapViewProps {
showOverlays?: boolean; showOverlays?: boolean;
/** 선박 신호 목록 (실시간 표출) */ /** 선박 신호 목록 (실시간 표출) */
vessels?: VesselPosition[]; vessels?: VesselPosition[];
/** 전체 선박 목록 (뷰포트 무관 검색용, 없으면 vessels 사용) */
allVessels?: VesselPosition[];
/** 지도 뷰포트 bounds 변경 콜백 (선박 신호 필터링에 사용) */ /** 지도 뷰포트 bounds 변경 콜백 (선박 신호 필터링에 사용) */
onBoundsChange?: (bounds: MapBounds) => void; onBoundsChange?: (bounds: MapBounds) => void;
} }
@ -372,6 +375,7 @@ export function MapView({
analysisCircleRadiusM = 0, analysisCircleRadiusM = 0,
showOverlays = true, showOverlays = true,
vessels = [], vessels = [],
allVessels,
onBoundsChange, onBoundsChange,
}: MapViewProps) { }: MapViewProps) {
const lightMode = true; const lightMode = true;
@ -395,8 +399,22 @@ 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<{
lng: number;
lat: number;
zoom: number;
} | 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);
@ -1281,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);
@ -1353,6 +1372,8 @@ export function MapView({
<MapFlyToIncident coord={flyToIncident} onFlyEnd={onIncidentFlyEnd} /> <MapFlyToIncident coord={flyToIncident} onFlyEnd={onIncidentFlyEnd} />
{/* 외부에서 flyTo 트리거 */} {/* 외부에서 flyTo 트리거 */}
<FlyToController target={flyToTarget} duration={1200} /> <FlyToController target={flyToTarget} duration={1200} />
{/* 선박 검색 결과로 flyTo */}
<FlyToController target={vesselSearchFlyTarget} duration={1200} />
{/* 예측 완료 시 궤적 전체 범위로 fitBounds */} {/* 예측 완료 시 궤적 전체 범위로 fitBounds */}
<FitBoundsController fitBoundsTarget={fitBoundsTarget} /> <FitBoundsController fitBoundsTarget={fitBoundsTarget} />
{/* 선박 신호 뷰포트 bounds 추적 */} {/* 선박 신호 뷰포트 bounds 추적 */}
@ -1395,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) &&
@ -1435,6 +1469,17 @@ export function MapView({
<MapControls center={center} zoom={zoom} /> <MapControls center={center} zoom={zoom} />
</Map> </Map>
{/* 선박 검색 */}
{(allVessels ?? vessels).length > 0 && !isDrawingBoom && measureMode === null && drawAnalysisMode === null && (
<VesselSearchBar
vessels={allVessels ?? vessels}
onFlyTo={(v) => {
setVesselSearchFlyTarget({ lng: v.lon, lat: v.lat, zoom: 16 });
setSearchedVesselMmsi(v.mmsi);
}}
/>
)}
{/* 드로잉 모드 안내 */} {/* 드로잉 모드 안내 */}
{isDrawingBoom && ( {isDrawingBoom && (
<div className="boom-drawing-indicator"> <div className="boom-drawing-indicator">

파일 보기

@ -0,0 +1,86 @@
import { useState, useRef, useEffect, useCallback } from 'react';
import type { VesselPosition } from '@/types/vessel';
interface VesselSearchBarProps {
vessels: VesselPosition[];
onFlyTo: (vessel: VesselPosition) => void;
}
export function VesselSearchBar({ vessels, onFlyTo }: VesselSearchBarProps) {
const [query, setQuery] = useState('');
const [open, setOpen] = useState(false);
const wrapRef = useRef<HTMLDivElement>(null);
const results = query.trim().length > 0
? vessels.filter((v) => {
const q = query.trim().toLowerCase();
return (
v.mmsi.toLowerCase().includes(q) ||
String(v.imo ?? '').includes(q) ||
(v.shipNm?.toLowerCase().includes(q) ?? false)
);
}).slice(0, 7)
: [];
const handleSelect = useCallback((vessel: VesselPosition) => {
onFlyTo(vessel);
setQuery('');
setOpen(false);
}, [onFlyTo]);
useEffect(() => {
const handler = (e: MouseEvent) => {
if (wrapRef.current && !wrapRef.current.contains(e.target as Node)) {
setOpen(false);
}
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, []);
return (
<div className="vsb-wrap" ref={wrapRef}>
<div className="vsb-input-wrap">
<svg className="vsb-icon" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="9" cy="9" r="5.5" stroke="currentColor" strokeWidth="1.5" />
<path d="M13.5 13.5L17 17" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
</svg>
<input
className="wing-input vsb-input"
type="text"
placeholder="선박명 또는 MMSI 검색…"
value={query}
onChange={(e) => {
setQuery(e.target.value);
setOpen(true);
}}
onFocus={() => query.trim().length > 0 && setOpen(true)}
/>
</div>
{open && query.trim().length > 0 && (
<div className="vsb-list">
{results.length > 0 ? (
results.map((vessel) => (
<div key={vessel.mmsi} className="vsb-item">
<div className="vsb-info">
<div className="vsb-name">{vessel.shipNm || '선박명 없음'}</div>
<div className="vsb-meta">
MMSI {vessel.mmsi}
{vessel.imo ? ` · IMO ${vessel.imo}` : ''}
{vessel.shipTy ? ` · ${vessel.shipTy}` : ''}
</div>
</div>
<button className="vsb-btn" onClick={() => handleSelect(vessel)}>
</button>
</div>
))
) : (
<div className="vsb-empty"> </div>
)}
</div>
)}
</div>
);
}

파일 보기

@ -76,7 +76,7 @@ export function HNSView() {
} | null>(null); } | null>(null);
const [isSelectingLocation, setIsSelectingLocation] = useState(false); const [isSelectingLocation, setIsSelectingLocation] = useState(false);
const [mapBounds, setMapBounds] = useState<MapBounds | null>(null); const [mapBounds, setMapBounds] = useState<MapBounds | null>(null);
const vessels = useVesselSignals(mapBounds); const { vessels, allVessels } = useVesselSignals(mapBounds);
const [isRunningPrediction, setIsRunningPrediction] = useState(false); const [isRunningPrediction, setIsRunningPrediction] = useState(false);
const [enabledLayers, setEnabledLayers] = useState<Set<string>>(new Set()); const [enabledLayers, setEnabledLayers] = useState<Set<string>>(new Set());
const [layerOpacity, setLayerOpacity] = useState(50); const [layerOpacity, setLayerOpacity] = useState(50);
@ -915,6 +915,7 @@ export function HNSView() {
dispersionHeatmap={heatmapData} dispersionHeatmap={heatmapData}
mapCaptureRef={mapCaptureRef} mapCaptureRef={mapCaptureRef}
vessels={vessels} vessels={vessels}
allVessels={allVessels}
onBoundsChange={setMapBounds} onBoundsChange={setMapBounds}
/> />
{/* 시간 슬라이더 (puff/dense_gas 모델용) */} {/* 시간 슬라이더 (puff/dense_gas 모델용) */}

파일 보기

@ -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';
@ -42,6 +42,8 @@ import {
} from '../utils/dischargeZoneData'; } from '../utils/dischargeZoneData';
import { useMapStore } from '@common/store/mapStore'; import { useMapStore } from '@common/store/mapStore';
import { FlyToController } from './contents/FlyToController'; import { FlyToController } from './contents/FlyToController';
import { FlyToController as VesselFlyToController } from '@components/common/map/FlyToController';
import { VesselSearchBar } from '@components/common/map/VesselSearchBar';
import { VesselPopupPanel } from './contents/VesselPopupPanel'; import { VesselPopupPanel } from './contents/VesselPopupPanel';
import { IncidentPopupContent } from './contents/IncidentPopupContent'; import { IncidentPopupContent } from './contents/IncidentPopupContent';
import { VesselDetailModal } from './contents/VesselDetailModal'; import { VesselDetailModal } from './contents/VesselDetailModal';
@ -125,7 +127,23 @@ export function IncidentsView() {
const [mapBounds, setMapBounds] = useState<MapBounds | null>(null); const [mapBounds, setMapBounds] = useState<MapBounds | null>(null);
const [mapZoom, setMapZoom] = useState<number>(10); const [mapZoom, setMapZoom] = useState<number>(10);
const realVessels = useVesselSignals(mapBounds); const { vessels: realVessels, allVessels: allRealVessels } = useVesselSignals(mapBounds);
const [vesselSearchFlyTarget, setVesselSearchFlyTarget] = useState<{
lng: number;
lat: number;
zoom: number;
} | 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(() => {
@ -617,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) {
@ -790,6 +809,20 @@ export function IncidentsView() {
<DeckGLOverlay layers={deckLayers} /> <DeckGLOverlay layers={deckLayers} />
<MapBoundsTracker onBoundsChange={setMapBounds} onZoomChange={setMapZoom} /> <MapBoundsTracker onBoundsChange={setMapBounds} onZoomChange={setMapZoom} />
<FlyToController incident={selectedIncident} /> <FlyToController incident={selectedIncident} />
<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 && (
@ -811,6 +844,17 @@ export function IncidentsView() {
)} )}
</BaseMap> </BaseMap>
{/* 선박 검색 */}
{(allRealVessels.length > 0 || realVessels.length > 0) && !dischargeMode && measureMode === null && (
<VesselSearchBar
vessels={allRealVessels.length > 0 ? allRealVessels : realVessels}
onFlyTo={(v) => {
setVesselSearchFlyTarget({ lng: v.lon, lat: v.lat, zoom: 13 });
setSearchedVesselMmsi(v.mmsi);
}}
/>
)}
{/* 호버 툴팁 */} {/* 호버 툴팁 */}
{hoverInfo && ( {hoverInfo && (
<div <div

파일 보기

@ -170,7 +170,7 @@ export function OilSpillView() {
const flyToTarget = null; const flyToTarget = null;
const fitBoundsTarget = null; const fitBoundsTarget = null;
const [mapBounds, setMapBounds] = useState<MapBounds | null>(null); const [mapBounds, setMapBounds] = useState<MapBounds | null>(null);
const vessels = useVesselSignals(mapBounds); const { vessels, allVessels } = useVesselSignals(mapBounds);
const [isSelectingLocation, setIsSelectingLocation] = useState(false); const [isSelectingLocation, setIsSelectingLocation] = useState(false);
const [oilTrajectory, setOilTrajectory] = useState<OilParticle[]>([]); const [oilTrajectory, setOilTrajectory] = useState<OilParticle[]>([]);
const [centerPoints, setCenterPoints] = useState<CenterPoint[]>([]); const [centerPoints, setCenterPoints] = useState<CenterPoint[]>([]);
@ -1329,6 +1329,7 @@ export function OilSpillView() {
showTimeLabel={displayControls.showTimeLabel} showTimeLabel={displayControls.showTimeLabel}
simulationStartTime={accidentTime || undefined} simulationStartTime={accidentTime || undefined}
vessels={vessels} vessels={vessels}
allVessels={allVessels}
onBoundsChange={setMapBounds} onBoundsChange={setMapBounds}
/> />

파일 보기

@ -182,7 +182,7 @@ export function RescueView() {
const [flyToCoord, setFlyToCoord] = useState<{ lon: number; lat: number } | undefined>(undefined); const [flyToCoord, setFlyToCoord] = useState<{ lon: number; lat: number } | undefined>(undefined);
const [isSelectingLocation, setIsSelectingLocation] = useState(false); const [isSelectingLocation, setIsSelectingLocation] = useState(false);
const [mapBounds, setMapBounds] = useState<MapBounds | null>(null); const [mapBounds, setMapBounds] = useState<MapBounds | null>(null);
const vessels = useVesselSignals(mapBounds); const { vessels, allVessels } = useVesselSignals(mapBounds);
useEffect(() => { useEffect(() => {
fetchGscAccidents() fetchGscAccidents()
@ -249,6 +249,7 @@ export function RescueView() {
enabledLayers={new Set()} enabledLayers={new Set()}
showOverlays={false} showOverlays={false}
vessels={vessels} vessels={vessels}
allVessels={allVessels}
onBoundsChange={setMapBounds} onBoundsChange={setMapBounds}
/> />
</div> </div>