ship-gis/src/common/stompClient.js
HeungTak Lee f4f0cb274f dark 프로젝트 구현 현재 상태 스냅샷
- Vite 마이그레이션, OpenLayers+Deck.gl 지도 연동
- STOMP WebSocket 선박 실시간 데이터 수신
- 선박 범례/필터/카운트, 다크시그널 처리
- Ctrl+Drag 박스선택, 우클릭 컨텍스트 메뉴
- 측정도구, 상세모달, 호버 툴팁
- darkSignalIds Set 패턴, INSHORE/OFFSHORE 타임아웃

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 13:01:54 +09:00

238 lines
6.9 KiB
JavaScript

/**
* STOMP WebSocket 클라이언트
* 참조: mda-react-front/src/common/stompClient.ts
* 참조: mda-react-front/src/map/MapUpdater.tsx
*/
import { Client } from '@stomp/stompjs';
import { SHIP_MSG_INDEX, STOMP_TOPICS } from '../types/constants';
/**
* STOMP 클라이언트 인스턴스
* 환경변수: VITE_SIGNAL_WS (예: ws://10.26.252.39:9090/connect)
*/
export const signalStompClient = new Client({
brokerURL: import.meta.env.VITE_SIGNAL_WS || 'ws://localhost:8080/connect',
reconnectDelay: 10000,
connectionTimeout: 5000,
debug: () => {
// STOMP 디버그 로그 비활성화 (너무 많은 로그 발생)
},
});
/**
* 파이프 구분 문자열을 배열로 파싱
* 메인 프로젝트 메시지 형식: "value1|value2|value3|..."
* @param {string} msgString - 파이프 구분 문자열
* @returns {Array} 파싱된 배열
*/
export function parsePipeMessage(msgString) {
return msgString.split('|');
}
/**
* 메시지 배열을 선박 객체로 변환
* 참조: mda-react-front/src/feature/commonFeature.ts - deckSplitCommonTarget()
*
* @param {Array} row - 파싱된 메시지 배열 (38개 요소)
* @returns {Object} 선박 데이터 객체
*/
export function rowToShipObject(row) {
const idx = SHIP_MSG_INDEX;
const targetId = row[idx.TARGET_ID] || '';
const originalTargetId = row[idx.ORIGINAL_TARGET_ID] || '';
const signalSourceCode = row[idx.SIGNAL_SOURCE_CODE] || '';
return {
// 고유 식별자 (signalSourceCode + originalTargetId)
featureId: signalSourceCode + originalTargetId,
// 기본 식별 정보
targetId, // 통합 TARGET_ID
originalTargetId, // 개별 장비 고유 TARGET_ID
signalSourceCode,
shipName: row[idx.SHIP_NAME] || '', // 선박명
shipType: row[idx.SHIP_TYPE] || '', // 선박 타입
// 위치 정보
longitude: parseFloat(row[idx.LONGITUDE]) || 0,
latitude: parseFloat(row[idx.LATITUDE]) || 0,
// 항해 정보
sog: parseFloat(row[idx.SOG]) || 0,
cog: parseFloat(row[idx.COG]) || 0,
// 시간 정보
receivedTime: row[idx.RECV_DATE_TIME] || '',
// 선종 코드
signalKindCode: row[idx.SIGNAL_KIND_CODE] || '',
// 상태 플래그
lost: row[idx.LOST] === '1',
integrate: row[idx.INTEGRATE] === '1',
isPriority: row[idx.IS_PRIORITY] === '1',
// 위험물 카테고리
hazardousCategory: row[idx.HAZARDOUS_CATEGORY] || '',
// 국적 코드
nationalCode: row[idx.NATIONAL_CODE] || '',
// IMO 번호
imo: row[idx.IMO] || '',
// 흘수
draught: row[idx.DRAUGHT] || '',
// 선박 크기 (DIM)
dimA: row[idx.DIM_A] || '',
dimB: row[idx.DIM_B] || '',
dimC: row[idx.DIM_C] || '',
dimD: row[idx.DIM_D] || '',
// AVETDR 신호장비 플래그
// 값: '1'=활성, '0'=비활성, ''=장비없음
ais: row[idx.AIS], // AIS
vpass: row[idx.VPASS], // V-Pass
enav: row[idx.ENAV], // E-Nav
vtsAis: row[idx.VTS_AIS], // VTS-AIS
dMfHf: row[idx.D_MF_HF], // D-MF/HF
vtsRadar: row[idx.VTS_RADAR], // VTS-Radar
// 다크시그널 판단은 shipStore에서 처리 (darkSignalIds Set + isAnyEquipmentActive)
// 장비 활성 플래그(ais, vpass, enav, vtsAis, dMfHf, vtsRadar)는 위에서 개별 저장
// 원본 배열 (상세정보 등에 필요)
_raw: row,
};
}
/**
* AVETDR 문자열 파싱
* @param {string} avetdr - "A_V_E_T_D_R" 형식 (0 또는 1)
* @returns {Object} 각 신호원 활성 상태
*/
export function parseAvetdr(avetdr) {
const parts = (avetdr || '0_0_0_0_0_0').split('_');
return {
A: parts[0] === '1', // AIS
V: parts[1] === '1', // VPASS
E: parts[2] === '1', // ENAV
T: parts[3] === '1', // VTS_AIS
D: parts[4] === '1', // D_MF_HF
R: parts[5] === '1', // RADAR
};
}
/**
* STOMP 연결 시작
* @param {Object} callbacks - 콜백 함수들
* @param {Function} callbacks.onConnect - 연결 성공 시
* @param {Function} callbacks.onDisconnect - 연결 해제 시
* @param {Function} callbacks.onError - 에러 발생 시
*/
export function connectStomp(callbacks = {}) {
const { onConnect, onDisconnect, onError } = callbacks;
signalStompClient.onConnect = (frame) => {
console.log('[STOMP] Connected');
onConnect?.(frame);
};
signalStompClient.onDisconnect = () => {
console.log('[STOMP] Disconnected');
onDisconnect?.();
};
signalStompClient.onStompError = (frame) => {
console.error('[STOMP] Error:', frame.headers?.message || 'Unknown error');
onError?.(frame);
};
signalStompClient.activate();
}
/**
* STOMP 연결 해제
*/
export function disconnectStomp() {
if (signalStompClient.connected) {
signalStompClient.deactivate();
}
}
/**
* 선박 토픽 구독
* - 개발: /topic/ship (실시간)
* - 프로덕션: /topic/ship-throttled-60s (위성망 대응)
* @param {Function} onMessage - 메시지 수신 콜백 (파싱된 선박 데이터 배열)
* @returns {Object} 구독 객체 (unsubscribe 호출용)
*/
export function subscribeShips(onMessage) {
// 환경변수로 쓰로틀 설정 (VITE_SHIP_THROTTLE: 0=실시간, 5/10/30/60=쓰로틀)
// const throttleSeconds = parseInt(import.meta.env.VITE_SHIP_THROTTLE || '0', 10);
const throttleSeconds = 60;
const topic = throttleSeconds > 0
? `${STOMP_TOPICS.SHIP_THROTTLED}${throttleSeconds}s`
: STOMP_TOPICS.SHIP;
console.log(`[STOMP] Subscribing to ${topic}`);
return signalStompClient.subscribe(topic, (message) => {
try {
const body = message.body;
const lines = body.split('\n').filter(line => line.trim());
const ships = lines.map(line => {
const row = parsePipeMessage(line);
return rowToShipObject(row);
});
onMessage(ships);
} catch (error) {
console.error('[STOMP] Ship message parse error:', error);
}
});
}
/**
* 선박 삭제 토픽 구독
* @param {Function} onDelete - 삭제 메시지 수신 콜백 (featureId)
* @returns {Object} 구독 객체
*/
export function subscribeShipDelete(onDelete) {
console.log(`[STOMP] Subscribing to ${STOMP_TOPICS.SHIP_DELETE}`);
return signalStompClient.subscribe(STOMP_TOPICS.SHIP_DELETE, (message) => {
try {
const body = message.body;
const [signalSourceCode, targetId] = body.split('|');
if (signalSourceCode && targetId) {
const featureId = signalSourceCode + targetId;
onDelete(featureId);
}
} catch (error) {
console.error('[STOMP] Ship delete parse error:', error);
}
});
}
/**
* 선박 카운트 토픽 구독
* @param {Function} onCount - 카운트 메시지 수신 콜백
* @returns {Object} 구독 객체
*/
export function subscribeShipCount(onCount) {
return signalStompClient.subscribe(STOMP_TOPICS.COUNT, (message) => {
try {
const counts = JSON.parse(message.body);
onCount(counts);
} catch (error) {
console.error('[STOMP] Count message parse error:', error);
}
});
}