- STS(Ship-to-Ship) 접촉 분석 기능 전체 구현 - API 연동 (vessel-contacts), 스토어, 레이어 훅, 레이어 레지스트리 - 접촉 쌍 그룹핑, 그룹 카드 목록, 상세 모달 (그리드 레이아웃) - ScatterplotLayer 접촉 포인트 + 위험도 색상 - 항적분석 탭 UI 분리 (구역분석 / STS분석) - AreaSearchPage → AreaSearchTab, StsAnalysisTab 추출 - 탭 전환 시 결과 초기화 확인, 구역 클리어 - 지도 호버 하이라이트 구현 (구역분석 + STS) - MapContainer pointermove에 STS 레이어 ID 핸들러 추가 - STS 쌍 항적 동시 하이라이트 (vesselId → groupIndex 매핑) - 목록↔지도 호버 연동 자동 스크롤 - pickingRadius 12→20 확대 - 재생 컨트롤러(AreaSearchTimeline) STS 지원 - 항적/궤적 토글 activeTab 기반 스토어 분기 - 닫기 시 양쪽 스토어 + 레이어 정리 - 패널 닫기 초기화 수정 (isOpen 감지, clearResults로 탭 보존) - 조회 중 로딩 오버레이 (LoadingOverlay 공통 컴포넌트) - 항적분석 다중 방문 대응, 선박 상세 모달, 구역 편집 기능 - trackLayer updateTriggers Set 직렬화, highlightedVesselIds 지원 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
929 lines
27 KiB
JavaScript
929 lines
27 KiB
JavaScript
/**
|
|
* 선박 데이터 Zustand 스토어
|
|
* 참조: mda-react-front/src/shared/model/deckStore.ts
|
|
* 참조: mda-react-front/src/common/deck.ts
|
|
*/
|
|
import { create } from 'zustand';
|
|
import { subscribeWithSelector } from 'zustand/middleware';
|
|
import {
|
|
SIGNAL_KIND_CODE_FISHING,
|
|
SIGNAL_KIND_CODE_KCGV,
|
|
SIGNAL_KIND_CODE_PASSENGER,
|
|
SIGNAL_KIND_CODE_CARGO,
|
|
SIGNAL_KIND_CODE_TANKER,
|
|
SIGNAL_KIND_CODE_GOV,
|
|
SIGNAL_KIND_CODE_NORMAL,
|
|
SIGNAL_KIND_CODE_BUOY,
|
|
SIGNAL_SOURCE_CODE_AIS,
|
|
SIGNAL_SOURCE_CODE_VPASS,
|
|
SIGNAL_SOURCE_CODE_ENAV,
|
|
SIGNAL_SOURCE_CODE_VTS_AIS,
|
|
SIGNAL_SOURCE_CODE_D_MF_HF,
|
|
SIGNAL_SOURCE_CODE_RADAR,
|
|
NATIONAL_CODE_KR,
|
|
NATIONAL_CODE_CN,
|
|
NATIONAL_CODE_JP,
|
|
NATIONAL_CODE_KP,
|
|
NATIONAL_CODE_OTHER,
|
|
SOURCE_PRIORITY_RANK,
|
|
SOURCE_TO_ACTIVE_KEY,
|
|
} from '../types/constants';
|
|
|
|
// =====================
|
|
// 국적 코드 매핑 (ShipBatchRenderer.js와 동일)
|
|
// =====================
|
|
function mapNationalCode(nationalCode) {
|
|
if (!nationalCode) return 'OTHER';
|
|
const code = nationalCode.toUpperCase();
|
|
if (code === 'KR' || code === 'KOR' || code === '440') return 'KR';
|
|
if (code === 'CN' || code === 'CHN' || code === '412' || code === '413' || code === '414') return 'CN';
|
|
if (code === 'JP' || code === 'JPN' || code === '431' || code === '432') return 'JP';
|
|
if (code === 'KP' || code === 'PRK' || code === '445') return 'KP';
|
|
return 'OTHER';
|
|
}
|
|
|
|
// =====================
|
|
// 서버 수신시간 파싱 (receivedTime → ms timestamp)
|
|
// 형식: "YYYYMMDDHHmmss"
|
|
// =====================
|
|
function parseReceivedTime(receivedTime) {
|
|
if (!receivedTime || receivedTime.length < 14) return Date.now();
|
|
const y = receivedTime.slice(0, 4);
|
|
const M = receivedTime.slice(4, 6);
|
|
const d = receivedTime.slice(6, 8);
|
|
const h = receivedTime.slice(8, 10);
|
|
const m = receivedTime.slice(10, 12);
|
|
const s = receivedTime.slice(12, 14);
|
|
const ts = new Date(`${y}-${M}-${d}T${h}:${m}:${s}`).getTime();
|
|
return isNaN(ts) ? Date.now() : ts;
|
|
}
|
|
|
|
// =====================
|
|
// 타임아웃 상수 (카운트 사이클에서 상태 전환/삭제 판정)
|
|
// =====================
|
|
//
|
|
// ■ 영해안 (LOST=0, Inshore)
|
|
// 국내 직접 수집수단(AIS 기지국, VTS 등)이 커버하는 해역.
|
|
// 수신 주기가 짧으므로(수 초~수 분) 12분 무수신 시 정상 이탈로 판단하여 삭제.
|
|
//
|
|
// ■ 영해밖 (LOST=1, Offshore)
|
|
// 직접 수집수단이 닿지 않아 위성 AIS(S-AIS) 등 간접 수단에 의존.
|
|
// 위성 AIS는 선박 위치·궤도에 따라 수신 간격이 30분~최대 1시간까지 벌어질 수 있어,
|
|
// 유효한 항해 중인 선박이 다크시그널로 오판되지 않도록 65분(3900초)으로 설정.
|
|
//
|
|
// ■ 레이더 (단독, 비통합)
|
|
// 레이더 신호는 실시간 회전 주기(수 초)에 맞춰 갱신되므로 타임아웃을 짧게 유지.
|
|
// 함정용은 /topic/ship-throttled-60s 채널 기반이므로 90초로 설정.
|
|
//
|
|
// 참조: mda-react-front/src/common/deck.ts
|
|
// 추후 사용자 설정 화면에서 커스텀 가능하도록 상수로 분리.
|
|
// =====================
|
|
const INSHORE_TIMEOUT_MS = 12 * 60 * 1000; // 720초 (12분) — 영해안: LOST=0, 무수신 시 삭제
|
|
const OFFSHORE_TIMEOUT_MS = 65 * 60 * 1000; // 3900초 (65분) — 영해밖: LOST=1, 무수신 시 다크시그널 전환
|
|
const RADAR_TIMEOUT_MS = 60 * 1000; // 90초 — 단독 레이더 비통합, 무수신 시 삭제
|
|
const SIGNAL_SOURCE_RADAR = '000005';
|
|
|
|
// =====================
|
|
// 장비 활성 상태 판단
|
|
// 참조: mda-react-front/src/common/deck.ts - isAnyEquipmentActive
|
|
// AVETDR 6개 장비 중 하나라도 '1'(활성)이면 true
|
|
// =====================
|
|
const EQUIPMENT_KEYS = ['ais', 'vpass', 'enav', 'vtsAis', 'dMfHf', 'vtsRadar'];
|
|
|
|
function isAnyEquipmentActive(ship) {
|
|
return EQUIPMENT_KEYS.some(key => ship[key] === '1');
|
|
}
|
|
|
|
/**
|
|
* 동적 대표 Set 생성
|
|
* 참조: mda-react-front/docs/dynamic-priority.md §4.1
|
|
*
|
|
* O(N) 단일 패스로 통합선박별 동적 대표 featureId의 Set 반환.
|
|
* 통합선박(targetId에 '_' 포함)만 처리하며, 단독선박은 스킵.
|
|
*
|
|
* @param {Map} features - 전체 선박 feature 맵
|
|
* @param {Set} enabledSources - 필터에서 ON된 신호원 코드 Set
|
|
* @param {Set} darkSignalIds - 다크시그널 선박 ID Set
|
|
* @returns {Set<string>} 동적 대표 featureId 집합
|
|
*/
|
|
function buildDynamicPrioritySet(features, enabledSources, darkSignalIds) {
|
|
const bestByTargetId = new Map(); // targetId → { featureId, rank }
|
|
|
|
features.forEach((ship, featureId) => {
|
|
// 다크시그널 스킵
|
|
if (darkSignalIds.has(featureId)) return;
|
|
|
|
// 단독선박 스킵 (targetId에 '_' 없고 integrate 플래그도 없음)
|
|
const targetId = ship.targetId;
|
|
if (!targetId || (!targetId.includes('_') && !ship.integrate)) return;
|
|
|
|
const sourceCode = ship.signalSourceCode;
|
|
|
|
// is_active 체크
|
|
const activeKey = SOURCE_TO_ACTIVE_KEY[sourceCode];
|
|
if (!activeKey || ship[activeKey] !== '1') return;
|
|
|
|
// 필터 체크
|
|
if (!enabledSources.has(sourceCode)) return;
|
|
|
|
const rank = SOURCE_PRIORITY_RANK[sourceCode] ?? 99;
|
|
const existing = bestByTargetId.get(targetId);
|
|
if (!existing || rank < existing.rank) {
|
|
bestByTargetId.set(targetId, { featureId, rank });
|
|
}
|
|
});
|
|
|
|
const result = new Set();
|
|
bestByTargetId.forEach(({ featureId }) => result.add(featureId));
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* 초기 선박 종류별 카운트
|
|
*/
|
|
const initialKindCounts = {
|
|
[SIGNAL_KIND_CODE_FISHING]: 0,
|
|
[SIGNAL_KIND_CODE_KCGV]: 0,
|
|
[SIGNAL_KIND_CODE_PASSENGER]: 0,
|
|
[SIGNAL_KIND_CODE_CARGO]: 0,
|
|
[SIGNAL_KIND_CODE_TANKER]: 0,
|
|
[SIGNAL_KIND_CODE_GOV]: 0,
|
|
[SIGNAL_KIND_CODE_NORMAL]: 0,
|
|
[SIGNAL_KIND_CODE_BUOY]: 0,
|
|
};
|
|
|
|
|
|
/**
|
|
* 초기 선박 종류별 표시 설정
|
|
*/
|
|
const initialKindVisibility = {
|
|
[SIGNAL_KIND_CODE_FISHING]: true,
|
|
[SIGNAL_KIND_CODE_KCGV]: true,
|
|
[SIGNAL_KIND_CODE_PASSENGER]: true,
|
|
[SIGNAL_KIND_CODE_CARGO]: true,
|
|
[SIGNAL_KIND_CODE_TANKER]: true,
|
|
[SIGNAL_KIND_CODE_GOV]: true,
|
|
[SIGNAL_KIND_CODE_NORMAL]: true,
|
|
[SIGNAL_KIND_CODE_BUOY]: true,
|
|
};
|
|
|
|
/**
|
|
* 초기 신호원별 표시 설정
|
|
*/
|
|
const initialSourceVisibility = {
|
|
[SIGNAL_SOURCE_CODE_AIS]: true,
|
|
[SIGNAL_SOURCE_CODE_VPASS]: true,
|
|
[SIGNAL_SOURCE_CODE_ENAV]: true,
|
|
[SIGNAL_SOURCE_CODE_VTS_AIS]: true,
|
|
[SIGNAL_SOURCE_CODE_D_MF_HF]: true,
|
|
[SIGNAL_SOURCE_CODE_RADAR]: true,
|
|
};
|
|
|
|
/**
|
|
* 초기 국적별 표시 설정
|
|
*/
|
|
const initialNationalVisibility = {
|
|
[NATIONAL_CODE_KR]: true,
|
|
[NATIONAL_CODE_CN]: true,
|
|
[NATIONAL_CODE_JP]: true,
|
|
[NATIONAL_CODE_KP]: true,
|
|
[NATIONAL_CODE_OTHER]: true,
|
|
};
|
|
|
|
/**
|
|
* 선박 스토어
|
|
*/
|
|
const useShipStore = create(subscribeWithSelector((set, get) => ({
|
|
// =====================
|
|
// 상태 (State)
|
|
// =====================
|
|
|
|
/** 선박 데이터 맵 (featureId -> shipData), featureId = signalSourceCode + targetId
|
|
* ※ immutable 패턴: 변경 시 new Map() 생성 → Zustand 참조 비교로 변경 감지
|
|
* (메인 프로젝트 동일 구조) */
|
|
features: new Map(),
|
|
|
|
/** 다크시그널 선박 ID Set (features와 별도 관리, 메인 프로젝트 동일 구조)
|
|
* 참조: mda-react-front/src/shared/model/deckStore.ts - darkSignalIds
|
|
* ※ immutable 패턴: 변경 시 new Set() 생성 → Zustand 참조 비교로 변경 감지 */
|
|
darkSignalIds: new Set(),
|
|
|
|
/** 선박 종류별 카운트 */
|
|
kindCounts: { ...initialKindCounts },
|
|
|
|
/** 선박 종류별 표시 여부 */
|
|
kindVisibility: { ...initialKindVisibility },
|
|
|
|
/** 신호원별 표시 여부 */
|
|
sourceVisibility: { ...initialSourceVisibility },
|
|
|
|
/** 국적별 표시 여부 */
|
|
nationalVisibility: { ...initialNationalVisibility },
|
|
|
|
/** 선택된 선박 ID (단일 클릭용, 레거시) */
|
|
selectedShipId: null,
|
|
|
|
/** Ctrl+Drag 다중 선택된 featureId 배열 (제한 없음) */
|
|
selectedShipIds: [],
|
|
|
|
/** 컨텍스트 메뉴 상태 { x, y, ships: [] } | null */
|
|
contextMenu: null,
|
|
|
|
/** 호버 중인 선박 정보 { ship, x, y } | null */
|
|
hoverInfo: null,
|
|
|
|
/** 상세 모달 배열 (최대 3개) [{ ship, id, initialPos }] */
|
|
detailModals: [],
|
|
|
|
/** 마지막 모달 위치 (새 모달 초기 위치 계산용) */
|
|
lastModalPos: null,
|
|
|
|
/** 다크시그널(소실신호) 표시 여부 */
|
|
darkSignalVisible: false,
|
|
|
|
/** 다크시그널 선박 수 */
|
|
darkSignalCount: 0,
|
|
|
|
/** 선박 표시 On/Off */
|
|
isShipVisible: true,
|
|
|
|
/** 선박 통합 모드 (통합선박에서 isPriority만 표시) */
|
|
isIntegrate: true,
|
|
|
|
/** 선명표시 여부 (개발 중 기본 활성화) */
|
|
showLabels: true,
|
|
|
|
/** 선명표시 옵션 (개발 중 기본 모두 활성화) */
|
|
labelOptions: {
|
|
showShipName: true, // 선박명
|
|
showSpeedVector: true, // 속도벡터
|
|
showShipSize: true, // 선박크기
|
|
showSignalStatus: false, // 신호상태
|
|
},
|
|
|
|
/** STOMP 연결 상태 */
|
|
isConnected: false,
|
|
|
|
/** 범례 표시 여부 */
|
|
showLegend: true,
|
|
|
|
/** 변경된 선박 ID 추적 (렌더링 최적화용) */
|
|
changedIds: new Set(),
|
|
|
|
/** 총 선박 수 */
|
|
totalCount: 0,
|
|
|
|
// =====================
|
|
// 액션 (Actions)
|
|
// =====================
|
|
|
|
/**
|
|
* 여러 선박 데이터 병합 (bulk update)
|
|
* 카운트는 ShipBatchRenderer의 렌더 사이클에서 계산 (메인 프로젝트 동일)
|
|
* @param {Array} ships - 선박 데이터 배열
|
|
*/
|
|
mergeFeatures: (ships) => {
|
|
// ※ immutable 패턴: 배치 단위로 변경 후 1회만 new Map()/new Set() 생성
|
|
const state = get();
|
|
const newFeatures = new Map(state.features);
|
|
const newDarkSignalIds = new Set(state.darkSignalIds);
|
|
let darkChanged = false;
|
|
|
|
ships.forEach((ship) => {
|
|
const featureId = ship.featureId;
|
|
if (!featureId) return;
|
|
|
|
// 좌표가 없으면 스킵
|
|
if (!ship.longitude || !ship.latitude) {
|
|
return;
|
|
}
|
|
|
|
// 타임스탬프 비교: 기존 데이터보다 오래된 메시지는 무시
|
|
// 참조: mda-react-front/src/shared/model/deckStore.ts - mergeFeatures (line 163)
|
|
const newTimestamp = parseReceivedTime(ship.receivedTime);
|
|
const currentFeature = newFeatures.get(featureId);
|
|
if (currentFeature && newTimestamp < currentFeature.receivedTimestamp) {
|
|
return; // 이전 시간대 데이터 → 무시
|
|
}
|
|
|
|
const hasActive = isAnyEquipmentActive(ship);
|
|
|
|
// 규칙 1: LOST=0(영해 내) + 모든 장비 비활성 → 저장하지 않음 (완전 삭제)
|
|
if (!ship.lost && !hasActive) {
|
|
newFeatures.delete(featureId);
|
|
if (newDarkSignalIds.delete(featureId)) darkChanged = true;
|
|
return;
|
|
}
|
|
|
|
// 다크시그널 상태 판정
|
|
if (hasActive) {
|
|
if (newDarkSignalIds.delete(featureId)) darkChanged = true;
|
|
} else {
|
|
if (!newDarkSignalIds.has(featureId)) {
|
|
newDarkSignalIds.add(featureId);
|
|
darkChanged = true;
|
|
}
|
|
}
|
|
|
|
newFeatures.set(featureId, { ...ship, receivedTimestamp: newTimestamp });
|
|
});
|
|
|
|
// immutable 참조 변경 → Zustand 감지
|
|
set({
|
|
features: newFeatures,
|
|
...(darkChanged ? { darkSignalIds: newDarkSignalIds } : {}),
|
|
});
|
|
},
|
|
|
|
/**
|
|
* 타임아웃 cleanup 적용 (ShipBatchRenderer에서 호출)
|
|
* immutable 패턴으로 삭제 + 다크시그널 전환을 한 번에 처리
|
|
* @param {Array<string>} deleteIds - 삭제할 featureId 배열
|
|
* @param {Array<string>} darkSignalConvertIds - 다크시그널로 전환할 featureId 배열
|
|
*/
|
|
applyCleanup: (deleteIds, darkSignalConvertIds) => {
|
|
if (deleteIds.length === 0 && darkSignalConvertIds.length === 0) return;
|
|
|
|
const state = get();
|
|
const newFeatures = new Map(state.features);
|
|
const newDarkSignalIds = new Set(state.darkSignalIds);
|
|
|
|
deleteIds.forEach(id => {
|
|
newFeatures.delete(id);
|
|
newDarkSignalIds.delete(id);
|
|
});
|
|
|
|
darkSignalConvertIds.forEach(id => newDarkSignalIds.add(id));
|
|
|
|
set({ features: newFeatures, darkSignalIds: newDarkSignalIds });
|
|
},
|
|
|
|
/**
|
|
* 단일 선박 추가/업데이트
|
|
* @param {Object} ship - 선박 데이터
|
|
*/
|
|
addOrUpdateFeature: (ship) => {
|
|
get().mergeFeatures([ship]);
|
|
},
|
|
|
|
/**
|
|
* 선박 삭제
|
|
* @param {string} featureId - 삭제할 선박 ID (signalSourceCode + targetId)
|
|
*/
|
|
deleteFeatureById: (featureId) => {
|
|
const state = get();
|
|
const newFeatures = new Map(state.features);
|
|
newFeatures.delete(featureId);
|
|
const updates = { features: newFeatures };
|
|
|
|
if (state.darkSignalIds.has(featureId)) {
|
|
const newDark = new Set(state.darkSignalIds);
|
|
newDark.delete(featureId);
|
|
updates.darkSignalIds = newDark;
|
|
}
|
|
|
|
if (state.selectedShipId === featureId) {
|
|
updates.selectedShipId = null;
|
|
}
|
|
|
|
set(updates);
|
|
},
|
|
|
|
/**
|
|
* 여러 선박 삭제
|
|
* @param {Array<string>} featureIds - 삭제할 선박 ID 배열 (signalSourceCode + targetId)
|
|
*/
|
|
deleteFeaturesByIds: (featureIds) => {
|
|
const state = get();
|
|
const newFeatures = new Map(state.features);
|
|
const newDarkSignalIds = new Set(state.darkSignalIds);
|
|
let darkChanged = false;
|
|
|
|
featureIds.forEach((featureId) => {
|
|
newFeatures.delete(featureId);
|
|
if (newDarkSignalIds.delete(featureId)) darkChanged = true;
|
|
});
|
|
|
|
set({
|
|
features: newFeatures,
|
|
...(darkChanged ? { darkSignalIds: newDarkSignalIds } : {}),
|
|
selectedShipId: featureIds.includes(state.selectedShipId) ? null : state.selectedShipId,
|
|
});
|
|
},
|
|
|
|
/**
|
|
* 선박 종류별 표시 토글
|
|
* 필터 변경 → useShipLayer subscription → immediateRender → 카운트 재계산
|
|
* @param {string} kindCode - 선박 종류 코드
|
|
*/
|
|
toggleKindVisibility: (kindCode) => {
|
|
set((state) => ({
|
|
kindVisibility: {
|
|
...state.kindVisibility,
|
|
[kindCode]: !state.kindVisibility[kindCode],
|
|
},
|
|
}));
|
|
},
|
|
|
|
/**
|
|
* 신호원별 표시 토글
|
|
* 필터 변경 → useShipLayer subscription → immediateRender → 카운트 재계산
|
|
* @param {string} sourceCode - 신호원 코드
|
|
*/
|
|
toggleSourceVisibility: (sourceCode) => {
|
|
set((state) => ({
|
|
sourceVisibility: {
|
|
...state.sourceVisibility,
|
|
[sourceCode]: !state.sourceVisibility[sourceCode],
|
|
},
|
|
}));
|
|
},
|
|
|
|
/**
|
|
* 국적별 표시 토글
|
|
* 필터 변경 → useShipLayer subscription → immediateRender → 카운트 재계산
|
|
* @param {string} nationalCode - 국적 코드
|
|
*/
|
|
toggleNationalVisibility: (nationalCode) => {
|
|
set((state) => ({
|
|
nationalVisibility: {
|
|
...state.nationalVisibility,
|
|
[nationalCode]: !state.nationalVisibility[nationalCode],
|
|
},
|
|
}));
|
|
},
|
|
|
|
/**
|
|
* 다크시그널 표시 토글
|
|
*/
|
|
toggleDarkSignalVisible: () => {
|
|
set((state) => ({
|
|
darkSignalVisible: !state.darkSignalVisible,
|
|
}));
|
|
},
|
|
|
|
/**
|
|
* 다크시그널 선박 일괄 삭제
|
|
*/
|
|
clearDarkSignals: () => {
|
|
const state = get();
|
|
const newFeatures = new Map(state.features);
|
|
state.darkSignalIds.forEach((fid) => {
|
|
newFeatures.delete(fid);
|
|
});
|
|
set({ features: newFeatures, darkSignalIds: new Set() });
|
|
},
|
|
|
|
/**
|
|
* 선박 표시 전체 On/Off
|
|
*/
|
|
toggleShipVisible: () => {
|
|
set((state) => ({
|
|
isShipVisible: !state.isShipVisible,
|
|
}));
|
|
},
|
|
|
|
/**
|
|
* 선명표시 On/Off
|
|
*/
|
|
toggleShowLabels: () => {
|
|
set((state) => ({
|
|
showLabels: !state.showLabels,
|
|
}));
|
|
},
|
|
|
|
/**
|
|
* 선명표시 옵션 설정
|
|
* @param {string} optionKey - 옵션 키 (showShipName, showSpeedVector, showShipSize, showSignalStatus)
|
|
*/
|
|
toggleLabelOption: (optionKey) => {
|
|
set((state) => ({
|
|
labelOptions: {
|
|
...state.labelOptions,
|
|
[optionKey]: !state.labelOptions[optionKey],
|
|
},
|
|
}));
|
|
},
|
|
|
|
/**
|
|
* 선명표시 옵션 직접 설정
|
|
* @param {Object} options - 옵션 객체
|
|
*/
|
|
setLabelOptions: (options) => {
|
|
set((state) => ({
|
|
labelOptions: {
|
|
...state.labelOptions,
|
|
...options,
|
|
},
|
|
}));
|
|
},
|
|
|
|
/**
|
|
* 선박 통합 모드 토글
|
|
* 통합 모드 On: isPriority=1인 선박만 표시
|
|
* 통합 모드 Off: 모든 선박 표시
|
|
*/
|
|
toggleIntegrate: () => {
|
|
const newMode = !get().isIntegrate;
|
|
get().syncSelectedWithIntegrateMode(newMode);
|
|
set({ isIntegrate: newMode });
|
|
},
|
|
|
|
/**
|
|
* 선박 선택
|
|
* @param {string|null} featureId - 선택할 선박 ID (null이면 선택 해제, signalSourceCode + targetId)
|
|
*/
|
|
selectShip: (featureId) => {
|
|
set({ selectedShipId: featureId });
|
|
},
|
|
|
|
/**
|
|
* Ctrl+Drag 다중 선택 설정
|
|
* @param {Array<string>} ids - featureId 배열
|
|
*/
|
|
setSelectedShipIds: (ids) => set({ selectedShipIds: ids }),
|
|
|
|
/**
|
|
* 다중 선택 해제
|
|
*/
|
|
clearSelectedShips: () => set({ selectedShipIds: [] }),
|
|
|
|
/**
|
|
* 컨텍스트 메뉴 열기
|
|
* @param {{ x: number, y: number, ships: Array }} info
|
|
*/
|
|
openContextMenu: (info) => set({ contextMenu: info }),
|
|
|
|
/**
|
|
* 컨텍스트 메뉴 닫기
|
|
*/
|
|
closeContextMenu: () => set({ contextMenu: null }),
|
|
|
|
/**
|
|
* 통합모드 전환 시 selectedShipIds 동기화
|
|
* 참조: mda-react-front/src/shared/model/deckStore.ts - syncSelectedFeaturesWithIntegrateMode
|
|
* @param {boolean} toIntegrateMode - 전환 후 통합모드 ON 여부
|
|
*/
|
|
syncSelectedWithIntegrateMode: (toIntegrateMode) => {
|
|
const { selectedShipIds, features } = get();
|
|
if (selectedShipIds.length === 0) return;
|
|
|
|
const EQUIPMENT_MAP = [
|
|
{ index: 0, signalSourceCode: '000001', dataKey: 'ais' },
|
|
{ index: 1, signalSourceCode: '000003', dataKey: 'vpass' },
|
|
{ index: 2, signalSourceCode: '000002', dataKey: 'enav' },
|
|
{ index: 3, signalSourceCode: '000004', dataKey: 'vtsAis' },
|
|
{ index: 4, signalSourceCode: '000016', dataKey: 'dMfHf' },
|
|
// index 5 = VTS-Radar → 확장 시 제외
|
|
];
|
|
|
|
if (toIntegrateMode) {
|
|
// OFF → ON: 개별 장비 → 대표(isPriority) 선박으로 축소
|
|
const newIds = [];
|
|
const seenTargetIds = new Set();
|
|
|
|
selectedShipIds.forEach((fid) => {
|
|
const ship = features.get(fid);
|
|
if (!ship) return;
|
|
|
|
if (!ship.integrate) {
|
|
newIds.push(fid);
|
|
return;
|
|
}
|
|
|
|
const tid = ship.targetId;
|
|
if (seenTargetIds.has(tid)) return;
|
|
seenTargetIds.add(tid);
|
|
|
|
let priorityFid = null;
|
|
features.forEach((s, id) => {
|
|
if (s.targetId === tid && s.isPriority) priorityFid = id;
|
|
});
|
|
newIds.push(priorityFid || fid);
|
|
});
|
|
|
|
set({ selectedShipIds: newIds });
|
|
} else {
|
|
// ON → OFF: 대표 선박 → isActive인 개별 장비로 확장
|
|
const newIds = [];
|
|
|
|
selectedShipIds.forEach((fid) => {
|
|
const ship = features.get(fid);
|
|
if (!ship) return;
|
|
|
|
if (!ship.integrate || !ship.isPriority) {
|
|
newIds.push(fid);
|
|
return;
|
|
}
|
|
|
|
const parts = ship.targetId.split('_');
|
|
let expanded = false;
|
|
|
|
EQUIPMENT_MAP.forEach(({ index, signalSourceCode, dataKey }) => {
|
|
const equipTargetId = parts[index];
|
|
if (!equipTargetId) return;
|
|
if (ship[dataKey] !== '1') return;
|
|
|
|
const equipFeatureId = signalSourceCode + equipTargetId;
|
|
if (features.has(equipFeatureId)) {
|
|
newIds.push(equipFeatureId);
|
|
expanded = true;
|
|
}
|
|
});
|
|
|
|
if (!expanded) newIds.push(fid);
|
|
});
|
|
|
|
set({ selectedShipIds: newIds });
|
|
}
|
|
},
|
|
|
|
/**
|
|
* 호버 정보 설정
|
|
* @param {Object|null} info - { ship, x, y } 또는 null
|
|
*/
|
|
setHoverInfo: (info) => {
|
|
set({ hoverInfo: info });
|
|
},
|
|
|
|
/**
|
|
* 상세 모달 열기 (최대 3개, 4번째부터 FIFO 제거)
|
|
* 새 모달은 마지막 모달의 현재 위치 기준 우측 140px 오프셋으로 생성
|
|
* 참조: mda-react-front/src/shared/model/deckStore.ts - setAddDetailModal
|
|
* @param {Object} ship - 선박 데이터
|
|
*/
|
|
openDetailModal: (ship) => {
|
|
set((state) => {
|
|
let displayShip = ship;
|
|
|
|
// 통합선박이고 신호원이 레이더인 경우, 비레이더 신호원으로 교체
|
|
const isIntegrated = ship.targetId && (ship.targetId.includes('_') || ship.integrate);
|
|
if (isIntegrated && ship.signalSourceCode === SIGNAL_SOURCE_CODE_RADAR) {
|
|
// 같은 targetId의 비레이더 신호원 중 우선순위가 가장 높은 것 찾기
|
|
const alternatives = Array.from(state.features.values())
|
|
.filter(f =>
|
|
f.targetId === ship.targetId &&
|
|
f.signalSourceCode !== SIGNAL_SOURCE_CODE_RADAR
|
|
)
|
|
.sort((a, b) => {
|
|
// 우선순위 정렬 (낮은 숫자 = 높은 우선순위)
|
|
const rankA = SOURCE_PRIORITY_RANK[a.signalSourceCode] ?? 99;
|
|
const rankB = SOURCE_PRIORITY_RANK[b.signalSourceCode] ?? 99;
|
|
return rankA - rankB;
|
|
});
|
|
|
|
if (alternatives.length > 0) {
|
|
displayShip = alternatives[0];
|
|
}
|
|
}
|
|
|
|
// 이미 열린 동일 선박 모달이면 무시
|
|
if (state.detailModals.some((m) => m.id === displayShip.featureId)) {
|
|
return state;
|
|
}
|
|
|
|
// 새 모달 초기 위치: 마지막 모달 위치 + 140px 우측
|
|
// 최초 모달은 화면 중앙 근처에서 시작 (가로: 화면 중앙 - 200px, 세로: 100px)
|
|
const defaultX = typeof window !== 'undefined' ? Math.max(100, (window.innerWidth / 2) - 200) : 400;
|
|
const basePos = state.lastModalPos || { x: defaultX - 140, y: 100 };
|
|
const initialPos = { x: basePos.x + 140, y: basePos.y };
|
|
|
|
const newModal = { ship: displayShip, id: displayShip.featureId, initialPos };
|
|
let modals = [...state.detailModals, newModal];
|
|
|
|
// 3개 초과 시 가장 오래된 모달 제거
|
|
if (modals.length > 3) {
|
|
modals = modals.slice(modals.length - 3);
|
|
}
|
|
|
|
return {
|
|
detailModals: modals,
|
|
lastModalPos: initialPos,
|
|
};
|
|
});
|
|
},
|
|
|
|
/**
|
|
* 모달 위치 업데이트 (드래그 후 호출)
|
|
* @param {string} modalId - 모달 ID
|
|
* @param {{ x: number, y: number }} pos - 현재 위치
|
|
*/
|
|
updateModalPos: (modalId, pos) => {
|
|
set({ lastModalPos: pos });
|
|
},
|
|
|
|
/**
|
|
* 특정 상세 모달 닫기
|
|
* @param {string} modalId - 모달 ID (featureId)
|
|
*/
|
|
closeDetailModal: (modalId) => {
|
|
set((state) => ({
|
|
detailModals: state.detailModals.filter((m) => m.id !== modalId),
|
|
}));
|
|
},
|
|
|
|
/**
|
|
* 모든 상세 모달 닫기
|
|
*/
|
|
closeAllDetailModals: () => {
|
|
set({ detailModals: [], lastModalPos: null });
|
|
},
|
|
|
|
/**
|
|
* STOMP 연결 상태 설정
|
|
* @param {boolean} connected - 연결 상태
|
|
*/
|
|
setConnected: (connected) => {
|
|
set({ isConnected: connected });
|
|
},
|
|
|
|
/**
|
|
* 범례 표시 토글
|
|
*/
|
|
toggleShowLegend: () => {
|
|
set((state) => ({ showLegend: !state.showLegend }));
|
|
},
|
|
|
|
/**
|
|
* 모든 선박 데이터 초기화
|
|
*/
|
|
clearFeatures: () => {
|
|
set({
|
|
features: new Map(),
|
|
darkSignalIds: new Set(),
|
|
kindCounts: { ...initialKindCounts },
|
|
selectedShipId: null,
|
|
selectedShipIds: [],
|
|
contextMenu: null,
|
|
totalCount: 0,
|
|
darkSignalCount: 0,
|
|
});
|
|
},
|
|
|
|
/**
|
|
* 변경 ID 초기화 (렌더링 후 호출)
|
|
*/
|
|
clearChangedIds: () => {
|
|
set({ changedIds: new Set() });
|
|
},
|
|
|
|
/**
|
|
* 선박 종류별 카운트 직접 설정 (서버 count 토픽용)
|
|
* @param {Object} counts - 종류별 카운트 객체
|
|
*/
|
|
setKindCounts: (counts) => {
|
|
const totalCount = Object.values(counts).reduce((sum, count) => sum + count, 0);
|
|
set({
|
|
kindCounts: { ...initialKindCounts, ...counts },
|
|
totalCount,
|
|
});
|
|
},
|
|
|
|
// =====================
|
|
// 셀렉터 (Selectors)
|
|
// =====================
|
|
|
|
/**
|
|
* 표시 가능한 선박 목록 (필터 적용)
|
|
* @returns {Array} 필터링된 선박 배열
|
|
*/
|
|
getVisibleShips: () => {
|
|
const state = get();
|
|
if (!state.isShipVisible) return [];
|
|
|
|
const { features, darkSignalIds, kindVisibility, sourceVisibility, darkSignalVisible } = state;
|
|
const result = [];
|
|
|
|
features.forEach((ship, featureId) => {
|
|
// 다크시그널은 독립 필터 (선종/신호원/국적 필터 무시)
|
|
if (darkSignalIds.has(featureId)) {
|
|
if (darkSignalVisible) result.push(ship);
|
|
return;
|
|
}
|
|
|
|
// 선박 종류 필터
|
|
if (!kindVisibility[ship.signalKindCode]) return;
|
|
|
|
// 신호원 필터
|
|
if (!sourceVisibility[ship.signalSourceCode]) return;
|
|
|
|
result.push(ship);
|
|
});
|
|
|
|
return result;
|
|
},
|
|
|
|
/**
|
|
* 선택된 선박 정보
|
|
* @returns {Object|null} 선박 데이터 또는 null
|
|
*/
|
|
getSelectedShip: () => {
|
|
const { features, selectedShipId } = get();
|
|
return selectedShipId ? features.get(selectedShipId) : null;
|
|
},
|
|
|
|
/**
|
|
* 선택된 모든 선박 정보 (하이라이트 표시용)
|
|
* selectedShipIds(박스선택) + detailModals(상세모달) 통합
|
|
* @returns {Array} 선박 데이터 배열
|
|
*/
|
|
getSelectedShips: () => {
|
|
const { features, selectedShipIds, detailModals } = get();
|
|
const result = [];
|
|
const seen = new Set();
|
|
|
|
selectedShipIds.forEach((fid) => {
|
|
const ship = features.get(fid);
|
|
if (ship && !seen.has(fid)) {
|
|
result.push(ship);
|
|
seen.add(fid);
|
|
}
|
|
});
|
|
|
|
detailModals.forEach((m) => {
|
|
if (!seen.has(m.id)) {
|
|
// 라이브 데이터 우선 사용 (m.id === featureId === signalSourceCode + targetId)
|
|
const liveShip = features.get(m.id);
|
|
if (liveShip) {
|
|
result.push(liveShip);
|
|
} else if (m.ship) {
|
|
result.push(m.ship);
|
|
}
|
|
seen.add(m.id);
|
|
}
|
|
});
|
|
|
|
return result;
|
|
},
|
|
|
|
/**
|
|
* CSV 다운로드용 선박 목록 (필터 적용)
|
|
* - 레이더 항상 제외
|
|
* - 통합 모드: isPriority만 포함
|
|
* - 다크시그널: 독립 필터 적용
|
|
* - 일반: 선종/신호원/국적 필터 적용
|
|
* @returns {Array} 다운로드용 선박 배열 (downloadTargetId 포함)
|
|
*/
|
|
getDownloadShips: () => {
|
|
const state = get();
|
|
const {
|
|
features, darkSignalIds, kindVisibility, sourceVisibility, nationalVisibility,
|
|
isIntegrate, darkSignalVisible,
|
|
} = state;
|
|
|
|
// 통합 모드: 동적 우선순위 Set 생성
|
|
let dynamicPrioritySet = null;
|
|
if (isIntegrate) {
|
|
const enabledSources = new Set();
|
|
Object.entries(sourceVisibility).forEach(([code, on]) => { if (on) enabledSources.add(code); });
|
|
dynamicPrioritySet = buildDynamicPrioritySet(features, enabledSources, darkSignalIds);
|
|
}
|
|
|
|
const result = [];
|
|
|
|
features.forEach((ship, featureId) => {
|
|
// 레이더 항상 제외
|
|
if (ship.signalSourceCode === '000005') return;
|
|
|
|
// 통합 모드: 동적 대표만 포함
|
|
if (isIntegrate && ship.targetId && ship.targetId.includes('_')) {
|
|
if (!dynamicPrioritySet.has(featureId)) return;
|
|
}
|
|
|
|
const downloadTargetId = isIntegrate ? ship.targetId : ship.originalTargetId;
|
|
|
|
// 다크시그널: 독립 필터
|
|
if (darkSignalIds.has(featureId)) {
|
|
if (darkSignalVisible) {
|
|
result.push({ ...ship, downloadTargetId });
|
|
}
|
|
return;
|
|
}
|
|
|
|
// 선종 필터
|
|
if (!kindVisibility[ship.signalKindCode]) return;
|
|
// 신호원 필터
|
|
if (!sourceVisibility[ship.signalSourceCode]) return;
|
|
// 국적 필터
|
|
const mapped = mapNationalCode(ship.nationalCode);
|
|
if (!nationalVisibility[mapped]) return;
|
|
|
|
result.push({ ...ship, downloadTargetId });
|
|
});
|
|
|
|
return result;
|
|
},
|
|
})));
|
|
|
|
export {
|
|
buildDynamicPrioritySet,
|
|
isAnyEquipmentActive,
|
|
mapNationalCode,
|
|
initialKindCounts,
|
|
INSHORE_TIMEOUT_MS,
|
|
OFFSHORE_TIMEOUT_MS,
|
|
RADAR_TIMEOUT_MS,
|
|
SIGNAL_SOURCE_RADAR,
|
|
};
|
|
export default useShipStore;
|