diff --git a/frontend/src/components/korea/FleetClusterLayer.tsx b/frontend/src/components/korea/FleetClusterLayer.tsx index 4cfced1..c5e5a29 100644 --- a/frontend/src/components/korea/FleetClusterLayer.tsx +++ b/frontend/src/components/korea/FleetClusterLayer.tsx @@ -6,8 +6,7 @@ import type { MapLayerMouseEvent } from 'maplibre-gl'; import type { Ship, VesselAnalysisDto } from '../../types'; import { fetchFleetCompanies } from '../../services/vesselAnalysis'; import type { FleetCompany } from '../../services/vesselAnalysis'; -import { classifyFishingZone } from '../../utils/fishingAnalysis'; -import { convexHull, padPolygon, clusterColor } from '../../utils/geometry'; +import type { UseGroupPolygonsResult } from '../../hooks/useGroupPolygons'; export interface SelectedGearGroupData { parent: Ship | null; @@ -29,31 +28,13 @@ interface Props { onFleetZoom?: (bounds: { minLat: number; maxLat: number; minLng: number; maxLng: number }) => void; onSelectedGearChange?: (data: SelectedGearGroupData | null) => void; onSelectedFleetChange?: (data: SelectedFleetData | null) => void; + groupPolygons?: UseGroupPolygonsResult; } -// GeoJSON feature에 color 속성으로 주입 -interface ClusterPolygonFeature { - type: 'Feature'; - id: number; - properties: { clusterId: number; color: string }; - geometry: { type: 'Polygon'; coordinates: [number, number][][] }; -} - -interface ClusterLineFeature { - type: 'Feature'; - id: number; - properties: { clusterId: number; color: string }; - geometry: { type: 'LineString'; coordinates: [number, number][] }; -} - -type ClusterFeature = ClusterPolygonFeature | ClusterLineFeature; - const EMPTY_ANALYSIS = new globalThis.Map(); -const EMPTY_CLUSTERS = new globalThis.Map(); -export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, clusters: clustersProp, onShipSelect, onFleetZoom, onSelectedGearChange, onSelectedFleetChange }: Props) { +export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipSelect, onFleetZoom, onSelectedGearChange, onSelectedFleetChange, groupPolygons }: Props) { const analysisMap = analysisMapProp ?? EMPTY_ANALYSIS; - const clusters = clustersProp ?? EMPTY_CLUSTERS; const [companies, setCompanies] = useState>(new Map()); const [expandedFleet, setExpandedFleet] = useState(null); const [sectionExpanded, setSectionExpanded] = useState>({ @@ -67,8 +48,7 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, cluster const [hoverTooltip, setHoverTooltip] = useState<{ lng: number; lat: number; type: 'fleet' | 'gear'; id: number | string } | null>(null); const { current: mapRef } = useMap(); const registeredRef = useRef(false); - // dataRef는 shipMap/gearGroupMap 선언 이후에 갱신 (아래 참조) - const dataRef = useRef<{ clusters: Map; shipMap: Map; gearGroupMap: Map; onFleetZoom: Props['onFleetZoom'] }>({ clusters, shipMap: new Map(), gearGroupMap: new Map(), onFleetZoom }); + const dataRef = useRef<{ shipMap: Map; groupPolygons: UseGroupPolygonsResult | undefined; onFleetZoom: Props['onFleetZoom'] }>({ shipMap: new Map(), groupPolygons, onFleetZoom }); useEffect(() => { fetchFleetCompanies().then(setCompanies).catch(() => {}); @@ -107,17 +87,15 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, cluster if (cid == null) return; const d = dataRef.current; setExpandedFleet(prev => prev === cid ? null : cid); - setExpanded(true); - const mmsiList = d.clusters.get(cid) ?? []; - if (mmsiList.length === 0) return; + setSectionExpanded(prev => ({ ...prev, fleet: true })); + const group = d.groupPolygons?.fleetGroups.find(g => Number(g.groupKey) === cid); + if (!group || group.members.length === 0) return; let minLat = Infinity, maxLat = -Infinity, minLng = Infinity, maxLng = -Infinity; - for (const mmsi of mmsiList) { - const ship = d.shipMap.get(mmsi); - if (!ship) continue; - if (ship.lat < minLat) minLat = ship.lat; - if (ship.lat > maxLat) maxLat = ship.lat; - if (ship.lng < minLng) minLng = ship.lng; - if (ship.lng > maxLng) maxLng = ship.lng; + 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) d.onFleetZoom?.({ minLat, maxLat, minLng, maxLng }); }; @@ -147,16 +125,17 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, cluster requestAnimationFrame(() => { document.getElementById(`gear-row-${name}`)?.scrollIntoView({ behavior: 'smooth', block: 'start' }); }); - const entry = d.gearGroupMap.get(name); - if (!entry) return; - const all: Ship[] = [...entry.gears]; - if (entry.parent) all.push(entry.parent); + const allGroups = d.groupPolygons + ? [...d.groupPolygons.gearInZoneGroups, ...d.groupPolygons.gearOutZoneGroups] + : []; + const group = allGroups.find(g => g.groupKey === name); + if (!group || group.members.length === 0) return; let minLat = Infinity, maxLat = -Infinity, minLng = Infinity, maxLng = -Infinity; - for (const s of all) { - if (s.lat < minLat) minLat = s.lat; - if (s.lat > maxLat) maxLat = s.lat; - if (s.lng < minLng) minLng = s.lng; - if (s.lng > maxLng) maxLng = s.lng; + 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) d.onFleetZoom?.({ minLat, maxLat, minLng, maxLng }); }; @@ -188,29 +167,6 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, cluster } }, [mapRef]); - // 선박명 → mmsi 맵 (어구 매칭용) - const gearsByParent = useMemo(() => { - const map = new Map(); // parent_mmsi → gears - const gearPattern = /^(.+?)_\d+_\d*$/; - const parentNames = new Map(); // name → mmsi - for (const s of ships) { - if (s.name && !gearPattern.test(s.name)) { - parentNames.set(s.name.trim(), s.mmsi); - } - } - for (const s of ships) { - const m = s.name?.match(gearPattern); - if (!m) continue; - const parentMmsi = parentNames.get(m[1].trim()); - if (parentMmsi) { - const arr = map.get(parentMmsi) ?? []; - arr.push(s); - map.set(parentMmsi, arr); - } - } - return map; - }, [ships]); - // ships map (mmsi → Ship) const shipMap = useMemo(() => { const m = new Map(); @@ -218,56 +174,8 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, cluster return m; }, [ships]); - // 비허가 어구 클러스터: parentName → { parent: Ship | null, gears: Ship[] } - const gearGroupMap = useMemo(() => { - const gearPattern = /^(.+?)_\d+_\d+_?$/; - const MAX_DIST_DEG = 0.15; // ~10NM - const STALE_MS = 60 * 60_000; - const now = Date.now(); - - const nameToShip = new Map(); - for (const s of ships) { - const nm = (s.name || '').trim(); - if (nm && !gearPattern.test(nm)) { - nameToShip.set(nm, s); - } - } - - // 1단계: 같은 모선명 어구 수집 (60분 이내만) - const rawGroups = new Map(); - for (const s of ships) { - if (now - s.lastSeen > STALE_MS) continue; - const m = (s.name || '').match(gearPattern); - if (!m) continue; - const parentName = m[1].trim(); - const arr = rawGroups.get(parentName) ?? []; - arr.push(s); - rawGroups.set(parentName, arr); - } - - // 2단계: 거리 기반 서브 클러스터링 (같은 이름이라도 멀면 분리) - const map = new Map(); - for (const [parentName, gears] of rawGroups) { - const parent = nameToShip.get(parentName) ?? null; - - // 기준점: 모선 있으면 모선 위치, 없으면 첫 어구 - const anchor = parent ?? gears[0]; - if (!anchor) continue; - - const nearby = gears.filter(g => { - const dlat = Math.abs(g.lat - anchor.lat); - const dlng = Math.abs(g.lng - anchor.lng); - return dlat <= MAX_DIST_DEG && dlng <= MAX_DIST_DEG; - }); - - if (nearby.length === 0) continue; - map.set(parentName, { parent, gears: nearby }); - } - return map; - }, [ships]); - - // stale closure 방지 — shipMap/gearGroupMap 선언 이후 갱신 - dataRef.current = { clusters, shipMap, gearGroupMap, onFleetZoom }; + // stale closure 방지 + dataRef.current = { shipMap, groupPolygons, onFleetZoom }; // 선택된 어구 그룹 데이터를 부모에 전달 (deck.gl 렌더링용) useEffect(() => { @@ -275,13 +183,33 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, cluster onSelectedGearChange?.(null); return; } - const entry = gearGroupMap.get(selectedGearGroup); - if (entry) { - onSelectedGearChange?.({ parent: entry.parent, gears: entry.gears, groupName: selectedGearGroup }); - } else { + const allGroups = groupPolygons + ? [...groupPolygons.gearInZoneGroups, ...groupPolygons.gearOutZoneGroups] + : []; + const group = allGroups.find(g => g.groupKey === selectedGearGroup); + if (!group) { onSelectedGearChange?.(null); + return; } - }, [selectedGearGroup, gearGroupMap, onSelectedGearChange]); + const parent = group.members.find(m => m.isParent); + const gears = group.members.filter(m => !m.isParent); + const toShip = (m: typeof group.members[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]); // 선택된 선단 데이터를 부모에 전달 (deck.gl 강조 렌더링용) useEffect(() => { @@ -289,64 +217,115 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, cluster onSelectedFleetChange?.(null); return; } - const mmsiList = clusters.get(expandedFleet) ?? []; - const fleetShips = mmsiList.map(mmsi => shipMap.get(mmsi)).filter((s): s is Ship => !!s); + 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 || `선단 #${expandedFleet}`, + companyName: company?.nameCn || group.groupLabel || `선단 #${expandedFleet}`, }); - }, [expandedFleet, clusters, shipMap, companies, onSelectedFleetChange]); + }, [expandedFleet, groupPolygons, companies, onSelectedFleetChange]); - // 어구 그룹을 수역 내/외로 분류 - const { inZoneGearGroups, outZoneGearGroups } = useMemo(() => { - const inZone: { name: string; parent: Ship | null; gears: Ship[]; zone: string }[] = []; - const outZone: { name: string; parent: Ship | null; gears: Ship[] }[] = []; - for (const [name, { parent, gears }] of gearGroupMap) { - const anchor = parent ?? gears[0]; - if (!anchor) { - // 비허가 어구: 2개 이상일 때만 그룹으로 탐지 - if (gears.length >= 2) outZone.push({ name, parent, gears }); - continue; - } - const zoneInfo = classifyFishingZone(anchor.lat, anchor.lng); - if (zoneInfo.zone !== 'OUTSIDE') { - inZone.push({ name, parent, gears, zone: zoneInfo.name }); - } else { - // 비허가 어구: 2개 이상일 때만 그룹으로 탐지 - if (gears.length >= 2) outZone.push({ name, parent, gears }); - } - } - inZone.sort((a, b) => b.gears.length - a.gears.length); - outZone.sort((a, b) => b.gears.length - a.gears.length); - return { inZoneGearGroups: inZone, outZoneGearGroups: outZone }; - }, [gearGroupMap]); + // API 기반 어구 그룹 분류 + const inZoneGearGroups = groupPolygons?.gearInZoneGroups ?? []; + const outZoneGearGroups = groupPolygons?.gearOutZoneGroups ?? []; - // 어구 클러스터 GeoJSON (수역 내: 붉은색, 수역 외: 오렌지) - // 비허가 어구(outZone)는 2개 이상만 폴리곤 생성 - const gearClusterGeoJson = useMemo((): GeoJSON => { - const inZoneNames = new Set(inZoneGearGroups.map(g => g.name)); - const outZoneNames = new Set(outZoneGearGroups.map(g => g.name)); + // 선단 폴리곤 GeoJSON (서버 제공) + const fleetPolygonGeoJSON = useMemo((): GeoJSON => { const features: GeoJSON.Feature[] = []; - for (const [parentName, { parent, gears }] of gearGroupMap) { - // 비허가(outZone) 1개짜리는 폴리곤에서 제외 - const isInZone = inZoneNames.has(parentName); - if (!isInZone && !outZoneNames.has(parentName)) continue; - const points: [number, number][] = gears.map(g => [g.lng, g.lat]); - if (parent) points.push([parent.lng, parent.lat]); - if (points.length < 3) continue; - const hull = convexHull(points); - const padded = padPolygon(hull, 0.01); - padded.push(padded[0]); + if (!groupPolygons) return { type: 'FeatureCollection', features }; + for (const g of groupPolygons.fleetGroups) { + if (!g.polygon) continue; features.push({ type: 'Feature', - properties: { name: parentName, gearCount: gears.length, inZone: isInZone ? 1 : 0 }, - geometry: { type: 'Polygon', coordinates: [padded] }, + properties: { clusterId: Number(g.groupKey), color: g.color }, + geometry: g.polygon, }); } return { type: 'FeatureCollection', features }; - }, [gearGroupMap, inZoneGearGroups, outZoneGearGroups]); + }, [groupPolygons?.fleetGroups]); + + // 2척 선단 라인은 서버에서 Shapely buffer로 이미 Polygon 처리되므로 빈 컬렉션 + const lineGeoJSON = useMemo((): GeoJSON => ({ + type: 'FeatureCollection', features: [], + }), []); + + // 호버 하이라이트용 단일 폴리곤 + const hoveredGeoJSON = useMemo((): GeoJSON => { + if (hoveredFleetId === null || !groupPolygons) return { type: 'FeatureCollection', features: [] }; + const g = groupPolygons.fleetGroups.find(f => Number(f.groupKey) === hoveredFleetId); + if (!g?.polygon) return { type: 'FeatureCollection', features: [] }; + return { + type: 'FeatureCollection', + features: [{ + type: 'Feature', + properties: { clusterId: hoveredFleetId, color: g.color }, + geometry: g.polygon, + }], + }; + }, [hoveredFleetId, groupPolygons?.fleetGroups]); + + // 어구 클러스터 GeoJSON (서버 제공) + const gearClusterGeoJson = useMemo((): GeoJSON => { + const features: GeoJSON.Feature[] = []; + if (!groupPolygons) return { type: 'FeatureCollection', features }; + for (const g of [...groupPolygons.gearInZoneGroups, ...groupPolygons.gearOutZoneGroups]) { + 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?.gearInZoneGroups, groupPolygons?.gearOutZoneGroups]); + + // 선단 목록 (멤버 수 내림차순) + const fleetList = useMemo(() => { + 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: g.color, + members: g.members, + })).sort((a, b) => b.memberCount - a.memberCount); + }, [groupPolygons?.fleetGroups]); + + 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 handleGearGroupZoom = useCallback((parentName: string) => { setSelectedGearGroup(prev => prev === parentName ? null : parentName); @@ -354,98 +333,21 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, cluster requestAnimationFrame(() => { document.getElementById(`gear-row-${parentName}`)?.scrollIntoView({ behavior: 'smooth', block: 'start' }); }); - const entry = gearGroupMap.get(parentName); - if (!entry) return; - const all: Ship[] = [...entry.gears]; - if (entry.parent) all.push(entry.parent); - if (all.length === 0) return; + const allGroups = groupPolygons + ? [...groupPolygons.gearInZoneGroups, ...groupPolygons.gearOutZoneGroups] + : []; + const group = allGroups.find(g => g.groupKey === parentName); + if (!group || group.members.length === 0) return; let minLat = Infinity, maxLat = -Infinity, minLng = Infinity, maxLng = -Infinity; - for (const s of all) { - if (s.lat < minLat) minLat = s.lat; - if (s.lat > maxLat) maxLat = s.lat; - if (s.lng < minLng) minLng = s.lng; - if (s.lng > maxLng) maxLng = s.lng; + 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 }); - }, [gearGroupMap, onFleetZoom]); - - // GeoJSON 피처 생성 - const polygonFeatures = useMemo((): ClusterFeature[] => { - const features: ClusterFeature[] = []; - for (const [clusterId, mmsiList] of clusters) { - const points: [number, number][] = []; - for (const mmsi of mmsiList) { - const ship = shipMap.get(mmsi); - if (ship) points.push([ship.lng, ship.lat]); - } - if (points.length < 2) continue; - - const color = clusterColor(clusterId); - - if (points.length === 2) { - features.push({ - type: 'Feature', - id: clusterId, - properties: { clusterId, color }, - geometry: { type: 'LineString', coordinates: points }, - }); - continue; - } - - const hull = convexHull(points); - const padded = padPolygon(hull, 0.02); - // 폴리곤 닫기 - const ring = [...padded, padded[0]]; - features.push({ - type: 'Feature', - id: clusterId, - properties: { clusterId, color }, - geometry: { type: 'Polygon', coordinates: [ring] }, - }); - } - return features; - }, [clusters, shipMap]); - - const polygonGeoJSON = useMemo((): GeoJSON => ({ - type: 'FeatureCollection', - features: polygonFeatures.filter(f => f.geometry.type === 'Polygon'), - }), [polygonFeatures]); - - const lineGeoJSON = useMemo((): GeoJSON => ({ - type: 'FeatureCollection', - features: polygonFeatures.filter(f => f.geometry.type === 'LineString'), - }), [polygonFeatures]); - - // 호버 하이라이트용 단일 폴리곤 - const hoveredGeoJSON = useMemo((): GeoJSON => { - if (hoveredFleetId === null) return { type: 'FeatureCollection', features: [] }; - const f = polygonFeatures.find(p => p.properties.clusterId === hoveredFleetId && p.geometry.type === 'Polygon'); - if (!f) return { type: 'FeatureCollection', features: [] }; - return { type: 'FeatureCollection', features: [f] }; - }, [hoveredFleetId, polygonFeatures]); - - const handleFleetZoom = useCallback((clusterId: number) => { - const mmsiList = clusters.get(clusterId) ?? []; - if (mmsiList.length === 0) return; - let minLat = Infinity, maxLat = -Infinity, minLng = Infinity, maxLng = -Infinity; - for (const mmsi of mmsiList) { - const ship = shipMap.get(mmsi); - if (!ship) continue; - if (ship.lat < minLat) minLat = ship.lat; - if (ship.lat > maxLat) maxLat = ship.lat; - if (ship.lng < minLng) minLng = ship.lng; - if (ship.lng > maxLng) maxLng = ship.lng; - } - if (minLat === Infinity) return; - onFleetZoom?.({ minLat, maxLat, minLng, maxLng }); - }, [clusters, shipMap, onFleetZoom]); - - const fleetList = useMemo(() => { - return Array.from(clusters.entries()) - .map(([id, mmsiList]) => ({ id, mmsiList })) - .sort((a, b) => b.mmsiList.length - a.mmsiList.length); - }, [clusters]); + }, [groupPolygons, onFleetZoom]); // 패널 스타일 (AnalysisStatsPanel 패턴) const panelStyle: React.CSSProperties = { @@ -492,7 +394,7 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, cluster return ( <> {/* 선단 폴리곤 레이어 */} - + - {/* 2척 선단 라인 */} + {/* 2척 선단 라인 (서버측 Polygon 처리로 빈 컬렉션) */} - {/* 선택된 어구 그룹 하이라이트 폴리곤 (deck.gl에서 어구 아이콘 + 모선 마커 표시) */} + {/* 선택된 어구 그룹 하이라이트 폴리곤 */} {selectedGearGroup && (() => { - const entry = gearGroupMap.get(selectedGearGroup); - if (!entry) return null; - const points: [number, number][] = entry.gears.map(g => [g.lng, g.lat]); - if (entry.parent) points.push([entry.parent.lng, entry.parent.lat]); - - const hlFeatures: GeoJSON.Feature[] = []; - if (points.length >= 3) { - const hull = convexHull(points); - const padded = padPolygon(hull, 0.01); - padded.push(padded[0]); - hlFeatures.push({ + const allGroups = groupPolygons + ? [...groupPolygons.gearInZoneGroups, ...groupPolygons.gearOutZoneGroups] + : []; + const group = allGroups.find(g => g.groupKey === selectedGearGroup); + if (!group?.polygon) return null; + const hlGeoJson: GeoJSON.FeatureCollection = { + type: 'FeatureCollection', + features: [{ type: 'Feature', properties: {}, - geometry: { type: 'Polygon', coordinates: [padded] }, - }); - } - if (hlFeatures.length === 0) return null; - const hlGeoJson: GeoJSON.FeatureCollection = { type: 'FeatureCollection', features: hlFeatures }; - + geometry: group.polygon, + }], + }; return ( @@ -592,28 +488,27 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, cluster {hoverTooltip && (() => { if (hoverTooltip.type === 'fleet') { const cid = hoverTooltip.id as number; - const mmsiList = clusters.get(cid) ?? []; + const group = groupPolygons?.fleetGroups.find(g => Number(g.groupKey) === cid); const company = companies.get(cid); - const gearCount = mmsiList.reduce((acc, mmsi) => acc + (gearsByParent.get(mmsi)?.length ?? 0), 0); + const memberCount = group?.memberCount ?? 0; return (
-
- {company?.nameCn || `선단 #${cid}`} +
+ {company?.nameCn || group?.groupLabel || `선단 #${cid}`}
-
선박 {mmsiList.length}척 · 어구 {gearCount}개
- {expandedFleet === cid && mmsiList.slice(0, 5).map(mmsi => { - const s = shipMap.get(mmsi); - const dto = analysisMap.get(mmsi); - const role = dto?.algorithms.fleetRole.role ?? ''; - return s ? ( -
- {role === 'LEADER' ? '★' : '·'} {s.name || mmsi} {s.speed?.toFixed(1)}kt +
선박 {memberCount}척
+ {expandedFleet === cid && group?.members.slice(0, 5).map(m => { + const dto = analysisMap.get(m.mmsi); + const role = dto?.algorithms.fleetRole.role ?? m.role; + return ( +
+ {role === 'LEADER' ? '★' : '·'} {m.name || m.mmsi} {m.sog.toFixed(1)}kt
- ) : null; + ); })}
클릭하여 상세 보기
@@ -622,8 +517,13 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, cluster } if (hoverTooltip.type === 'gear') { const name = hoverTooltip.id as string; - const entry = gearGroupMap.get(name); - if (!entry) return null; + const allGroups = groupPolygons + ? [...groupPolygons.gearInZoneGroups, ...groupPolygons.gearOutZoneGroups] + : []; + const group = allGroups.find(g => g.groupKey === name); + if (!group) return null; + const parentMember = group.members.find(m => m.isParent); + const gearMembers = group.members.filter(m => !m.isParent); return (
- {name} 어구 {entry.gears.length}개 + {name} 어구 {gearMembers.length}개
- {entry.parent && ( -
모선: {entry.parent.name || entry.parent.mmsi}
+ {parentMember && ( +
모선: {parentMember.name || parentMember.mmsi}
)} - {selectedGearGroup === name && entry.gears.slice(0, 5).map(g => ( -
- · {g.name || g.mmsi} + {selectedGearGroup === name && gearMembers.slice(0, 5).map(m => ( +
+ · {m.name || m.mmsi}
))}
클릭하여 선택/해제
@@ -657,7 +557,7 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, cluster 선단 현황 ({fleetList.length}개) -
@@ -668,18 +568,17 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, cluster 선단 데이터 없음
) : ( - fleetList.map(({ id, mmsiList }) => { + fleetList.map(({ id, mmsiList, label, color, members }) => { const company = companies.get(id); - const companyName = company?.nameCn ?? `선단 #${id}`; - const color = clusterColor(id); + const companyName = company?.nameCn ?? label ?? `선단 #${id}`; const isOpen = expandedFleet === id; const isHovered = hoveredFleetId === id; - const mainVessels = mmsiList.filter(mmsi => { - const dto = analysisMap.get(mmsi); + const mainMembers = members.filter(m => { + const dto = analysisMap.get(m.mmsi); return dto?.algorithms.fleetRole.role === 'LEADER' || dto?.algorithms.fleetRole.role === 'MEMBER'; }); - const gearCount = mmsiList.reduce((acc, mmsi) => acc + (gearsByParent.get(mmsi)?.length ?? 0), 0); + const displayMembers = mainMembers.length > 0 ? mainMembers : members; return (
@@ -721,7 +620,7 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, cluster whiteSpace: 'nowrap', cursor: 'pointer', }} - title={company ? `${company.nameCn} / ${company.nameEn}` : `선단 #${id}`} + title={company ? `${company.nameCn} / ${company.nameEn}` : companyName} > {companyName} @@ -731,6 +630,7 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, cluster {/* zoom 버튼 */}
); })} - - {/* 어구 목록 */} - {gearCount > 0 && ( - <> -
- 어구: {gearCount}개 -
- {mmsiList.flatMap(mmsi => gearsByParent.get(mmsi) ?? []).map(gear => ( -
- {gear.name || gear.mmsi} -
- ))} - - )}
)}
@@ -838,9 +724,13 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, cluster {sectionExpanded.inZone && (
- {inZoneGearGroups.map(({ name, parent, gears, zone }) => { + {inZoneGearGroups.map(g => { + const name = g.groupKey; const isOpen = expandedGearGroup === name; const accentColor = '#dc2626'; + const parentMember = g.members.find(m => m.isParent); + const gearMembers = g.members.filter(m => !m.isParent); + const zoneName = g.zoneName ?? ''; return (
setExpandedGearGroup(prev => (prev === name ? null : name))} style={{ color: '#94a3b8', fontSize: 9, flexShrink: 0, cursor: 'pointer' }}>{isOpen ? '▾' : '▸'} - setExpandedGearGroup(prev => (prev === name ? null : name))} style={{ flex: 1, color: '#e2e8f0', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', cursor: 'pointer' }} title={`${name} — ${zone}`}>{name} - {zone} - ({gears.length}) + setExpandedGearGroup(prev => (prev === name ? null : name))} style={{ flex: 1, color: '#e2e8f0', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', cursor: 'pointer' }} title={`${name} — ${zoneName}`}>{name} + {zoneName} + ({gearMembers.length})
{isOpen && (
- {parent &&
모선: {parent.name || parent.mmsi}
} + {parentMember &&
모선: {parentMember.name || parentMember.mmsi}
}
어구 목록:
- {gears.map(g => ( -
- {g.name || g.mmsi} - + {gearMembers.map(m => ( +
+ {m.name || m.mmsi} +
))}
@@ -882,14 +772,17 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, cluster 비허가 어구 ({outZoneGearGroups.length}개) -
{sectionExpanded.outZone && (
- {outZoneGearGroups.map(({ name, parent, gears }) => { + {outZoneGearGroups.map(g => { + const name = g.groupKey; const isOpen = expandedGearGroup === name; + const parentMember = g.members.find(m => m.isParent); + const gearMembers = g.members.filter(m => !m.isParent); return (
- ({gears.length}개) + ({gearMembers.length}개) diff --git a/frontend/src/components/korea/KoreaDashboard.tsx b/frontend/src/components/korea/KoreaDashboard.tsx index 2a56df0..b190b72 100644 --- a/frontend/src/components/korea/KoreaDashboard.tsx +++ b/frontend/src/components/korea/KoreaDashboard.tsx @@ -11,6 +11,7 @@ import { ReplayControls } from '../common/ReplayControls'; import { TimelineSlider } from '../common/TimelineSlider'; import { useKoreaData } from '../../hooks/useKoreaData'; import { useVesselAnalysis } from '../../hooks/useVesselAnalysis'; +import { useGroupPolygons } from '../../hooks/useGroupPolygons'; import { useKoreaFilters } from '../../hooks/useKoreaFilters'; import { useSharedFilters } from '../../hooks/useSharedFilters'; import { EAST_ASIA_PORTS } from '../../data/ports'; @@ -161,6 +162,7 @@ export const KoreaDashboard = ({ }); const vesselAnalysis = useVesselAnalysis(true); + const groupPolygons = useGroupPolygons(true); const koreaFiltersResult = useKoreaFilters( koreaData.ships, @@ -329,6 +331,7 @@ export const KoreaDashboard = ({ cnFishingSuspects={koreaFiltersResult.cnFishingSuspects} dokdoAlerts={koreaFiltersResult.dokdoAlerts} vesselAnalysis={vesselAnalysis} + groupPolygons={groupPolygons} hiddenShipCategories={hiddenShipCategories} hiddenNationalities={hiddenNationalities} /> diff --git a/frontend/src/components/korea/KoreaMap.tsx b/frontend/src/components/korea/KoreaMap.tsx index 080517a..62aaba6 100644 --- a/frontend/src/components/korea/KoreaMap.tsx +++ b/frontend/src/components/korea/KoreaMap.tsx @@ -36,6 +36,7 @@ import type { PowerFacility } from '../../services/infra'; import type { Ship, Aircraft, SatellitePosition } from '../../types'; import type { OsintItem } from '../../services/osint'; import type { UseVesselAnalysisResult } from '../../hooks/useVesselAnalysis'; +import type { UseGroupPolygonsResult } from '../../hooks/useGroupPolygons'; import { countryLabelsGeoJSON } from '../../data/countryLabels'; import { useLocalStorage } from '../../hooks/useLocalStorage'; import 'maplibre-gl/dist/maplibre-gl.css'; @@ -65,6 +66,7 @@ interface Props { cnFishingSuspects: Set; dokdoAlerts: { mmsi: string; name: string; dist: number; time: number }[]; vesselAnalysis?: UseVesselAnalysisResult; + groupPolygons?: UseGroupPolygonsResult; hiddenShipCategories?: Set; hiddenNationalities?: Set; } @@ -142,7 +144,7 @@ const FILTER_I18N_KEY: Record = { cnFishing: 'filters.cnFishingMonitor', }; -export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintFeed, currentTime, koreaFilters, transshipSuspects, cableWatchSuspects, dokdoWatchSuspects, dokdoAlerts, vesselAnalysis, hiddenShipCategories, hiddenNationalities }: Props) { +export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintFeed, currentTime, koreaFilters, transshipSuspects, cableWatchSuspects, dokdoWatchSuspects, dokdoAlerts, vesselAnalysis, groupPolygons, hiddenShipCategories, hiddenNationalities }: Props) { const { t } = useTranslation(); const mapRef = useRef(null); const [infra, setInfra] = useState([]); @@ -661,6 +663,7 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF ships={allShips ?? ships} analysisMap={vesselAnalysis ? vesselAnalysis.analysisMap : undefined} clusters={vesselAnalysis ? vesselAnalysis.clusters : undefined} + groupPolygons={groupPolygons} onShipSelect={handleAnalysisShipSelect} onFleetZoom={handleFleetZoom} onSelectedGearChange={setSelectedGearData} diff --git a/frontend/src/hooks/useGroupPolygons.ts b/frontend/src/hooks/useGroupPolygons.ts new file mode 100644 index 0000000..b3cd9af --- /dev/null +++ b/frontend/src/hooks/useGroupPolygons.ts @@ -0,0 +1,69 @@ +import { useState, useEffect, useRef, useMemo, useCallback } from 'react'; +import { fetchGroupPolygons } from '../services/vesselAnalysis'; +import type { GroupPolygonDto } from '../services/vesselAnalysis'; + +const POLL_INTERVAL_MS = 5 * 60_000; // 5분 + +export interface UseGroupPolygonsResult { + fleetGroups: GroupPolygonDto[]; + gearInZoneGroups: GroupPolygonDto[]; + gearOutZoneGroups: GroupPolygonDto[]; + allGroups: GroupPolygonDto[]; + isLoading: boolean; + lastUpdated: number; +} + +const EMPTY: UseGroupPolygonsResult = { + fleetGroups: [], + gearInZoneGroups: [], + gearOutZoneGroups: [], + allGroups: [], + isLoading: false, + lastUpdated: 0, +}; + +export function useGroupPolygons(enabled: boolean): UseGroupPolygonsResult { + const [allGroups, setAllGroups] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [lastUpdated, setLastUpdated] = useState(0); + const timerRef = useRef>(); + + const load = useCallback(async () => { + setIsLoading(true); + try { + const groups = await fetchGroupPolygons(); + setAllGroups(groups); + setLastUpdated(Date.now()); + } catch { + // 네트워크 오류 시 기존 데이터 유지 + } finally { + setIsLoading(false); + } + }, []); + + useEffect(() => { + if (!enabled) return; + load(); + timerRef.current = setInterval(load, POLL_INTERVAL_MS); + return () => clearInterval(timerRef.current); + }, [enabled, load]); + + const fleetGroups = useMemo( + () => allGroups.filter(g => g.groupType === 'FLEET'), + [allGroups], + ); + + const gearInZoneGroups = useMemo( + () => allGroups.filter(g => g.groupType === 'GEAR_IN_ZONE'), + [allGroups], + ); + + const gearOutZoneGroups = useMemo( + () => allGroups.filter(g => g.groupType === 'GEAR_OUT_ZONE'), + [allGroups], + ); + + if (!enabled) return EMPTY; + + return { fleetGroups, gearInZoneGroups, gearOutZoneGroups, allGroups, isLoading, lastUpdated }; +} diff --git a/frontend/src/services/vesselAnalysis.ts b/frontend/src/services/vesselAnalysis.ts index c871c11..257268b 100644 --- a/frontend/src/services/vesselAnalysis.ts +++ b/frontend/src/services/vesselAnalysis.ts @@ -17,6 +17,58 @@ export interface FleetCompany { nameEn: string; } +/* ── Group Polygon Types ─────────────────────────────────────── */ + +export interface MemberInfo { + mmsi: string; + name: string; + lat: number; + lon: number; + sog: number; + cog: number; + role: string; + isParent: boolean; +} + +export interface GroupPolygonDto { + groupType: 'FLEET' | 'GEAR_IN_ZONE' | 'GEAR_OUT_ZONE'; + groupKey: string; + groupLabel: string; + snapshotTime: string; + polygon: GeoJSON.Polygon | null; + centerLat: number; + centerLon: number; + areaSqNm: number; + memberCount: number; + zoneId: string | null; + zoneName: string | null; + members: MemberInfo[]; + color: string; +} + +export async function fetchGroupPolygons(): Promise { + const res = await fetch(`${API_BASE}/vessel-analysis/groups`, { + headers: { accept: 'application/json' }, + }); + if (!res.ok) return []; + const data: { count: number; items: GroupPolygonDto[] } = await res.json(); + return data.items ?? []; +} + +export async function fetchGroupDetail(groupKey: string): Promise { + const res = await fetch(`${API_BASE}/vessel-analysis/groups/${encodeURIComponent(groupKey)}/detail`); + if (!res.ok) return null; + return res.json(); +} + +export async function fetchGroupHistory(groupKey: string, hours = 24): Promise { + const res = await fetch(`${API_BASE}/vessel-analysis/groups/${encodeURIComponent(groupKey)}/history?hours=${hours}`); + if (!res.ok) return []; + return res.json(); +} + +/* ── Fleet Companies ─────────────────────────────────────────── */ + // 캐시 (세션 중 1회 로드) let companyCache: Map | null = null;