- Vite 마이그레이션, OpenLayers+Deck.gl 지도 연동 - STOMP WebSocket 선박 실시간 데이터 수신 - 선박 범례/필터/카운트, 다크시그널 처리 - Ctrl+Drag 박스선택, 우클릭 컨텍스트 메뉴 - 측정도구, 상세모달, 호버 툴팁 - darkSignalIds Set 패턴, INSHORE/OFFSHORE 타임아웃 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
238 lines
6.9 KiB
JavaScript
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);
|
|
}
|
|
});
|
|
}
|