signal-batch/frontend/src/features/viewport-replay/services/replayWebSocket.ts
htlee 4e6e6392c6 fix: 항적 조회 500 에러 + 리플레이 쿼리 무반응 수정
- gisApi: mmsiList → vessels 필드명 백엔드 DTO 일치
- ReplaySetupPanel: datetime 포맷 ISO 'T' 유지 (백엔드 @JsonFormat 호환)
- replayWebSocket: STOMP 에러/응답 로깅 강화

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 15:36:42 +09:00

265 lines
7.0 KiB
TypeScript

import { Client, type IMessage } from '@stomp/stompjs'
import { useMergedTrackStore } from '../stores/mergedTrackStore'
import { useReplayStore } from '../stores/replayStore'
import type { ViewportBounds } from '../../vessel-map'
const CONNECTION_TIMEOUT = 10_000
const QUERY_TIMEOUT = 300_000
export interface TrackQueryRequest {
startTime: string
endTime: string
viewport?: {
minLon: number
maxLon: number
minLat: number
maxLat: number
}
chunkedMode: boolean
chunkSize: number
simplificationMode: string
zoomLevel: number
}
export interface TrackChunkResponse {
queryId: string
chunkIndex: number
totalChunks?: number | null
tracks?: TrackChunkData[]
mergedTracks?: TrackChunkData[]
compactTracks?: TrackChunkData[]
isLastChunk?: boolean
}
export interface TrackChunkData {
vesselId: string
shipName?: string
shipKindCode?: string
nationalCode?: string
geometry?: [number, number][]
timestamps?: (string | number)[]
speeds?: number[]
totalDistance?: number
maxSpeed?: number
avgSpeed?: number
}
/** 타임스탬프를 ms 단위로 정규화 */
function parseTimestamp(ts: string | number): number {
if (typeof ts === 'number') {
return ts < 1e12 ? ts * 1000 : ts
}
if (/^\d{10,}$/.test(ts)) {
return parseInt(ts, 10) * 1000
}
if (ts.includes(' ') && !ts.includes('T')) {
const [datePart, timePart] = ts.split(' ')
return new Date(`${datePart}T${timePart}`).getTime()
}
const parsed = new Date(ts).getTime()
return isNaN(parsed) ? 0 : parsed
}
/**
* STOMP WebSocket 리플레이 서비스
* 싱글턴 — connect/disconnect/executeQuery/cancel
*/
class ReplayWebSocketService {
private client: Client | null = null
private currentQueryId: string | null = null
private queryTimeoutId: ReturnType<typeof setTimeout> | null = null
/** WebSocket 연결 */
connect(wsUrl: string): Promise<void> {
return new Promise((resolve, reject) => {
if (this.client?.connected) {
resolve()
return
}
const replayStore = useReplayStore.getState()
replayStore.setConnectionState('connecting')
this.client = new Client({
brokerURL: wsUrl,
reconnectDelay: 0,
connectionTimeout: CONNECTION_TIMEOUT,
heartbeatIncoming: 10_000,
heartbeatOutgoing: 10_000,
onConnect: () => {
replayStore.setConnectionState('connected')
this.setupSubscriptions()
resolve()
},
onStompError: (frame) => {
console.error('[ReplayWS] STOMP error:', frame.headers.message, frame.body)
if (replayStore.querying) {
replayStore.completeQuery()
}
replayStore.setConnectionState('error')
reject(new Error(frame.headers.message))
},
onWebSocketError: (evt) => {
console.error('[ReplayWS] WebSocket error:', evt)
replayStore.setConnectionState('error')
reject(new Error('WebSocket connection failed'))
},
onDisconnect: () => {
replayStore.setConnectionState('disconnected')
},
})
this.client.activate()
})
}
/** WebSocket 연결 해제 */
disconnect(): void {
this.clearQueryTimeout()
if (this.client) {
this.client.deactivate()
this.client = null
}
useReplayStore.getState().setConnectionState('disconnected')
}
/** 항적 쿼리 실행 */
executeQuery(
startTime: string,
endTime: string,
viewport: ViewportBounds,
zoomLevel: number,
): void {
if (!this.client?.connected) {
console.error('[ReplayWS] Not connected')
return
}
// 이전 쿼리 정리
if (this.currentQueryId) {
this.cancelQuery()
}
useMergedTrackStore.getState().clear()
useReplayStore.getState().startQuery()
const request: TrackQueryRequest = {
startTime,
endTime,
viewport: {
minLon: viewport.west,
maxLon: viewport.east,
minLat: viewport.south,
maxLat: viewport.north,
},
chunkedMode: true,
chunkSize: 20_000,
simplificationMode: 'AUTO',
zoomLevel,
}
console.log('[ReplayWS] Sending query:', request.startTime, '~', request.endTime, 'zoom:', request.zoomLevel)
this.client.publish({
destination: '/app/tracks/query',
body: JSON.stringify(request),
})
this.queryTimeoutId = setTimeout(() => {
console.warn('[ReplayWS] Query timeout')
useReplayStore.getState().completeQuery()
}, QUERY_TIMEOUT)
}
/** 진행 중인 쿼리 취소 */
cancelQuery(): void {
if (this.currentQueryId && this.client?.connected) {
this.client.publish({
destination: `/app/tracks/cancel/${this.currentQueryId}`,
body: '',
})
}
this.clearQueryTimeout()
this.currentQueryId = null
useReplayStore.getState().completeQuery()
}
private setupSubscriptions(): void {
if (!this.client) return
this.client.subscribe('/user/queue/tracks/chunk', (msg: IMessage) => {
this.handleChunkMessage(msg)
})
this.client.subscribe('/user/queue/tracks/status', (msg: IMessage) => {
this.handleStatusMessage(msg)
})
this.client.subscribe('/user/queue/tracks/response', (msg: IMessage) => {
try {
const data = JSON.parse(msg.body)
console.log('[ReplayWS] Response:', data.status, data.queryId)
if (data.queryId) {
this.currentQueryId = data.queryId
}
if (data.status === 'ERROR') {
console.error('[ReplayWS] Query error:', data.message)
this.clearQueryTimeout()
useReplayStore.getState().completeQuery()
}
} catch { /* ignore */ }
})
}
private handleChunkMessage(msg: IMessage): void {
try {
const chunk = JSON.parse(msg.body) as TrackChunkResponse
const tracks = chunk.tracks || chunk.mergedTracks || chunk.compactTracks || []
if (tracks.length === 0) return
// 타임스탬프 정규화
const normalizedTracks = tracks.map((t) => ({
...t,
timestampsMs: (t.timestamps || []).map(parseTimestamp),
}))
useMergedTrackStore.getState().addChunk(normalizedTracks)
const replayStore = useReplayStore.getState()
replayStore.updateProgress(
replayStore.receivedChunks + 1,
chunk.totalChunks ?? replayStore.totalChunks,
)
if (chunk.isLastChunk) {
this.clearQueryTimeout()
replayStore.completeQuery()
}
} catch (err) {
console.error('[ReplayWS] Chunk parse error:', err)
}
}
private handleStatusMessage(msg: IMessage): void {
try {
const data = JSON.parse(msg.body)
if (data.status === 'COMPLETED' || data.status === 'ERROR') {
this.clearQueryTimeout()
useReplayStore.getState().completeQuery()
}
} catch { /* ignore */ }
}
private clearQueryTimeout(): void {
if (this.queryTimeoutId) {
clearTimeout(this.queryTimeoutId)
this.queryTimeoutId = null
}
}
}
export const replayWebSocket = new ReplayWebSocketService()