wing-ops/frontend/src/common/services/vesselSignalClient.ts
jeonghyo.k 1f2e493226 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>
2026-04-20 15:10:58 +09:00

147 lines
4.1 KiB
TypeScript

import type { VesselPosition, MapBounds } from '@/types/vessel';
import { getVesselsInArea, getAllVessels } from './vesselApi';
export interface VesselSignalClient {
start(
onVessels: (vessels: VesselPosition[]) => void,
getViewportBounds: () => MapBounds | null,
onAllVessels?: (vessels: VesselPosition[]) => void,
): void;
stop(): void;
/**
* 즉시 1회 새로고침. 폴링 모드에선 현재 bbox로 REST 호출,
* WebSocket 모드에선 no-op(서버 push에 의존).
*/
refresh(): void;
}
// 개발환경: setInterval(60s) → 백엔드 REST API 호출
class PollingVesselClient implements VesselSignalClient {
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> {
const bounds = this.getViewportBounds?.();
if (!bounds || !this.onVessels) return;
try {
const vessels = await getVesselsInArea(bounds);
this.onVessels(vessels);
} catch {
// 폴링 실패 시 무시 (다음 인터벌에 재시도)
}
}
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;
this.poll();
this.pollAll();
this.intervalId = setInterval(() => this.poll(), 60_000);
this.allIntervalId = setInterval(() => this.pollAll(), 3 * 60_000);
}
stop(): void {
clearInterval(this.intervalId);
clearInterval(this.allIntervalId);
this.intervalId = undefined;
this.allIntervalId = undefined;
this.onVessels = null;
this.onAllVessels = undefined;
this.getViewportBounds = null;
}
refresh(): void {
this.poll();
}
}
// 운영환경: 실시간 WebSocket 서버에 직접 연결
class DirectWebSocketVesselClient implements VesselSignalClient {
private ws: WebSocket | null = null;
private readonly wsUrl: string;
constructor(wsUrl: string) {
this.wsUrl = wsUrl;
}
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) {
onVessels(allVessels);
return;
}
const filtered = allVessels.filter(
(v) =>
v.lon >= bounds.minLon &&
v.lon <= bounds.maxLon &&
v.lat >= bounds.minLat &&
v.lat <= bounds.maxLat,
);
onVessels(filtered);
} catch {
// 파싱 실패 무시
}
};
this.ws.onerror = () => {
console.error('[vesselSignalClient] WebSocket 연결 오류');
};
this.ws.onclose = () => {
console.warn('[vesselSignalClient] WebSocket 연결 종료');
};
}
stop(): void {
if (this.ws) {
this.ws.close();
this.ws = null;
}
}
refresh(): void {
// 운영 WS 모드에선 서버 push에 의존하므로 별도 새로고침 동작 없음
}
}
export function createVesselSignalClient(): VesselSignalClient {
if (import.meta.env.VITE_VESSEL_SIGNAL_MODE === 'websocket') {
const wsUrl = import.meta.env.VITE_VESSEL_WS_URL as string;
return new DirectWebSocketVesselClient(wsUrl);
}
return new PollingVesselClient();
}