Merge pull request 'feat(vessel): ���� �˻� ���� ���� (��ü ij�� Ȯ�롤���̶���Ʈ �������� �̵�����)' (#194) from feature/vessel-search-on-map into develop
This commit is contained in:
커밋
b5c1f88706
@ -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">
|
||||||
|
|||||||
86
frontend/src/components/common/map/VesselSearchBar.tsx
Normal file
86
frontend/src/components/common/map/VesselSearchBar.tsx
Normal file
@ -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>
|
||||||
|
|||||||
불러오는 중...
Reference in New Issue
Block a user