import { useMemo } from 'react'; import type { GeoJSON } from 'geojson'; import type { Ship, VesselAnalysisDto } from '../../types'; import type { GearCorrelationItem, CorrelationVesselTrack, GroupPolygonDto } from '../../services/vesselAnalysis'; import type { UseGroupPolygonsResult } from '../../hooks/useGroupPolygons'; import type { FleetListItem } from './fleetClusterTypes'; import { buildInterpPolygon } from './fleetClusterUtils'; import { MODEL_ORDER, MODEL_COLORS } from './fleetClusterConstants'; export interface UseFleetClusterGeoJsonParams { ships: Ship[]; shipMap: Map; groupPolygons: UseGroupPolygonsResult | undefined; analysisMap: Map; hoveredFleetId: number | null; selectedGearGroup: string | null; pickerHoveredGroup: string | null; historyActive: boolean; correlationData: GearCorrelationItem[]; correlationTracks: CorrelationVesselTrack[]; enabledModels: Set; enabledVessels: Set; hoveredMmsi: string | null; } export interface FleetClusterGeoJsonResult { // static/base GeoJSON fleetPolygonGeoJSON: GeoJSON; lineGeoJSON: GeoJSON; hoveredGeoJSON: GeoJSON; gearClusterGeoJson: GeoJSON; memberMarkersGeoJson: GeoJSON; pickerHighlightGeoJson: GeoJSON; selectedGearHighlightGeoJson: GeoJSON.FeatureCollection | null; // correlation GeoJSON correlationVesselGeoJson: GeoJSON; correlationTrailGeoJson: GeoJSON; modelBadgesGeoJson: GeoJSON; hoverHighlightGeoJson: GeoJSON; hoverHighlightTrailGeoJson: GeoJSON; // operational polygons (per model) operationalPolygons: { modelName: string; color: string; geojson: GeoJSON.FeatureCollection }[]; // derived values fleetList: FleetListItem[]; correlationByModel: Map; availableModels: { name: string; count: number; isDefault: boolean }[]; } const EMPTY_FC: GeoJSON = { type: 'FeatureCollection', features: [] }; // 선단 색상: 바다색(짙은파랑)과 대비되는 밝은 파스텔 팔레트 (clusterId 해시) const FLEET_PALETTE = [ '#e879f9', '#a78bfa', '#67e8f9', '#34d399', '#fbbf24', '#fb923c', '#f87171', '#a3e635', '#38bdf8', '#c084fc', ]; /** 같은 groupKey의 모든 서브클러스터에서 멤버를 합산 (중복 mmsi 제거) */ function mergeSubClusterMembers(groups: GroupPolygonDto[], groupKey: string) { const matches = groups.filter(g => g.groupKey === groupKey); if (matches.length === 0) return { members: [] as GroupPolygonDto['members'], groups: matches }; const seen = new Set(); const members: GroupPolygonDto['members'] = []; for (const g of matches) { for (const m of g.members) { if (!seen.has(m.mmsi)) { seen.add(m.mmsi); members.push(m); } } } return { members, groups: matches }; } export function useFleetClusterGeoJson(params: UseFleetClusterGeoJsonParams): FleetClusterGeoJsonResult { const { ships, shipMap, groupPolygons, hoveredFleetId, selectedGearGroup, pickerHoveredGroup, historyActive, correlationData, correlationTracks, enabledModels, enabledVessels, hoveredMmsi, } = params; // ── 선단 폴리곤 GeoJSON (서버 제공) ── const fleetPolygonGeoJSON = useMemo((): GeoJSON => { const features: GeoJSON.Feature[] = []; if (!groupPolygons) return { type: 'FeatureCollection', features }; for (const g of groupPolygons.fleetGroups) { if (!g.polygon) continue; const cid = Number(g.groupKey); const color = FLEET_PALETTE[cid % FLEET_PALETTE.length]; features.push({ type: 'Feature', properties: { clusterId: cid, color }, geometry: g.polygon, }); } return { type: 'FeatureCollection', features }; }, [groupPolygons]); // 2척 선단 라인은 서버에서 Shapely buffer로 이미 Polygon 처리되므로 빈 컬렉션 const lineGeoJSON = useMemo((): GeoJSON => ({ type: 'FeatureCollection', features: [], }), []); // 호버 하이라이트용 단일 폴리곤 const hoveredGeoJSON = useMemo((): GeoJSON => { if (hoveredFleetId === null || !groupPolygons) return EMPTY_FC; const g = groupPolygons.fleetGroups.find(f => Number(f.groupKey) === hoveredFleetId); if (!g?.polygon) return EMPTY_FC; return { type: 'FeatureCollection', features: [{ type: 'Feature', properties: { clusterId: hoveredFleetId, color: FLEET_PALETTE[hoveredFleetId % FLEET_PALETTE.length] }, geometry: g.polygon, }], }; }, [hoveredFleetId, groupPolygons]); // 모델별 연관성 데이터 그룹핑 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]); // 오퍼레이셔널 폴리곤 (비재생 정적 연산 — 서브클러스터별 분리, subClusterId 기반) const operationalPolygons = useMemo(() => { if (!selectedGearGroup || !groupPolygons) return []; // 서브클러스터별 개별 그룹 (allGroups = raw, 서브클러스터 분리 유지) const rawMatches = groupPolygons.allGroups.filter( g => g.groupKey === selectedGearGroup && g.groupType !== 'FLEET', ); if (rawMatches.length === 0) return []; // 서브클러스터별 basePts const subMap = new Map(); for (const g of rawMatches) { const sid = g.subClusterId ?? 0; subMap.set(sid, g.members.map(m => [m.lon, m.lat])); } const result: { modelName: string; color: string; geojson: GeoJSON.FeatureCollection }[] = []; for (const [mn, items] of correlationByModel) { if (!enabledModels.has(mn)) continue; const color = MODEL_COLORS[mn] ?? '#94a3b8'; // 연관 선박을 subClusterId로 그룹핑 const subExtras = new Map(); for (const c of items) { if (c.score < 0.7) continue; const s = ships.find(x => x.mmsi === c.targetMmsi); if (!s) continue; const sid = c.subClusterId ?? 0; const list = subExtras.get(sid) ?? []; list.push([s.lng, s.lat]); subExtras.set(sid, list); } const features: GeoJSON.Feature[] = []; for (const [sid, extraPts] of subExtras) { if (extraPts.length === 0) continue; const basePts = subMap.get(sid) ?? subMap.get(0) ?? []; const polygon = buildInterpPolygon([...basePts, ...extraPts]); if (polygon) features.push({ type: 'Feature', properties: { modelName: mn, color, subClusterId: sid }, geometry: polygon }); } if (features.length > 0) { result.push({ modelName: mn, color, geojson: { type: 'FeatureCollection', features } }); } } return result; }, [selectedGearGroup, groupPolygons, correlationByModel, enabledModels, ships]); // 어구 클러스터 GeoJSON — allGroups에서 직접 (서브클러스터별 개별 폴리곤 유지) const gearClusterGeoJson = useMemo((): GeoJSON => { const features: GeoJSON.Feature[] = []; if (!groupPolygons) return { type: 'FeatureCollection', features }; for (const g of groupPolygons.allGroups.filter(x => x.groupType !== 'FLEET')) { if (!g.polygon) continue; features.push({ type: 'Feature', properties: { name: g.groupKey, gearCount: g.memberCount, inZone: g.groupType === 'GEAR_IN_ZONE' ? 1 : 0, }, geometry: g.polygon, }); } return { type: 'FeatureCollection', features }; }, [groupPolygons]); // 가상 선박 마커 GeoJSON (API members + shipMap heading 보정) const memberMarkersGeoJson = useMemo((): GeoJSON => { const features: GeoJSON.Feature[] = []; if (!groupPolygons) return { type: 'FeatureCollection', features }; const addMember = ( m: { mmsi: string; name: string; lat: number; lon: number; cog: number; isParent: boolean; role: string }, groupKey: string, groupType: string, color: string, ) => { const realShip = shipMap.get(m.mmsi); const heading = realShip?.heading ?? m.cog ?? 0; const lat = realShip?.lat ?? m.lat; const lon = realShip?.lng ?? m.lon; features.push({ type: 'Feature', properties: { mmsi: m.mmsi, name: m.name, groupKey, groupType, role: m.role, isParent: m.isParent ? 1 : 0, isGear: (groupType !== 'FLEET' && !m.isParent) ? 1 : 0, color, cog: heading, baseSize: (groupType !== 'FLEET' && !m.isParent) ? 0.11 : m.isParent ? 0.18 : 0.14, }, geometry: { type: 'Point', coordinates: [lon, lat] }, }); }; for (const g of groupPolygons.fleetGroups) { const cid = Number(g.groupKey); const fleetColor = FLEET_PALETTE[cid % FLEET_PALETTE.length]; for (const m of g.members) addMember(m, g.groupKey, 'FLEET', fleetColor); } for (const g of [...groupPolygons.gearInZoneGroups, ...groupPolygons.gearOutZoneGroups]) { const color = g.groupType === 'GEAR_IN_ZONE' ? '#dc2626' : '#f97316'; for (const m of g.members) addMember(m, g.groupKey, g.groupType, color); } return { type: 'FeatureCollection', features }; }, [groupPolygons, shipMap]); // picker 호버 하이라이트 (선단 + 어구 통합) const pickerHighlightGeoJson = useMemo((): GeoJSON => { if (!pickerHoveredGroup || !groupPolygons) return EMPTY_FC; const fleet = groupPolygons.fleetGroups.find(x => String(x.groupKey) === pickerHoveredGroup); if (fleet?.polygon) return { type: 'FeatureCollection', features: [{ type: 'Feature', properties: {}, geometry: fleet.polygon }] }; const all = [...groupPolygons.gearInZoneGroups, ...groupPolygons.gearOutZoneGroups]; const g = all.find(x => x.groupKey === pickerHoveredGroup); if (!g?.polygon) return EMPTY_FC; return { type: 'FeatureCollection', features: [{ type: 'Feature', properties: {}, geometry: g.polygon }] }; }, [pickerHoveredGroup, groupPolygons]); // 선택된 어구 그룹 하이라이트 폴리곤 const selectedGearHighlightGeoJson = useMemo((): GeoJSON.FeatureCollection | null => { if (!selectedGearGroup || !enabledModels.has('identity') || historyActive) return null; const allGroups = groupPolygons ? [...groupPolygons.gearInZoneGroups, ...groupPolygons.gearOutZoneGroups] : []; const matches = allGroups.filter(g => g.groupKey === selectedGearGroup && g.polygon); if (matches.length === 0) return null; return { type: 'FeatureCollection', features: matches.map(g => ({ type: 'Feature' as const, properties: { subClusterId: g.subClusterId }, geometry: g.polygon!, })), }; }, [selectedGearGroup, enabledModels, historyActive, groupPolygons]); // ── 연관 대상 마커 (ships[] fallback) ── const correlationVesselGeoJson = useMemo((): GeoJSON => { if (!selectedGearGroup || correlationByModel.size === 0) return EMPTY_FC; const features: GeoJSON.Feature[] = []; const seen = new Set(); for (const [mn, items] of correlationByModel) { if (!enabledModels.has(mn)) continue; const color = MODEL_COLORS[mn] ?? '#94a3b8'; for (const c of items) { if (seen.has(c.targetMmsi)) continue; const s = ships.find(x => x.mmsi === c.targetMmsi); if (!s) continue; seen.add(c.targetMmsi); features.push({ type: 'Feature', properties: { mmsi: c.targetMmsi, name: c.targetName || c.targetMmsi, score: c.score, cog: s.course ?? 0, color, isVessel: c.targetType === 'VESSEL' ? 1 : 0, }, geometry: { type: 'Point', coordinates: [s.lng, s.lat] }, }); } } return { type: 'FeatureCollection', features }; }, [selectedGearGroup, correlationByModel, enabledModels, ships]); // 연관 대상 트레일 (전체 항적) const correlationTrailGeoJson = useMemo((): GeoJSON => { if (correlationTracks.length === 0) return EMPTY_FC; const features: GeoJSON.Feature[] = []; const vesselColor = new Map(); for (const [mn, items] of correlationByModel) { if (!enabledModels.has(mn)) continue; for (const c of items) { if (!vesselColor.has(c.targetMmsi)) vesselColor.set(c.targetMmsi, MODEL_COLORS[mn] ?? '#60a5fa'); } } for (const vt of correlationTracks) { if (!enabledVessels.has(vt.mmsi)) continue; const color = vesselColor.get(vt.mmsi) ?? '#60a5fa'; const coords: [number, number][] = vt.track.map(pt => [pt.lon, pt.lat]); if (coords.length >= 2) { features.push({ type: 'Feature', properties: { mmsi: vt.mmsi, color }, geometry: { type: 'LineString', coordinates: coords } }); } } return { type: 'FeatureCollection', features }; }, [correlationTracks, enabledVessels, correlationByModel, enabledModels]); // 모델 배지 GeoJSON (groupPolygons 기반) const modelBadgesGeoJson = useMemo((): GeoJSON => { if (!selectedGearGroup) return EMPTY_FC; const targets = new Map }>(); if (enabledModels.has('identity') && groupPolygons) { const all = [...groupPolygons.gearInZoneGroups, ...groupPolygons.gearOutZoneGroups]; const { members } = mergeSubClusterMembers(all, selectedGearGroup); for (const m of members) { const e = targets.get(m.mmsi) ?? { lon: m.lon, lat: m.lat, models: new Set() }; e.lon = m.lon; e.lat = m.lat; e.models.add('identity'); targets.set(m.mmsi, e); } } for (const [mn, items] of correlationByModel) { if (!enabledModels.has(mn)) continue; for (const c of items) { if (c.score < 0.3) continue; const s = ships.find(x => x.mmsi === c.targetMmsi); if (!s) continue; const e = targets.get(c.targetMmsi) ?? { lon: s.lng, lat: s.lat, models: new Set() }; e.lon = s.lng; e.lat = s.lat; e.models.add(mn); targets.set(c.targetMmsi, e); } } const features: GeoJSON.Feature[] = []; for (const [mmsi, t] of targets) { if (t.models.size === 0) continue; const props: Record = { mmsi }; for (let i = 0; i < MODEL_ORDER.length; i++) props[`m${i}`] = t.models.has(MODEL_ORDER[i]) ? 1 : 0; features.push({ type: 'Feature', properties: props, geometry: { type: 'Point', coordinates: [t.lon, t.lat] } }); } return { type: 'FeatureCollection', features }; }, [selectedGearGroup, enabledModels, groupPolygons, correlationByModel, ships]); // 호버 하이라이트 — 대상 위치 const hoverHighlightGeoJson = useMemo((): GeoJSON => { if (!hoveredMmsi || !selectedGearGroup) return EMPTY_FC; if (groupPolygons) { const all = [...groupPolygons.gearInZoneGroups, ...groupPolygons.gearOutZoneGroups]; const { members: allMembers } = mergeSubClusterMembers(all, selectedGearGroup); const m = allMembers.find(x => x.mmsi === hoveredMmsi); if (m) return { type: 'FeatureCollection', features: [{ type: 'Feature', properties: {}, geometry: { type: 'Point', coordinates: [m.lon, m.lat] } }] }; } const s = ships.find(x => x.mmsi === hoveredMmsi); if (s) return { type: 'FeatureCollection', features: [{ type: 'Feature', properties: {}, geometry: { type: 'Point', coordinates: [s.lng, s.lat] } }] }; return EMPTY_FC; }, [hoveredMmsi, selectedGearGroup, groupPolygons, ships]); // 호버 하이라이트 — 대상 항적 const hoverHighlightTrailGeoJson = useMemo((): GeoJSON => { if (!hoveredMmsi) return EMPTY_FC; const vt = correlationTracks.find(v => v.mmsi === hoveredMmsi); if (!vt) return EMPTY_FC; const coords: [number, number][] = vt.track.map(pt => [pt.lon, pt.lat]); if (coords.length < 2) return EMPTY_FC; return { type: 'FeatureCollection', features: [{ type: 'Feature', properties: {}, geometry: { type: 'LineString', coordinates: coords } }] }; }, [hoveredMmsi, correlationTracks]); // 선단 목록 (멤버 수 내림차순) const fleetList = useMemo((): FleetListItem[] => { if (!groupPolygons) return []; return groupPolygons.fleetGroups.map(g => ({ id: Number(g.groupKey), mmsiList: g.members.map(m => m.mmsi), label: g.groupLabel, memberCount: g.memberCount, areaSqNm: g.areaSqNm, color: FLEET_PALETTE[Number(g.groupKey) % FLEET_PALETTE.length], members: g.members, })).sort((a, b) => b.memberCount - a.memberCount); }, [groupPolygons]); return { fleetPolygonGeoJSON, lineGeoJSON, hoveredGeoJSON, gearClusterGeoJson, memberMarkersGeoJson, pickerHighlightGeoJson, selectedGearHighlightGeoJson, correlationVesselGeoJson, correlationTrailGeoJson, modelBadgesGeoJson, hoverHighlightGeoJson, hoverHighlightTrailGeoJson, operationalPolygons, fleetList, correlationByModel, availableModels, }; }