From c4186a327d68ecb860e77605b032f4f8d7bb2445 Mon Sep 17 00:00:00 2001 From: htlee Date: Tue, 31 Mar 2026 08:26:07 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20=EC=97=B0=EA=B4=80=20=EC=84=A0=EB=B0=95?= =?UTF-8?q?=20=EC=9C=84=EC=B9=98=20live=20fallback=20=E2=80=94=20=ED=8A=B8?= =?UTF-8?q?=EB=9E=99=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EC=97=86=EC=9D=84=20?= =?UTF-8?q?=EB=95=8C=20ships=20=EB=B0=B0=EC=97=B4=20=EC=82=AC=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - useGearReplayLayers에 shipsRef 파라미터 추가 - corrPositions 계산: 트랙 보간 우선 → live 선박 위치 fallback - KoreaMap: allShips → shipsRef에 매 렌더 동기화 (ref로 re-render 방지) - globalThis.Map으로 react-map-gl Map 타입 충돌 해결 Co-Authored-By: Claude Opus 4.6 (1M context) --- frontend/src/components/korea/KoreaMap.tsx | 10 +++- frontend/src/hooks/useGearReplayLayers.ts | 60 ++++++++++++++-------- 2 files changed, 47 insertions(+), 23 deletions(-) diff --git a/frontend/src/components/korea/KoreaMap.tsx b/frontend/src/components/korea/KoreaMap.tsx index 0dc38be..59c8fa8 100644 --- a/frontend/src/components/korea/KoreaMap.tsx +++ b/frontend/src/components/korea/KoreaMap.tsx @@ -238,13 +238,21 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF // ── deck.gl 리플레이 레이어 (Zustand → imperative setProps, React 렌더 우회) ── const reactLayersRef = useRef([]); + type ShipPos = { lng: number; lat: number; course?: number }; + const shipsRef = useRef(new globalThis.Map()); + // live 선박 위치를 ref에 동기화 (리플레이 fallback용) + const allShipsList = allShips ?? ships; + const shipPosMap = new globalThis.Map(); + for (const s of allShipsList) shipPosMap.set(s.mmsi, { lng: s.lng, lat: s.lat, course: s.course }); + shipsRef.current = shipPosMap; + const requestRender = useCallback(() => { if (!overlayRef.current) return; overlayRef.current.setProps({ layers: [...reactLayersRef.current, ...replayLayerRef.current], }); }, []); - useGearReplayLayers(replayLayerRef, requestRender); + useGearReplayLayers(replayLayerRef, requestRender, shipsRef); useEffect(() => { fetchKoreaInfra().then(setInfra).catch(() => {}); diff --git a/frontend/src/hooks/useGearReplayLayers.ts b/frontend/src/hooks/useGearReplayLayers.ts index 6fbd91f..a420228 100644 --- a/frontend/src/hooks/useGearReplayLayers.ts +++ b/frontend/src/hooks/useGearReplayLayers.ts @@ -52,6 +52,7 @@ interface CorrPosition { export function useGearReplayLayers( replayLayerRef: React.MutableRefObject, requestRender: () => void, + shipsRef: React.MutableRefObject>, ): void { // ── React selectors (infrequent changes only) ──────────────────────────── const historyFrames = useGearReplayStore(s => s.historyFrames); @@ -263,9 +264,10 @@ export function useGearReplayLayers( })); } - // 6. Correlation vessel positions (interpolated from correlationTripsData) + // 6. Correlation vessel positions (트랙 보간 우선, 없으면 live 위치 fallback) const corrPositions: CorrPosition[] = []; const corrTrackMap = new Map(correlationTripsData.map(d => [d.id, d])); + const liveShips = shipsRef.current; for (const [mn, items] of correlationByModel) { if (!enabledModels.has(mn)) continue; @@ -275,29 +277,43 @@ export function useGearReplayLayers( for (const c of items as GearCorrelationItem[]) { if (corrPositions.some(p => p.mmsi === c.targetMmsi)) continue; + let lon: number | undefined; + let lat: number | undefined; + let cog = 0; + + // 방법 1: 트랙 데이터 보간 const tripData = corrTrackMap.get(c.targetMmsi); - if (!tripData) continue; - - const relTime = ct - st; - const ts = tripData.timestamps; - const path = tripData.path; - if (ts.length === 0) continue; - if (relTime < ts[0] || relTime > ts[ts.length - 1]) continue; - - // Binary search in timestamps - let lo = 0; - let hi = ts.length - 1; - while (lo < hi - 1) { - const mid = (lo + hi) >> 1; - if (ts[mid] <= relTime) lo = mid; else hi = mid; + if (tripData && tripData.timestamps.length > 0) { + const relTime = ct - st; + const ts = tripData.timestamps; + const path = tripData.path; + if (relTime >= ts[0] && relTime <= ts[ts.length - 1]) { + let lo = 0; + let hi = ts.length - 1; + while (lo < hi - 1) { + const mid = (lo + hi) >> 1; + if (ts[mid] <= relTime) lo = mid; else hi = mid; + } + const ratio = ts[hi] !== ts[lo] ? (relTime - ts[lo]) / (ts[hi] - ts[lo]) : 0; + lon = path[lo][0] + (path[hi][0] - path[lo][0]) * ratio; + lat = path[lo][1] + (path[hi][1] - path[lo][1]) * ratio; + const dx = path[hi][0] - path[lo][0]; + const dy = path[hi][1] - path[lo][1]; + cog = (dx === 0 && dy === 0) ? 0 : (Math.atan2(dx, dy) * 180 / Math.PI + 360) % 360; + } } - const ratio = ts[hi] !== ts[lo] ? (relTime - ts[lo]) / (ts[hi] - ts[lo]) : 0; - const lon = path[lo][0] + (path[hi][0] - path[lo][0]) * ratio; - const lat = path[lo][1] + (path[hi][1] - path[lo][1]) * ratio; - // heading from segment direction - const dx = path[hi][0] - path[lo][0]; - const dy = path[hi][1] - path[lo][1]; - const cog = (dx === 0 && dy === 0) ? 0 : (Math.atan2(dx, dy) * 180 / Math.PI + 360) % 360; + + // 방법 2: live 선박 위치 fallback + if (lon === undefined) { + const ship = liveShips.get(c.targetMmsi); + if (ship) { + lon = ship.lng; + lat = ship.lat; + cog = ship.course ?? 0; + } + } + + if (lon === undefined || lat === undefined) continue; corrPositions.push({ mmsi: c.targetMmsi,