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 | null = null; private onVessels: ((vessels: VesselPosition[]) => void) | null = null; private getViewportBounds: (() => MapBounds | null) | null = null; private async poll(): Promise { 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(); }