feat(vessel): 선박 검색을 전체 캐시 대상으로 확대

뷰포트에 관계없이 백엔드 캐시의 전체 선박을 검색 가능하도록 개선.

- backend: GET /api/vessels/all 엔드포인트 추가 (getAllVessels)
- vesselSignalClient: onAllVessels? 콜백 추가; PollingClient는 3분마다 pollAll(), WS Client는 필터링 전 전송
- useVesselSignals: { vessels, allVessels } 반환, 초기 스냅샷도 allVessels에 반영
- MapView: allVessels prop 추가, VesselSearchBar에 우선 전달
- OilSpillView/HNSView/RescueView/IncidentsView: allVessels 구조분해 후 MapView/VesselSearchBar에 전달

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
jeonghyo.k 2026-04-20 15:10:58 +09:00
부모 d69e057d8b
커밋 1f2e493226
12개의 변경된 파일313개의 추가작업 그리고 16개의 파일을 삭제

파일 보기

@ -1,5 +1,5 @@
import { Router } from 'express';
import { getVesselsInBounds, getCacheStatus } from './vesselService.js';
import { getVesselsInBounds, getAllVessels, getCacheStatus } from './vesselService.js';
import type { BoundingBox } from './vesselTypes.js';
const vesselRouter = Router();
@ -24,6 +24,12 @@ vesselRouter.post('/in-area', (req, res) => {
res.json(vessels);
});
// GET /api/vessels/all — 캐시된 전체 선박 목록 반환 (검색용)
vesselRouter.get('/all', (_req, res) => {
const vessels = getAllVessels();
res.json(vessels);
});
// GET /api/vessels/status — 캐시 상태 확인 (디버그용)
vesselRouter.get('/status', (_req, res) => {
const status = getCacheStatus();

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

@ -1544,4 +1544,135 @@
[data-theme='light'] .combo-list {
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);
}
}

파일 보기

@ -41,6 +41,7 @@ import {
VesselDetailModal,
type VesselHoverInfo,
} from './VesselInteraction';
import { VesselSearchBar } from './VesselSearchBar';
import type { VesselPosition, MapBounds } from '@/types/vessel';
/* eslint-disable react-refresh/only-export-components */
@ -187,6 +188,8 @@ interface MapViewProps {
showOverlays?: boolean;
/** 선박 신호 목록 (실시간 표출) */
vessels?: VesselPosition[];
/** 전체 선박 목록 (뷰포트 무관 검색용, 없으면 vessels 사용) */
allVessels?: VesselPosition[];
/** 지도 뷰포트 bounds 변경 콜백 (선박 신호 필터링에 사용) */
onBoundsChange?: (bounds: MapBounds) => void;
}
@ -372,6 +375,7 @@ export function MapView({
analysisCircleRadiusM = 0,
showOverlays = true,
vessels = [],
allVessels,
onBoundsChange,
}: MapViewProps) {
const lightMode = true;
@ -395,6 +399,11 @@ export function MapView({
const [vesselHover, setVesselHover] = useState<VesselHoverInfo | null>(null);
const [selectedVessel, setSelectedVessel] = useState<VesselPosition | null>(null);
const [detailVessel, setDetailVessel] = useState<VesselPosition | null>(null);
const [vesselSearchFlyTarget, setVesselSearchFlyTarget] = useState<{
lng: number;
lat: number;
zoom: number;
} | null>(null);
const currentTime = isControlled ? externalCurrentTime : internalCurrentTime;
const handleMapCenterChange = useCallback((lat: number, lng: number, zoom: number) => {
@ -1353,6 +1362,8 @@ export function MapView({
<MapFlyToIncident coord={flyToIncident} onFlyEnd={onIncidentFlyEnd} />
{/* 외부에서 flyTo 트리거 */}
<FlyToController target={flyToTarget} duration={1200} />
{/* 선박 검색 결과로 flyTo */}
<FlyToController target={vesselSearchFlyTarget} duration={1200} />
{/* 예측 완료 시 궤적 전체 범위로 fitBounds */}
<FitBoundsController fitBoundsTarget={fitBoundsTarget} />
{/* 선박 신호 뷰포트 bounds 추적 */}
@ -1435,6 +1446,14 @@ export function MapView({
<MapControls center={center} zoom={zoom} />
</Map>
{/* 선박 검색 */}
{(allVessels ?? vessels).length > 0 && !isDrawingBoom && measureMode === null && drawAnalysisMode === null && (
<VesselSearchBar
vessels={allVessels ?? vessels}
onFlyTo={(v) => setVesselSearchFlyTarget({ lng: v.lon, lat: v.lat, zoom: 13 })}
/>
)}
{/* 드로잉 모드 안내 */}
{isDrawingBoom && (
<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);
const [isSelectingLocation, setIsSelectingLocation] = useState(false);
const [mapBounds, setMapBounds] = useState<MapBounds | null>(null);
const vessels = useVesselSignals(mapBounds);
const { vessels, allVessels } = useVesselSignals(mapBounds);
const [isRunningPrediction, setIsRunningPrediction] = useState(false);
const [enabledLayers, setEnabledLayers] = useState<Set<string>>(new Set());
const [layerOpacity, setLayerOpacity] = useState(50);
@ -915,6 +915,7 @@ export function HNSView() {
dispersionHeatmap={heatmapData}
mapCaptureRef={mapCaptureRef}
vessels={vessels}
allVessels={allVessels}
onBoundsChange={setMapBounds}
/>
{/* 시간 슬라이더 (puff/dense_gas 모델용) */}

파일 보기

@ -42,6 +42,8 @@ import {
} from '../utils/dischargeZoneData';
import { useMapStore } from '@common/store/mapStore';
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 { IncidentPopupContent } from './contents/IncidentPopupContent';
import { VesselDetailModal } from './contents/VesselDetailModal';
@ -125,7 +127,12 @@ export function IncidentsView() {
const [mapBounds, setMapBounds] = useState<MapBounds | null>(null);
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 [vesselStatus, setVesselStatus] = useState<VesselCacheStatus | null>(null);
useEffect(() => {
@ -790,6 +797,7 @@ export function IncidentsView() {
<DeckGLOverlay layers={deckLayers} />
<MapBoundsTracker onBoundsChange={setMapBounds} onZoomChange={setMapZoom} />
<FlyToController incident={selectedIncident} />
<VesselFlyToController target={vesselSearchFlyTarget} duration={1200} />
{/* 사고 팝업 */}
{incidentPopup && (
@ -811,6 +819,14 @@ export function IncidentsView() {
)}
</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 })}
/>
)}
{/* 호버 툴팁 */}
{hoverInfo && (
<div

파일 보기

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

파일 보기

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