ship-gis/src/stores/shipStore.js
HeungTak Lee ce54d9d0db perf: Map/Set mutable update + 버전 카운터 패턴 적용
mergeFeatures, updateCountsThrottled, deleteFeatureById,
deleteFeaturesByIds, clearDarkSignals에서 new Map()/new Set()
전체 복사를 제거하고 기존 인스턴스를 직접 mutate.
Zustand 변경 감지는 featuresVersion/darkSignalVersion
카운터로 트리거.

5000척 기준 배치당 O(5000) Map 복사 → O(batch) 변경으로 개선.

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

1005 lines
30 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,
} 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 = 90 * 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');
}
// =====================
// 카운트 쓰로틀링 상수 및 캐시
// 참조: mda-react-front/src/common/deck.ts (271-331)
// =====================
const LIVE_COUNT_THROTTLE_MS = 5000; // 5초
/**
* 카운트 캐시 (스토어 외부에 저장)
* - counts: 마지막으로 계산된 종류별 카운트
* - lastCalcTime: 마지막 계산 시간
* - lastFilterHash: 마지막 필터 해시 (필터 변경 감지용)
*/
const countCache = {
counts: null,
lastCalcTime: 0,
lastFilterHash: '',
};
/**
* 필터 해시 생성 (필터 변경 감지용)
* @param {Object} kindVisibility - 선박 종류별 표시 여부
* @param {Object} sourceVisibility - 신호원별 표시 여부
* @returns {string} 필터 해시 문자열
*/
function generateFilterHash(kindVisibility, sourceVisibility, nationalVisibility, darkSignalVisible) {
const kindKeys = Object.keys(kindVisibility).sort();
const sourceKeys = Object.keys(sourceVisibility).sort();
const nationalKeys = Object.keys(nationalVisibility).sort();
const kindHash = kindKeys.map(k => kindVisibility[k] ? '1' : '0').join('');
const sourceHash = sourceKeys.map(k => sourceVisibility[k] ? '1' : '0').join('');
const nationalHash = nationalKeys.map(k => nationalVisibility[k] ? '1' : '0').join('');
return `${kindHash}_${sourceHash}_${nationalHash}_${darkSignalVisible ? '1' : '0'}`;
}
/**
* 초기 선박 종류별 카운트
*/
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
* ※ 성능 최적화: Map 인스턴스를 직접 mutate하고 featuresVersion으로 변경 감지
* (5000척 기준 new Map() 전체 복사 제거 → 배치당 O(batch)만 발생) */
features: new Map(),
/** features 변경 버전 카운터 (Zustand 참조 동등성 감지용)
* features Map은 동일 인스턴스를 유지하면서 내부만 변경하므로,
* 구독자가 변경을 감지할 수 있도록 버전 번호를 증가시킨다. */
featuresVersion: 0,
/** 다크시그널 선박 ID Set (features와 별도 관리, 메인 프로젝트 동일 구조)
* 참조: mda-react-front/src/shared/model/deckStore.ts - darkSignalIds
* ※ 성능 최적화: Set 인스턴스를 직접 mutate하고 darkSignalVersion으로 변경 감지 */
darkSignalIds: new Set(),
/** darkSignalIds 변경 버전 카운터 */
darkSignalVersion: 0,
/** 선박 종류별 카운트 */
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: true,
/** 다크시그널 선박 수 */
darkSignalCount: 0,
/** 선박 표시 On/Off */
isShipVisible: true,
/** 선박 통합 모드 (통합선박에서 isPriority만 표시) */
isIntegrate: true,
/** 선명표시 여부 (개발 중 기본 활성화) */
showLabels: true,
/** 선명표시 옵션 (개발 중 기본 모두 활성화) */
labelOptions: {
showShipName: true, // 선박명
showSpeedVector: true, // 속도벡터
showShipSize: true, // 선박크기
showSignalStatus: true, // 신호상태
},
/** STOMP 연결 상태 */
isConnected: false,
/** 범례 표시 여부 */
showLegend: true,
/** 변경된 선박 ID 추적 (렌더링 최적화용) */
changedIds: new Set(),
/** 총 선박 수 */
totalCount: 0,
// =====================
// 액션 (Actions)
// =====================
/**
* 여러 선박 데이터 병합 (bulk update)
* 카운트는 5초 쓰로틀로 업데이트 (메인 프로젝트 동일)
* @param {Array} ships - 선박 데이터 배열
*/
mergeFeatures: (ships) => {
// ※ 성능 최적화: Map/Set을 직접 mutate하여 O(n) 전체 복사를 제거
// 기존: new Map(state.features) → O(5000) 복사 + O(batch) 변경
// 최적화: state.features에 직접 set/delete → O(batch)만 발생
// Zustand 변경 감지는 featuresVersion 카운터로 트리거
const state = get();
const { features, darkSignalIds } = state;
let darkChanged = false;
ships.forEach((ship) => {
const featureId = ship.featureId;
if (!featureId) return;
// 좌표가 없으면 스킵
if (!ship.longitude || !ship.latitude) {
return;
}
const hasActive = isAnyEquipmentActive(ship);
// 규칙 1: LOST=0(영해 내) + 모든 장비 비활성 → 저장하지 않음 (완전 삭제)
if (!ship.lost && !hasActive) {
features.delete(featureId);
if (darkSignalIds.delete(featureId)) darkChanged = true;
return;
}
// 다크시그널 상태 판정
if (hasActive) {
// 장비 활성 → 다크시그널 해제 (회복)
if (darkSignalIds.delete(featureId)) darkChanged = true;
} else {
// 모든 장비 비활성 (상태 전환용 신호)
// → 이미 다크시그널이면 유지, 아니면 등록
// → 상태 플래그(위치/시간 등)는 갱신하되 카운트에 반영하지 않음
if (!darkSignalIds.has(featureId)) {
darkSignalIds.add(featureId);
darkChanged = true;
}
}
// receivedTimestamp 부여: 서버 수신시간 기반 (초기 로드 시 정확한 경과 시간 판단)
// 장비 비활성 신호도 상태 플래그(위치, 시간 등)는 항상 갱신
features.set(featureId, { ...ship, receivedTimestamp: parseReceivedTime(ship.receivedTime) });
});
// 버전 카운터 증가로 구독자에게 변경 알림
set((s) => ({
featuresVersion: s.featuresVersion + 1,
...(darkChanged ? { darkSignalVersion: s.darkSignalVersion + 1 } : {}),
}));
// 카운트 재계산 (다크시그널 전환, 레이더 삭제도 여기서 처리)
// 쓰로틀 카운트 업데이트
get().updateCountsThrottled();
},
/**
* 쓰로틀 카운트 업데이트
* 5초 이내에는 캐시된 값 사용, 필터 변경 시 즉시 재계산
* 참조: mda-react-front/src/common/deck.ts - updateLayerData()
*/
updateCountsThrottled: () => {
const state = get();
const { features, kindVisibility, sourceVisibility, nationalVisibility, isIntegrate, darkSignalVisible } = state;
const now = Date.now();
const currentFilterHash = generateFilterHash(kindVisibility, sourceVisibility, nationalVisibility, darkSignalVisible) + `_${isIntegrate}`;
// 5초 이내이고 필터 변경 없으면 스킵
const elapsed = now - countCache.lastCalcTime;
const filterChanged = currentFilterHash !== countCache.lastFilterHash;
if (elapsed < LIVE_COUNT_THROTTLE_MS && !filterChanged && countCache.counts) {
return; // 캐시 사용 (업데이트 스킵)
}
// 카운트 재계산 + 상태 전환 (다크시그널/레이더 삭제)
// 참조: mda-react-front/src/common/deck.ts - updateLayerData()
//
// 처리 순서 (메인 프로젝트 동일):
// ① darkSignalIds.has(featureId) → darkCount++, return
// ② 레이더(000005)+비통합 → timeout? delete : return
// ③ LOST=0 + INSHORE timeout → delete, return
// ④ LOST=1 + OFFSHORE timeout → darkSignal 전환, return
// ⑤ !isAnyEquipmentActive → darkSignal 전환, return
// ⑥ isPriority 필터 + 필터 + 카운트
const { darkSignalIds } = state;
const newKindCounts = { ...initialKindCounts };
let newDarkSignalCount = 0;
const seenTargetIds = new Set();
const newDarkIds = []; // 다크시그널 전환 대상 (④⑤)
const deleteIds = []; // 삭제 대상 (②③)
features.forEach((ship, featureId) => {
// ① 이미 다크시그널 → 카운트만 (비용 최소: Set.has O(1))
if (darkSignalIds.has(featureId)) {
newDarkSignalCount++;
return;
}
// ② 단독 레이더(비통합) → 타임아웃이면 삭제, 카운트 제외
if (ship.signalSourceCode === SIGNAL_SOURCE_RADAR && !ship.integrate) {
if (now - ship.receivedTimestamp > RADAR_TIMEOUT_MS) {
deleteIds.push(featureId);
}
return;
}
const elapsed = now - ship.receivedTimestamp;
// ③ 영해안 (LOST≠1) + INSHORE 타임아웃 → 삭제
if (!ship.lost && elapsed > INSHORE_TIMEOUT_MS) {
deleteIds.push(featureId);
return;
}
// ④ 영해밖 (LOST=1) + OFFSHORE 타임아웃 → 다크시그널 전환
if (ship.lost && elapsed > OFFSHORE_TIMEOUT_MS) {
newDarkIds.push(featureId);
newDarkSignalCount++;
return;
}
// ⑤ 모든 장비 비활성 → 즉시 다크시그널 전환
if (!isAnyEquipmentActive(ship)) {
newDarkIds.push(featureId);
newDarkSignalCount++;
return;
}
// ⑥ 통합 모드: isPriority가 아니면 카운트에서 제외 (삭제/전환은 위에서 처리 완료)
if (isIntegrate && ship.integrate && !ship.isPriority) return;
// 필터 적용된 선박만 카운트
if (!kindVisibility[ship.signalKindCode]) return;
if (!sourceVisibility[ship.signalSourceCode]) return;
const mappedNational = mapNationalCode(ship.nationalCode);
if (!nationalVisibility[mappedNational]) return;
// targetId(통합ID) 기준 중복 제거
if (ship.targetId && seenTargetIds.has(ship.targetId)) return;
if (ship.targetId) seenTargetIds.add(ship.targetId);
if (ship.signalKindCode && newKindCounts[ship.signalKindCode] !== undefined) {
newKindCounts[ship.signalKindCode]++;
}
});
const totalCount = Object.values(newKindCounts).reduce((sum, count) => sum + count, 0);
// 캐시 업데이트
countCache.counts = newKindCounts;
countCache.lastCalcTime = now;
countCache.lastFilterHash = currentFilterHash;
// features/darkSignalIds 변경이 필요한 경우 (직접 mutate + 버전 카운터)
if (newDarkIds.length > 0 || deleteIds.length > 0) {
newDarkIds.forEach((fid) => darkSignalIds.add(fid));
deleteIds.forEach((fid) => {
features.delete(fid);
darkSignalIds.delete(fid);
});
set((s) => ({
featuresVersion: s.featuresVersion + 1,
darkSignalVersion: s.darkSignalVersion + 1,
kindCounts: newKindCounts,
totalCount,
darkSignalCount: newDarkSignalCount,
}));
} else {
set({
kindCounts: newKindCounts,
totalCount,
darkSignalCount: newDarkSignalCount,
});
}
},
/**
* 카운트 즉시 재계산 (필터 변경 시 호출)
*/
recalculateCounts: () => {
// 캐시 무효화하여 즉시 재계산
countCache.lastCalcTime = 0;
get().updateCountsThrottled();
},
/**
/**
* 단일 선박 추가/업데이트
* @param {Object} ship - 선박 데이터
*/
addOrUpdateFeature: (ship) => {
get().mergeFeatures([ship]);
},
/**
* 선박 삭제
* @param {string} featureId - 삭제할 선박 ID (signalSourceCode + targetId)
*/
deleteFeatureById: (featureId) => {
const state = get();
state.features.delete(featureId);
const darkChanged = state.darkSignalIds.delete(featureId);
set((s) => ({
featuresVersion: s.featuresVersion + 1,
...(darkChanged ? { darkSignalVersion: s.darkSignalVersion + 1 } : {}),
selectedShipId: s.selectedShipId === featureId ? null : s.selectedShipId,
}));
// 쓰로틀 카운트 업데이트
get().updateCountsThrottled();
},
/**
* 여러 선박 삭제
* @param {Array<string>} featureIds - 삭제할 선박 ID 배열 (signalSourceCode + targetId)
*/
deleteFeaturesByIds: (featureIds) => {
const state = get();
let darkChanged = false;
featureIds.forEach((featureId) => {
state.features.delete(featureId);
if (state.darkSignalIds.delete(featureId)) darkChanged = true;
});
set((s) => ({
featuresVersion: s.featuresVersion + 1,
...(darkChanged ? { darkSignalVersion: s.darkSignalVersion + 1 } : {}),
selectedShipId: featureIds.includes(s.selectedShipId) ? null : s.selectedShipId,
}));
// 쓰로틀 카운트 업데이트
get().updateCountsThrottled();
},
/**
* 선박 종류별 표시 토글
* 필터 변경 시 카운트 즉시 재계산
* @param {string} kindCode - 선박 종류 코드
*/
toggleKindVisibility: (kindCode) => {
set((state) => ({
kindVisibility: {
...state.kindVisibility,
[kindCode]: !state.kindVisibility[kindCode],
},
}));
// 필터 변경 시 즉시 카운트 재계산
get().recalculateCounts();
},
/**
* 신호원별 표시 토글
* 필터 변경 시 카운트 즉시 재계산
* @param {string} sourceCode - 신호원 코드
*/
toggleSourceVisibility: (sourceCode) => {
set((state) => ({
sourceVisibility: {
...state.sourceVisibility,
[sourceCode]: !state.sourceVisibility[sourceCode],
},
}));
// 필터 변경 시 즉시 카운트 재계산
get().recalculateCounts();
},
/**
* 국적별 표시 토글
* 필터 변경 시 카운트 즉시 재계산
* @param {string} nationalCode - 국적 코드
*/
toggleNationalVisibility: (nationalCode) => {
set((state) => ({
nationalVisibility: {
...state.nationalVisibility,
[nationalCode]: !state.nationalVisibility[nationalCode],
},
}));
// 필터 변경 시 즉시 카운트 재계산
get().recalculateCounts();
},
/**
* 다크시그널 표시 토글
*/
toggleDarkSignalVisible: () => {
set((state) => ({
darkSignalVisible: !state.darkSignalVisible,
}));
get().recalculateCounts();
},
/**
* 다크시그널 선박 일괄 삭제
*/
clearDarkSignals: () => {
const state = get();
state.darkSignalIds.forEach((fid) => {
state.features.delete(fid);
});
state.darkSignalIds.clear();
set((s) => ({
featuresVersion: s.featuresVersion + 1,
darkSignalVersion: s.darkSignalVersion + 1,
}));
get().recalculateCounts();
},
/**
* 선박 표시 전체 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 });
// 필터 변경 시 즉시 카운트 재계산
get().recalculateCounts();
},
/**
* 선박 선택
* @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) => {
// 이미 열린 동일 선박 모달이면 무시
if (state.detailModals.some((m) => m.id === ship.featureId)) {
return state;
}
// 새 모달 초기 위치: 마지막 모달 위치 + 140px 우측
const basePos = state.lastModalPos || { x: 0, y: 100 };
const initialPos = { x: basePos.x + 140, y: basePos.y };
const newModal = { ship, id: ship.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: () => {
// 캐시도 초기화
countCache.counts = null;
countCache.lastCalcTime = 0;
countCache.lastFilterHash = '';
set((s) => ({
features: new Map(),
featuresVersion: s.featuresVersion + 1,
darkSignalIds: new Set(),
darkSignalVersion: s.darkSignalVersion + 1,
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 (m.ship && !seen.has(m.id)) {
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;
const result = [];
features.forEach((ship, featureId) => {
// 레이더 항상 제외
if (ship.signalSourceCode === '000005') return;
// 통합 모드: isPriority만 포함
if (isIntegrate && ship.integrate && !ship.isPriority) 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 default useShipStore;