- 실시간 선박 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>
97 lines
2.9 KiB
TypeScript
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 };
|
|
}
|