diff --git a/frontend/src/components/korea/FleetClusterLayer.tsx b/frontend/src/components/korea/FleetClusterLayer.tsx index cdfde40..e4c2e7b 100644 --- a/frontend/src/components/korea/FleetClusterLayer.tsx +++ b/frontend/src/components/korea/FleetClusterLayer.tsx @@ -241,9 +241,11 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipS candidates: { name: string; count: number; inZone: boolean; isFleet: boolean; clusterId?: number }[]; } | null>(null); const [pickerHoveredGroup, setPickerHoveredGroup] = useState(null); - // 어구 연관성 데이터 + // 어구 연관성 데이터 (전체 모델) const [correlationData, setCorrelationData] = useState([]); const [correlationLoading, setCorrelationLoading] = useState(false); + // 활성화된 모델 ('identity' = 이름기반, 'default' = 기본모델, 나머지 = 추가모델) + const [enabledModels, setEnabledModels] = useState>(new Set(['identity', 'default'])); // 히스토리 애니메이션 — 12시간 실시간 타임라인 const [historyData, setHistoryData] = useState(null); const [, setHistoryGroupKey] = useState(null); @@ -551,8 +553,7 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipS fetchGroupCorrelations(selectedGearGroup, 0.3) .then(res => { if (!cancelled) { - // default 모델 결과만 팝업에 표시 - setCorrelationData(res.items.filter(i => i.isDefault)); + setCorrelationData(res.items); } }) .catch(() => { if (!cancelled) setCorrelationData([]); }) @@ -630,6 +631,90 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipS }; }, [hoveredFleetId, groupPolygons?.fleetGroups]); + // 모델별 연관성 데이터 그룹핑 + const correlationByModel = useMemo(() => { + const map = new Map(); + for (const c of correlationData) { + const list = map.get(c.modelName) ?? []; + list.push(c); + map.set(c.modelName, list); + } + return map; + }, [correlationData]); + + // 사용 가능한 모델 목록 (데이터가 있는 모델만) + const availableModels = useMemo(() => { + const models: { name: string; count: number; isDefault: boolean }[] = []; + for (const [name, items] of correlationByModel) { + models.push({ name, count: items.length, isDefault: items[0]?.isDefault ?? false }); + } + models.sort((a, b) => (a.isDefault ? -1 : 0) - (b.isDefault ? -1 : 0)); + return models; + }, [correlationByModel]); + + // 모델별 오퍼레이셔널 폴리곤 GeoJSON (identity 제외, correlation 모델만) + const MODEL_COLORS: Record = { + 'default': '#3b82f6', // 파랑 + 'aggressive': '#22c55e', // 초록 + 'conservative': '#a855f7', // 보라 + 'proximity-heavy': '#06b6d4', // 시안 + 'visit-pattern': '#f43f5e', // 로즈 + }; + + const operationalPolygons = useMemo(() => { + if (!selectedGearGroup || !groupPolygons) return []; + + const allGroups = [...groupPolygons.gearInZoneGroups, ...groupPolygons.gearOutZoneGroups]; + const group = allGroups.find(g => g.groupKey === selectedGearGroup); + if (!group) return []; + + // 이름 기반 멤버 위치 + const baseMemberPositions: [number, number][] = group.members.map(m => [m.lon, m.lat]); + + // ships prop에서 위치 조회용 맵 + const posMap = new Map(); + for (const s of ships) { + posMap.set(s.mmsi, { lat: s.lat, lon: s.lng }); + } + + const result: { modelName: string; color: string; geojson: GeoJSON.FeatureCollection }[] = []; + + for (const [modelName, items] of correlationByModel) { + if (!enabledModels.has(modelName)) continue; + + // 70%+ 점수 대상의 위치 수집 + const extraPositions: [number, number][] = []; + for (const c of items) { + if (c.score < 0.7) continue; + const pos = posMap.get(c.targetMmsi); + if (pos) extraPositions.push([pos.lon, pos.lat]); + } + + if (extraPositions.length === 0) continue; + + // 이름 기반 + 연관 대상 합산 + const allPoints = [...baseMemberPositions, ...extraPositions]; + const polygon = buildInterpPolygon(allPoints); + if (!polygon) continue; + + const color = MODEL_COLORS[modelName] ?? '#94a3b8'; + result.push({ + modelName, + color, + geojson: { + type: 'FeatureCollection', + features: [{ + type: 'Feature', + properties: { modelName, color }, + geometry: polygon, + }], + }, + }); + } + + return result; + }, [selectedGearGroup, groupPolygons, correlationByModel, enabledModels, ships]); + // 어구 클러스터 GeoJSON (서버 제공) const gearClusterGeoJson = useMemo((): GeoJSON => { const features: GeoJSON.Feature[] = []; @@ -957,8 +1042,8 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipS /> - {/* 선택된 어구 그룹 하이라이트 폴리곤 — 히스토리 모드에서는 숨김 */} - {selectedGearGroup && !historyData && (() => { + {/* 선택된 어구 그룹 — 이름 기반 하이라이트 폴리곤 */} + {selectedGearGroup && !historyData && enabledModels.has('identity') && (() => { const allGroups = groupPolygons ? [...groupPolygons.gearInZoneGroups, ...groupPolygons.gearOutZoneGroups] : []; @@ -980,6 +1065,19 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipS ); })()} + {/* 선택된 어구 그룹 — 모델별 오퍼레이셔널 폴리곤 오버레이 */} + {selectedGearGroup && !historyData && operationalPolygons.map(op => ( + + + + + ))} + {/* 비허가 어구 클러스터 폴리곤 */}
@@ -1142,38 +1240,83 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipS · {m.name || m.mmsi}
))} - {/* 연관 선박/어구 (선택된 그룹만) */} - {selectedGearGroup === name && correlationData.length > 0 && ( + {/* 모델 폴리곤 토글 (선택된 그룹만) */} + {selectedGearGroup === name && !correlationLoading && availableModels.length > 0 && (
-
- 연관 {correlationData.length}건 +
+ 폴리곤 오버레이
- {correlationData.slice(0, 8).map(c => { - const pct = (c.score * 100).toFixed(0); - const barW = Math.max(2, c.score * 60); - const barColor = c.score >= 0.7 ? '#22c55e' : c.score >= 0.5 ? '#eab308' : '#94a3b8'; + {/* 이름 기반 (항상 표시) */} + + {/* 모델별 체크박스 */} + {availableModels.map(m => { + const color = MODEL_COLORS[m.name] ?? '#94a3b8'; + const modelItems = correlationByModel.get(m.name) ?? []; + const above70 = modelItems.filter(c => c.score >= 0.7).length; return ( -
- - {c.targetType === 'VESSEL' ? '⛴' : '◆'} - - - {c.targetName || c.targetMmsi} - -
-
-
-
- {pct}% -
-
+ ); })} - {correlationData.length > 8 && ( -
+{correlationData.length - 8}건 더
- )}
)} + {/* 연관 선박 상위 목록 (default 모델) */} + {selectedGearGroup === name && (() => { + const defaultItems = correlationData.filter(c => c.isDefault); + if (defaultItems.length === 0) return null; + return ( +
+
연관 선박 (default 상위 8)
+ {defaultItems.slice(0, 8).map(c => { + const pct = (c.score * 100).toFixed(0); + const barW = Math.max(2, c.score * 60); + const barColor = c.score >= 0.7 ? '#22c55e' : c.score >= 0.5 ? '#eab308' : '#94a3b8'; + return ( +
+ + {c.targetType === 'VESSEL' ? '⛴' : '◆'} + + + {c.targetName || c.targetMmsi} + +
+
+
+
+ {pct}% +
+
+ ); + })} + {defaultItems.length > 8 && ( +
+{defaultItems.length - 8}건 더
+ )} +
+ ); + })()} {selectedGearGroup === name && correlationLoading && (
연관 분석 로딩...
)}