1524 lines
64 KiB
TypeScript
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)}
|
|
/>
|
|
</>
|
|
);
|
|
}
|