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(); 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(); 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(); 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(); 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, ) { 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; clusters?: Map; 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>(new Map()); const [expandedFleet, setExpandedFleet] = useState(null); const [activeSection, setActiveSection] = useState('fleet'); const toggleSection = (key: string) => setActiveSection(prev => prev === key ? null : key); const [hoveredFleetId, setHoveredFleetId] = useState(null); const [hoveredGearCompositeKey, setHoveredGearCompositeKey] = useState(null); const [expandedGearGroup, setExpandedGearGroup] = useState(null); const [selectedGearGroup, setSelectedGearGroup] = useState(null); // ── 맵 팝업/툴팁 상태 ── const [hoverTooltip, setHoverTooltip] = useState(null); const [gearPickerPopup, setGearPickerPopup] = useState(null); const [pickerHoveredGroup, setPickerHoveredGroup] = useState(null); // ── 연관성 데이터 ── const [correlationData, setCorrelationData] = useState([]); const [correlationLoading, setCorrelationLoading] = useState(false); const [correlationTracks, setCorrelationTracks] = useState([]); const [enabledVessels, setEnabledVessels] = useState>(new Set()); const [enabledModels, setEnabledModels] = useState>(new Set(['identity', 'default', 'aggressive', 'conservative', 'proximity-heavy', 'visit-pattern'])); const [hoveredTarget, setHoveredTarget] = useState<{ mmsi: string; model: string } | null>(null); const [parentInferenceItems, setParentInferenceItems] = useState([]); const [parentInferenceQueue, setParentInferenceQueue] = useState([]); const [parentActiveGroupExclusions, setParentActiveGroupExclusions] = useState([]); const [parentActiveGlobalExclusions, setParentActiveGlobalExclusions] = useState([]); const [parentActiveLabelSessions, setParentActiveLabelSessions] = useState([]); const [parentInferenceLoading, setParentInferenceLoading] = useState(false); const [parentInferenceSubmittingKey, setParentInferenceSubmittingKey] = useState(null); const [parentReviewActor, setParentReviewActor] = useLocalStorage('parentReviewActor', legacyReviewActor); const [storedParentWorkflowDurationDays, setStoredParentWorkflowDurationDays] = useLocalStorage<1 | 3 | 5>('parentReviewDurationDays', 1); const [storedReviewMinTopScorePct, setStoredReviewMinTopScorePct] = useState(MIN_PARENT_REVIEW_SCORE_PCT); const [storedReviewMinMemberCount, setStoredReviewMinMemberCount] = useState(MIN_PARENT_REVIEW_MEMBER_COUNT); const [storedReviewSortMode, setStoredReviewSortMode] = useState('backend'); const [storedReviewSearchText, setStoredReviewSearchText] = useState(''); const [reviewFiltersTouched, setReviewFiltersTouched] = useState(false); const [reviewHighlightedQueueKey, setReviewHighlightedQueueKey] = useState(null); const [selectedReviewQueueKey, setSelectedReviewQueueKey] = useState(null); const [reviewSpatialFilterVertices, setReviewSpatialFilterVertices] = useState([]); const [reviewSpatialFilterDrawing, setReviewSpatialFilterDrawing] = useState(false); const [reviewSpatialCursor, setReviewSpatialCursor] = useState(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[] = []; 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(); 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(); 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(); for (const s of ships) m.set(s.mmsi, s); return m; }, [ships]); const gearGroupLookup = useMemo(() => { const byCompositeKey = new Map(); const byGroupKey = new Map(); 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(); 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(() => { 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() : reviewDrivenOverlayMode ? new Set(['identity']) : enabledModels), [enabledModels, reviewAutoFocusMode, reviewDrivenOverlayMode], ); const visibleReviewQueueKeys = useMemo( () => (reviewMapFilterActive ? displayedParentInferenceQueueKeys : null), [displayedParentInferenceQueueKeys, reviewMapFilterActive], ); const activeLabelSessionByQueueKey = useMemo(() => { const map = new Map(); for (const session of parentActiveLabelSessions) { map.set(reviewItemKey(session.groupKey, session.subClusterId), session); } return map; }, [parentActiveLabelSessions]); const activeGroupExclusionByKey = useMemo(() => { const map = new Map(); 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(); 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(); 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 ( <> {/* ── 맵 레이어 ── */} { setGearPickerPopup(null); setPickerHoveredGroup(null); }} /> {/* ── 연관성 패널 ── */} {selectedGearGroup && !reviewAutoFocusMode && ( setEnabledModels(updater)} onEnabledVesselsChange={(updater) => setEnabledVessels(updater)} onHoveredTargetChange={setHoveredTarget} /> )} {selectedGearGroup && ( 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 && ( )} {/* ── 좌측 목록 패널 ── */} onShipSelect?.(mmsi)} /> ); }