kcg-monitoring/frontend/src/hooks/useGroupPolygons.ts
htlee 6f4044ce39 feat: MapLibre → deck.gl 전면 전환 + 어구 서브클러스터 구조 개선
- 실시간 선박 13K: MapLibre symbol → deck.gl IconLayer (useShipDeckLayers + shipDeckStore)
- 선단/어구 폴리곤: MapLibre Source/Layer → deck.gl GeoJsonLayer (useFleetClusterDeckLayers)
- 선박 팝업: MapLibre Popup → React 오버레이 (ShipPopupOverlay + ShipHoverTooltip)
- 리플레이 집중 모드 (focusMode), 라벨 클러스터링, fontScale 연동
- Python: group_key 고정 + sub_cluster_id 분리, 한국 국적 어구 오탐 제외
- DB: sub_cluster_id 컬럼 추가 + 기존 '#N' 데이터 마이그레이션
- Backend: DISTINCT ON CTE로 서브클러스터 중복 제거, subClusterId DTO 추가

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 15:44:09 +09:00

97 lines
2.9 KiB
TypeScript

import { useState, useEffect, useRef, useMemo, useCallback } from 'react';
import { fetchGroupPolygons } from '../services/vesselAnalysis';
import type { GroupPolygonDto } from '../services/vesselAnalysis';
const POLL_INTERVAL_MS = 5 * 60_000; // 5분
/** 같은 groupKey의 서브클러스터를 하나로 합산 (멤버 합산, 가장 큰 폴리곤 사용) */
function mergeByGroupKey(groups: GroupPolygonDto[]): GroupPolygonDto[] {
const byKey = new Map<string, GroupPolygonDto[]>();
for (const g of groups) {
const list = byKey.get(g.groupKey) ?? [];
list.push(g);
byKey.set(g.groupKey, list);
}
const result: GroupPolygonDto[] = [];
for (const [, items] of byKey) {
if (items.length === 1) { result.push(items[0]); continue; }
const seen = new Set<string>();
const allMembers: GroupPolygonDto['members'] = [];
for (const item of items) for (const m of item.members) {
if (!seen.has(m.mmsi)) { seen.add(m.mmsi); allMembers.push(m); }
}
const biggest = items.reduce((a, b) => a.memberCount >= b.memberCount ? a : b);
result.push({
...biggest,
subClusterId: 0,
members: allMembers,
memberCount: allMembers.length,
});
}
return result;
}
export interface UseGroupPolygonsResult {
fleetGroups: GroupPolygonDto[];
gearInZoneGroups: GroupPolygonDto[];
gearOutZoneGroups: GroupPolygonDto[];
allGroups: GroupPolygonDto[];
isLoading: boolean;
lastUpdated: number;
}
const EMPTY: UseGroupPolygonsResult = {
fleetGroups: [],
gearInZoneGroups: [],
gearOutZoneGroups: [],
allGroups: [],
isLoading: false,
lastUpdated: 0,
};
export function useGroupPolygons(enabled: boolean): UseGroupPolygonsResult {
const [allGroups, setAllGroups] = useState<GroupPolygonDto[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [lastUpdated, setLastUpdated] = useState(0);
const timerRef = useRef<ReturnType<typeof setInterval>>();
const load = useCallback(async () => {
setIsLoading(true);
try {
const groups = await fetchGroupPolygons();
setAllGroups(groups);
setLastUpdated(Date.now());
} catch {
// 네트워크 오류 시 기존 데이터 유지
} finally {
setIsLoading(false);
}
}, []);
useEffect(() => {
if (!enabled) return;
load();
timerRef.current = setInterval(load, POLL_INTERVAL_MS);
return () => clearInterval(timerRef.current);
}, [enabled, load]);
const fleetGroups = useMemo(
() => mergeByGroupKey(allGroups.filter(g => g.groupType === 'FLEET')),
[allGroups],
);
const gearInZoneGroups = useMemo(
() => mergeByGroupKey(allGroups.filter(g => g.groupType === 'GEAR_IN_ZONE')),
[allGroups],
);
const gearOutZoneGroups = useMemo(
() => mergeByGroupKey(allGroups.filter(g => g.groupType === 'GEAR_OUT_ZONE')),
[allGroups],
);
if (!enabled) return EMPTY;
return { fleetGroups, gearInZoneGroups, gearOutZoneGroups, allGroups, isLoading, lastUpdated };
}