/** * 선박 데이터 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 TIMEOUT_CHECK_INTERVAL_MS = 5000; // 5초 // ===================== // Incremental 카운트 레지스트리 (스토어 외부) // ===================== // // 기존 방식: 5초마다 features 전체 O(n) 순회하며 카운트 재계산 // 최적화: 변경된 선박만 카운트 증감 O(batch), 5초 주기는 타임아웃 체크에만 사용 // // - categories: 각 featureId가 마지막으로 기여한 카운트 카테고리 // 'dark' = 다크시그널 카운트, signalKindCode = 선종 카운트, null = 카운트 미포함 // - kindCounts: 선종별 누적 카운트 (incremental 유지) // - darkSignalCount: 다크시그널 누적 카운트 // ===================== const countRegistry = { categories: new Map(), // featureId → 'dark' | signalKindCode | null kindCounts: { ...initialKindCounts }, darkSignalCount: 0, totalCount: 0, }; /** 마지막 타임아웃 체크 시간 */ let lastTimeoutCheckTime = 0; /** * 선박 1건의 카운트 카테고리 판정 * 처리 순서 (메인 프로젝트 동일): * ① darkSignalIds.has → 'dark' * ② 단독 레이더 → null (카운트 제외) * ⑤ !isAnyEquipmentActive → 'dark' * ⑥ isPriority 필터 + 선종/신호원/국적 필터 → signalKindCode | null * * ※ ②③④ 타임아웃 판정은 processTimeoutsThrottled에서 별도 처리 * * @param {Object} ship - 선박 데이터 * @param {string} featureId * @param {Set} darkSignalIds * @param {boolean} isIntegrate * @param {Object} kindVisibility * @param {Object} sourceVisibility * @param {Object} nationalVisibility * @returns {string|null} 'dark' | signalKindCode | null */ function resolveShipCategory(ship, featureId, darkSignalIds, isIntegrate, kindVisibility, sourceVisibility, nationalVisibility) { if (darkSignalIds.has(featureId)) return 'dark'; if (ship.signalSourceCode === SIGNAL_SOURCE_RADAR && !ship.integrate) return null; if (!isAnyEquipmentActive(ship)) return 'dark'; if (isIntegrate && ship.integrate && !ship.isPriority) return null; if (!kindVisibility[ship.signalKindCode]) return null; if (!sourceVisibility[ship.signalSourceCode]) return null; const mapped = mapNationalCode(ship.nationalCode); if (!nationalVisibility[mapped]) return null; return ship.signalKindCode || null; } /** * 단일 featureId의 카운트 증감 (delta) * @param {string} featureId * @param {string|null} newCategory - 새 카테고리 */ function updateSingleCount(featureId, newCategory) { const oldCategory = countRegistry.categories.get(featureId) || null; if (oldCategory === newCategory) return; // 이전 카테고리 카운트 감소 if (oldCategory === 'dark') { countRegistry.darkSignalCount--; } else if (oldCategory && countRegistry.kindCounts[oldCategory] !== undefined) { countRegistry.kindCounts[oldCategory]--; } // 새 카테고리 카운트 증가 if (newCategory === 'dark') { countRegistry.darkSignalCount++; } else if (newCategory && countRegistry.kindCounts[newCategory] !== undefined) { countRegistry.kindCounts[newCategory]++; } if (newCategory) { countRegistry.categories.set(featureId, newCategory); } else { countRegistry.categories.delete(featureId); } } /** * featureId를 카운트에서 제거 * @param {string} featureId */ function removeFromCount(featureId) { updateSingleCount(featureId, null); } /** * countRegistry의 totalCount 재계산 */ function recalcTotal() { countRegistry.totalCount = Object.values(countRegistry.kindCounts).reduce((a, b) => a + b, 0); } /** * 전체 카운트 재계산 (필터 변경, 통합모드 전환 시) * targetId 중복 제거 포함 * @param {Map} features * @param {Set} darkSignalIds * @param {boolean} isIntegrate * @param {Object} kindVisibility * @param {Object} sourceVisibility * @param {Object} nationalVisibility */ function fullRecount(features, darkSignalIds, isIntegrate, kindVisibility, sourceVisibility, nationalVisibility) { // 레지스트리 초기화 countRegistry.categories.clear(); Object.keys(countRegistry.kindCounts).forEach(k => { countRegistry.kindCounts[k] = 0; }); countRegistry.darkSignalCount = 0; const seenTargetIds = new Set(); features.forEach((ship, featureId) => { let category = resolveShipCategory(ship, featureId, darkSignalIds, isIntegrate, kindVisibility, sourceVisibility, nationalVisibility); // targetId 중복 제거 (다크시그널은 개별 카운트이므로 제외) if (category && category !== 'dark' && ship.targetId) { if (seenTargetIds.has(ship.targetId)) { category = null; } else { seenTargetIds.add(ship.targetId); } } if (category === 'dark') { countRegistry.darkSignalCount++; countRegistry.categories.set(featureId, category); } else if (category && countRegistry.kindCounts[category] !== undefined) { countRegistry.kindCounts[category]++; countRegistry.categories.set(featureId, category); } // null → categories에 저장하지 않음 }); recalcTotal(); } /** * 초기 선박 종류별 카운트 */ 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) => { // ※ 성능 최적화 #1: Map/Set을 직접 mutate (O(n) 전체 복사 제거) // ※ 성능 최적화 #2: Incremental count (변경된 선박만 카운트 증감) const state = get(); const { features, darkSignalIds, isIntegrate, kindVisibility, sourceVisibility, nationalVisibility } = state; let darkChanged = false; const deletedIds = []; const changedIds = []; 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; deletedIds.push(featureId); return; } // 다크시그널 상태 판정 if (hasActive) { if (darkSignalIds.delete(featureId)) darkChanged = true; } else { if (!darkSignalIds.has(featureId)) { darkSignalIds.add(featureId); darkChanged = true; } } features.set(featureId, { ...ship, receivedTimestamp: parseReceivedTime(ship.receivedTime) }); changedIds.push(featureId); }); // 버전 카운터 증가 set((s) => ({ featuresVersion: s.featuresVersion + 1, ...(darkChanged ? { darkSignalVersion: s.darkSignalVersion + 1 } : {}), })); // Incremental count: 삭제된 선박은 카운트에서 제거, 변경된 선박은 카테고리 재판정 deletedIds.forEach(fid => removeFromCount(fid)); changedIds.forEach(fid => { const ship = features.get(fid); if (!ship) return; const category = resolveShipCategory(ship, fid, darkSignalIds, isIntegrate, kindVisibility, sourceVisibility, nationalVisibility); updateSingleCount(fid, category); }); recalcTotal(); set({ kindCounts: { ...countRegistry.kindCounts }, totalCount: countRegistry.totalCount, darkSignalCount: countRegistry.darkSignalCount, }); // 타임아웃 체크 (5초 주기) get().processTimeoutsThrottled(); }, /** * 타임아웃 체크 (5초 주기) * features 전체를 순회하여 타임아웃된 선박을 삭제/다크시그널 전환 * 카운트는 영향받는 선박만 incremental 업데이트 * * 처리 순서 (메인 프로젝트 동일): * ② 레이더(000005)+비통합 → timeout? delete * ③ LOST=0 + INSHORE timeout → delete * ④ LOST=1 + OFFSHORE timeout → darkSignal 전환 * ⑤ !isAnyEquipmentActive → darkSignal 전환 */ processTimeoutsThrottled: () => { const now = Date.now(); if (now - lastTimeoutCheckTime < TIMEOUT_CHECK_INTERVAL_MS) return; lastTimeoutCheckTime = now; const state = get(); const { features, darkSignalIds } = state; const newDarkIds = []; const deleteIds = []; features.forEach((ship, featureId) => { // 이미 다크시그널 → 타임아웃 체크 불필요 if (darkSignalIds.has(featureId)) 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); return; } // ⑤ 모든 장비 비활성 → 즉시 다크시그널 전환 if (!isAnyEquipmentActive(ship)) { newDarkIds.push(featureId); } }); if (newDarkIds.length === 0 && deleteIds.length === 0) return; // features/darkSignalIds 직접 mutate newDarkIds.forEach(fid => darkSignalIds.add(fid)); deleteIds.forEach(fid => { features.delete(fid); darkSignalIds.delete(fid); }); // Incremental count: 영향받는 선박만 카운트 조정 deleteIds.forEach(fid => removeFromCount(fid)); newDarkIds.forEach(fid => updateSingleCount(fid, 'dark')); recalcTotal(); set((s) => ({ featuresVersion: s.featuresVersion + 1, darkSignalVersion: s.darkSignalVersion + 1, kindCounts: { ...countRegistry.kindCounts }, totalCount: countRegistry.totalCount, darkSignalCount: countRegistry.darkSignalCount, })); }, /** * 카운트 전체 재계산 (필터 변경, 통합모드 전환 시) * targetId 중복 제거 포함 전체 O(n) 순회 */ recalculateCounts: () => { const state = get(); const { features, darkSignalIds, isIntegrate, kindVisibility, sourceVisibility, nationalVisibility } = state; fullRecount(features, darkSignalIds, isIntegrate, kindVisibility, sourceVisibility, nationalVisibility); set({ kindCounts: { ...countRegistry.kindCounts }, totalCount: countRegistry.totalCount, darkSignalCount: countRegistry.darkSignalCount, }); }, /** * 단일 선박 추가/업데이트 * @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, })); // Incremental count: 삭제된 선박 카운트 제거 removeFromCount(featureId); recalcTotal(); set({ kindCounts: { ...countRegistry.kindCounts }, totalCount: countRegistry.totalCount, darkSignalCount: countRegistry.darkSignalCount, }); }, /** * 여러 선박 삭제 * @param {Array} 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, })); // Incremental count: 삭제된 선박들 카운트 제거 featureIds.forEach(fid => removeFromCount(fid)); recalcTotal(); set({ kindCounts: { ...countRegistry.kindCounts }, totalCount: countRegistry.totalCount, darkSignalCount: countRegistry.darkSignalCount, }); }, /** * 선박 종류별 표시 토글 * 필터 변경 시 카운트 즉시 재계산 * @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(); // Incremental count: 각 다크시그널 선박 카운트 제거 state.darkSignalIds.forEach((fid) => { removeFromCount(fid); state.features.delete(fid); }); state.darkSignalIds.clear(); recalcTotal(); set((s) => ({ featuresVersion: s.featuresVersion + 1, darkSignalVersion: s.darkSignalVersion + 1, kindCounts: { ...countRegistry.kindCounts }, totalCount: countRegistry.totalCount, darkSignalCount: countRegistry.darkSignalCount, })); }, /** * 선박 표시 전체 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} 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: () => { // countRegistry 초기화 countRegistry.categories.clear(); Object.keys(countRegistry.kindCounts).forEach(k => { countRegistry.kindCounts[k] = 0; }); countRegistry.darkSignalCount = 0; countRegistry.totalCount = 0; 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;