kcg-monitoring/frontend/src/components/korea/FleetClusterLayer.tsx
htlee 15f5f680fd fix: FleetClusterLayer codex 원본 복원 + ESLint suppress
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 01:31:04 +09:00

1524 lines
64 KiB
TypeScript

import { useState, useEffect, useMemo, useCallback, useRef } from 'react';
import type { Layer as DeckLayer } from '@deck.gl/core';
import { GeoJsonLayer, ScatterplotLayer } from '@deck.gl/layers';
import booleanPointInPolygon from '@turf/boolean-point-in-polygon';
import { point, polygon, lineString } from '@turf/helpers';
import type { Ship, VesselAnalysisDto } from '../../types';
import {
fetchFleetCompanies,
fetchGroupHistory,
fetchGroupCorrelations,
fetchCorrelationTracks,
fetchGroupParentInference,
fetchParentCandidateExclusions,
fetchParentLabelSessions,
fetchParentInferenceReview,
createGroupParentLabelSession,
createGroupCandidateExclusion,
createGlobalCandidateExclusion,
cancelParentLabelSession,
releaseCandidateExclusion,
} from '../../services/vesselAnalysis';
import type {
FleetCompany,
GearCorrelationItem,
CorrelationVesselTrack,
GroupParentInferenceItem,
ParentCandidateExclusion,
ParentLabelSession,
} from '../../services/vesselAnalysis';
import type { UseGroupPolygonsResult } from '../../hooks/useGroupPolygons';
import { useGearReplayStore } from '../../stores/gearReplayStore';
import type { ReplayReviewCandidate } from '../../stores/gearReplayStore';
import { useFleetClusterDeckLayers } from '../../hooks/useFleetClusterDeckLayers';
import { useShipDeckStore } from '../../stores/shipDeckStore';
import type { PickedPolygonFeature } from '../../hooks/useFleetClusterDeckLayers';
import { useFontScale } from '../../hooks/useFontScale';
import { useLocalStorage } from '../../hooks/useLocalStorage';
import { useAuth } from '../../hooks/useAuth';
import type { GroupPolygonDto } from '../../services/vesselAnalysis';
/** 서브클러스터 center 시계열 (독립 추적용) */
export interface SubClusterCenter {
subClusterId: number;
path: [number, number][]; // [lon, lat] 시간순
timestamps: number[]; // epoch ms
}
/**
* 히스토리를 서브클러스터별로 분리하여:
* 1. frames: 시간별 멤버 합산 프레임 (리플레이 애니메이션용)
* 2. subClusterCenters: 서브클러스터별 독립 center 궤적 (PathLayer용)
*/
function splitAndMergeHistory(history: GroupPolygonDto[]) {
// 시간순 정렬 (오래된 것 먼저)
const sorted = [...history].sort((a, b) =>
new Date(a.snapshotTime).getTime() - new Date(b.snapshotTime).getTime(),
);
// 1. 서브클러스터별 center 궤적 수집
const centerMap = new Map<number, { path: [number, number][]; timestamps: number[] }>();
for (const h of sorted) {
const sid = h.subClusterId ?? 0;
const entry = centerMap.get(sid) ?? { path: [], timestamps: [] };
entry.path.push([h.centerLon, h.centerLat]);
entry.timestamps.push(new Date(h.snapshotTime).getTime());
centerMap.set(sid, entry);
}
const subClusterCenters: SubClusterCenter[] = [...centerMap.entries()].map(
([subClusterId, data]) => ({ subClusterId, ...data }),
);
// 2. 시간별 그룹핑 후 서브클러스터 보존
const byTime = new Map<string, GroupPolygonDto[]>();
for (const h of sorted) {
const list = byTime.get(h.snapshotTime) ?? [];
list.push(h);
byTime.set(h.snapshotTime, list);
}
const frames: GroupPolygonDto[] = [];
for (const [, items] of byTime) {
const allSameId = items.every(item => (item.subClusterId ?? 0) === 0);
if (items.length === 1 || allSameId) {
// 단일 아이템 또는 모두 subClusterId=0: 통합 서브프레임 1개
const base = items.length === 1 ? items[0] : (() => {
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));
return { ...biggest, subClusterId: 0, members: allMembers, memberCount: allMembers.length };
})();
const subFrames: SubFrame[] = [{
subClusterId: 0,
centerLon: base.centerLon,
centerLat: base.centerLat,
members: base.members,
memberCount: base.memberCount,
}];
frames.push({ ...base, subFrames } as GroupPolygonDto & { subFrames: SubFrame[] });
} else {
// 서로 다른 subClusterId: 각 아이템을 개별 서브프레임으로 보존
const subFrames: SubFrame[] = items.map(item => ({
subClusterId: item.subClusterId ?? 0,
centerLon: item.centerLon,
centerLat: item.centerLat,
members: item.members,
memberCount: item.memberCount,
}));
const seen = new Set<string>();
const allMembers: GroupPolygonDto['members'] = [];
for (const sf of subFrames) {
for (const m of sf.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));
frames.push({
...biggest,
subClusterId: 0,
members: allMembers,
memberCount: allMembers.length,
centerLat: biggest.centerLat,
centerLon: biggest.centerLon,
subFrames,
} as GroupPolygonDto & { subFrames: SubFrame[] });
}
}
return { frames: fillGapFrames(frames as HistoryFrame[]), subClusterCenters };
}
function compareReviewQueueItems(
a: GroupParentInferenceItem,
b: GroupParentInferenceItem,
mode: ReviewQueueSortMode,
zoneDistanceByKey?: Map<string, number>,
) {
switch (mode) {
case 'topScore':
return (b.parentInference?.topScore ?? -1) - (a.parentInference?.topScore ?? -1)
|| (b.memberCount ?? -1) - (a.memberCount ?? -1)
|| a.groupKey.localeCompare(b.groupKey)
|| a.subClusterId - b.subClusterId;
case 'memberCount':
return (b.memberCount ?? -1) - (a.memberCount ?? -1)
|| (b.parentInference?.topScore ?? -1) - (a.parentInference?.topScore ?? -1)
|| a.groupKey.localeCompare(b.groupKey)
|| a.subClusterId - b.subClusterId;
case 'candidateCount':
return (b.candidateCount ?? -1) - (a.candidateCount ?? -1)
|| (b.parentInference?.topScore ?? -1) - (a.parentInference?.topScore ?? -1)
|| a.groupKey.localeCompare(b.groupKey)
|| a.subClusterId - b.subClusterId;
case 'name':
return a.groupKey.localeCompare(b.groupKey)
|| a.subClusterId - b.subClusterId;
case 'zoneDistance':
return (zoneDistanceByKey?.get(reviewItemKey(a.groupKey, a.subClusterId)) ?? Number.POSITIVE_INFINITY)
- (zoneDistanceByKey?.get(reviewItemKey(b.groupKey, b.subClusterId)) ?? Number.POSITIVE_INFINITY)
|| (b.parentInference?.topScore ?? -1) - (a.parentInference?.topScore ?? -1)
|| (b.memberCount ?? -1) - (a.memberCount ?? -1)
|| a.groupKey.localeCompare(b.groupKey)
|| a.subClusterId - b.subClusterId;
case 'backend':
default:
return 0;
}
}
interface MapPointerPayload {
coordinate: [number, number];
screen: [number, number];
}
interface ReviewSpatialVertex {
coordinate: [number, number];
screen: [number, number];
}
function reviewItemKey(groupKey: string, subClusterId: number) {
return `${groupKey}:${subClusterId}`;
}
function haversineNm(a: [number, number], b: [number, number]) {
const toRad = (deg: number) => deg * Math.PI / 180;
const [lng1, lat1] = a;
const [lng2, lat2] = b;
const dLat = toRad(lat2 - lat1);
const dLng = toRad(lng2 - lng1);
const rLat1 = toRad(lat1);
const rLat2 = toRad(lat2);
const hav = Math.sin(dLat / 2) ** 2
+ Math.cos(rLat1) * Math.cos(rLat2) * Math.sin(dLng / 2) ** 2;
const km = 6371 * (2 * Math.atan2(Math.sqrt(hav), Math.sqrt(1 - hav)));
return km / 1.852;
}
function zoneDistanceNm(group: GroupPolygonDto | undefined) {
if (!group) return Number.POSITIVE_INFINITY;
const zone = classifyFishingZone(group.centerLat, group.centerLon);
if (zone.zone !== 'OUTSIDE') return 0;
const center: [number, number] = [group.centerLon, group.centerLat];
let nearest = Number.POSITIVE_INFINITY;
for (const boundaryPoint of ZONE_BOUNDARY_POINTS) {
nearest = Math.min(nearest, haversineNm(center, boundaryPoint));
}
return nearest;
}
// ── 분리된 모듈 ──
import type { PickerCandidate, HoverTooltipState, GearPickerPopupState, SubFrame, HistoryFrame } from './fleetClusterTypes';
import { EMPTY_ANALYSIS } from './fleetClusterTypes';
import { fillGapFrames } from './fleetClusterUtils';
import { useFleetClusterGeoJson } from './useFleetClusterGeoJson';
import FleetClusterMapLayers from './FleetClusterMapLayers';
import CorrelationPanel from './CorrelationPanel';
import HistoryReplayController from './HistoryReplayController';
import FleetGearListPanel from './FleetGearListPanel';
import ParentReviewPanel, { type ReviewQueueSortMode } from './ParentReviewPanel';
import {
MIN_PARENT_REVIEW_MEMBER_COUNT,
MIN_PARENT_REVIEW_SCORE,
MIN_PARENT_REVIEW_SCORE_PCT,
} from './parentInferenceConstants';
import { ZONE_POLYGONS, classifyFishingZone } from '../../utils/fishingAnalysis';
const REVIEW_SORT_MODES: ReviewQueueSortMode[] = ['backend', 'topScore', 'memberCount', 'candidateCount', 'name', 'zoneDistance'];
function sanitizeWorkflowDurationDays(value: unknown): 1 | 3 | 5 {
return value === 3 || value === 5 ? value : 1;
}
function sanitizeReviewMinTopScorePct(value: unknown) {
const numeric = Number(value);
if (!Number.isFinite(numeric)) return MIN_PARENT_REVIEW_SCORE_PCT;
return Math.max(MIN_PARENT_REVIEW_SCORE_PCT, Math.min(100, Math.round(numeric)));
}
function sanitizeReviewMinMemberCount(value: unknown) {
const numeric = Number(value);
if (!Number.isFinite(numeric)) return MIN_PARENT_REVIEW_MEMBER_COUNT;
return Math.max(MIN_PARENT_REVIEW_MEMBER_COUNT, Math.floor(numeric));
}
function sanitizeReviewSortMode(value: unknown): ReviewQueueSortMode {
return typeof value === 'string' && REVIEW_SORT_MODES.includes(value as ReviewQueueSortMode)
? value as ReviewQueueSortMode
: 'backend';
}
function sanitizeReviewSearchText(value: unknown) {
if (typeof value !== 'string') return '';
return value.slice(0, 80);
}
function normalizeReviewSearchText(value: string) {
return value.toLowerCase().replace(/\s+/g, '');
}
const ZONE_BOUNDARY_POINTS: [number, number][] = ZONE_POLYGONS.flatMap(zone =>
zone.geojson.geometry.coordinates.flatMap(poly =>
poly[0].map(coord => [coord[0], coord[1]] as [number, number]),
),
);
// ── re-export (KoreaMap 호환) ──
export type { SelectedGearGroupData, SelectedFleetData } from './fleetClusterTypes';
interface Props {
ships: Ship[];
analysisMap?: Map<string, VesselAnalysisDto>;
clusters?: Map<number, string[]>;
onShipSelect?: (mmsi: string) => void;
onFleetZoom?: (bounds: { minLat: number; maxLat: number; minLng: number; maxLng: number }) => void;
onSelectedGearChange?: (data: import('./fleetClusterTypes').SelectedGearGroupData | null) => void;
onSelectedFleetChange?: (data: import('./fleetClusterTypes').SelectedFleetData | null) => void;
groupPolygons?: UseGroupPolygonsResult;
zoomScale?: number;
onDeckLayersChange?: (layers: DeckLayer[]) => void;
registerMapClickHandler?: (handler: ((payload: MapPointerPayload) => void) | null) => void;
registerMapMoveHandler?: (handler: ((payload: MapPointerPayload) => void) | null) => void;
autoOpenReviewPanel?: boolean;
}
export function FleetClusterLayer({
ships,
analysisMap: analysisMapProp,
onShipSelect,
onFleetZoom,
onSelectedGearChange,
onSelectedFleetChange,
groupPolygons,
zoomScale = 1,
onDeckLayersChange,
registerMapClickHandler,
registerMapMoveHandler,
autoOpenReviewPanel = false,
}: Props) {
const analysisMap = analysisMapProp ?? EMPTY_ANALYSIS;
const { fontScale } = useFontScale();
const { user } = useAuth();
const legacyReviewActor = typeof window === 'undefined'
? ''
: (window.localStorage.getItem('kcg-parent-review-actor') || '').trim();
// ── 선단/어구 패널 상태 ──
const [companies, setCompanies] = useState<Map<number, FleetCompany>>(new Map());
const [expandedFleet, setExpandedFleet] = useState<number | null>(null);
const [activeSection, setActiveSection] = useState<string | null>('fleet');
const toggleSection = (key: string) => setActiveSection(prev => prev === key ? null : key);
const [hoveredFleetId, setHoveredFleetId] = useState<number | null>(null);
const [hoveredGearCompositeKey, setHoveredGearCompositeKey] = useState<string | null>(null);
const [expandedGearGroup, setExpandedGearGroup] = useState<string | null>(null);
const [selectedGearGroup, setSelectedGearGroup] = useState<string | null>(null);
// ── 맵 팝업/툴팁 상태 ──
const [hoverTooltip, setHoverTooltip] = useState<HoverTooltipState | null>(null);
const [gearPickerPopup, setGearPickerPopup] = useState<GearPickerPopupState | null>(null);
const [pickerHoveredGroup, setPickerHoveredGroup] = useState<string | null>(null);
// ── 연관성 데이터 ──
const [correlationData, setCorrelationData] = useState<GearCorrelationItem[]>([]);
const [correlationLoading, setCorrelationLoading] = useState(false);
const [correlationTracks, setCorrelationTracks] = useState<CorrelationVesselTrack[]>([]);
const [enabledVessels, setEnabledVessels] = useState<Set<string>>(new Set());
const [enabledModels, setEnabledModels] = useState<Set<string>>(new Set(['identity', 'default', 'aggressive', 'conservative', 'proximity-heavy', 'visit-pattern']));
const [hoveredTarget, setHoveredTarget] = useState<{ mmsi: string; model: string } | null>(null);
const [parentInferenceItems, setParentInferenceItems] = useState<GroupParentInferenceItem[]>([]);
const [parentInferenceQueue, setParentInferenceQueue] = useState<GroupParentInferenceItem[]>([]);
const [parentActiveGroupExclusions, setParentActiveGroupExclusions] = useState<ParentCandidateExclusion[]>([]);
const [parentActiveGlobalExclusions, setParentActiveGlobalExclusions] = useState<ParentCandidateExclusion[]>([]);
const [parentActiveLabelSessions, setParentActiveLabelSessions] = useState<ParentLabelSession[]>([]);
const [parentInferenceLoading, setParentInferenceLoading] = useState(false);
const [parentInferenceSubmittingKey, setParentInferenceSubmittingKey] = useState<string | null>(null);
const [parentReviewActor, setParentReviewActor] = useLocalStorage<string>('parentReviewActor', legacyReviewActor);
const [storedParentWorkflowDurationDays, setStoredParentWorkflowDurationDays] = useLocalStorage<1 | 3 | 5>('parentReviewDurationDays', 1);
const [storedReviewMinTopScorePct, setStoredReviewMinTopScorePct] = useState<number>(MIN_PARENT_REVIEW_SCORE_PCT);
const [storedReviewMinMemberCount, setStoredReviewMinMemberCount] = useState<number>(MIN_PARENT_REVIEW_MEMBER_COUNT);
const [storedReviewSortMode, setStoredReviewSortMode] = useState<ReviewQueueSortMode>('backend');
const [storedReviewSearchText, setStoredReviewSearchText] = useState<string>('');
const [reviewFiltersTouched, setReviewFiltersTouched] = useState(false);
const [reviewHighlightedQueueKey, setReviewHighlightedQueueKey] = useState<string | null>(null);
const [selectedReviewQueueKey, setSelectedReviewQueueKey] = useState<string | null>(null);
const [reviewSpatialFilterVertices, setReviewSpatialFilterVertices] = useState<ReviewSpatialVertex[]>([]);
const [reviewSpatialFilterDrawing, setReviewSpatialFilterDrawing] = useState(false);
const [reviewSpatialCursor, setReviewSpatialCursor] = useState<MapPointerPayload | null>(null);
const lastReviewSpatialClickRef = useRef<{ coordinate: [number, number]; at: number } | null>(null);
const autoOpenedReviewRef = useRef(false);
// ── Zustand store (히스토리 재생) ──
const historyActive = useGearReplayStore(s => s.historyFrames.length > 0);
// ── 맵 + ref ──
// ── 초기 로드 ──
useEffect(() => {
fetchFleetCompanies().then(setCompanies).catch(() => {});
}, []);
useEffect(() => {
const sanitized = sanitizeWorkflowDurationDays(storedParentWorkflowDurationDays);
if (sanitized !== storedParentWorkflowDurationDays) {
setStoredParentWorkflowDurationDays(sanitized);
}
}, [setStoredParentWorkflowDurationDays, storedParentWorkflowDurationDays]);
useEffect(() => {
if (!user?.name) return;
setParentReviewActor(user.name);
}, [setParentReviewActor, user?.name]);
const parentWorkflowDurationDays = sanitizeWorkflowDurationDays(storedParentWorkflowDurationDays);
const reviewMinTopScorePct = sanitizeReviewMinTopScorePct(storedReviewMinTopScorePct);
const reviewMinMemberCount = sanitizeReviewMinMemberCount(storedReviewMinMemberCount);
const reviewSortMode = sanitizeReviewSortMode(storedReviewSortMode);
const reviewSearchText = sanitizeReviewSearchText(storedReviewSearchText);
const reviewDrivenOverlayMode = !!selectedGearGroup;
const getSnappedReviewPointer = useCallback((payload: MapPointerPayload): MapPointerPayload => {
if (reviewSpatialFilterVertices.length < 3) return payload;
const first = reviewSpatialFilterVertices[0];
const dx = first.screen[0] - payload.screen[0];
const dy = first.screen[1] - payload.screen[1];
const pixelDistance = Math.hypot(dx, dy);
if (pixelDistance <= 18) {
return {
coordinate: first.coordinate,
screen: first.screen,
};
}
return payload;
}, [reviewSpatialFilterVertices]);
const handleReviewSpatialFilterMove = useCallback((payload: MapPointerPayload) => {
if (!reviewSpatialFilterDrawing) return;
setReviewSpatialCursor(getSnappedReviewPointer(payload));
}, [getSnappedReviewPointer, reviewSpatialFilterDrawing]);
const handleReviewSpatialFilterClick = useCallback((payload: MapPointerPayload) => {
if (!reviewSpatialFilterDrawing) return;
const snapped = getSnappedReviewPointer(payload);
const now = Date.now();
const last = lastReviewSpatialClickRef.current;
if (
last
&& now - last.at < 400
&& Math.abs(last.coordinate[0] - snapped.coordinate[0]) < 1e-6
&& Math.abs(last.coordinate[1] - snapped.coordinate[1]) < 1e-6
) {
return;
}
lastReviewSpatialClickRef.current = { coordinate: snapped.coordinate, at: now };
setReviewSpatialCursor(snapped);
if (reviewSpatialFilterVertices.length >= 3) {
const first = reviewSpatialFilterVertices[0];
const isClosingClick = Math.abs(first.coordinate[0] - snapped.coordinate[0]) < 1e-10
&& Math.abs(first.coordinate[1] - snapped.coordinate[1]) < 1e-10;
if (isClosingClick) {
setReviewSpatialFilterDrawing(false);
return;
}
}
setReviewSpatialFilterVertices(prev => [...prev, {
coordinate: snapped.coordinate,
screen: snapped.screen,
}]);
}, [getSnappedReviewPointer, reviewSpatialFilterDrawing, reviewSpatialFilterVertices]);
const startReviewSpatialFilter = useCallback(() => {
setGearPickerPopup(null);
setPickerHoveredGroup(null);
lastReviewSpatialClickRef.current = null;
setReviewSpatialFilterVertices([]);
setReviewSpatialCursor(null);
setReviewSpatialFilterDrawing(true);
}, []);
const finishReviewSpatialFilter = useCallback(() => {
setReviewSpatialFilterDrawing(prev => (reviewSpatialFilterVertices.length >= 3 ? false : prev));
}, [reviewSpatialFilterVertices.length]);
const clearReviewSpatialFilter = useCallback(() => {
lastReviewSpatialClickRef.current = null;
setReviewSpatialFilterDrawing(false);
setReviewSpatialFilterVertices([]);
setReviewSpatialCursor(null);
}, []);
useEffect(() => {
registerMapClickHandler?.(reviewSpatialFilterDrawing ? handleReviewSpatialFilterClick : null);
registerMapMoveHandler?.(reviewSpatialFilterDrawing ? handleReviewSpatialFilterMove : null);
return () => {
registerMapClickHandler?.(null);
registerMapMoveHandler?.(null);
};
}, [
handleReviewSpatialFilterClick,
handleReviewSpatialFilterMove,
registerMapClickHandler,
registerMapMoveHandler,
reviewSpatialFilterDrawing,
]);
const reloadParentInference = useCallback(async (
groupKey: string,
options?: { reloadQueue?: boolean },
) => {
setParentInferenceLoading(true);
try {
const requests: Promise<unknown>[] = [];
if (options?.reloadQueue !== false) {
requests.push(
fetchParentInferenceReview('REVIEW_REQUIRED', 100).catch(
() => ({ count: 0, items: [] as GroupParentInferenceItem[] }),
),
);
}
requests.push(
fetchGroupParentInference(groupKey).catch(() => ({ groupKey, count: 0, items: [] as GroupParentInferenceItem[] })),
fetchParentCandidateExclusions({ groupKey, activeOnly: true, limit: 200 }).catch(
() => ({ count: 0, items: [] as ParentCandidateExclusion[] }),
),
fetchParentCandidateExclusions({ scopeType: 'GLOBAL', activeOnly: true, limit: 500 }).catch(
() => ({ count: 0, items: [] as ParentCandidateExclusion[] }),
),
fetchParentLabelSessions({ groupKey, activeOnly: true, limit: 100 }).catch(
() => ({ count: 0, items: [] as ParentLabelSession[] }),
),
);
const results = await Promise.all(requests);
let resultIndex = 0;
if (options?.reloadQueue !== false) {
const queueRes = results[resultIndex] as { count: number; items: GroupParentInferenceItem[] };
setParentInferenceQueue(queueRes.items);
resultIndex += 1;
}
const detailRes = results[resultIndex++] as { groupKey: string; count: number; items: GroupParentInferenceItem[] };
const groupExclusionRes = results[resultIndex++] as { count: number; items: ParentCandidateExclusion[] };
const globalExclusionRes = results[resultIndex++] as { count: number; items: ParentCandidateExclusion[] };
const labelSessionRes = results[resultIndex] as { count: number; items: ParentLabelSession[] };
setParentInferenceItems(detailRes.items);
setParentActiveGroupExclusions(groupExclusionRes.items);
setParentActiveGlobalExclusions(globalExclusionRes.items);
setParentActiveLabelSessions(labelSessionRes.items);
} finally {
setParentInferenceLoading(false);
}
}, []);
const refreshParentInferenceQueue = useCallback(async () => {
setParentInferenceLoading(true);
try {
const queueRes = await fetchParentInferenceReview('REVIEW_REQUIRED', 100).catch(
() => ({ count: 0, items: [] as GroupParentInferenceItem[] }),
);
setParentInferenceQueue(queueRes.items);
if (selectedGearGroup) {
const [groupExclusionRes, globalExclusionRes, labelSessionRes] = await Promise.all([
fetchParentCandidateExclusions({ groupKey: selectedGearGroup, activeOnly: true, limit: 200 }).catch(
() => ({ count: 0, items: [] as ParentCandidateExclusion[] }),
),
fetchParentCandidateExclusions({ scopeType: 'GLOBAL', activeOnly: true, limit: 500 }).catch(
() => ({ count: 0, items: [] as ParentCandidateExclusion[] }),
),
fetchParentLabelSessions({ groupKey: selectedGearGroup, activeOnly: true, limit: 100 }).catch(
() => ({ count: 0, items: [] as ParentLabelSession[] }),
),
]);
setParentActiveGroupExclusions(groupExclusionRes.items);
setParentActiveGlobalExclusions(globalExclusionRes.items);
setParentActiveLabelSessions(labelSessionRes.items);
}
} finally {
setParentInferenceLoading(false);
}
}, [selectedGearGroup]);
// ── 히스토리 로드 (3개 API 순차 await → 스토어 초기화 → 재생) ──
const loadHistory = async (groupKey: string) => {
// 1. 모든 데이터를 병렬 fetch
const [history, corrRes, trackRes] = await Promise.all([
fetchGroupHistory(groupKey, 12),
fetchGroupCorrelations(groupKey, 0.3).catch(() => ({ items: [] as GearCorrelationItem[] })),
fetchCorrelationTracks(groupKey, 24, 0).catch(() => ({ vessels: [] as CorrelationVesselTrack[] })),
]);
// 2. resolution별 분리 → 1h(primary) + 6h(secondary)
const history1h = history.filter(h => h.resolution === '1h' || h.resolution === '1h-fb');
const history6h = history.filter(h => h.resolution === '6h');
// fallback: resolution 필드 없는 기존 데이터는 6h로 취급
const effective1h = history1h.length > 0 ? history1h : history;
const effective6h = history6h;
const { frames: filled, subClusterCenters } = splitAndMergeHistory(effective1h);
const { frames: filled6h, subClusterCenters: subClusterCenters6h } = splitAndMergeHistory(effective6h);
const corrData = corrRes.items;
const corrTracks = trackRes.vessels;
// 디버그: fetch 결과 확인
const withTrack = corrTracks.filter(v => v.track && v.track.length > 0).length;
console.log('[loadHistory] fetch 완료:', {
history: history.length,
'1h': history1h.length,
'6h': history6h.length,
'filled1h': filled.length,
'filled6h': filled6h.length,
corrData: corrData.length,
corrTracks: corrTracks.length,
withTrack,
});
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. 스토어 초기화 (1h + 6h 모든 데이터 포함) → 재생 시작
const store = useGearReplayStore.getState();
store.loadHistory(filled, corrTracks, corrData, new Set(['identity']), vessels, filled6h);
// 서브클러스터별 독립 center 궤적 + 전체 구간 고유 멤버 저장
const seen = new Set<string>();
const allHistoryMembers: { mmsi: string; name: string; isParent: boolean }[] = [];
for (const f of history) {
for (const m of f.members) {
if (!seen.has(m.mmsi)) {
seen.add(m.mmsi);
allHistoryMembers.push({ mmsi: m.mmsi, name: m.name, isParent: m.isParent });
}
}
}
useGearReplayStore.setState({ subClusterCenters, subClusterCenters6h, allHistoryMembers });
store.play();
};
const closeHistory = useCallback(() => {
useGearReplayStore.getState().reset();
setHoveredTarget(null);
}, []);
// ── cnFishing 탭 off (unmount) 시 재생 상태 + deck layers 전체 초기화 ──
useEffect(() => {
return () => {
useGearReplayStore.getState().reset();
onDeckLayersChange?.([]);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// ── enabledModels/enabledVessels/hoveredTarget → store 동기화 ──
useEffect(() => {
useGearReplayStore
.getState()
.setEnabledModels(reviewDrivenOverlayMode ? new Set(['identity']) : enabledModels);
}, [enabledModels, reviewDrivenOverlayMode]);
useEffect(() => {
useGearReplayStore.getState().setEnabledVessels(enabledVessels);
}, [enabledVessels]);
useEffect(() => {
useGearReplayStore.getState().setHoveredMmsi(hoveredTarget?.mmsi ?? null);
}, [hoveredTarget]);
useEffect(() => {
const replayStore = useGearReplayStore.getState();
if (!selectedGearGroup || parentInferenceItems.length === 0) {
replayStore.setReviewCandidates([]);
return;
}
const dedup = new Map<string, ReplayReviewCandidate>();
for (const item of parentInferenceItems) {
for (const candidate of (item.candidates ?? []).filter(entry => (entry.finalScore ?? 0) >= MIN_PARENT_REVIEW_SCORE)) {
const next: ReplayReviewCandidate = {
mmsi: candidate.candidateMmsi,
name: candidate.candidateName || candidate.candidateMmsi,
rank: candidate.rank,
score: candidate.finalScore ?? null,
trackAvailable: !!candidate.trackAvailable,
subClusterId: item.subClusterId,
};
const prev = dedup.get(candidate.candidateMmsi);
if (!prev || next.rank < prev.rank || ((next.score ?? 0) > (prev.score ?? 0))) {
dedup.set(candidate.candidateMmsi, next);
}
}
}
replayStore.setReviewCandidates(
[...dedup.values()].sort((a, b) => {
if (a.rank !== b.rank) return a.rank - b.rank;
return (b.score ?? 0) - (a.score ?? 0);
}),
);
}, [parentInferenceItems, selectedGearGroup]);
useEffect(() => {
if ((selectedGearGroup && parentInferenceItems.length > 0) || hoveredTarget?.model !== 'parent-review') return;
setHoveredTarget(null);
}, [hoveredTarget, parentInferenceItems.length, selectedGearGroup]);
// ── ESC 키 ──
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
if (historyActive) closeHistory();
setSelectedGearGroup(null);
setExpandedFleet(null);
setExpandedGearGroup(null);
}
};
window.addEventListener('keydown', onKeyDown);
return () => window.removeEventListener('keydown', onKeyDown);
}, [historyActive, closeHistory]);
// 맵 이벤트 → deck.gl 콜백으로 전환 완료 (handleDeckPolygonClick/Hover)
// ── ships map ──
const shipMap = useMemo(() => {
const m = new Map<string, Ship>();
for (const s of ships) m.set(s.mmsi, s);
return m;
}, [ships]);
const gearGroupLookup = useMemo(() => {
const byCompositeKey = new Map<string, GroupPolygonDto>();
const byGroupKey = new Map<string, GroupPolygonDto>();
const allGroups = groupPolygons?.allGroups.filter(group => group.groupType !== 'FLEET') ?? [];
for (const group of allGroups) {
byCompositeKey.set(`${group.groupKey}:${group.subClusterId ?? 0}`, group);
if (!byGroupKey.has(group.groupKey)) byGroupKey.set(group.groupKey, group);
}
return { byCompositeKey, byGroupKey };
}, [groupPolygons?.allGroups]);
const reviewSpatialFilterPoints = useMemo(
() => reviewSpatialFilterVertices.map(vertex => vertex.coordinate),
[reviewSpatialFilterVertices],
);
const reviewSpatialPreviewCoordinate = reviewSpatialCursor?.coordinate ?? null;
const reviewSpatialFilterPolygon = useMemo(() => {
if (reviewSpatialFilterPoints.length < 3 || reviewSpatialFilterDrawing) return null;
return polygon([[...reviewSpatialFilterPoints, reviewSpatialFilterPoints[0]]]);
}, [reviewSpatialFilterDrawing, reviewSpatialFilterPoints]);
const reviewZoneDistanceByKey = useMemo(() => {
const map = new Map<string, number>();
for (const item of parentInferenceQueue) {
const key = reviewItemKey(item.groupKey, item.subClusterId);
const group = gearGroupLookup.byCompositeKey.get(key) ?? gearGroupLookup.byGroupKey.get(item.groupKey);
map.set(key, zoneDistanceNm(group));
}
return map;
}, [gearGroupLookup.byCompositeKey, gearGroupLookup.byGroupKey, parentInferenceQueue]);
const reviewSpatialFilterLayers = useMemo<DeckLayer[]>(() => {
if (reviewSpatialFilterPoints.length === 0) return [];
const layers: DeckLayer[] = [];
const previewCoordinates = reviewSpatialPreviewCoordinate
? [...reviewSpatialFilterPoints, reviewSpatialPreviewCoordinate]
: reviewSpatialFilterPoints;
const previewPolygon = reviewSpatialFilterDrawing && previewCoordinates.length >= 3
? polygon([[...previewCoordinates, previewCoordinates[0]]])
: null;
const geometry = reviewSpatialFilterPolygon
?? previewPolygon
?? (previewCoordinates.length >= 2 ? lineString(previewCoordinates) : point(previewCoordinates[0]));
layers.push(new GeoJsonLayer({
id: 'parent-review-spatial-filter-shape',
data: geometry,
pickable: false,
stroked: true,
filled: !!(reviewSpatialFilterPolygon || previewPolygon),
getLineColor: reviewSpatialFilterDrawing ? [56, 189, 248, 255] : [34, 197, 94, 255],
getFillColor: reviewSpatialFilterDrawing ? [56, 189, 248, 28] : [34, 197, 94, 26],
lineWidthMinPixels: reviewSpatialFilterPolygon ? 3 : 2,
}));
layers.push(new ScatterplotLayer({
id: 'parent-review-spatial-filter-points',
data: [
...reviewSpatialFilterPoints.map((position, index) => ({
position,
isLast: index === reviewSpatialFilterPoints.length - 1,
isPreview: false,
isStart: index === 0,
})),
...(reviewSpatialFilterDrawing && reviewSpatialPreviewCoordinate
? [{
position: reviewSpatialPreviewCoordinate,
isLast: false,
isPreview: true,
isStart: false,
}]
: []),
],
pickable: false,
getPosition: (datum: { position: [number, number] }) => datum.position,
getFillColor: (datum: { position: [number, number]; isLast: boolean; isPreview: boolean; isStart: boolean }) => {
if (datum.isPreview) return [56, 189, 248, 220];
if (datum.isStart && reviewSpatialPreviewCoordinate
&& datum.position[0] === reviewSpatialPreviewCoordinate[0]
&& datum.position[1] === reviewSpatialPreviewCoordinate[1]) {
return [34, 197, 94, 255];
}
return datum.isLast ? [125, 211, 252, 255] : [226, 232, 240, 220];
},
getLineColor: [8, 15, 24, 255],
getRadius: (datum: { position: [number, number]; isLast: boolean; isPreview: boolean; isStart: boolean }) => {
if (datum.isPreview) return 7;
if (datum.isStart && reviewSpatialPreviewCoordinate
&& datum.position[0] === reviewSpatialPreviewCoordinate[0]
&& datum.position[1] === reviewSpatialPreviewCoordinate[1]) {
return 9;
}
return datum.isLast ? 8 : 6;
},
radiusUnits: 'pixels',
stroked: true,
lineWidthMinPixels: 1.5,
}));
return layers;
}, [
reviewSpatialFilterDrawing,
reviewSpatialFilterPoints,
reviewSpatialFilterPolygon,
reviewSpatialPreviewCoordinate,
]);
const filteredParentInferenceQueue = useMemo(() => {
const threshold = Math.max(MIN_PARENT_REVIEW_SCORE_PCT, reviewMinTopScorePct) / 100;
const normalizedSearch = normalizeReviewSearchText(reviewSearchText);
const filtered = parentInferenceQueue.filter(item => {
const topScore = item.parentInference?.topScore ?? 0;
if (topScore < threshold) return false;
if ((item.memberCount ?? 0) < reviewMinMemberCount) return false;
if (normalizedSearch) {
const haystack = normalizeReviewSearchText([
item.groupKey,
item.groupLabel,
item.zoneName,
item.parentInference?.selectedParentName,
].filter(Boolean).join(' '));
if (!haystack.includes(normalizedSearch)) return false;
}
if (!reviewSpatialFilterPolygon) return true;
const group = gearGroupLookup.byCompositeKey.get(`${item.groupKey}:${item.subClusterId}`)
?? gearGroupLookup.byGroupKey.get(item.groupKey);
if (!group) return false;
return booleanPointInPolygon(point([group.centerLon, group.centerLat]), reviewSpatialFilterPolygon);
});
if (reviewSortMode === 'backend') return filtered;
return [...filtered].sort((a, b) => compareReviewQueueItems(a, b, reviewSortMode, reviewZoneDistanceByKey));
}, [
gearGroupLookup.byCompositeKey,
gearGroupLookup.byGroupKey,
parentInferenceQueue,
reviewMinMemberCount,
reviewSearchText,
reviewMinTopScorePct,
reviewSortMode,
reviewSpatialFilterPolygon,
reviewZoneDistanceByKey,
]);
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const filteredParentInferenceQueueKeys = useMemo(
() => new Set(filteredParentInferenceQueue.map(item => reviewItemKey(item.groupKey, item.subClusterId))),
[filteredParentInferenceQueue],
);
const reviewFilterFallbackActive = autoOpenReviewPanel
&& !reviewFiltersTouched
&& filteredParentInferenceQueue.length === 0
&& parentInferenceQueue.length > 0;
const displayedParentInferenceQueue = reviewFilterFallbackActive
? parentInferenceQueue
: filteredParentInferenceQueue;
const displayedParentInferenceQueueKeys = useMemo(
() => new Set(displayedParentInferenceQueue.map(item => reviewItemKey(item.groupKey, item.subClusterId))),
[displayedParentInferenceQueue],
);
const selectedQueueKeyForView = useMemo(() => {
if (selectedReviewQueueKey) return selectedReviewQueueKey;
if (!selectedGearGroup) return null;
const firstMatch = displayedParentInferenceQueue.find(item => item.groupKey === selectedGearGroup)
?? parentInferenceQueue.find(item => item.groupKey === selectedGearGroup);
return firstMatch ? reviewItemKey(firstMatch.groupKey, firstMatch.subClusterId) : null;
}, [displayedParentInferenceQueue, parentInferenceQueue, selectedGearGroup, selectedReviewQueueKey]);
const focusedReviewQueueKey = reviewHighlightedQueueKey ?? (historyActive ? selectedQueueKeyForView : null);
const reviewScrollTargetQueueKey = useMemo(() => {
if (hoverTooltip?.type === 'gear') {
const hoveredQueueKey = hoverTooltip.compositeKey ?? null;
return hoveredQueueKey && displayedParentInferenceQueueKeys.has(hoveredQueueKey) ? hoveredQueueKey : null;
}
return historyActive ? selectedQueueKeyForView : null;
}, [displayedParentInferenceQueueKeys, historyActive, hoverTooltip, selectedQueueKeyForView]);
const reviewMapFilterActive = reviewMinTopScorePct > MIN_PARENT_REVIEW_SCORE_PCT
|| reviewMinMemberCount > MIN_PARENT_REVIEW_MEMBER_COUNT
|| reviewSearchText.trim().length > 0
|| !!reviewSpatialFilterPolygon;
const reviewAutoFocusMode = autoOpenReviewPanel && !historyActive;
const effectiveEnabledModels = useMemo(
() => (reviewAutoFocusMode ? new Set<string>() : reviewDrivenOverlayMode ? new Set<string>(['identity']) : enabledModels),
[enabledModels, reviewAutoFocusMode, reviewDrivenOverlayMode],
);
const visibleReviewQueueKeys = useMemo(
() => (reviewMapFilterActive ? displayedParentInferenceQueueKeys : null),
[displayedParentInferenceQueueKeys, reviewMapFilterActive],
);
const activeLabelSessionByQueueKey = useMemo(() => {
const map = new Map<string, ParentLabelSession>();
for (const session of parentActiveLabelSessions) {
map.set(reviewItemKey(session.groupKey, session.subClusterId), session);
}
return map;
}, [parentActiveLabelSessions]);
const activeGroupExclusionByKey = useMemo(() => {
const map = new Map<string, ParentCandidateExclusion>();
for (const exclusion of parentActiveGroupExclusions) {
if (!exclusion.groupKey || exclusion.subClusterId == null) continue;
map.set(
`${reviewItemKey(exclusion.groupKey, exclusion.subClusterId)}:${exclusion.candidateMmsi}`,
exclusion,
);
}
return map;
}, [parentActiveGroupExclusions]);
const activeGlobalExclusionByMmsi = useMemo(() => {
const map = new Map<string, ParentCandidateExclusion>();
for (const exclusion of parentActiveGlobalExclusions) {
map.set(exclusion.candidateMmsi, exclusion);
}
return map;
}, [parentActiveGlobalExclusions]);
// ── 부모 콜백 동기화: 어구 그룹 선택 ──
useEffect(() => {
if (!selectedGearGroup || historyActive) {
onSelectedGearChange?.(null);
if (historyActive) return;
return;
}
const allGroups = groupPolygons ? [...groupPolygons.gearInZoneGroups, ...groupPolygons.gearOutZoneGroups] : [];
const matches = allGroups.filter(g => g.groupKey === selectedGearGroup);
if (matches.length === 0) { onSelectedGearChange?.(null); return; }
// 서브클러스터 멤버 합산
const seen = new Set<string>();
const allMembers: typeof matches[0]['members'] = [];
for (const g of matches) for (const m of g.members) { if (!seen.has(m.mmsi)) { seen.add(m.mmsi); allMembers.push(m); } }
const parent = allMembers.find(m => m.isParent);
const gears = allMembers.filter(m => !m.isParent);
const toShip = (m: typeof allMembers[0]): Ship => ({
mmsi: m.mmsi, name: m.name, lat: m.lat, lng: m.lon,
heading: m.cog, speed: m.sog, course: m.cog,
category: 'fishing', lastSeen: Date.now(),
});
onSelectedGearChange?.({ parent: parent ? toShip(parent) : null, gears: gears.map(toShip), groupName: selectedGearGroup });
}, [selectedGearGroup, groupPolygons, onSelectedGearChange, historyActive]);
// ── 연관성 데이터 로드 (loadHistory에서 일괄 처리, 여기서는 비재생 모드만) ──
useEffect(() => {
if (!selectedGearGroup || historyActive) { if (!selectedGearGroup) { setCorrelationData([]); } return; }
let cancelled = false;
setCorrelationLoading(true);
fetchGroupCorrelations(selectedGearGroup, 0.3)
.then(res => { if (!cancelled) setCorrelationData(res.items); })
.catch(() => { if (!cancelled) setCorrelationData([]); })
.finally(() => { if (!cancelled) setCorrelationLoading(false); });
return () => { cancelled = true; };
}, [selectedGearGroup, historyActive]);
useEffect(() => {
if (!autoOpenReviewPanel) return;
void refreshParentInferenceQueue();
}, [autoOpenReviewPanel, refreshParentInferenceQueue]);
useEffect(() => {
if (!selectedGearGroup) {
setParentInferenceItems([]);
setParentActiveGroupExclusions([]);
setParentActiveGlobalExclusions([]);
setParentActiveLabelSessions([]);
setSelectedReviewQueueKey(null);
return;
}
let cancelled = false;
void reloadParentInference(selectedGearGroup, { reloadQueue: false }).catch(() => {
if (!cancelled) {
setParentInferenceItems([]);
setParentActiveGroupExclusions([]);
setParentActiveGlobalExclusions([]);
setParentActiveLabelSessions([]);
}
});
return () => { cancelled = true; };
}, [reloadParentInference, selectedGearGroup]);
useEffect(() => {
if (!selectedReviewQueueKey) return;
if (!selectedGearGroup || !selectedReviewQueueKey.startsWith(`${selectedGearGroup}:`)) {
setSelectedReviewQueueKey(null);
}
}, [selectedGearGroup, selectedReviewQueueKey]);
// ── 연관 선박 항적 로드 (loadHistory에서 일괄 처리, 여기서는 비재생 모드만) ──
useEffect(() => {
if (!selectedGearGroup || historyActive) { if (!selectedGearGroup) { setCorrelationTracks([]); setEnabledVessels(new Set()); } return; }
let cancelled = false;
fetchCorrelationTracks(selectedGearGroup, 24, 0)
.then(res => {
if (!cancelled) {
setCorrelationTracks(res.vessels);
setEnabledVessels(new Set(res.vessels.filter(v => v.score >= 0.7).map(v => v.mmsi)));
}
})
.catch(() => { if (!cancelled) setCorrelationTracks([]); });
return () => { cancelled = true; };
}, [selectedGearGroup, historyActive]);
// ── 부모 콜백 동기화: 선단 선택 ──
useEffect(() => {
if (expandedFleet === null || historyActive) {
onSelectedFleetChange?.(null);
if (historyActive) return;
return;
}
const group = groupPolygons?.fleetGroups.find(g => Number(g.groupKey) === expandedFleet);
const company = companies.get(expandedFleet);
if (!group) { onSelectedFleetChange?.(null); return; }
const fleetShips: Ship[] = group.members.map(m => ({
mmsi: m.mmsi, name: m.name, lat: m.lat, lng: m.lon,
heading: m.cog, speed: m.sog, course: m.cog,
category: 'fishing', lastSeen: Date.now(),
}));
onSelectedFleetChange?.({ clusterId: expandedFleet, ships: fleetShips, companyName: company?.nameCn || group.groupLabel || `선단 #${expandedFleet}` });
}, [expandedFleet, groupPolygons, companies, onSelectedFleetChange, historyActive]);
// ── GeoJSON 훅 ──
const hoveredMmsi = hoveredTarget?.mmsi ?? null;
const hoveredParentCandidateMmsi = hoveredTarget?.model === 'parent-review' ? hoveredTarget.mmsi : null;
const geo = useFleetClusterGeoJson({
ships, shipMap, groupPolygons, analysisMap,
hoveredFleetId,
hoveredGearCompositeKey,
visibleGearCompositeKeys: visibleReviewQueueKeys,
selectedGearGroup,
selectedGearCompositeKey: selectedQueueKeyForView,
pickerHoveredGroup,
historyActive,
correlationData, correlationTracks,
enabledModels: effectiveEnabledModels, enabledVessels, hoveredMmsi,
});
// ── deck.gl 이벤트 콜백 ──
const handleDeckPolygonClick = useCallback((features: PickedPolygonFeature[], coordinate: [number, number]) => {
if (reviewSpatialFilterDrawing) {
handleReviewSpatialFilterClick(
reviewSpatialCursor
? { coordinate, screen: reviewSpatialCursor.screen }
: { coordinate, screen: [0, 0] },
);
return;
}
if (features.length === 0) return;
if (features.length === 1) {
const c = features[0];
if (c.type === 'fleet' && c.clusterId != null) {
setExpandedFleet(prev => prev === c.clusterId ? null : c.clusterId!);
setActiveSection('fleet');
setSelectedReviewQueueKey(null);
const group = groupPolygons?.fleetGroups.find(g => Number(g.groupKey) === c.clusterId);
if (group && group.members.length > 0) {
let minLat = Infinity, maxLat = -Infinity, minLng = Infinity, maxLng = -Infinity;
for (const m of group.members) {
if (m.lat < minLat) minLat = m.lat;
if (m.lat > maxLat) maxLat = m.lat;
if (m.lon < minLng) minLng = m.lon;
if (m.lon > maxLng) maxLng = m.lon;
}
if (minLat !== Infinity) onFleetZoom?.({ minLat, maxLat, minLng, maxLng });
}
loadHistory(String(c.clusterId));
} else if (c.type === 'gear' && c.groupKey) {
const nextQueueKey = c.compositeKey ?? reviewItemKey(c.groupKey, c.subClusterId ?? 0);
const isSameSelection = selectedGearGroup === c.groupKey && selectedQueueKeyForView === nextQueueKey;
setSelectedReviewQueueKey(isSameSelection ? null : nextQueueKey);
setSelectedGearGroup(isSameSelection ? null : c.groupKey);
setExpandedGearGroup(c.groupKey);
const isInZone = groupPolygons?.gearInZoneGroups.some(g => g.groupKey === c.groupKey);
setActiveSection(isInZone ? 'inZone' : 'outZone');
const allGroups = groupPolygons ? [...groupPolygons.gearInZoneGroups, ...groupPolygons.gearOutZoneGroups] : [];
const group = c.compositeKey
? gearGroupLookup.byCompositeKey.get(c.compositeKey) ?? allGroups.find(g => g.groupKey === c.groupKey)
: allGroups.find(g => g.groupKey === c.groupKey);
if (group && group.members.length > 0) {
let minLat = Infinity, maxLat = -Infinity, minLng = Infinity, maxLng = -Infinity;
for (const m of group.members) {
if (m.lat < minLat) minLat = m.lat;
if (m.lat > maxLat) maxLat = m.lat;
if (m.lon < minLng) minLng = m.lon;
if (m.lon > maxLng) maxLng = m.lon;
}
if (minLat !== Infinity) onFleetZoom?.({ minLat, maxLat, minLng, maxLng });
}
loadHistory(c.groupKey);
}
} else {
// 겹친 폴리곤 → 피커 팝업
const candidates: PickerCandidate[] = features.map(f => {
if (f.type === 'fleet') {
const g = groupPolygons?.fleetGroups.find(x => Number(x.groupKey) === f.clusterId);
return { name: g?.groupLabel ?? `선단 #${f.clusterId}`, count: g?.memberCount ?? 0, inZone: false, isFleet: true, clusterId: f.clusterId };
}
return { name: f.name ?? '', count: f.gearCount ?? 0, inZone: !!f.inZone, isFleet: false };
});
setGearPickerPopup({ lng: coordinate[0], lat: coordinate[1], candidates });
}
}, [
groupPolygons,
gearGroupLookup.byCompositeKey,
handleReviewSpatialFilterClick,
loadHistory,
onFleetZoom,
reviewSpatialCursor,
reviewSpatialFilterDrawing,
selectedGearGroup,
selectedQueueKeyForView,
]);
const handleDeckPolygonHover = useCallback((info: {
lng: number;
lat: number;
type: 'fleet' | 'gear';
id: string | number;
groupKey?: string;
subClusterId?: number;
compositeKey?: string;
} | null) => {
if (info) {
if (info.type === 'fleet') {
setHoveredFleetId(info.id as number);
setHoveredGearCompositeKey(null);
setReviewHighlightedQueueKey(null);
} else {
setHoveredFleetId(null);
const compositeKey = info.compositeKey ?? null;
setHoveredGearCompositeKey(compositeKey);
setReviewHighlightedQueueKey(compositeKey && displayedParentInferenceQueueKeys.has(compositeKey) ? compositeKey : null);
}
setHoverTooltip({
lng: info.lng,
lat: info.lat,
type: info.type,
id: info.id,
groupKey: info.groupKey,
subClusterId: info.subClusterId,
compositeKey: info.compositeKey,
});
} else {
setHoveredFleetId(null);
setHoveredGearCompositeKey(null);
setReviewHighlightedQueueKey(null);
setHoverTooltip(null);
}
}, [displayedParentInferenceQueueKeys]);
// ── deck.gl 레이어 빌드 ──
const focusMode = useGearReplayStore(s => s.focusMode);
const zoomLevel = useShipDeckStore(s => s.zoomLevel);
const fleetDeckLayers = useFleetClusterDeckLayers(geo, {
selectedGearGroup,
hoveredMmsi,
hoveredGearCompositeKey,
enabledModels: effectiveEnabledModels,
historyActive,
hasCorrelationTracks: correlationTracks.length > 0,
zoomScale,
zoomLevel,
fontScale: fontScale.analysis,
focusMode,
onPolygonClick: handleDeckPolygonClick,
onPolygonHover: handleDeckPolygonHover,
});
useEffect(() => {
onDeckLayersChange?.([...fleetDeckLayers, ...reviewSpatialFilterLayers]);
}, [fleetDeckLayers, onDeckLayersChange, reviewSpatialFilterLayers]);
// ── 어구 그룹 데이터 ──
const inZoneGearGroups = groupPolygons?.gearInZoneGroups ?? [];
const outZoneGearGroups = groupPolygons?.gearOutZoneGroups ?? [];
// ── 핸들러 ──
const handleFleetZoom = useCallback((clusterId: number) => {
const group = groupPolygons?.fleetGroups.find(g => Number(g.groupKey) === clusterId);
if (!group || group.members.length === 0) return;
let minLat = Infinity, maxLat = -Infinity, minLng = Infinity, maxLng = -Infinity;
for (const m of group.members) {
if (m.lat < minLat) minLat = m.lat;
if (m.lat > maxLat) maxLat = m.lat;
if (m.lon < minLng) minLng = m.lon;
if (m.lon > maxLng) maxLng = m.lon;
}
if (minLat === Infinity) return;
onFleetZoom?.({ minLat, maxLat, minLng, maxLng });
}, [groupPolygons?.fleetGroups, onFleetZoom]);
const focusGearGroupBounds = useCallback((parentName: string, subClusterId?: number | null) => {
const allGroups = groupPolygons ? [...groupPolygons.gearInZoneGroups, ...groupPolygons.gearOutZoneGroups] : [];
const group = subClusterId != null
? gearGroupLookup.byCompositeKey.get(reviewItemKey(parentName, subClusterId))
?? allGroups.find(g => g.groupKey === parentName && (g.subClusterId ?? 0) === subClusterId)
?? allGroups.find(g => g.groupKey === parentName)
: allGroups.find(g => g.groupKey === parentName);
if (!group || group.members.length === 0) return;
let minLat = Infinity;
let maxLat = -Infinity;
let minLng = Infinity;
let maxLng = -Infinity;
for (const m of group.members) {
if (m.lat < minLat) minLat = m.lat;
if (m.lat > maxLat) maxLat = m.lat;
if (m.lon < minLng) minLng = m.lon;
if (m.lon > maxLng) maxLng = m.lon;
}
if (minLat === Infinity) return;
onFleetZoom?.({ minLat, maxLat, minLng, maxLng });
}, [gearGroupLookup.byCompositeKey, groupPolygons, onFleetZoom]);
const handleGearGroupZoom = useCallback((parentName: string, subClusterId?: number | null) => {
const nextQueueKey = subClusterId != null ? reviewItemKey(parentName, subClusterId) : null;
const isSameSelection = subClusterId != null
? selectedGearGroup === parentName && selectedQueueKeyForView === nextQueueKey
: selectedGearGroup === parentName;
if (subClusterId != null) {
setSelectedReviewQueueKey(isSameSelection ? null : nextQueueKey);
}
setSelectedGearGroup(isSameSelection ? null : parentName);
setExpandedGearGroup(parentName);
const isInZone = groupPolygons?.gearInZoneGroups.some(g => g.groupKey === parentName);
setActiveSection(isInZone ? 'inZone' : 'outZone');
requestAnimationFrame(() => {
setTimeout(() => {
document.getElementById(`gear-row-${parentName}`)?.scrollIntoView({ behavior: 'smooth', block: 'start' });
}, 50);
});
focusGearGroupBounds(parentName, subClusterId);
loadHistory(parentName);
}, [focusGearGroupBounds, groupPolygons, selectedGearGroup, selectedQueueKeyForView]);
const handleJumpToReviewGroup = useCallback((groupKey: string, subClusterId: number) => {
setSelectedGearGroup(groupKey);
setExpandedGearGroup(groupKey);
setSelectedReviewQueueKey(reviewItemKey(groupKey, subClusterId));
const isInZone = groupPolygons?.gearInZoneGroups.some(g => g.groupKey === groupKey);
setActiveSection(isInZone ? 'inZone' : 'outZone');
focusGearGroupBounds(groupKey, subClusterId);
}, [focusGearGroupBounds, groupPolygons?.gearInZoneGroups]);
const handleParentWorkflowAction = useCallback(async (
subClusterId: number,
action: 'LABEL' | 'GROUP_EXCLUDE' | 'GLOBAL_EXCLUDE' | 'CANCEL_LABEL' | 'RELEASE_GROUP_EXCLUSION' | 'RELEASE_GLOBAL_EXCLUSION',
candidateMmsi?: string,
) => {
if (!selectedGearGroup) return;
const actor = parentReviewActor.trim() || user?.name?.trim() || 'lab-ui';
const submitKey = `${selectedGearGroup}:${subClusterId}`;
const queueKey = reviewItemKey(selectedGearGroup, subClusterId);
setParentInferenceSubmittingKey(submitKey);
try {
switch (action) {
case 'LABEL':
if (!candidateMmsi) throw new Error('selectedParentMmsi is required');
await createGroupParentLabelSession(selectedGearGroup, subClusterId, {
selectedParentMmsi: candidateMmsi,
durationDays: parentWorkflowDurationDays,
actor,
});
break;
case 'GROUP_EXCLUDE':
if (!candidateMmsi) throw new Error('candidateMmsi is required');
await createGroupCandidateExclusion(selectedGearGroup, subClusterId, {
candidateMmsi,
durationDays: parentWorkflowDurationDays,
actor,
});
break;
case 'GLOBAL_EXCLUDE':
if (!candidateMmsi) throw new Error('candidateMmsi is required');
await createGlobalCandidateExclusion({
candidateMmsi,
actor,
});
break;
case 'CANCEL_LABEL': {
const session = activeLabelSessionByQueueKey.get(queueKey);
if (!session) throw new Error('active label session not found');
await cancelParentLabelSession(session.id, { actor });
break;
}
case 'RELEASE_GROUP_EXCLUSION': {
if (!candidateMmsi) throw new Error('candidateMmsi is required');
const exclusion = activeGroupExclusionByKey.get(`${queueKey}:${candidateMmsi}`);
if (!exclusion) throw new Error('active group exclusion not found');
await releaseCandidateExclusion(exclusion.id, { actor });
break;
}
case 'RELEASE_GLOBAL_EXCLUSION': {
if (!candidateMmsi) throw new Error('candidateMmsi is required');
const exclusion = activeGlobalExclusionByMmsi.get(candidateMmsi);
if (!exclusion) throw new Error('active global exclusion not found');
await releaseCandidateExclusion(exclusion.id, { actor });
break;
}
default:
throw new Error('unsupported parent workflow action');
}
await reloadParentInference(selectedGearGroup);
} catch (error) {
const message = error instanceof Error ? error.message : 'parent workflow action failed';
console.warn('[parent-workflow] 실패:', message);
window.alert(message);
} finally {
setParentInferenceSubmittingKey(null);
}
}, [
activeGlobalExclusionByMmsi,
activeGroupExclusionByKey,
activeLabelSessionByQueueKey,
parentReviewActor,
parentWorkflowDurationDays,
reloadParentInference,
selectedGearGroup,
user?.name,
]);
const handleParentReviewSelectGroup = useCallback((groupKey: string, subClusterId: number) => {
setSelectedReviewQueueKey(reviewItemKey(groupKey, subClusterId));
if (groupKey === selectedGearGroup) {
void reloadParentInference(groupKey);
return;
}
handleGearGroupZoom(groupKey, subClusterId);
}, [handleGearGroupZoom, reloadParentInference, selectedGearGroup]);
useEffect(() => {
if (!autoOpenReviewPanel || autoOpenedReviewRef.current || selectedGearGroup || displayedParentInferenceQueue.length === 0) {
return;
}
const first = displayedParentInferenceQueue[0];
autoOpenedReviewRef.current = true;
setSelectedReviewQueueKey(reviewItemKey(first.groupKey, first.subClusterId));
setSelectedGearGroup(first.groupKey);
setExpandedGearGroup(first.groupKey);
const isInZone = groupPolygons?.gearInZoneGroups.some(g => g.groupKey === first.groupKey);
setActiveSection(isInZone ? 'inZone' : 'outZone');
}, [autoOpenReviewPanel, displayedParentInferenceQueue, groupPolygons?.gearInZoneGroups, selectedGearGroup]);
const resetParentReviewFilters = useCallback(() => {
setReviewFiltersTouched(false);
setStoredReviewMinTopScorePct(MIN_PARENT_REVIEW_SCORE_PCT);
setStoredReviewMinMemberCount(MIN_PARENT_REVIEW_MEMBER_COUNT);
setStoredReviewSortMode('backend');
setStoredReviewSearchText('');
clearReviewSpatialFilter();
}, [
clearReviewSpatialFilter,
setStoredReviewMinMemberCount,
setStoredReviewMinTopScorePct,
setStoredReviewSearchText,
setStoredReviewSortMode,
]);
// ── Picker 콜백 ──
const handlePickerSelect = useCallback((c: PickerCandidate) => {
if (c.isFleet && c.clusterId != null) {
setExpandedFleet(prev => prev === c.clusterId! ? null : c.clusterId!);
setActiveSection('fleet');
handleFleetZoom(c.clusterId);
} else {
handleGearGroupZoom(c.name);
}
setGearPickerPopup(null);
setPickerHoveredGroup(null);
}, [handleFleetZoom, handleGearGroupZoom]);
// ── CorrelationPanel용 멤버 수 ──
const selectedGroupMemberCount = useMemo(() => {
if (!selectedGearGroup || !groupPolygons) return 0;
const allGroups = [...groupPolygons.gearInZoneGroups, ...groupPolygons.gearOutZoneGroups];
return allGroups.filter(g => g.groupKey === selectedGearGroup).reduce((sum, g) => sum + g.memberCount, 0);
}, [selectedGearGroup, groupPolygons]);
return (
<>
{/* ── 맵 레이어 ── */}
<FleetClusterMapLayers
selectedGearGroup={selectedGearGroup}
expandedFleet={expandedFleet}
hoverTooltip={hoverTooltip}
gearPickerPopup={gearPickerPopup}
pickerHoveredGroup={pickerHoveredGroup}
groupPolygons={groupPolygons}
companies={companies}
analysisMap={analysisMap}
onPickerHover={setPickerHoveredGroup}
onPickerSelect={handlePickerSelect}
onPickerClose={() => { setGearPickerPopup(null); setPickerHoveredGroup(null); }}
/>
{/* ── 연관성 패널 ── */}
{selectedGearGroup && !reviewAutoFocusMode && (
<CorrelationPanel
selectedGearGroup={selectedGearGroup}
memberCount={selectedGroupMemberCount}
groupPolygons={groupPolygons}
correlationByModel={geo.correlationByModel}
availableModels={geo.availableModels}
enabledModels={enabledModels}
enabledVessels={enabledVessels}
correlationLoading={correlationLoading}
hoveredTarget={hoveredTarget}
hasRightReviewPanel={!!selectedGearGroup}
reviewDriven={reviewDrivenOverlayMode}
onEnabledModelsChange={(updater) => setEnabledModels(updater)}
onEnabledVesselsChange={(updater) => setEnabledVessels(updater)}
onHoveredTargetChange={setHoveredTarget}
/>
)}
{selectedGearGroup && (
<ParentReviewPanel
selectedGearGroup={selectedGearGroup}
items={parentInferenceItems}
reviewQueue={displayedParentInferenceQueue}
reviewQueueFilteredCount={filteredParentInferenceQueue.length}
activeGroupExclusions={parentActiveGroupExclusions}
activeGlobalExclusions={parentActiveGlobalExclusions}
activeLabelSessions={parentActiveLabelSessions}
reviewQueueTotalCount={parentInferenceQueue.length}
filterFallbackActive={reviewFilterFallbackActive}
isLoading={parentInferenceLoading}
submittingKey={parentInferenceSubmittingKey}
actor={parentReviewActor}
workflowDurationDays={parentWorkflowDurationDays}
hoveredCandidateMmsi={hoveredParentCandidateMmsi}
minTopScorePct={reviewMinTopScorePct}
minMemberCount={reviewMinMemberCount}
sortMode={reviewSortMode}
searchText={reviewSearchText}
selectedQueueKey={selectedQueueKeyForView}
focusedQueueKey={focusedReviewQueueKey}
scrollTargetQueueKey={reviewScrollTargetQueueKey}
isSpatialFilterDrawing={reviewSpatialFilterDrawing}
hasSpatialFilter={!!reviewSpatialFilterPolygon}
spatialFilterPointCount={reviewSpatialFilterPoints.length}
onActorChange={setParentReviewActor}
onWorkflowDurationDaysChange={(value) => setStoredParentWorkflowDurationDays(sanitizeWorkflowDurationDays(value))}
onRefresh={() => { void reloadParentInference(selectedGearGroup); }}
onSelectGroup={handleParentReviewSelectGroup}
onJumpToGroup={handleJumpToReviewGroup}
onQueueHover={(queueKey) => {
setReviewHighlightedQueueKey(queueKey);
setHoveredGearCompositeKey(queueKey);
}}
onCandidateHover={(mmsi) => {
setHoveredTarget(mmsi ? { mmsi, model: 'parent-review' } : null);
}}
onMinTopScorePctChange={(value) => {
setReviewFiltersTouched(true);
setStoredReviewMinTopScorePct(sanitizeReviewMinTopScorePct(value));
}}
onMinMemberCountChange={(value) => {
setReviewFiltersTouched(true);
setStoredReviewMinMemberCount(sanitizeReviewMinMemberCount(value));
}}
onSortModeChange={(value) => {
setReviewFiltersTouched(true);
setStoredReviewSortMode(sanitizeReviewSortMode(value));
}}
onSearchTextChange={(value) => {
setReviewFiltersTouched(true);
setStoredReviewSearchText(sanitizeReviewSearchText(value));
}}
onResetFilters={resetParentReviewFilters}
onStartSpatialFilter={() => {
setReviewFiltersTouched(true);
startReviewSpatialFilter();
}}
onFinishSpatialFilter={() => {
setReviewFiltersTouched(true);
finishReviewSpatialFilter();
}}
onClearSpatialFilter={() => {
setReviewFiltersTouched(true);
clearReviewSpatialFilter();
}}
onWorkflowAction={(subClusterId, action, candidateMmsi) => {
void handleParentWorkflowAction(subClusterId, action, candidateMmsi);
}}
/>
)}
{/* ── 재생 컨트롤러 ── */}
{historyActive && (
<HistoryReplayController
onClose={closeHistory}
hasRightReviewPanel={!!selectedGearGroup}
/>
)}
{/* ── 좌측 목록 패널 ── */}
<FleetGearListPanel
fleetList={geo.fleetList}
companies={companies}
analysisMap={analysisMap}
inZoneGearGroups={inZoneGearGroups}
outZoneGearGroups={outZoneGearGroups}
activeSection={activeSection}
expandedFleet={expandedFleet}
expandedGearGroup={expandedGearGroup}
hoveredFleetId={hoveredFleetId}
onToggleSection={toggleSection}
onExpandFleet={setExpandedFleet}
onHoverFleet={setHoveredFleetId}
onFleetZoom={handleFleetZoom}
onGearGroupZoom={handleGearGroupZoom}
onExpandGearGroup={setExpandedGearGroup}
onShipSelect={(mmsi) => onShipSelect?.(mmsi)}
/>
</>
);
}