From c4e40a0cef63f213a1685056ba4d537bc2636665 Mon Sep 17 00:00:00 2001 From: HeungTak Lee Date: Fri, 30 Jan 2026 13:39:19 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20=EC=B9=B4=EC=9A=B4=ED=8A=B8=205=EC=B4=88?= =?UTF-8?q?=20=EC=93=B0=EB=A1=9C=ED=8B=80=20=EB=B3=B5=EC=9B=90=20=EB=B0=8F?= =?UTF-8?q?=20targetId=20=EC=A4=91=EB=B3=B5=20=EC=A0=9C=EA=B1=B0=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 문제: - incremental count가 매 메시지마다 스토어 갱신하여 범례 실시간 변동 - targetId 중복 제거 없이 개별 장비별로 카운트되어 수치 과다 수정: - incremental count 제거, 5초 주기 fullRecount 방식으로 복원 - updateCountsThrottled: 타임아웃 체크 + calculateCounts 통합 (5초 주기) - calculateCounts: targetId 중복 제거 포함 정확한 카운트 계산 - mergeFeatures에서는 features/darkSignalIds만 갱신, 카운트는 5초마다 - 필터 변경/삭제 시에는 recalculateCounts로 즉시 재계산 Co-Authored-By: Claude Opus 4.5 --- src/stores/shipStore.js | 282 ++++++++++++---------------------------- 1 file changed, 83 insertions(+), 199 deletions(-) diff --git a/src/stores/shipStore.js b/src/stores/shipStore.js index ada44a08..727c4a8a 100644 --- a/src/stores/shipStore.js +++ b/src/stores/shipStore.js @@ -96,8 +96,6 @@ function isAnyEquipmentActive(ship) { // 타임아웃 체크 쓰로틀 간격 // 참조: mda-react-front/src/common/deck.ts (271-331) // ===================== -const TIMEOUT_CHECK_INTERVAL_MS = 5000; // 5초 - /** * 초기 선박 종류별 카운트 */ @@ -113,144 +111,76 @@ const initialKindCounts = { }; // ===================== -// Incremental 카운트 레지스트리 (스토어 외부) +// 카운트 쓰로틀링 (5초 주기) +// 참조: mda-react-front/src/common/deck.ts (271-331) // ===================== -// -// 기존 방식: 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, -}; +const COUNT_THROTTLE_MS = 5000; // 5초 + +/** 마지막 카운트 계산 시간 */ +let lastCountTime = 0; /** 마지막 타임아웃 체크 시간 */ let lastTimeoutCheckTime = 0; /** - * 선박 1건의 카운트 카테고리 판정 + * 전체 카운트 계산 (targetId 중복 제거 포함) + * * 처리 순서 (메인 프로젝트 동일): - * ① darkSignalIds.has → 'dark' - * ② 단독 레이더 → null (카운트 제외) - * ⑤ !isAnyEquipmentActive → 'dark' - * ⑥ isPriority 필터 + 선종/신호원/국적 필터 → signalKindCode | null + * ① darkSignalIds.has → darkCount++ + * ② 단독 레이더 → 카운트 제외 + * ⑤ !isAnyEquipmentActive → darkCount++ + * ⑥ isPriority 필터 + 선종/신호원/국적 필터 → kindCounts++ + * + targetId 기준 중복 제거 * - * ※ ②③④ 타임아웃 판정은 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 + * @returns {{ kindCounts: Object, darkSignalCount: number, totalCount: number }} */ -function fullRecount(features, darkSignalIds, isIntegrate, kindVisibility, sourceVisibility, nationalVisibility) { - // 레지스트리 초기화 - countRegistry.categories.clear(); - Object.keys(countRegistry.kindCounts).forEach(k => { countRegistry.kindCounts[k] = 0; }); - countRegistry.darkSignalCount = 0; - +function calculateCounts(features, darkSignalIds, isIntegrate, kindVisibility, sourceVisibility, nationalVisibility) { + const kindCounts = { ...initialKindCounts }; + let 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 (darkSignalIds.has(featureId)) { + darkSignalCount++; + return; } - 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); + // ② 단독 레이더 → 카운트 제외 + if (ship.signalSourceCode === SIGNAL_SOURCE_RADAR && !ship.integrate) return; + + // ⑤ 모든 장비 비활성 → 다크시그널 + if (!isAnyEquipmentActive(ship)) { + darkSignalCount++; + return; + } + + // ⑥ 통합 모드: isPriority가 아니면 카운트 제외 + if (isIntegrate && ship.integrate && !ship.isPriority) return; + + // 필터 적용 + if (!kindVisibility[ship.signalKindCode]) return; + if (!sourceVisibility[ship.signalSourceCode]) return; + const mapped = mapNationalCode(ship.nationalCode); + if (!nationalVisibility[mapped]) return; + + // targetId 중복 제거 + if (ship.targetId && seenTargetIds.has(ship.targetId)) return; + if (ship.targetId) seenTargetIds.add(ship.targetId); + + if (ship.signalKindCode && kindCounts[ship.signalKindCode] !== undefined) { + kindCounts[ship.signalKindCode]++; } - // null → categories에 저장하지 않음 }); - recalcTotal(); + const totalCount = Object.values(kindCounts).reduce((a, b) => a + b, 0); + return { kindCounts, darkSignalCount, totalCount }; } /** @@ -391,13 +321,11 @@ const useShipStore = create(subscribeWithSelector((set, get) => ({ * @param {Array} ships - 선박 데이터 배열 */ mergeFeatures: (ships) => { - // ※ 성능 최적화 #1: Map/Set을 직접 mutate (O(n) 전체 복사 제거) - // ※ 성능 최적화 #2: Incremental count (변경된 선박만 카운트 증감) + // ※ 성능 최적화: Map/Set을 직접 mutate (O(n) 전체 복사 제거) + // Zustand 변경 감지는 featuresVersion 카운터로 트리거 const state = get(); - const { features, darkSignalIds, isIntegrate, kindVisibility, sourceVisibility, nationalVisibility } = state; + const { features, darkSignalIds } = state; let darkChanged = false; - const deletedIds = []; - const changedIds = []; ships.forEach((ship) => { const featureId = ship.featureId; @@ -414,7 +342,6 @@ const useShipStore = create(subscribeWithSelector((set, get) => ({ if (!ship.lost && !hasActive) { features.delete(featureId); if (darkSignalIds.delete(featureId)) darkChanged = true; - deletedIds.push(featureId); return; } @@ -429,7 +356,6 @@ const useShipStore = create(subscribeWithSelector((set, get) => ({ } features.set(featureId, { ...ship, receivedTimestamp: parseReceivedTime(ship.receivedTime) }); - changedIds.push(featureId); }); // 버전 카운터 증가 @@ -438,52 +364,35 @@ const useShipStore = create(subscribeWithSelector((set, get) => ({ ...(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초 쓰로틀) + get().updateCountsThrottled(); }, /** - * 타임아웃 체크 (5초 주기) - * features 전체를 순회하여 타임아웃된 선박을 삭제/다크시그널 전환 - * 카운트는 영향받는 선박만 incremental 업데이트 + * 쓰로틀 카운트 업데이트 (5초 주기) + * 타임아웃 체크 + 카운트 재계산을 5초마다 수행 + * 참조: mda-react-front/src/common/deck.ts - updateLayerData() * - * 처리 순서 (메인 프로젝트 동일): + * 타임아웃 처리 순서 (메인 프로젝트 동일): * ② 레이더(000005)+비통합 → timeout? delete * ③ LOST=0 + INSHORE timeout → delete * ④ LOST=1 + OFFSHORE timeout → darkSignal 전환 * ⑤ !isAnyEquipmentActive → darkSignal 전환 */ - processTimeoutsThrottled: () => { + updateCountsThrottled: () => { const now = Date.now(); - if (now - lastTimeoutCheckTime < TIMEOUT_CHECK_INTERVAL_MS) return; - lastTimeoutCheckTime = now; + if (now - lastCountTime < COUNT_THROTTLE_MS) return; + lastCountTime = 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; @@ -491,60 +400,58 @@ const useShipStore = create(subscribeWithSelector((set, get) => ({ 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(); + // === 카운트 계산 (targetId 중복 제거 포함) === + const { isIntegrate, kindVisibility, sourceVisibility, nationalVisibility } = get(); + const counts = calculateCounts(features, darkSignalIds, isIntegrate, kindVisibility, sourceVisibility, nationalVisibility); + + const hasTimeoutChanges = newDarkIds.length > 0 || deleteIds.length > 0; set((s) => ({ - featuresVersion: s.featuresVersion + 1, - darkSignalVersion: s.darkSignalVersion + 1, - kindCounts: { ...countRegistry.kindCounts }, - totalCount: countRegistry.totalCount, - darkSignalCount: countRegistry.darkSignalCount, + ...(hasTimeoutChanges ? { + featuresVersion: s.featuresVersion + 1, + darkSignalVersion: s.darkSignalVersion + 1, + } : {}), + kindCounts: counts.kindCounts, + totalCount: counts.totalCount, + darkSignalCount: counts.darkSignalCount, })); }, /** - * 카운트 전체 재계산 (필터 변경, 통합모드 전환 시) + * 카운트 즉시 재계산 (필터 변경, 통합모드 전환 시) * targetId 중복 제거 포함 전체 O(n) 순회 */ recalculateCounts: () => { const state = get(); const { features, darkSignalIds, isIntegrate, kindVisibility, sourceVisibility, nationalVisibility } = state; - fullRecount(features, darkSignalIds, isIntegrate, kindVisibility, sourceVisibility, nationalVisibility); + const counts = calculateCounts(features, darkSignalIds, isIntegrate, kindVisibility, sourceVisibility, nationalVisibility); set({ - kindCounts: { ...countRegistry.kindCounts }, - totalCount: countRegistry.totalCount, - darkSignalCount: countRegistry.darkSignalCount, + kindCounts: counts.kindCounts, + totalCount: counts.totalCount, + darkSignalCount: counts.darkSignalCount, }); }, @@ -571,14 +478,8 @@ const useShipStore = create(subscribeWithSelector((set, get) => ({ selectedShipId: s.selectedShipId === featureId ? null : s.selectedShipId, })); - // Incremental count: 삭제된 선박 카운트 제거 - removeFromCount(featureId); - recalcTotal(); - set({ - kindCounts: { ...countRegistry.kindCounts }, - totalCount: countRegistry.totalCount, - darkSignalCount: countRegistry.darkSignalCount, - }); + // 즉시 카운트 재계산 + get().recalculateCounts(); }, /** @@ -599,14 +500,8 @@ const useShipStore = create(subscribeWithSelector((set, get) => ({ 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, - }); + // 즉시 카운트 재계산 + get().recalculateCounts(); }, /** @@ -672,21 +567,16 @@ 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(); }, /** @@ -940,12 +830,6 @@ const useShipStore = create(subscribeWithSelector((set, get) => ({ * 모든 선박 데이터 초기화 */ 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,