fix: 순차 데이터 로딩 + enabledModels 토글 제어

데이터 로딩:
- loadHistory: Promise.all로 history/correlation/tracks 병렬 fetch
  → 모든 응답 완료 후 store.loadHistory + play() 순차 실행
- 개별 fetch effect는 비재생 모드에서만 실행 (historyActive 가드)
- 타이밍 문제 원천 제거 (race condition 없음)

enabledModels 토글 제어:
- identity OFF → 멤버 폴리곤/마커/트레일 숨김
- 각 모델 OFF → 해당 모델의 연관 선박/트레일 숨김
- 모든 모델 OFF → 센터 트레일/점만 표시
- correlation trails도 활성 모델에 속하는 선박만 표시

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
htlee 2026-03-31 08:17:53 +09:00
부모 e68f314093
커밋 6ba3db5cee
2개의 변경된 파일81개의 추가작업 그리고 60개의 파일을 삭제

파일 보기

@ -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(() => {

파일 보기

@ -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<string>();
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<MemberPosition>({
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) ─────────────────