diff --git a/src/common/stompClient.js b/src/common/stompClient.js index 81894628..50ceb13c 100644 --- a/src/common/stompClient.js +++ b/src/common/stompClient.js @@ -108,22 +108,6 @@ export function rowToShipObject(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 연결 시작 @@ -172,7 +156,7 @@ export function disconnectStomp() { 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 throttleSeconds = 0; const topic = throttleSeconds > 0 ? `${STOMP_TOPICS.SHIP_THROTTLED}${throttleSeconds}s` diff --git a/src/index.js b/src/index.js deleted file mode 100644 index 6e206294..00000000 --- a/src/index.js +++ /dev/null @@ -1,15 +0,0 @@ -import ReactDOM from 'react-dom/client'; -import WrapComponent from './component/WrapComponent'; -import './scss/WrapComponent.scss' -import './scss/HeaderComponent.scss' -import './scss/SideComponent.scss' -import './scss/MainComponent.scss' -import './scss/ToolComponent.scss' -import { BrowserRouter } from 'react-router-dom'; - -const root = ReactDOM.createRoot(document.getElementById('root')); -root.render( - - - -); \ No newline at end of file diff --git a/src/stores/shipStore.js b/src/stores/shipStore.js index 44b4a513..6c418046 100644 --- a/src/stores/shipStore.js +++ b/src/stores/shipStore.js @@ -93,39 +93,150 @@ function isAnyEquipmentActive(ship) { } // ===================== -// 카운트 쓰로틀링 상수 및 캐시 +// 타임아웃 체크 쓰로틀 간격 // 참조: mda-react-front/src/common/deck.ts (271-331) // ===================== -const LIVE_COUNT_THROTTLE_MS = 5000; // 5초 +const TIMEOUT_CHECK_INTERVAL_MS = 5000; // 5초 -/** - * 카운트 캐시 (스토어 외부에 저장) - * - counts: 마지막으로 계산된 종류별 카운트 - * - lastCalcTime: 마지막 계산 시간 - * - lastFilterHash: 마지막 필터 해시 (필터 변경 감지용) - */ -const countCache = { - counts: null, - lastCalcTime: 0, - lastFilterHash: '', +// ===================== +// 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; + /** - * 필터 해시 생성 (필터 변경 감지용) - * @param {Object} kindVisibility - 선박 종류별 표시 여부 - * @param {Object} sourceVisibility - 신호원별 표시 여부 - * @returns {string} 필터 해시 문자열 + * 선박 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 generateFilterHash(kindVisibility, sourceVisibility, nationalVisibility, darkSignalVisible) { - const kindKeys = Object.keys(kindVisibility).sort(); - const sourceKeys = Object.keys(sourceVisibility).sort(); - const nationalKeys = Object.keys(nationalVisibility).sort(); +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; +} - 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(''); +/** + * 단일 featureId의 카운트 증감 (delta) + * @param {string} featureId + * @param {string|null} newCategory - 새 카테고리 + */ +function updateSingleCount(featureId, newCategory) { + const oldCategory = countRegistry.categories.get(featureId) || null; + if (oldCategory === newCategory) return; - return `${kindHash}_${sourceHash}_${nationalHash}_${darkSignalVisible ? '1' : '0'}`; + // 이전 카테고리 카운트 감소 + 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(); } /** @@ -280,13 +391,13 @@ const useShipStore = create(subscribeWithSelector((set, get) => ({ * @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 카운터로 트리거 + // ※ 성능 최적화 #1: Map/Set을 직접 mutate (O(n) 전체 복사 제거) + // ※ 성능 최적화 #2: Incremental count (변경된 선박만 카운트 증감) const state = get(); - const { features, darkSignalIds } = state; + const { features, darkSignalIds, isIntegrate, kindVisibility, sourceVisibility, nationalVisibility } = state; let darkChanged = false; + const deletedIds = []; + const changedIds = []; ships.forEach((ship) => { const featureId = ship.featureId; @@ -303,88 +414,78 @@ const useShipStore = create(subscribeWithSelector((set, get) => ({ 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; } } - // receivedTimestamp 부여: 서버 수신시간 기반 (초기 로드 시 정확한 경과 시간 판단) - // 장비 비활성 신호도 상태 플래그(위치, 시간 등)는 항상 갱신 features.set(featureId, { ...ship, receivedTimestamp: parseReceivedTime(ship.receivedTime) }); + changedIds.push(featureId); }); - // 버전 카운터 증가로 구독자에게 변경 알림 + // 버전 카운터 증가 set((s) => ({ featuresVersion: s.featuresVersion + 1, ...(darkChanged ? { darkSignalVersion: s.darkSignalVersion + 1 } : {}), })); - // 카운트 재계산 (다크시그널 전환, 레이더 삭제도 여기서 처리) - // 쓰로틀 카운트 업데이트 - get().updateCountsThrottled(); + // 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초 이내에는 캐시된 값 사용, 필터 변경 시 즉시 재계산 - * 참조: mda-react-front/src/common/deck.ts - updateLayerData() + * 타임아웃 체크 (5초 주기) + * features 전체를 순회하여 타임아웃된 선박을 삭제/다크시그널 전환 + * 카운트는 영향받는 선박만 incremental 업데이트 + * + * 처리 순서 (메인 프로젝트 동일): + * ② 레이더(000005)+비통합 → timeout? delete + * ③ LOST=0 + INSHORE timeout → delete + * ④ LOST=1 + OFFSHORE timeout → darkSignal 전환 + * ⑤ !isAnyEquipmentActive → darkSignal 전환 */ - updateCountsThrottled: () => { - const state = get(); - const { features, kindVisibility, sourceVisibility, nationalVisibility, isIntegrate, darkSignalVisible } = state; - + processTimeoutsThrottled: () => { const now = Date.now(); - const currentFilterHash = generateFilterHash(kindVisibility, sourceVisibility, nationalVisibility, darkSignalVisible) + `_${isIntegrate}`; + if (now - lastTimeoutCheckTime < TIMEOUT_CHECK_INTERVAL_MS) return; + lastTimeoutCheckTime = now; - // 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 = []; // 삭제 대상 (②③) + const state = get(); + const { features, darkSignalIds } = state; + const newDarkIds = []; + const deleteIds = []; features.forEach((ship, featureId) => { - // ① 이미 다크시그널 → 카운트만 (비용 최소: Set.has O(1)) - if (darkSignalIds.has(featureId)) { - newDarkSignalCount++; - return; - } + // 이미 다크시그널 → 타임아웃 체크 불필요 + if (darkSignalIds.has(featureId)) return; - // ② 단독 레이더(비통합) → 타임아웃이면 삭제, 카운트 제외 + // ② 단독 레이더(비통합) → 타임아웃이면 삭제 if (ship.signalSourceCode === SIGNAL_SOURCE_RADAR && !ship.integrate) { - if (now - ship.receivedTimestamp > RADAR_TIMEOUT_MS) { - deleteIds.push(featureId); - } + if (now - ship.receivedTimestamp > RADAR_TIMEOUT_MS) deleteIds.push(featureId); return; } @@ -399,76 +500,54 @@ const useShipStore = create(subscribeWithSelector((set, get) => ({ // ④ 영해밖 (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); + if (newDarkIds.length === 0 && deleteIds.length === 0) return; - // 캐시 업데이트 - countCache.counts = newKindCounts; - countCache.lastCalcTime = now; - countCache.lastFilterHash = currentFilterHash; + // features/darkSignalIds 직접 mutate + newDarkIds.forEach(fid => darkSignalIds.add(fid)); + deleteIds.forEach(fid => { + features.delete(fid); + darkSignalIds.delete(fid); + }); - // 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); - }); + // 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: newKindCounts, - totalCount, - darkSignalCount: newDarkSignalCount, - })); - } else { - set({ - kindCounts: newKindCounts, - totalCount, - darkSignalCount: newDarkSignalCount, - }); - } + set((s) => ({ + featuresVersion: s.featuresVersion + 1, + darkSignalVersion: s.darkSignalVersion + 1, + kindCounts: { ...countRegistry.kindCounts }, + totalCount: countRegistry.totalCount, + darkSignalCount: countRegistry.darkSignalCount, + })); }, /** - * 카운트 즉시 재계산 (필터 변경 시 호출) + * 카운트 전체 재계산 (필터 변경, 통합모드 전환 시) + * targetId 중복 제거 포함 전체 O(n) 순회 */ recalculateCounts: () => { - // 캐시 무효화하여 즉시 재계산 - countCache.lastCalcTime = 0; - get().updateCountsThrottled(); + 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 - 선박 데이터 @@ -492,8 +571,14 @@ const useShipStore = create(subscribeWithSelector((set, get) => ({ selectedShipId: s.selectedShipId === featureId ? null : s.selectedShipId, })); - // 쓰로틀 카운트 업데이트 - get().updateCountsThrottled(); + // Incremental count: 삭제된 선박 카운트 제거 + removeFromCount(featureId); + recalcTotal(); + set({ + kindCounts: { ...countRegistry.kindCounts }, + totalCount: countRegistry.totalCount, + darkSignalCount: countRegistry.darkSignalCount, + }); }, /** @@ -514,8 +599,14 @@ const useShipStore = create(subscribeWithSelector((set, get) => ({ selectedShipId: featureIds.includes(s.selectedShipId) ? null : s.selectedShipId, })); - // 쓰로틀 카운트 업데이트 - get().updateCountsThrottled(); + // Incremental count: 삭제된 선박들 카운트 제거 + featureIds.forEach(fid => removeFromCount(fid)); + recalcTotal(); + set({ + kindCounts: { ...countRegistry.kindCounts }, + totalCount: countRegistry.totalCount, + darkSignalCount: countRegistry.darkSignalCount, + }); }, /** @@ -581,16 +672,21 @@ const useShipStore = create(subscribeWithSelector((set, get) => ({ */ 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, })); - get().recalculateCounts(); }, /** @@ -844,10 +940,11 @@ const useShipStore = create(subscribeWithSelector((set, get) => ({ * 모든 선박 데이터 초기화 */ clearFeatures: () => { - // 캐시도 초기화 - countCache.counts = null; - countCache.lastCalcTime = 0; - countCache.lastFilterHash = ''; + // 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(),