From 87d1b31ef3a640bdc766dc7eea8091dc39b7604c Mon Sep 17 00:00:00 2001 From: htlee Date: Tue, 31 Mar 2026 07:54:50 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=96=B4=EA=B5=AC=20=EB=A6=AC=ED=94=8C?= =?UTF-8?q?=EB=A0=88=EC=9D=B4=20deck.gl=20+=20Zustand=20=EC=A0=84=ED=99=98?= =?UTF-8?q?=20=EC=99=84=EB=A3=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 3: DeckGLOverlay에 overlayRef 추가, KoreaMap에서 리플레이 레이어 합성 (imperative setProps → React 렌더 우회) Phase 4: 기존 MapLibre 리플레이 레이어 → deck.gl 전환 - FleetClusterLayer: 애니메이션 state/ref/timer 제거 → Zustand 스토어 - useFleetClusterGeoJson: 리플레이 useMemo 15개 제거 (618→389줄) - FleetClusterMapLayers: MapLibre 재생 레이어 6개 제거 (492→397줄) - HistoryReplayController: React refs → Zustand subscribe 바인딩 성능: React re-render 20회/초 → 0회/초 (재생 중) GeoJSON 직렬화 15개/프레임 → 0 (raw 배열 → deck.gl) 트레일: 매 프레임 재생성 → TripsLayer GPU 셰이더 Co-Authored-By: Claude Opus 4.6 (1M context) --- .../components/korea/FleetClusterLayer.tsx | 117 ++----- .../korea/FleetClusterMapLayers.tsx | 123 +------ .../korea/HistoryReplayController.tsx | 92 +++--- frontend/src/components/korea/KoreaMap.tsx | 42 ++- .../korea/useFleetClusterGeoJson.ts | 309 +++--------------- .../src/components/layers/DeckGLOverlay.tsx | 6 +- 6 files changed, 163 insertions(+), 526 deletions(-) diff --git a/frontend/src/components/korea/FleetClusterLayer.tsx b/frontend/src/components/korea/FleetClusterLayer.tsx index 1b9d198..20c71e7 100644 --- a/frontend/src/components/korea/FleetClusterLayer.tsx +++ b/frontend/src/components/korea/FleetClusterLayer.tsx @@ -5,10 +5,11 @@ import type { Ship, VesselAnalysisDto } from '../../types'; import { fetchFleetCompanies, fetchGroupHistory, fetchGroupCorrelations, fetchCorrelationTracks } from '../../services/vesselAnalysis'; import type { FleetCompany, GearCorrelationItem, CorrelationVesselTrack } from '../../services/vesselAnalysis'; import type { UseGroupPolygonsResult } from '../../hooks/useGroupPolygons'; +import { useGearReplayStore } from '../../stores/gearReplayStore'; // ── 분리된 모듈 ── -import type { HistoryFrame, PickerCandidate, HoverTooltipState, GearPickerPopupState } from './fleetClusterTypes'; -import { TIMELINE_DURATION_MS, PLAYBACK_CYCLE_SEC, TICK_MS, EMPTY_ANALYSIS } from './fleetClusterTypes'; +import type { PickerCandidate, HoverTooltipState, GearPickerPopupState } from './fleetClusterTypes'; +import { EMPTY_ANALYSIS } from './fleetClusterTypes'; import { fillGapFrames } from './fleetClusterUtils'; import { useFleetClusterGeoJson } from './useFleetClusterGeoJson'; import FleetClusterMapLayers from './FleetClusterMapLayers'; @@ -55,19 +56,8 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipS const [enabledModels, setEnabledModels] = useState>(new Set(['identity', 'default', 'aggressive', 'conservative', 'proximity-heavy', 'visit-pattern'])); const [hoveredTarget, setHoveredTarget] = useState<{ mmsi: string; model: string } | null>(null); - // ── 히스토리 애니메이션 상태 ── - const [historyData, setHistoryData] = useState(null); - const [, setHistoryGroupKey] = useState(null); - const [isPlaying, setIsPlaying] = useState(true); - const [displayFrameIdx, setDisplayFrameIdx] = useState(-1); - const timelinePosRef = useRef(0); - const progressBarRef = useRef(null); - const progressIndicatorRef = useRef(null); - const timeDisplayRef = useRef(null); - const animTimerRef = useRef>(); - const historyStartRef = useRef(0); - const historyEndRef = useRef(0); - const frameTimesRef = useRef([]); + // ── Zustand store (히스토리 재생) ── + const historyActive = useGearReplayStore(s => s.historyFrames.length > 0); // ── 맵 + ref ── const { current: mapRef } = useMap(); @@ -81,68 +71,37 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipS // ── 히스토리 로드/닫기 ── const loadHistory = async (groupKey: string) => { - setHistoryGroupKey(groupKey); - timelinePosRef.current = 0; - setDisplayFrameIdx(-1); - setIsPlaying(true); const history = await fetchGroupHistory(groupKey, 12); const sorted = history.reverse(); const filled = fillGapFrames(sorted); - const now = Date.now(); - historyStartRef.current = now - TIMELINE_DURATION_MS; - historyEndRef.current = now; - frameTimesRef.current = filled.map(h => new Date(h.snapshotTime).getTime()); - setHistoryData(filled); + useGearReplayStore.getState().loadHistory( + filled, correlationTracks, correlationData, enabledModels, enabledVessels, + ); }; const closeHistory = useCallback(() => { - setHistoryData(null); - setHistoryGroupKey(null); - timelinePosRef.current = 0; - setDisplayFrameIdx(-1); - setIsPlaying(true); + useGearReplayStore.getState().reset(); setSelectedGearGroup(null); - clearInterval(animTimerRef.current); }, []); - // ── 재생 타이머 (ref 기반, 프레임 변경 시에만 setState) ── + // ── enabledModels/enabledVessels/hoveredTarget → store 동기화 ── useEffect(() => { - if (!historyData || !isPlaying) { - clearInterval(animTimerRef.current); - return; - } - const step = TICK_MS / (PLAYBACK_CYCLE_SEC * 1000); - const ft = frameTimesRef.current; - animTimerRef.current = setInterval(() => { - timelinePosRef.current += step; - if (timelinePosRef.current >= 1) timelinePosRef.current = 0; + useGearReplayStore.getState().setEnabledModels(enabledModels); + }, [enabledModels]); - const pos = timelinePosRef.current; - if (progressBarRef.current) progressBarRef.current.value = String(Math.round(pos * 1000)); - if (progressIndicatorRef.current) progressIndicatorRef.current.style.left = `${pos * 100}%`; - const t = historyStartRef.current + timelinePosRef.current * TIMELINE_DURATION_MS; - if (timeDisplayRef.current) { - timeDisplayRef.current.textContent = new Date(t).toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit' }); - } + useEffect(() => { + useGearReplayStore.getState().setEnabledVessels(enabledVessels); + }, [enabledVessels]); - let best = 0, bestDiff = Infinity; - for (let i = 0; i < ft.length; i++) { - const d = Math.abs(ft[i] - t); - if (d < bestDiff) { bestDiff = d; best = i; } - } - const fi = bestDiff < 1_800_000 ? best : -1; - setDisplayFrameIdx(prev => prev === fi ? prev : fi); - }, TICK_MS); - return () => clearInterval(animTimerRef.current); - }, [historyData, isPlaying]); - - const effectiveSnapIdx = displayFrameIdx; + useEffect(() => { + useGearReplayStore.getState().setHoveredMmsi(hoveredTarget?.mmsi ?? null); + }, [hoveredTarget]); // ── ESC 키 ── useEffect(() => { const onKeyDown = (e: KeyboardEvent) => { if (e.key === 'Escape') { - if (historyData) closeHistory(); + if (historyActive) closeHistory(); setSelectedGearGroup(null); setExpandedFleet(null); setExpandedGearGroup(null); @@ -150,7 +109,7 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipS }; window.addEventListener('keydown', onKeyDown); return () => window.removeEventListener('keydown', onKeyDown); - }, [historyData, closeHistory]); + }, [historyActive, closeHistory]); // ── 맵 이벤트 등록 ── useEffect(() => { @@ -299,9 +258,9 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipS // ── 부모 콜백 동기화: 어구 그룹 선택 ── useEffect(() => { - if (!selectedGearGroup || historyData) { + if (!selectedGearGroup || historyActive) { onSelectedGearChange?.(null); - if (historyData) return; + if (historyActive) return; return; } const allGroups = groupPolygons ? [...groupPolygons.gearInZoneGroups, ...groupPolygons.gearOutZoneGroups] : []; @@ -315,7 +274,7 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipS category: 'fishing', lastSeen: Date.now(), }); onSelectedGearChange?.({ parent: parent ? toShip(parent) : null, gears: gears.map(toShip), groupName: selectedGearGroup }); - }, [selectedGearGroup, groupPolygons, onSelectedGearChange, historyData]); + }, [selectedGearGroup, groupPolygons, onSelectedGearChange, historyActive]); // ── 연관성 데이터 로드 ── useEffect(() => { @@ -346,9 +305,9 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipS // ── 부모 콜백 동기화: 선단 선택 ── useEffect(() => { - if (expandedFleet === null || historyData) { + if (expandedFleet === null || historyActive) { onSelectedFleetChange?.(null); - if (historyData) return; + if (historyActive) return; return; } const group = groupPolygons?.fleetGroups.find(g => Number(g.groupKey) === expandedFleet); @@ -360,17 +319,16 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipS category: 'fishing', lastSeen: Date.now(), })); onSelectedFleetChange?.({ clusterId: expandedFleet, ships: fleetShips, companyName: company?.nameCn || group.groupLabel || `선단 #${expandedFleet}` }); - }, [expandedFleet, groupPolygons, companies, onSelectedFleetChange, historyData]); + }, [expandedFleet, groupPolygons, companies, onSelectedFleetChange, historyActive]); // ── GeoJSON 훅 ── const hoveredMmsi = hoveredTarget?.mmsi ?? null; const geo = useFleetClusterGeoJson({ ships, shipMap, groupPolygons, analysisMap, hoveredFleetId, selectedGearGroup, pickerHoveredGroup, - historyData, effectiveSnapIdx, + historyActive, correlationData, correlationTracks, enabledModels, enabledVessels, hoveredMmsi, - historyStartMs: historyStartRef.current, }); // ── 어구 그룹 데이터 ── @@ -446,8 +404,7 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipS hoveredMmsi={hoveredMmsi} enabledModels={enabledModels} expandedFleet={expandedFleet} - historyData={historyData} - effectiveSnapIdx={effectiveSnapIdx} + historyActive={historyActive} hoverTooltip={hoverTooltip} gearPickerPopup={gearPickerPopup} pickerHoveredGroup={pickerHoveredGroup} @@ -472,8 +429,8 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipS enabledModels={enabledModels} enabledVessels={enabledVessels} correlationLoading={correlationLoading} - historyData={historyData} - effectiveSnapIdx={effectiveSnapIdx} + historyData={null} + effectiveSnapIdx={-1} hoveredTarget={hoveredTarget} onEnabledModelsChange={(updater) => setEnabledModels(updater)} onEnabledVesselsChange={(updater) => setEnabledVessels(updater)} @@ -482,20 +439,8 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipS )} {/* ── 재생 컨트롤러 ── */} - {historyData && ( + {historyActive && ( setIsPlaying(p => !p)} - onFrameChange={(idx) => { setIsPlaying(false); setDisplayFrameIdx(idx); }} onClose={closeHistory} /> )} diff --git a/frontend/src/components/korea/FleetClusterMapLayers.tsx b/frontend/src/components/korea/FleetClusterMapLayers.tsx index be47930..b73f5ab 100644 --- a/frontend/src/components/korea/FleetClusterMapLayers.tsx +++ b/frontend/src/components/korea/FleetClusterMapLayers.tsx @@ -4,7 +4,6 @@ import type { FleetCompany } from '../../services/vesselAnalysis'; import type { VesselAnalysisDto } from '../../types'; import type { UseGroupPolygonsResult } from '../../hooks/useGroupPolygons'; import type { - HistoryFrame, HoverTooltipState, GearPickerPopupState, PickerCandidate, @@ -18,8 +17,7 @@ interface FleetClusterMapLayersProps { hoveredMmsi: string | null; enabledModels: Set; expandedFleet: number | null; - historyData: HistoryFrame[] | null; - effectiveSnapIdx: number; + historyActive: boolean; // Popup/tooltip state hoverTooltip: HoverTooltipState | null; gearPickerPopup: GearPickerPopupState | null; @@ -42,8 +40,7 @@ const FleetClusterMapLayers = ({ hoveredMmsi, enabledModels, expandedFleet, - historyData, - effectiveSnapIdx, + historyActive, hoverTooltip, gearPickerPopup, pickerHoveredGroup, @@ -63,17 +60,11 @@ const FleetClusterMapLayers = ({ memberMarkersGeoJson, pickerHighlightGeoJson, operationalPolygons, - memberTrailsGeoJson, - centerTrailGeoJson, - currentCenterGeoJson, - animPolygonGeoJson, - animMembersGeoJson, correlationVesselGeoJson, correlationTrailGeoJson, modelBadgesGeoJson, hoverHighlightGeoJson, hoverHighlightTrailGeoJson, - isStale, } = geo; return ( @@ -126,7 +117,7 @@ const FleetClusterMapLayers = ({ {/* 선택된 어구 그룹 — 이름 기반 하이라이트 폴리곤 */} - {selectedGearGroup && enabledModels.has('identity') && !historyData && (() => { + {selectedGearGroup && enabledModels.has('identity') && !historyActive && (() => { const allGroups = groupPolygons ? [...groupPolygons.gearInZoneGroups, ...groupPolygons.gearOutZoneGroups] : []; @@ -149,7 +140,7 @@ const FleetClusterMapLayers = ({ })()} {/* 선택된 어구 그룹 — 모델별 오퍼레이셔널 폴리곤 오버레이 */} - {selectedGearGroup && operationalPolygons.map(op => ( + {selectedGearGroup && !historyActive && operationalPolygons.map(op => ( - {/* 가상 선박 마커 — 히스토리 재생 모드에서는 숨김 (애니메이션 아이콘으로 대체) */} - + {/* 가상 선박 마커 — 히스토리 재생 모드에서는 숨김 (deck.gl 레이어로 대체) */} + )} - {selectedGearGroup && ( + {selectedGearGroup && !historyActive && ( )} - {/* ── 모델 배지 (아이콘 우측 컬러 dot) ── */} - {selectedGearGroup && ( + {/* ── 모델 배지 (비재생 모드) ── */} + {selectedGearGroup && !historyActive && ( {MODEL_ORDER.map((model, i) => ( enabledModels.has(model) ? ( @@ -379,8 +370,8 @@ const FleetClusterMapLayers = ({ )} - {/* ── 호버 하이라이트 (글로우 + 항적 강조) ── */} - {hoveredMmsi && ( + {/* ── 호버 하이라이트 (비재생 모드) ── */} + {hoveredMmsi && !historyActive && ( )} - {hoveredMmsi && ( + {hoveredMmsi && !historyActive && ( )} - - {/* ── 히스토리 애니메이션 레이어 (최상위) ── */} - {historyData && ( - - - - )} - {historyData && ( - - - - - )} - {/* 보간 경로는 centerTrailGeoJson에 통합됨 (interpolated=1 세그먼트) */} - {/* 현재 재생 위치 포인트 (실데이터=빨강, 보간=주황) */} - {historyData && effectiveSnapIdx >= 0 && ( - - - - )} - {historyData && ( - - - - - )} - {/* 가상 아이콘 — 현재 프레임 멤버 위치 (최상위) */} - {historyData && ( - - - - - )} ); }; diff --git a/frontend/src/components/korea/HistoryReplayController.tsx b/frontend/src/components/korea/HistoryReplayController.tsx index 875df93..6cdc819 100644 --- a/frontend/src/components/korea/HistoryReplayController.tsx +++ b/frontend/src/components/korea/HistoryReplayController.tsx @@ -1,40 +1,41 @@ -import React from 'react'; +import { useRef, useEffect } from 'react'; import { FONT_MONO } from '../../styles/fonts'; -import type { HistoryFrame } from './fleetClusterTypes'; -import { TIMELINE_DURATION_MS } from './fleetClusterTypes'; +import { useGearReplayStore } from '../../stores/gearReplayStore'; interface HistoryReplayControllerProps { - historyData: HistoryFrame[]; - effectiveSnapIdx: number; - isPlaying: boolean; - snapshotRanges: number[]; - progressBarRef: React.RefObject; - progressIndicatorRef: React.RefObject; - timeDisplayRef: React.RefObject; - historyStartRef: React.RefObject; - timelinePosRef: React.MutableRefObject; - frameTimesRef: React.RefObject; - onTogglePlay: () => void; - onFrameChange: (idx: number) => void; onClose: () => void; } -const HistoryReplayController = ({ - historyData, - effectiveSnapIdx, - isPlaying, - snapshotRanges, - progressBarRef, - progressIndicatorRef, - timeDisplayRef, - historyStartRef, - timelinePosRef, - frameTimesRef, - onTogglePlay, - onFrameChange, - onClose, -}: HistoryReplayControllerProps) => { - const hasSnap = effectiveSnapIdx >= 0; +const HistoryReplayController = ({ onClose }: HistoryReplayControllerProps) => { + // React selectors (infrequent changes) + const isPlaying = useGearReplayStore(s => s.isPlaying); + const snapshotRanges = useGearReplayStore(s => s.snapshotRanges); + const frameCount = useGearReplayStore(s => s.historyFrames.length); + + // DOM refs for imperative updates + const progressBarRef = useRef(null); + const progressIndicatorRef = useRef(null); + const timeDisplayRef = useRef(null); + + // Subscribe to currentTime for DOM updates (no React re-render) + useEffect(() => { + const unsub = useGearReplayStore.subscribe( + s => s.currentTime, + (currentTime) => { + const { startTime, endTime } = useGearReplayStore.getState(); + if (endTime <= startTime) return; + const progress = (currentTime - startTime) / (endTime - startTime); + if (progressBarRef.current) progressBarRef.current.value = String(Math.round(progress * 1000)); + if (progressIndicatorRef.current) progressIndicatorRef.current.style.left = `${progress * 100}%`; + if (timeDisplayRef.current) { + timeDisplayRef.current.textContent = new Date(currentTime).toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit' }); + } + }, + ); + return unsub; + }, []); + + const store = useGearReplayStore; return (
@@ -92,7 +93,7 @@ const HistoryReplayController = ({