- 백엔드 vessels 라우터/서비스/스케줄러 추가 (1분 주기 한국 해역 폴링) - 공통 useVesselSignals 훅 + vesselApi/vesselSignalClient 서비스 추가 - MapView에 VesselLayer/VesselInteraction/MapBoundsTracker 통합 (호버·팝업·상세 모달) - OilSpill/HNS/Rescue/Incidents 뷰에 선박 신호 연동 - vesselMockData 정리, aerial IMAGE_API_URL 기본값 변경
126 lines
3.3 KiB
TypeScript
126 lines
3.3 KiB
TypeScript
import type { VesselPosition, MapBounds } from '@common/types/vessel';
|
|
import { getVesselsInArea } from './vesselApi';
|
|
|
|
export interface VesselSignalClient {
|
|
start(
|
|
onVessels: (vessels: VesselPosition[]) => void,
|
|
getViewportBounds: () => MapBounds | null,
|
|
): 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> | null = null;
|
|
private onVessels: ((vessels: VesselPosition[]) => void) | null = null;
|
|
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 {
|
|
// 폴링 실패 시 무시 (다음 인터벌에 재시도)
|
|
}
|
|
}
|
|
|
|
start(
|
|
onVessels: (vessels: VesselPosition[]) => void,
|
|
getViewportBounds: () => MapBounds | null,
|
|
): void {
|
|
this.onVessels = onVessels;
|
|
this.getViewportBounds = getViewportBounds;
|
|
|
|
// 즉시 1회 실행 후 60초 간격으로 반복
|
|
this.poll();
|
|
this.intervalId = setInterval(() => this.poll(), 60_000);
|
|
}
|
|
|
|
stop(): void {
|
|
if (this.intervalId !== null) {
|
|
clearInterval(this.intervalId);
|
|
this.intervalId = null;
|
|
}
|
|
this.onVessels = null;
|
|
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,
|
|
): void {
|
|
this.ws = new WebSocket(this.wsUrl);
|
|
|
|
this.ws.onmessage = (event) => {
|
|
try {
|
|
const allVessels = JSON.parse(event.data as string) as VesselPosition[];
|
|
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();
|
|
}
|