diff --git a/frontend/src/components/korea/FleetClusterLayer.tsx b/frontend/src/components/korea/FleetClusterLayer.tsx index e3f6690..3cde5fc 100644 --- a/frontend/src/components/korea/FleetClusterLayer.tsx +++ b/frontend/src/components/korea/FleetClusterLayer.tsx @@ -69,17 +69,31 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipS fetchFleetCompanies().then(setCompanies).catch(() => {}); }, []); - // ── 히스토리 로드/닫기 ── + // ── 히스토리 로드 (3개 API 순차 await → 스토어 초기화 → 재생) ── const loadHistory = async (groupKey: string) => { - const history = await fetchGroupHistory(groupKey, 12); + // 1. 모든 데이터를 병렬 fetch + const [history, corrRes, trackRes] = await Promise.all([ + fetchGroupHistory(groupKey, 12), + fetchGroupCorrelations(groupKey, 0.3).catch(() => ({ items: [] as GearCorrelationItem[] })), + fetchCorrelationTracks(groupKey, 24, 0.3).catch(() => ({ vessels: [] as CorrelationVesselTrack[] })), + ]); + + // 2. 데이터 전처리 const sorted = history.reverse(); const filled = fillGapFrames(sorted); + const corrData = corrRes.items; + const corrTracks = trackRes.vessels; + const vessels = new Set(corrTracks.filter(v => v.score >= 0.7).map(v => v.mmsi)); + + // 3. React 상태 동기화 (패널 표시용) + setCorrelationData(corrData); + setCorrelationTracks(corrTracks); + setEnabledVessels(vessels); + setCorrelationLoading(false); + + // 4. 스토어 초기화 (모든 데이터 포함) → 재생 시작 const store = useGearReplayStore.getState(); - store.loadHistory(filled, correlationTracks, correlationData, enabledModels, enabledVessels); - // correlation 데이터가 이미 로드된 경우 즉시 동기화 - if (correlationData.length > 0 || correlationTracks.length > 0) { - store.updateCorrelation(correlationData, correlationTracks); - } + store.loadHistory(filled, corrTracks, corrData, enabledModels, vessels); store.play(); }; @@ -101,14 +115,6 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipS useGearReplayStore.getState().setHoveredMmsi(hoveredTarget?.mmsi ?? null); }, [hoveredTarget]); - // ── correlation 데이터 → store 동기화 ── - // historyActive 의존: history 로드 후 이미 도착한 correlation 데이터 반영 - useEffect(() => { - if (historyActive && (correlationData.length > 0 || correlationTracks.length > 0)) { - useGearReplayStore.getState().updateCorrelation(correlationData, correlationTracks); - } - }, [correlationData, correlationTracks, historyActive]); - // ── ESC 키 ── useEffect(() => { const onKeyDown = (e: KeyboardEvent) => { @@ -288,9 +294,9 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipS onSelectedGearChange?.({ parent: parent ? toShip(parent) : null, gears: gears.map(toShip), groupName: selectedGearGroup }); }, [selectedGearGroup, groupPolygons, onSelectedGearChange, historyActive]); - // ── 연관성 데이터 로드 ── + // ── 연관성 데이터 로드 (loadHistory에서 일괄 처리, 여기서는 비재생 모드만) ── useEffect(() => { - if (!selectedGearGroup) { setCorrelationData([]); return; } + if (!selectedGearGroup || historyActive) { if (!selectedGearGroup) { setCorrelationData([]); } return; } let cancelled = false; setCorrelationLoading(true); fetchGroupCorrelations(selectedGearGroup, 0.3) @@ -298,11 +304,11 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipS .catch(() => { if (!cancelled) setCorrelationData([]); }) .finally(() => { if (!cancelled) setCorrelationLoading(false); }); return () => { cancelled = true; }; - }, [selectedGearGroup]); + }, [selectedGearGroup, historyActive]); - // ── 연관 선박 항적 로드 ── + // ── 연관 선박 항적 로드 (loadHistory에서 일괄 처리, 여기서는 비재생 모드만) ── useEffect(() => { - if (!selectedGearGroup) { setCorrelationTracks([]); setEnabledVessels(new Set()); return; } + if (!selectedGearGroup || historyActive) { if (!selectedGearGroup) { setCorrelationTracks([]); setEnabledVessels(new Set()); } return; } let cancelled = false; fetchCorrelationTracks(selectedGearGroup, 24, 0.3) .then(res => { @@ -313,7 +319,7 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipS }) .catch(() => { if (!cancelled) setCorrelationTracks([]); }); return () => { cancelled = true; }; - }, [selectedGearGroup]); + }, [selectedGearGroup, historyActive]); // ── 부모 콜백 동기화: 선단 선택 ── useEffect(() => { diff --git a/frontend/src/hooks/useGearReplayLayers.ts b/frontend/src/hooks/useGearReplayLayers.ts index fcf819c..6fbd91f 100644 --- a/frontend/src/hooks/useGearReplayLayers.ts +++ b/frontend/src/hooks/useGearReplayLayers.ts @@ -147,27 +147,55 @@ export function useGearReplayLayers( const frame = state.historyFrames[frameIdx]; const isStale = !!frame._longGap || !!frame._interp; - // Member positions (interpolated) + // Member positions (interpolated) — 항상 계산 (배지/오퍼레이셔널에서 사용) const members = interpolateMemberPositions(state.historyFrames, frameIdx, ct); + const memberPts: [number, number][] = members.map(m => [m.lon, m.lat]); - // 1. TripsLayer — member trails (GPU animated) - if (memberTripsData.length > 0) { - layers.push(new TripsLayer({ - id: 'replay-member-trails', - data: memberTripsData, - getPath: d => d.path, - getTimestamps: d => d.timestamps, - getColor: d => d.color, - widthMinPixels: 1.5, - fadeTrail: true, - trailLength: TRAIL_LENGTH_MS, - currentTime: ct - st, - })); + // 1. Identity 모델: 멤버 트레일 + 폴리곤 + 마커 (enabledModels 체크) + if (enabledModels.has('identity')) { + // TripsLayer — member trails (GPU animated) + if (memberTripsData.length > 0) { + layers.push(new TripsLayer({ + id: 'replay-member-trails', + data: memberTripsData, + getPath: d => d.path, + getTimestamps: d => d.timestamps, + getColor: d => d.color, + widthMinPixels: 1.5, + fadeTrail: true, + trailLength: TRAIL_LENGTH_MS, + currentTime: ct - st, + })); + } + + // Current animated polygon (convex hull of members) + const polygon = buildInterpPolygon(memberPts); + if (polygon) { + layers.push(new PolygonLayer({ + id: 'replay-polygon', + data: [{ polygon: polygon.coordinates }], + getPolygon: (d: { polygon: number[][][] }) => d.polygon, + getFillColor: isStale ? [148, 163, 184, 30] : [251, 191, 36, 40], + getLineColor: isStale ? [148, 163, 184, 100] : [251, 191, 36, 180], + getLineWidth: isStale ? 1 : 2, + lineWidthMinPixels: 1, + filled: true, + stroked: true, + })); + } } - // 2. TripsLayer — correlation trails (GPU animated) + // 2. Correlation trails (GPU animated, enabledModels 체크) if (correlationTripsData.length > 0) { - const enabledTrips = correlationTripsData.filter(d => enabledVessels.has(d.id)); + // 활성 모델에 속하는 선박의 트랙만 표시 + const activeMmsis = new Set(); + for (const [mn, items] of correlationByModel) { + if (!enabledModels.has(mn)) continue; + for (const c of items as GearCorrelationItem[]) { + if (enabledVessels.has(c.targetMmsi)) activeMmsis.add(c.targetMmsi); + } + } + const enabledTrips = correlationTripsData.filter(d => activeMmsis.has(d.id)); if (enabledTrips.length > 0) { layers.push(new TripsLayer({ id: 'replay-corr-trails', @@ -183,23 +211,6 @@ export function useGearReplayLayers( } } - // 3. Current animated polygon (convex hull of members) - const memberPts: [number, number][] = members.map(m => [m.lon, m.lat]); - const polygon = buildInterpPolygon(memberPts); - if (polygon) { - layers.push(new PolygonLayer({ - id: 'replay-polygon', - data: [{ polygon: polygon.coordinates }], - getPolygon: (d: { polygon: number[][][] }) => d.polygon, - getFillColor: isStale ? [148, 163, 184, 30] : [251, 191, 36, 40], - getLineColor: isStale ? [148, 163, 184, 100] : [251, 191, 36, 180], - getLineWidth: isStale ? 1 : 2, - lineWidthMinPixels: 1, - filled: true, - stroked: true, - })); - } - // 4. Current center point layers.push(new ScatterplotLayer({ id: 'replay-center', @@ -214,8 +225,8 @@ export function useGearReplayLayers( lineWidthMinPixels: 2, })); - // 5. Member position markers (IconLayer — ship-triangle / gear-diamond) - if (members.length > 0) { + // 5. Member position markers (IconLayer, identity 모델 활성 시만) + if (members.length > 0 && enabledModels.has('identity')) { layers.push(new IconLayer({ id: 'replay-members', data: members, @@ -482,13 +493,17 @@ export function useGearReplayLayers( replayLayerRef, requestRender, ]); - // correlationByModel이 갱신되면 디버그 로그 리셋 (새 데이터 도착 확인) + // 데이터/필터 변경 시 디버그 로그 리셋 useEffect(() => { + debugLoggedRef.current = false; if (correlationByModel.size > 0) { - debugLoggedRef.current = false; - console.log('[GearReplay] correlationByModel 갱신:', correlationByModel.size, '모델', [...correlationByModel.keys()]); + console.log('[GearReplay] 데이터 갱신:', { + models: [...correlationByModel.keys()], + enabledModels: [...enabledModels], + corrTrips: correlationTripsData.length, + }); } - }, [correlationByModel]); + }, [correlationByModel, enabledModels, correlationTripsData]); // ── zustand.subscribe effect (currentTime → renderFrame) ─────────────────