diff --git a/backend/src/main/java/gc/mda/kcg/config/CacheConfig.java b/backend/src/main/java/gc/mda/kcg/config/CacheConfig.java index f2274a0..703e5e5 100644 --- a/backend/src/main/java/gc/mda/kcg/config/CacheConfig.java +++ b/backend/src/main/java/gc/mda/kcg/config/CacheConfig.java @@ -21,6 +21,7 @@ public class CacheConfig { public static final String SEISMIC = "seismic"; public static final String PRESSURE = "pressure"; public static final String VESSEL_ANALYSIS = "vessel-analysis"; + public static final String GROUP_POLYGONS = "group-polygons"; @Bean public CacheManager cacheManager() { @@ -29,7 +30,8 @@ public class CacheConfig { OSINT_IRAN, OSINT_KOREA, SATELLITES, SEISMIC, PRESSURE, - VESSEL_ANALYSIS + VESSEL_ANALYSIS, + GROUP_POLYGONS ); manager.setCaffeine(Caffeine.newBuilder() .expireAfterWrite(2, TimeUnit.DAYS) diff --git a/backend/src/main/java/gc/mda/kcg/domain/fleet/GroupPolygonController.java b/backend/src/main/java/gc/mda/kcg/domain/fleet/GroupPolygonController.java new file mode 100644 index 0000000..97b9e9e --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/domain/fleet/GroupPolygonController.java @@ -0,0 +1,51 @@ +package gc.mda.kcg.domain.fleet; + +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.Map; + +@RestController +@RequestMapping("/api/vessel-analysis/groups") +@RequiredArgsConstructor +public class GroupPolygonController { + + private final GroupPolygonService groupPolygonService; + + /** + * 전체 그룹 폴리곤 목록 (최신 스냅샷, 5분 캐시) + */ + @GetMapping + public ResponseEntity> getGroups() { + List groups = groupPolygonService.getLatestGroups(); + return ResponseEntity.ok(Map.of( + "count", groups.size(), + "items", groups + )); + } + + /** + * 특정 그룹 상세 (멤버, 면적, 폴리곤 생성 근거) + */ + @GetMapping("/{groupKey}/detail") + public ResponseEntity getGroupDetail(@PathVariable String groupKey) { + GroupPolygonDto detail = groupPolygonService.getGroupDetail(groupKey); + if (detail == null) { + return ResponseEntity.notFound().build(); + } + return ResponseEntity.ok(detail); + } + + /** + * 특정 그룹 히스토리 (시간별 폴리곤 변화) + */ + @GetMapping("/{groupKey}/history") + public ResponseEntity> getGroupHistory( + @PathVariable String groupKey, + @RequestParam(defaultValue = "24") int hours) { + List history = groupPolygonService.getGroupHistory(groupKey, hours); + return ResponseEntity.ok(history); + } +} diff --git a/backend/src/main/java/gc/mda/kcg/domain/fleet/GroupPolygonDto.java b/backend/src/main/java/gc/mda/kcg/domain/fleet/GroupPolygonDto.java new file mode 100644 index 0000000..dd2fa28 --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/domain/fleet/GroupPolygonDto.java @@ -0,0 +1,27 @@ +package gc.mda.kcg.domain.fleet; + +import com.fasterxml.jackson.annotation.JsonInclude; +import lombok.Builder; +import lombok.Getter; + +import java.util.List; +import java.util.Map; + +@Getter +@Builder +@JsonInclude(JsonInclude.Include.NON_NULL) +public class GroupPolygonDto { + private String groupType; + private String groupKey; + private String groupLabel; + private String snapshotTime; + private Object polygon; // GeoJSON Polygon (parsed from ST_AsGeoJSON) + private double centerLat; + private double centerLon; + private double areaSqNm; + private int memberCount; + private String zoneId; + private String zoneName; + private List> members; + private String color; +} diff --git a/backend/src/main/java/gc/mda/kcg/domain/fleet/GroupPolygonService.java b/backend/src/main/java/gc/mda/kcg/domain/fleet/GroupPolygonService.java new file mode 100644 index 0000000..eb9f289 --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/domain/fleet/GroupPolygonService.java @@ -0,0 +1,131 @@ +package gc.mda.kcg.domain.fleet; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import gc.mda.kcg.config.CacheConfig; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.cache.Cache; +import org.springframework.cache.CacheManager; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; + +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.List; +import java.util.Map; + +@Slf4j +@Service +@RequiredArgsConstructor +public class GroupPolygonService { + + private final JdbcTemplate jdbcTemplate; + private final CacheManager cacheManager; + private final ObjectMapper objectMapper; + + private static final String LATEST_GROUPS_SQL = """ + SELECT group_type, group_key, group_label, snapshot_time, + ST_AsGeoJSON(polygon) AS polygon_geojson, + ST_Y(center_point) AS center_lat, ST_X(center_point) AS center_lon, + area_sq_nm, member_count, zone_id, zone_name, members, color + FROM kcg.group_polygon_snapshots + WHERE snapshot_time = (SELECT MAX(snapshot_time) FROM kcg.group_polygon_snapshots) + ORDER BY group_type, member_count DESC + """; + + private static final String GROUP_DETAIL_SQL = """ + SELECT group_type, group_key, group_label, snapshot_time, + ST_AsGeoJSON(polygon) AS polygon_geojson, + ST_Y(center_point) AS center_lat, ST_X(center_point) AS center_lon, + area_sq_nm, member_count, zone_id, zone_name, members, color + FROM kcg.group_polygon_snapshots + WHERE group_key = ? + ORDER BY snapshot_time DESC + LIMIT 1 + """; + + private static final String GROUP_HISTORY_SQL = """ + SELECT group_type, group_key, group_label, snapshot_time, + ST_AsGeoJSON(polygon) AS polygon_geojson, + ST_Y(center_point) AS center_lat, ST_X(center_point) AS center_lon, + area_sq_nm, member_count, zone_id, zone_name, members, color + FROM kcg.group_polygon_snapshots + WHERE group_key = ? AND snapshot_time > NOW() - CAST(? || ' hours' AS INTERVAL) + ORDER BY snapshot_time DESC + """; + + /** + * 최신 스냅샷의 전체 그룹 폴리곤 목록 (5분 캐시). + */ + @SuppressWarnings("unchecked") + public List getLatestGroups() { + Cache cache = cacheManager.getCache(CacheConfig.GROUP_POLYGONS); + if (cache != null) { + Cache.ValueWrapper wrapper = cache.get("data"); + if (wrapper != null) { + return (List) wrapper.get(); + } + } + + List results = jdbcTemplate.query(LATEST_GROUPS_SQL, this::mapRow); + + if (cache != null) { + cache.put("data", results); + } + return results; + } + + /** + * 특정 그룹의 최신 상세 정보. + */ + public GroupPolygonDto getGroupDetail(String groupKey) { + List results = jdbcTemplate.query(GROUP_DETAIL_SQL, this::mapRow, groupKey); + return results.isEmpty() ? null : results.get(0); + } + + /** + * 특정 그룹의 시간별 히스토리. + */ + public List getGroupHistory(String groupKey, int hours) { + return jdbcTemplate.query(GROUP_HISTORY_SQL, this::mapRow, groupKey, String.valueOf(hours)); + } + + private GroupPolygonDto mapRow(ResultSet rs, int rowNum) throws SQLException { + Object polygonObj = null; + String polygonJson = rs.getString("polygon_geojson"); + if (polygonJson != null) { + try { + polygonObj = objectMapper.readValue(polygonJson, new TypeReference>() {}); + } catch (Exception e) { + log.warn("Failed to parse polygon GeoJSON: {}", e.getMessage()); + } + } + + List> members = List.of(); + String membersJson = rs.getString("members"); + if (membersJson != null) { + try { + members = objectMapper.readValue(membersJson, new TypeReference<>() {}); + } catch (Exception e) { + log.warn("Failed to parse members JSON: {}", e.getMessage()); + } + } + + return GroupPolygonDto.builder() + .groupType(rs.getString("group_type")) + .groupKey(rs.getString("group_key")) + .groupLabel(rs.getString("group_label")) + .snapshotTime(rs.getString("snapshot_time")) + .polygon(polygonObj) + .centerLat(rs.getDouble("center_lat")) + .centerLon(rs.getDouble("center_lon")) + .areaSqNm(rs.getDouble("area_sq_nm")) + .memberCount(rs.getInt("member_count")) + .zoneId(rs.getString("zone_id")) + .zoneName(rs.getString("zone_name")) + .members(members) + .color(rs.getString("color")) + .build(); + } +} diff --git a/database/migration/009_group_polygons.sql b/database/migration/009_group_polygons.sql new file mode 100644 index 0000000..e8b68b0 --- /dev/null +++ b/database/migration/009_group_polygons.sql @@ -0,0 +1,49 @@ +-- 009: 선단/어구그룹 폴리곤 스냅샷 테이블 +-- 5분 주기 APPEND, 7일 보존 + +SET search_path TO kcg, public; + +CREATE TABLE IF NOT EXISTS kcg.group_polygon_snapshots ( + id BIGSERIAL PRIMARY KEY, + + -- 그룹 식별 + group_type VARCHAR(20) NOT NULL, -- FLEET | GEAR_IN_ZONE | GEAR_OUT_ZONE + group_key VARCHAR(100) NOT NULL, -- fleet: company_id, gear: parent_name + group_label TEXT, -- 표시명 (회사명 또는 모선명) + + -- 스냅샷 시각 + snapshot_time TIMESTAMPTZ NOT NULL, + + -- PostGIS geometry + polygon geometry(Polygon, 4326), -- convex hull + buffer (3점 미만 시 NULL) + center_point geometry(Point, 4326), -- 중심점 + + -- 지표 + area_sq_nm DOUBLE PRECISION DEFAULT 0, -- 면적 (제곱 해리) + member_count INT NOT NULL DEFAULT 0, -- 소속 선박/어구 수 + + -- 수역 분류 (어구그룹용) + zone_id VARCHAR(20), -- ZONE_I ~ ZONE_IV | OUTSIDE + zone_name TEXT, + + -- 멤버 상세 (JSONB 배열) + members JSONB NOT NULL DEFAULT '[]', + -- [{mmsi, name, lat, lon, sog, cog, role, isParent}] + + -- 색상 힌트 + color VARCHAR(20), + + created_at TIMESTAMPTZ DEFAULT NOW() +); + +-- 조회 성능 인덱스 +CREATE INDEX IF NOT EXISTS idx_gps_type_time + ON kcg.group_polygon_snapshots(group_type, snapshot_time DESC); +CREATE INDEX IF NOT EXISTS idx_gps_key_time + ON kcg.group_polygon_snapshots(group_key, snapshot_time DESC); +CREATE INDEX IF NOT EXISTS idx_gps_snapshot_time + ON kcg.group_polygon_snapshots(snapshot_time DESC); + +-- 공간 인덱스 +CREATE INDEX IF NOT EXISTS idx_gps_polygon_gist + ON kcg.group_polygon_snapshots USING GIST(polygon); diff --git a/docs/RELEASE-NOTES.md b/docs/RELEASE-NOTES.md index bc07144..11b650f 100644 --- a/docs/RELEASE-NOTES.md +++ b/docs/RELEASE-NOTES.md @@ -4,6 +4,19 @@ ## [Unreleased] +### 추가 +- 선단/어구그룹 폴리곤 서버사이드 생성: Shapely convex hull + buffer → PostGIS 저장 +- DB migration 009: group_polygon_snapshots 테이블 (5분 APPEND, 7일 보존) +- Backend API: GET /api/vessel-analysis/groups (목록/상세/히스토리) +- useGroupPolygons 훅: 5분 폴링 (fleet/gearInZone/gearOutZone) + +### 변경 +- FleetClusterLayer: 클라이언트 convexHull/padPolygon 제거 → API GeoJSON 직접 렌더링 +- 프론트 어구그룹 탐지(regex+거리 클러스터링) Python 이관 + +### 수정 +- 불법어선 탭 복원 (임시 숨김 해제) + ## [2026-03-24.1] ### 추가 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..f96c6cf 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, @@ -259,12 +261,10 @@ export const KoreaDashboard = ({ <> {headerSlot && createPortal(
- {/* 불법어선 탭 — 준비 중, 임시 숨김 - */}