diff --git a/backend/src/vessels/vesselRouter.ts b/backend/src/vessels/vesselRouter.ts index 601c970..3a580b0 100644 --- a/backend/src/vessels/vesselRouter.ts +++ b/backend/src/vessels/vesselRouter.ts @@ -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(); diff --git a/backend/src/vessels/vesselService.ts b/backend/src/vessels/vesselService.ts index 5b2640c..596d581 100644 --- a/backend/src/vessels/vesselService.ts +++ b/backend/src/vessels/vesselService.ts @@ -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; diff --git a/frontend/src/common/hooks/useVesselSignals.ts b/frontend/src/common/hooks/useVesselSignals.ts index 709b7ac..d9fb3e1 100644 --- a/frontend/src/common/hooks/useVesselSignals.ts +++ b/frontend/src/common/hooks/useVesselSignals.ts @@ -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([]); + const [allVessels, setAllVessels] = useState([]); const boundsRef = useRef(mapBounds); const clientRef = useRef(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 }; } diff --git a/frontend/src/common/services/vesselApi.ts b/frontend/src/common/services/vesselApi.ts index 00c9ce5..2bdbbd5 100644 --- a/frontend/src/common/services/vesselApi.ts +++ b/frontend/src/common/services/vesselApi.ts @@ -6,6 +6,11 @@ export async function getVesselsInArea(bounds: MapBounds): Promise { + const res = await api.get('/vessels/all'); + return res.data; +} + /** * 로그인/새로고침 직후 1회 호출하는 초기 스냅샷 API. * 운영 환경의 별도 REST 서버가 현재 시각 기준 최근 10분치 선박 신호를 반환한다. diff --git a/frontend/src/common/services/vesselSignalClient.ts b/frontend/src/common/services/vesselSignalClient.ts index 8a47fe2..9114886 100644 --- a/frontend/src/common/services/vesselSignalClient.ts +++ b/frontend/src/common/services/vesselSignalClient.ts @@ -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 | null = null; + private intervalId: ReturnType | undefined = undefined; + private allIntervalId: ReturnType | 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 { @@ -31,24 +34,38 @@ class PollingVesselClient implements VesselSignalClient { } } + private async pollAll(): Promise { + 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) { diff --git a/frontend/src/common/styles/components.css b/frontend/src/common/styles/components.css index c8a810f..240358f 100644 --- a/frontend/src/common/styles/components.css +++ b/frontend/src/common/styles/components.css @@ -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); + } } diff --git a/frontend/src/components/common/map/MapView.tsx b/frontend/src/components/common/map/MapView.tsx index 4ca7b8a..5ce5a2e 100644 --- a/frontend/src/components/common/map/MapView.tsx +++ b/frontend/src/components/common/map/MapView.tsx @@ -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(null); const [selectedVessel, setSelectedVessel] = useState(null); const [detailVessel, setDetailVessel] = useState(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({ {/* 외부에서 flyTo 트리거 */} + {/* 선박 검색 결과로 flyTo */} + {/* 예측 완료 시 궤적 전체 범위로 fitBounds */} {/* 선박 신호 뷰포트 bounds 추적 */} @@ -1435,6 +1446,14 @@ export function MapView({ + {/* 선박 검색 */} + {(allVessels ?? vessels).length > 0 && !isDrawingBoom && measureMode === null && drawAnalysisMode === null && ( + setVesselSearchFlyTarget({ lng: v.lon, lat: v.lat, zoom: 13 })} + /> + )} + {/* 드로잉 모드 안내 */} {isDrawingBoom && (
diff --git a/frontend/src/components/common/map/VesselSearchBar.tsx b/frontend/src/components/common/map/VesselSearchBar.tsx new file mode 100644 index 0000000..641ce96 --- /dev/null +++ b/frontend/src/components/common/map/VesselSearchBar.tsx @@ -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(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 ( +
+
+ + + + + { + setQuery(e.target.value); + setOpen(true); + }} + onFocus={() => query.trim().length > 0 && setOpen(true)} + /> +
+ + {open && query.trim().length > 0 && ( +
+ {results.length > 0 ? ( + results.map((vessel) => ( +
+
+
{vessel.shipNm || '선박명 없음'}
+
+ MMSI {vessel.mmsi} + {vessel.imo ? ` · IMO ${vessel.imo}` : ''} + {vessel.shipTy ? ` · ${vessel.shipTy}` : ''} +
+
+ +
+ )) + ) : ( +
검색 결과 없음
+ )} +
+ )} +
+ ); +} diff --git a/frontend/src/components/hns/components/HNSView.tsx b/frontend/src/components/hns/components/HNSView.tsx index 529e519..7a0c1cd 100644 --- a/frontend/src/components/hns/components/HNSView.tsx +++ b/frontend/src/components/hns/components/HNSView.tsx @@ -76,7 +76,7 @@ export function HNSView() { } | null>(null); const [isSelectingLocation, setIsSelectingLocation] = useState(false); const [mapBounds, setMapBounds] = useState(null); - const vessels = useVesselSignals(mapBounds); + const { vessels, allVessels } = useVesselSignals(mapBounds); const [isRunningPrediction, setIsRunningPrediction] = useState(false); const [enabledLayers, setEnabledLayers] = useState>(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 모델용) */} diff --git a/frontend/src/components/incidents/components/IncidentsView.tsx b/frontend/src/components/incidents/components/IncidentsView.tsx index 4a5e4c7..a600dbc 100644 --- a/frontend/src/components/incidents/components/IncidentsView.tsx +++ b/frontend/src/components/incidents/components/IncidentsView.tsx @@ -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(null); const [mapZoom, setMapZoom] = useState(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(null); useEffect(() => { @@ -790,6 +797,7 @@ export function IncidentsView() { + {/* 사고 팝업 */} {incidentPopup && ( @@ -811,6 +819,14 @@ export function IncidentsView() { )} + {/* 선박 검색 */} + {(allRealVessels.length > 0 || realVessels.length > 0) && !dischargeMode && measureMode === null && ( + 0 ? allRealVessels : realVessels} + onFlyTo={(v) => setVesselSearchFlyTarget({ lng: v.lon, lat: v.lat, zoom: 13 })} + /> + )} + {/* 호버 툴팁 */} {hoverInfo && (
(null); - const vessels = useVesselSignals(mapBounds); + const { vessels, allVessels } = useVesselSignals(mapBounds); const [isSelectingLocation, setIsSelectingLocation] = useState(false); const [oilTrajectory, setOilTrajectory] = useState([]); const [centerPoints, setCenterPoints] = useState([]); @@ -1329,6 +1329,7 @@ export function OilSpillView() { showTimeLabel={displayControls.showTimeLabel} simulationStartTime={accidentTime || undefined} vessels={vessels} + allVessels={allVessels} onBoundsChange={setMapBounds} /> diff --git a/frontend/src/components/rescue/components/RescueView.tsx b/frontend/src/components/rescue/components/RescueView.tsx index f833d81..7f46ab4 100644 --- a/frontend/src/components/rescue/components/RescueView.tsx +++ b/frontend/src/components/rescue/components/RescueView.tsx @@ -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(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} />