Merge pull request 'release: 2026-03-24.2 (폴리곤 서버사이드 이관)' (#179) from develop into main
All checks were successful
Deploy KCG / deploy (push) Successful in 1m55s
All checks were successful
Deploy KCG / deploy (push) Successful in 1m55s
This commit is contained in:
커밋
03747d3c63
@ -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)
|
||||
|
||||
@ -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<Map<String, Object>> getGroups() {
|
||||
List<GroupPolygonDto> groups = groupPolygonService.getLatestGroups();
|
||||
return ResponseEntity.ok(Map.of(
|
||||
"count", groups.size(),
|
||||
"items", groups
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 그룹 상세 (멤버, 면적, 폴리곤 생성 근거)
|
||||
*/
|
||||
@GetMapping("/{groupKey}/detail")
|
||||
public ResponseEntity<GroupPolygonDto> 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<List<GroupPolygonDto>> getGroupHistory(
|
||||
@PathVariable String groupKey,
|
||||
@RequestParam(defaultValue = "24") int hours) {
|
||||
List<GroupPolygonDto> history = groupPolygonService.getGroupHistory(groupKey, hours);
|
||||
return ResponseEntity.ok(history);
|
||||
}
|
||||
}
|
||||
@ -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<Map<String, Object>> members;
|
||||
private String color;
|
||||
}
|
||||
@ -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<GroupPolygonDto> getLatestGroups() {
|
||||
Cache cache = cacheManager.getCache(CacheConfig.GROUP_POLYGONS);
|
||||
if (cache != null) {
|
||||
Cache.ValueWrapper wrapper = cache.get("data");
|
||||
if (wrapper != null) {
|
||||
return (List<GroupPolygonDto>) wrapper.get();
|
||||
}
|
||||
}
|
||||
|
||||
List<GroupPolygonDto> results = jdbcTemplate.query(LATEST_GROUPS_SQL, this::mapRow);
|
||||
|
||||
if (cache != null) {
|
||||
cache.put("data", results);
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 그룹의 최신 상세 정보.
|
||||
*/
|
||||
public GroupPolygonDto getGroupDetail(String groupKey) {
|
||||
List<GroupPolygonDto> results = jdbcTemplate.query(GROUP_DETAIL_SQL, this::mapRow, groupKey);
|
||||
return results.isEmpty() ? null : results.get(0);
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 그룹의 시간별 히스토리.
|
||||
*/
|
||||
public List<GroupPolygonDto> 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<Map<String, Object>>() {});
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to parse polygon GeoJSON: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
List<Map<String, Object>> 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();
|
||||
}
|
||||
}
|
||||
49
database/migration/009_group_polygons.sql
Normal file
49
database/migration/009_group_polygons.sql
Normal file
@ -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);
|
||||
@ -4,6 +4,21 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [2026-03-24.2]
|
||||
|
||||
### 추가
|
||||
- 선단/어구그룹 폴리곤 서버사이드 생성: 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]
|
||||
|
||||
### 추가
|
||||
|
||||
@ -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<string, VesselAnalysisDto>();
|
||||
const EMPTY_CLUSTERS = new globalThis.Map<number, string[]>();
|
||||
|
||||
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<Map<number, FleetCompany>>(new Map());
|
||||
const [expandedFleet, setExpandedFleet] = useState<number | null>(null);
|
||||
const [sectionExpanded, setSectionExpanded] = useState<Record<string, boolean>>({
|
||||
@ -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<number, string[]>; shipMap: Map<string, Ship>; gearGroupMap: Map<string, { parent: Ship | null; gears: Ship[] }>; onFleetZoom: Props['onFleetZoom'] }>({ clusters, shipMap: new Map(), gearGroupMap: new Map(), onFleetZoom });
|
||||
const dataRef = useRef<{ shipMap: Map<string, Ship>; 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<string, Ship[]>(); // parent_mmsi → gears
|
||||
const gearPattern = /^(.+?)_\d+_\d*$/;
|
||||
const parentNames = new Map<string, string>(); // 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<string, Ship>();
|
||||
@ -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<string, Ship>();
|
||||
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<string, Ship[]>();
|
||||
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<string, { parent: Ship | null; gears: Ship[] }>();
|
||||
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 (
|
||||
<>
|
||||
{/* 선단 폴리곤 레이어 */}
|
||||
<Source id="fleet-cluster-fill" type="geojson" data={polygonGeoJSON}>
|
||||
<Source id="fleet-cluster-fill" type="geojson" data={fleetPolygonGeoJSON}>
|
||||
<Layer
|
||||
id="fleet-cluster-fill-layer"
|
||||
type="fill"
|
||||
@ -512,7 +414,7 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, cluster
|
||||
/>
|
||||
</Source>
|
||||
|
||||
{/* 2척 선단 라인 */}
|
||||
{/* 2척 선단 라인 (서버측 Polygon 처리로 빈 컬렉션) */}
|
||||
<Source id="fleet-cluster-line" type="geojson" data={lineGeoJSON}>
|
||||
<Layer
|
||||
id="fleet-cluster-line-only"
|
||||
@ -538,27 +440,21 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, cluster
|
||||
/>
|
||||
</Source>
|
||||
|
||||
{/* 선택된 어구 그룹 하이라이트 폴리곤 (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 (
|
||||
<Source id="gear-cluster-selected" type="geojson" data={hlGeoJson}>
|
||||
<Layer id="gear-selected-fill" type="fill" paint={{ 'fill-color': '#f97316', 'fill-opacity': 0.25 }} />
|
||||
@ -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 (
|
||||
<Popup longitude={hoverTooltip.lng} latitude={hoverTooltip.lat}
|
||||
closeButton={false} closeOnClick={false} anchor="bottom"
|
||||
className="gl-popup" maxWidth="220px"
|
||||
>
|
||||
<div style={{ fontFamily: FONT_MONO, fontSize: 10, padding: 4, color: '#e2e8f0' }}>
|
||||
<div style={{ fontWeight: 700, color: clusterColor(cid), marginBottom: 3 }}>
|
||||
{company?.nameCn || `선단 #${cid}`}
|
||||
<div style={{ fontWeight: 700, color: group?.color ?? '#63b3ed', marginBottom: 3 }}>
|
||||
{company?.nameCn || group?.groupLabel || `선단 #${cid}`}
|
||||
</div>
|
||||
<div style={{ color: '#94a3b8' }}>선박 {mmsiList.length}척 · 어구 {gearCount}개</div>
|
||||
{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 ? (
|
||||
<div key={mmsi} style={{ fontSize: 9, color: '#7ea8c4', marginTop: 2 }}>
|
||||
{role === 'LEADER' ? '★' : '·'} {s.name || mmsi} <span style={{ color: '#4a6b82' }}>{s.speed?.toFixed(1)}kt</span>
|
||||
<div style={{ color: '#94a3b8' }}>선박 {memberCount}척</div>
|
||||
{expandedFleet === cid && group?.members.slice(0, 5).map(m => {
|
||||
const dto = analysisMap.get(m.mmsi);
|
||||
const role = dto?.algorithms.fleetRole.role ?? m.role;
|
||||
return (
|
||||
<div key={m.mmsi} style={{ fontSize: 9, color: '#7ea8c4', marginTop: 2 }}>
|
||||
{role === 'LEADER' ? '★' : '·'} {m.name || m.mmsi} <span style={{ color: '#4a6b82' }}>{m.sog.toFixed(1)}kt</span>
|
||||
</div>
|
||||
) : null;
|
||||
);
|
||||
})}
|
||||
<div style={{ fontSize: 8, color: '#4a6b82', marginTop: 3 }}>클릭하여 상세 보기</div>
|
||||
</div>
|
||||
@ -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 (
|
||||
<Popup longitude={hoverTooltip.lng} latitude={hoverTooltip.lat}
|
||||
closeButton={false} closeOnClick={false} anchor="bottom"
|
||||
@ -631,14 +531,14 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, cluster
|
||||
>
|
||||
<div style={{ fontFamily: FONT_MONO, fontSize: 10, padding: 4, color: '#e2e8f0' }}>
|
||||
<div style={{ fontWeight: 700, color: '#f97316', marginBottom: 3 }}>
|
||||
{name} <span style={{ fontWeight: 400, color: '#94a3b8' }}>어구 {entry.gears.length}개</span>
|
||||
{name} <span style={{ fontWeight: 400, color: '#94a3b8' }}>어구 {gearMembers.length}개</span>
|
||||
</div>
|
||||
{entry.parent && (
|
||||
<div style={{ fontSize: 9, color: '#fbbf24' }}>모선: {entry.parent.name || entry.parent.mmsi}</div>
|
||||
{parentMember && (
|
||||
<div style={{ fontSize: 9, color: '#fbbf24' }}>모선: {parentMember.name || parentMember.mmsi}</div>
|
||||
)}
|
||||
{selectedGearGroup === name && entry.gears.slice(0, 5).map(g => (
|
||||
<div key={g.mmsi} style={{ fontSize: 9, color: '#7ea8c4', marginTop: 1 }}>
|
||||
· {g.name || g.mmsi}
|
||||
{selectedGearGroup === name && gearMembers.slice(0, 5).map(m => (
|
||||
<div key={m.mmsi} style={{ fontSize: 9, color: '#7ea8c4', marginTop: 1 }}>
|
||||
· {m.name || m.mmsi}
|
||||
</div>
|
||||
))}
|
||||
<div style={{ fontSize: 8, color: '#4a6b82', marginTop: 3 }}>클릭하여 선택/해제</div>
|
||||
@ -657,7 +557,7 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, cluster
|
||||
<span style={{ fontWeight: 700, color: '#63b3ed', letterSpacing: 0.5 }}>
|
||||
선단 현황 ({fleetList.length}개)
|
||||
</span>
|
||||
<button style={toggleButtonStyle} aria-label="선단 현황 접기/펴기">
|
||||
<button type="button" style={toggleButtonStyle} aria-label="선단 현황 접기/펴기">
|
||||
{sectionExpanded.fleet ? '▲' : '▼'}
|
||||
</button>
|
||||
</div>
|
||||
@ -668,18 +568,17 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, cluster
|
||||
선단 데이터 없음
|
||||
</div>
|
||||
) : (
|
||||
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 (
|
||||
<div key={id}>
|
||||
@ -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}
|
||||
</span>
|
||||
@ -731,6 +630,7 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, cluster
|
||||
</span>
|
||||
{/* zoom 버튼 */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={e => { e.stopPropagation(); handleFleetZoom(id); }}
|
||||
style={{
|
||||
background: 'none',
|
||||
@ -761,14 +661,13 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, cluster
|
||||
}}>
|
||||
{/* 선박 목록 */}
|
||||
<div style={{ color: '#64748b', fontSize: 9, marginBottom: 3 }}>선박:</div>
|
||||
{(mainVessels.length > 0 ? mainVessels : mmsiList).map(mmsi => {
|
||||
const ship = shipMap.get(mmsi);
|
||||
const dto = analysisMap.get(mmsi);
|
||||
const role = dto?.algorithms.fleetRole.role ?? 'MEMBER';
|
||||
const displayName = ship?.name || mmsi;
|
||||
{displayMembers.map(m => {
|
||||
const dto = analysisMap.get(m.mmsi);
|
||||
const role = dto?.algorithms.fleetRole.role ?? m.role;
|
||||
const displayName = m.name || m.mmsi;
|
||||
return (
|
||||
<div
|
||||
key={mmsi}
|
||||
key={m.mmsi}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
@ -783,7 +682,8 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, cluster
|
||||
({role === 'LEADER' ? 'MAIN' : 'SUB'})
|
||||
</span>
|
||||
<button
|
||||
onClick={() => onShipSelect?.(mmsi)}
|
||||
type="button"
|
||||
onClick={() => onShipSelect?.(m.mmsi)}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
@ -801,20 +701,6 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, cluster
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* 어구 목록 */}
|
||||
{gearCount > 0 && (
|
||||
<>
|
||||
<div style={{ color: '#64748b', fontSize: 9, marginTop: 4, marginBottom: 2 }}>
|
||||
어구: {gearCount}개
|
||||
</div>
|
||||
{mmsiList.flatMap(mmsi => gearsByParent.get(mmsi) ?? []).map(gear => (
|
||||
<div key={gear.mmsi} style={{ color: '#475569', fontSize: 9, marginBottom: 1 }}>
|
||||
{gear.name || gear.mmsi}
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@ -838,9 +724,13 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, cluster
|
||||
</div>
|
||||
{sectionExpanded.inZone && (
|
||||
<div style={{ padding: '4px 0' }}>
|
||||
{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 (
|
||||
<div key={name} id={`gear-row-${name}`}>
|
||||
<div
|
||||
@ -850,19 +740,19 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, cluster
|
||||
>
|
||||
<span onClick={() => setExpandedGearGroup(prev => (prev === name ? null : name))} style={{ color: '#94a3b8', fontSize: 9, flexShrink: 0, cursor: 'pointer' }}>{isOpen ? '▾' : '▸'}</span>
|
||||
<span style={{ width: 8, height: 8, borderRadius: '50%', backgroundColor: accentColor, flexShrink: 0 }} />
|
||||
<span onClick={() => setExpandedGearGroup(prev => (prev === name ? null : name))} style={{ flex: 1, color: '#e2e8f0', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', cursor: 'pointer' }} title={`${name} — ${zone}`}>{name}</span>
|
||||
<span style={{ color: '#ef4444', fontSize: 9, flexShrink: 0 }}>{zone}</span>
|
||||
<span style={{ color: '#64748b', fontSize: 10, flexShrink: 0 }}>({gears.length})</span>
|
||||
<span onClick={() => setExpandedGearGroup(prev => (prev === name ? null : name))} style={{ flex: 1, color: '#e2e8f0', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', cursor: 'pointer' }} title={`${name} — ${zoneName}`}>{name}</span>
|
||||
<span style={{ color: '#ef4444', fontSize: 9, flexShrink: 0 }}>{zoneName}</span>
|
||||
<span style={{ color: '#64748b', fontSize: 10, flexShrink: 0 }}>({gearMembers.length})</span>
|
||||
<button type="button" onClick={e => { e.stopPropagation(); handleGearGroupZoom(name); }} style={{ background: 'none', border: `1px solid rgba(220,38,38,0.5)`, borderRadius: 3, color: accentColor, fontSize: 9, cursor: 'pointer', padding: '1px 4px', flexShrink: 0 }} title="이 어구 그룹으로 지도 이동">zoom</button>
|
||||
</div>
|
||||
{isOpen && (
|
||||
<div style={{ paddingLeft: 24, paddingRight: 10, paddingBottom: 4, fontSize: 9, color: '#94a3b8', borderLeft: `2px solid rgba(220,38,38,0.25)`, marginLeft: 10 }}>
|
||||
{parent && <div style={{ color: '#fbbf24', marginBottom: 2 }}>모선: {parent.name || parent.mmsi}</div>}
|
||||
{parentMember && <div style={{ color: '#fbbf24', marginBottom: 2 }}>모선: {parentMember.name || parentMember.mmsi}</div>}
|
||||
<div style={{ color: '#64748b', marginBottom: 2 }}>어구 목록:</div>
|
||||
{gears.map(g => (
|
||||
<div key={g.mmsi} style={{ display: 'flex', alignItems: 'center', gap: 4, marginBottom: 1 }}>
|
||||
<span style={{ flex: 1, color: '#475569', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{g.name || g.mmsi}</span>
|
||||
<button type="button" onClick={() => onShipSelect?.(g.mmsi)} style={{ background: 'none', border: 'none', color: accentColor, fontSize: 10, cursor: 'pointer', padding: '0 2px', flexShrink: 0 }} title="어구 위치로 이동">▶</button>
|
||||
{gearMembers.map(m => (
|
||||
<div key={m.mmsi} style={{ display: 'flex', alignItems: 'center', gap: 4, marginBottom: 1 }}>
|
||||
<span style={{ flex: 1, color: '#475569', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{m.name || m.mmsi}</span>
|
||||
<button type="button" onClick={() => onShipSelect?.(m.mmsi)} style={{ background: 'none', border: 'none', color: accentColor, fontSize: 10, cursor: 'pointer', padding: '0 2px', flexShrink: 0 }} title="어구 위치로 이동">▶</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@ -882,14 +772,17 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, cluster
|
||||
<span style={{ fontWeight: 700, color: '#f97316', letterSpacing: 0.3 }}>
|
||||
비허가 어구 ({outZoneGearGroups.length}개)
|
||||
</span>
|
||||
<button style={toggleButtonStyle} aria-label="비허가 어구 접기/펴기">
|
||||
<button type="button" style={toggleButtonStyle} aria-label="비허가 어구 접기/펴기">
|
||||
{sectionExpanded.outZone ? '▲' : '▼'}
|
||||
</button>
|
||||
</div>
|
||||
{sectionExpanded.outZone && (
|
||||
<div style={{ padding: '4px 0' }}>
|
||||
{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 (
|
||||
<div key={name} id={`gear-row-${name}`}>
|
||||
<div
|
||||
@ -930,7 +823,7 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, cluster
|
||||
{name}
|
||||
</span>
|
||||
<span style={{ color: '#64748b', fontSize: 10, flexShrink: 0 }}>
|
||||
({gears.length}개)
|
||||
({gearMembers.length}개)
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
@ -961,25 +854,25 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, cluster
|
||||
borderLeft: '2px solid rgba(249,115,22,0.2)',
|
||||
marginLeft: 10,
|
||||
}}>
|
||||
{parent && (
|
||||
{parentMember && (
|
||||
<div style={{ color: '#fbbf24', marginBottom: 2 }}>
|
||||
모선: {parent.name || parent.mmsi}
|
||||
모선: {parentMember.name || parentMember.mmsi}
|
||||
</div>
|
||||
)}
|
||||
<div style={{ color: '#64748b', marginBottom: 2 }}>어구 목록:</div>
|
||||
{gears.map(g => (
|
||||
<div key={g.mmsi} style={{
|
||||
{gearMembers.map(m => (
|
||||
<div key={m.mmsi} style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
marginBottom: 1,
|
||||
}}>
|
||||
<span style={{ flex: 1, color: '#475569', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{g.name || g.mmsi}
|
||||
{m.name || m.mmsi}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onShipSelect?.(g.mmsi)}
|
||||
onClick={() => onShipSelect?.(m.mmsi)}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
@ -990,7 +883,7 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, cluster
|
||||
flexShrink: 0,
|
||||
}}
|
||||
title="어구 위치로 이동"
|
||||
aria-label={`${g.name || g.mmsi} 위치로 이동`}
|
||||
aria-label={`${m.name || m.mmsi} 위치로 이동`}
|
||||
>
|
||||
▶
|
||||
</button>
|
||||
|
||||
@ -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(
|
||||
<div className="mode-toggle">
|
||||
{/* 불법어선 탭 — 준비 중, 임시 숨김
|
||||
<button type="button" className={`mode-btn ${koreaFiltersResult.filters.illegalFishing ? 'active live' : ''}`}
|
||||
onClick={() => koreaFiltersResult.setFilter('illegalFishing', !koreaFiltersResult.filters.illegalFishing)} title={t('filters.illegalFishing')}>
|
||||
<span className="text-[11px]">🚫🐟</span>{t('filters.illegalFishing')}
|
||||
</button>
|
||||
*/}
|
||||
<button type="button" className={`mode-btn ${koreaFiltersResult.filters.illegalTransship ? 'active live' : ''}`}
|
||||
onClick={() => koreaFiltersResult.setFilter('illegalTransship', !koreaFiltersResult.filters.illegalTransship)} title={t('filters.illegalTransship')}>
|
||||
<span className="text-[11px]">⚓</span>{t('filters.illegalTransship')}
|
||||
@ -329,6 +329,7 @@ export const KoreaDashboard = ({
|
||||
cnFishingSuspects={koreaFiltersResult.cnFishingSuspects}
|
||||
dokdoAlerts={koreaFiltersResult.dokdoAlerts}
|
||||
vesselAnalysis={vesselAnalysis}
|
||||
groupPolygons={groupPolygons}
|
||||
hiddenShipCategories={hiddenShipCategories}
|
||||
hiddenNationalities={hiddenNationalities}
|
||||
/>
|
||||
|
||||
@ -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<string>;
|
||||
dokdoAlerts: { mmsi: string; name: string; dist: number; time: number }[];
|
||||
vesselAnalysis?: UseVesselAnalysisResult;
|
||||
groupPolygons?: UseGroupPolygonsResult;
|
||||
hiddenShipCategories?: Set<string>;
|
||||
hiddenNationalities?: Set<string>;
|
||||
}
|
||||
@ -142,7 +144,7 @@ const FILTER_I18N_KEY: Record<string, string> = {
|
||||
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<MapRef>(null);
|
||||
const [infra, setInfra] = useState<PowerFacility[]>([]);
|
||||
@ -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}
|
||||
|
||||
69
frontend/src/hooks/useGroupPolygons.ts
Normal file
69
frontend/src/hooks/useGroupPolygons.ts
Normal file
@ -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<GroupPolygonDto[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [lastUpdated, setLastUpdated] = useState(0);
|
||||
const timerRef = useRef<ReturnType<typeof setInterval>>();
|
||||
|
||||
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 };
|
||||
}
|
||||
@ -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<GroupPolygonDto[]> {
|
||||
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<GroupPolygonDto | null> {
|
||||
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<GroupPolygonDto[]> {
|
||||
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<number, FleetCompany> | null = null;
|
||||
|
||||
|
||||
422
prediction/algorithms/polygon_builder.py
Normal file
422
prediction/algorithms/polygon_builder.py
Normal file
@ -0,0 +1,422 @@
|
||||
"""선단/어구그룹 폴리곤 생성기.
|
||||
|
||||
프론트엔드 FleetClusterLayer.tsx의 어구그룹 탐지 + convexHull/padPolygon 로직을
|
||||
Python으로 이관한다. Shapely 라이브러리로 폴리곤 생성.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import math
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
try:
|
||||
from shapely.geometry import MultiPoint, Point
|
||||
from shapely import wkt as shapely_wkt
|
||||
_SHAPELY_AVAILABLE = True
|
||||
except ImportError:
|
||||
_SHAPELY_AVAILABLE = False
|
||||
|
||||
from algorithms.location import classify_zone
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 프론트 FleetClusterLayer.tsx gearGroupMap 패턴과 동일
|
||||
GEAR_PATTERN = re.compile(r'^(.+?)_\d+_\d+_?$')
|
||||
MAX_DIST_DEG = 0.15 # ~10NM
|
||||
STALE_SEC = 3600 # 60분
|
||||
FLEET_BUFFER_DEG = 0.02
|
||||
GEAR_BUFFER_DEG = 0.01
|
||||
MIN_GEAR_GROUP_SIZE = 2 # 최소 어구 수 (비허가 구역 외)
|
||||
|
||||
# 수역 내 어구 색상, 수역 외 어구 색상
|
||||
_COLOR_GEAR_IN_ZONE = '#ef4444'
|
||||
_COLOR_GEAR_OUT_ZONE = '#f97316'
|
||||
|
||||
# classify_zone이 수역 내로 판정하는 zone 값 목록
|
||||
_IN_ZONE_PREFIXES = ('ZONE_',)
|
||||
|
||||
|
||||
def _is_in_zone(zone_info: dict) -> bool:
|
||||
"""classify_zone 결과가 특정어업수역 내인지 판별."""
|
||||
zone = zone_info.get('zone', '')
|
||||
return any(zone.startswith(prefix) for prefix in _IN_ZONE_PREFIXES)
|
||||
|
||||
|
||||
def _cluster_color(seed: int) -> str:
|
||||
"""프론트 clusterColor(id) 이관 — hsl({(seed * 137) % 360}, 80%, 55%)."""
|
||||
h = (seed * 137) % 360
|
||||
return f'hsl({h}, 80%, 55%)'
|
||||
|
||||
|
||||
def compute_area_sq_nm(polygon, center_lat: float) -> float:
|
||||
"""Shapely Polygon의 면적(degrees²) → 제곱 해리 변환.
|
||||
|
||||
1도 위도 ≈ 60 NM, 1도 경도 ≈ 60 * cos(lat) NM
|
||||
sq_nm = area_deg2 * 60 * 60 * cos(center_lat_rad)
|
||||
"""
|
||||
area_deg2 = polygon.area
|
||||
center_lat_rad = math.radians(center_lat)
|
||||
sq_nm = area_deg2 * 60.0 * 60.0 * math.cos(center_lat_rad)
|
||||
return round(sq_nm, 4)
|
||||
|
||||
|
||||
def build_group_polygon(
|
||||
points: list[tuple[float, float]],
|
||||
buffer_deg: float,
|
||||
) -> tuple[Optional[str], Optional[str], float, float, float]:
|
||||
"""좌표 목록으로 버퍼 폴리곤을 생성한다.
|
||||
|
||||
Args:
|
||||
points: (lon, lat) 좌표 목록 — Shapely (x, y) 순서.
|
||||
buffer_deg: 버퍼 크기(도).
|
||||
|
||||
Returns:
|
||||
(polygon_wkt, center_wkt, area_sq_nm, center_lat, center_lon)
|
||||
— polygon_wkt/center_wkt: ST_GeomFromText에 사용할 WKT 문자열.
|
||||
— 좌표가 없거나 Shapely 미설치 시 (None, None, 0.0, 0.0, 0.0).
|
||||
"""
|
||||
if not _SHAPELY_AVAILABLE:
|
||||
logger.warning('shapely 미설치 — build_group_polygon 건너뜀')
|
||||
return None, None, 0.0, 0.0, 0.0
|
||||
|
||||
if not points:
|
||||
return None, None, 0.0, 0.0, 0.0
|
||||
|
||||
if len(points) == 1:
|
||||
geom = Point(points[0]).buffer(buffer_deg)
|
||||
elif len(points) == 2:
|
||||
# LineString → buffer로 Polygon 생성
|
||||
from shapely.geometry import LineString
|
||||
geom = LineString(points).buffer(buffer_deg)
|
||||
else:
|
||||
# 3점 이상 → convex_hull → buffer
|
||||
geom = MultiPoint(points).convex_hull.buffer(buffer_deg)
|
||||
|
||||
# 중심 계산
|
||||
centroid = geom.centroid
|
||||
center_lon = centroid.x
|
||||
center_lat = centroid.y
|
||||
|
||||
area_sq_nm = compute_area_sq_nm(geom, center_lat)
|
||||
polygon_wkt = shapely_wkt.dumps(geom, rounding_precision=6)
|
||||
center_wkt = f'POINT({center_lon:.6f} {center_lat:.6f})'
|
||||
|
||||
return polygon_wkt, center_wkt, area_sq_nm, center_lat, center_lon
|
||||
|
||||
|
||||
def detect_gear_groups(
|
||||
vessel_dfs: dict,
|
||||
vessel_store,
|
||||
now: Optional[datetime] = None,
|
||||
) -> list[dict]:
|
||||
"""어구 이름 패턴으로 어구그룹을 탐지한다.
|
||||
|
||||
프론트엔드 FleetClusterLayer.tsx gearGroupMap useMemo 로직 이관.
|
||||
|
||||
Args:
|
||||
vessel_dfs: {mmsi: DataFrame} — 각 DataFrame은 lat, lon, sog, cog, timestamp 칼럼.
|
||||
vessel_store: VesselStore — get_vessel_info(mmsi) → {name, ...}.
|
||||
now: 기준 시각 (None이면 UTC now).
|
||||
|
||||
Returns:
|
||||
[{parent_name, parent_mmsi, members: [{mmsi, name, lat, lon, sog, cog}]}]
|
||||
"""
|
||||
if now is None:
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
# 선박명 → mmsi 맵 (모선 탐색용)
|
||||
name_to_mmsi: dict[str, str] = {}
|
||||
for mmsi, df in vessel_dfs.items():
|
||||
if df is None or len(df) == 0:
|
||||
continue
|
||||
info = vessel_store.get_vessel_info(mmsi)
|
||||
name: str = (info or {}).get('name', '') or ''
|
||||
name = name.strip()
|
||||
if name and not GEAR_PATTERN.match(name):
|
||||
name_to_mmsi[name] = mmsi
|
||||
|
||||
# 1단계: 같은 모선명 어구 수집 (60분 이내만)
|
||||
raw_groups: dict[str, list[dict]] = {}
|
||||
for mmsi, df in vessel_dfs.items():
|
||||
if df is None or len(df) == 0:
|
||||
continue
|
||||
last = df.iloc[-1]
|
||||
ts = last.get('timestamp') if hasattr(last, 'get') else last['timestamp']
|
||||
|
||||
# timestamp → datetime 변환
|
||||
if isinstance(ts, datetime):
|
||||
last_dt = ts if ts.tzinfo is not None else ts.replace(tzinfo=timezone.utc)
|
||||
else:
|
||||
# pandas Timestamp 또는 숫자(unix seconds)
|
||||
try:
|
||||
import pandas as pd
|
||||
last_dt = pd.Timestamp(ts).to_pydatetime()
|
||||
if last_dt.tzinfo is None:
|
||||
last_dt = last_dt.replace(tzinfo=timezone.utc)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
age_sec = (now - last_dt).total_seconds()
|
||||
if age_sec > STALE_SEC:
|
||||
continue
|
||||
|
||||
info = vessel_store.get_vessel_info(mmsi)
|
||||
name = (info or {}).get('name', '') or ''
|
||||
name = name.strip()
|
||||
|
||||
m = GEAR_PATTERN.match(name)
|
||||
if not m:
|
||||
continue
|
||||
|
||||
parent_name = m.group(1).strip()
|
||||
entry = {
|
||||
'mmsi': mmsi,
|
||||
'name': name,
|
||||
'lat': float(last['lat']),
|
||||
'lon': float(last['lon']),
|
||||
'sog': float(last.get('sog', 0) if hasattr(last, 'get') else last['sog']),
|
||||
'cog': float(last.get('cog', 0) if hasattr(last, 'get') else last['cog']),
|
||||
}
|
||||
raw_groups.setdefault(parent_name, []).append(entry)
|
||||
|
||||
# 2단계: 거리 기반 서브 클러스터링 (anchor 기준 MAX_DIST_DEG 이내만)
|
||||
results: list[dict] = []
|
||||
for parent_name, gears in raw_groups.items():
|
||||
parent_mmsi = name_to_mmsi.get(parent_name)
|
||||
|
||||
# 기준점(anchor): 모선 있으면 모선 위치, 없으면 첫 어구
|
||||
anchor_lat: Optional[float] = None
|
||||
anchor_lon: Optional[float] = None
|
||||
|
||||
if parent_mmsi and parent_mmsi in vessel_dfs:
|
||||
parent_df = vessel_dfs[parent_mmsi]
|
||||
if parent_df is not None and len(parent_df) > 0:
|
||||
parent_last = parent_df.iloc[-1]
|
||||
anchor_lat = float(parent_last['lat'])
|
||||
anchor_lon = float(parent_last['lon'])
|
||||
|
||||
if anchor_lat is None and gears:
|
||||
anchor_lat = gears[0]['lat']
|
||||
anchor_lon = gears[0]['lon']
|
||||
|
||||
if anchor_lat is None or anchor_lon is None:
|
||||
continue
|
||||
|
||||
# MAX_DIST_DEG 이내 어구만 포함
|
||||
_anchor_lat: float = anchor_lat
|
||||
_anchor_lon: float = anchor_lon
|
||||
nearby = [
|
||||
g for g in gears
|
||||
if abs(g['lat'] - _anchor_lat) <= MAX_DIST_DEG
|
||||
and abs(g['lon'] - _anchor_lon) <= MAX_DIST_DEG
|
||||
]
|
||||
|
||||
if not nearby:
|
||||
continue
|
||||
|
||||
# members 구성: 어구 목록
|
||||
members = [
|
||||
{
|
||||
'mmsi': g['mmsi'],
|
||||
'name': g['name'],
|
||||
'lat': g['lat'],
|
||||
'lon': g['lon'],
|
||||
'sog': g['sog'],
|
||||
'cog': g['cog'],
|
||||
}
|
||||
for g in nearby
|
||||
]
|
||||
|
||||
results.append({
|
||||
'parent_name': parent_name,
|
||||
'parent_mmsi': parent_mmsi,
|
||||
'members': members,
|
||||
})
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def build_all_group_snapshots(
|
||||
vessel_dfs: dict,
|
||||
vessel_store,
|
||||
company_vessels: dict[int, list[str]],
|
||||
companies: dict[int, dict],
|
||||
) -> list[dict]:
|
||||
"""선단(FLEET) + 어구그룹(GEAR) 폴리곤 스냅샷을 생성한다.
|
||||
|
||||
Shapely 미설치 시 빈 리스트를 반환한다.
|
||||
|
||||
Args:
|
||||
vessel_dfs: {mmsi: DataFrame}.
|
||||
vessel_store: VesselStore — get_vessel_info(mmsi).
|
||||
company_vessels: {company_id: [mmsi_list]}.
|
||||
companies: {id: {name_cn, name_en}}.
|
||||
|
||||
Returns:
|
||||
DB INSERT용 dict 목록.
|
||||
"""
|
||||
if not _SHAPELY_AVAILABLE:
|
||||
logger.warning('shapely 미설치 — build_all_group_snapshots 빈 리스트 반환')
|
||||
return []
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
snapshots: list[dict] = []
|
||||
|
||||
# ── FLEET 타입: company_vessels 순회 ──────────────────────────
|
||||
for company_id, mmsi_list in company_vessels.items():
|
||||
company_info = companies.get(company_id, {})
|
||||
group_label = company_info.get('name_cn') or company_info.get('name_en') or str(company_id)
|
||||
|
||||
# 각 선박의 최신 좌표 추출
|
||||
points: list[tuple[float, float]] = []
|
||||
members: list[dict] = []
|
||||
|
||||
for mmsi in mmsi_list:
|
||||
df = vessel_dfs.get(mmsi)
|
||||
if df is None or len(df) == 0:
|
||||
continue
|
||||
last = df.iloc[-1]
|
||||
lat = float(last['lat'])
|
||||
lon = float(last['lon'])
|
||||
sog_val = last.get('sog', 0) if hasattr(last, 'get') else last['sog']
|
||||
cog_val = last.get('cog', 0) if hasattr(last, 'get') else last['cog']
|
||||
sog = float(sog_val)
|
||||
cog = float(cog_val)
|
||||
points.append((lon, lat))
|
||||
members.append({
|
||||
'mmsi': mmsi,
|
||||
'name': (vessel_store.get_vessel_info(mmsi) or {}).get('name', ''),
|
||||
'lat': lat,
|
||||
'lon': lon,
|
||||
'sog': sog,
|
||||
'cog': cog,
|
||||
'role': 'LEADER' if mmsi == mmsi_list[0] else 'MEMBER',
|
||||
'isParent': False,
|
||||
})
|
||||
|
||||
# 2척 미만은 폴리곤 미생성
|
||||
if len(points) < 2:
|
||||
continue
|
||||
|
||||
polygon_wkt, center_wkt, area_sq_nm, center_lat, center_lon = build_group_polygon(
|
||||
points, FLEET_BUFFER_DEG
|
||||
)
|
||||
|
||||
snapshots.append({
|
||||
'group_type': 'FLEET',
|
||||
'group_key': str(company_id),
|
||||
'group_label': group_label,
|
||||
'snapshot_time': now,
|
||||
'polygon_wkt': polygon_wkt,
|
||||
'center_wkt': center_wkt,
|
||||
'area_sq_nm': area_sq_nm,
|
||||
'member_count': len(members),
|
||||
'zone_id': None,
|
||||
'zone_name': None,
|
||||
'members': members,
|
||||
'color': _cluster_color(company_id),
|
||||
})
|
||||
|
||||
# ── GEAR 타입: detect_gear_groups 결과 순회 ───────────────────
|
||||
gear_groups = detect_gear_groups(vessel_dfs, vessel_store, now=now)
|
||||
|
||||
for group in gear_groups:
|
||||
parent_name: str = group['parent_name']
|
||||
parent_mmsi: Optional[str] = group['parent_mmsi']
|
||||
gear_members: list[dict] = group['members']
|
||||
|
||||
# 수역 분류: anchor(모선 or 첫 어구) 위치 기준
|
||||
anchor_lat: Optional[float] = None
|
||||
anchor_lon: Optional[float] = None
|
||||
|
||||
if parent_mmsi and parent_mmsi in vessel_dfs:
|
||||
parent_df = vessel_dfs.get(parent_mmsi)
|
||||
if parent_df is not None and len(parent_df) > 0:
|
||||
p_last = parent_df.iloc[-1]
|
||||
anchor_lat = float(p_last['lat'])
|
||||
anchor_lon = float(p_last['lon'])
|
||||
|
||||
if anchor_lat is None and gear_members:
|
||||
anchor_lat = gear_members[0]['lat']
|
||||
anchor_lon = gear_members[0]['lon']
|
||||
|
||||
if anchor_lat is None:
|
||||
continue
|
||||
|
||||
zone_info = classify_zone(float(anchor_lat), float(anchor_lon))
|
||||
in_zone = _is_in_zone(zone_info)
|
||||
zone_id = zone_info.get('zone') if in_zone else None
|
||||
zone_name = zone_info.get('zone_name') if in_zone else None
|
||||
|
||||
# 비허가(수역 외) 어구: MIN_GEAR_GROUP_SIZE 미만 제외
|
||||
if not in_zone and len(gear_members) < MIN_GEAR_GROUP_SIZE:
|
||||
continue
|
||||
|
||||
# 폴리곤 points: 어구 좌표 + 모선 좌표
|
||||
points = [(g['lon'], g['lat']) for g in gear_members]
|
||||
if parent_mmsi and parent_mmsi in vessel_dfs:
|
||||
parent_df = vessel_dfs.get(parent_mmsi)
|
||||
if parent_df is not None and len(parent_df) > 0:
|
||||
p_last = parent_df.iloc[-1]
|
||||
p_lat = float(p_last['lat'])
|
||||
p_lon = float(p_last['lon'])
|
||||
if (p_lon, p_lat) not in points:
|
||||
points.append((p_lon, p_lat))
|
||||
|
||||
polygon_wkt, center_wkt, area_sq_nm, _clat, _clon = build_group_polygon(
|
||||
points, GEAR_BUFFER_DEG
|
||||
)
|
||||
|
||||
# members JSONB 구성
|
||||
members_out: list[dict] = []
|
||||
# 모선 먼저
|
||||
if parent_mmsi and parent_mmsi in vessel_dfs:
|
||||
parent_df = vessel_dfs.get(parent_mmsi)
|
||||
if parent_df is not None and len(parent_df) > 0:
|
||||
p_last = parent_df.iloc[-1]
|
||||
p_sog = float(p_last.get('sog', 0) if hasattr(p_last, 'get') else p_last['sog'])
|
||||
p_cog = float(p_last.get('cog', 0) if hasattr(p_last, 'get') else p_last['cog'])
|
||||
members_out.append({
|
||||
'mmsi': parent_mmsi,
|
||||
'name': parent_name,
|
||||
'lat': float(p_last['lat']),
|
||||
'lon': float(p_last['lon']),
|
||||
'sog': p_sog,
|
||||
'cog': p_cog,
|
||||
'role': 'PARENT',
|
||||
'isParent': True,
|
||||
})
|
||||
# 어구 목록
|
||||
for g in gear_members:
|
||||
members_out.append({
|
||||
'mmsi': g['mmsi'],
|
||||
'name': g['name'],
|
||||
'lat': g['lat'],
|
||||
'lon': g['lon'],
|
||||
'sog': g['sog'],
|
||||
'cog': g['cog'],
|
||||
'role': 'GEAR',
|
||||
'isParent': False,
|
||||
})
|
||||
|
||||
color = _COLOR_GEAR_IN_ZONE if in_zone else _COLOR_GEAR_OUT_ZONE
|
||||
|
||||
snapshots.append({
|
||||
'group_type': 'GEAR_IN_ZONE' if in_zone else 'GEAR_OUT_ZONE',
|
||||
'group_key': parent_name,
|
||||
'group_label': parent_name,
|
||||
'snapshot_time': now,
|
||||
'polygon_wkt': polygon_wkt,
|
||||
'center_wkt': center_wkt,
|
||||
'area_sq_nm': area_sq_nm,
|
||||
'member_count': len(members_out),
|
||||
'zone_id': zone_id,
|
||||
'zone_name': zone_name,
|
||||
'members': members_out,
|
||||
'color': color,
|
||||
})
|
||||
|
||||
return snapshots
|
||||
@ -1,3 +1,4 @@
|
||||
import json
|
||||
import logging
|
||||
from contextlib import contextmanager
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
@ -137,3 +138,76 @@ def cleanup_old(hours: int = 48) -> int:
|
||||
except Exception as e:
|
||||
logger.error('failed to cleanup old results: %s', e)
|
||||
return 0
|
||||
|
||||
|
||||
def save_group_snapshots(snapshots: list[dict]) -> int:
|
||||
"""group_polygon_snapshots에 폴리곤 스냅샷 배치 INSERT.
|
||||
|
||||
snapshots: polygon_builder.build_all_group_snapshots() 결과
|
||||
각 항목은: group_type, group_key, group_label, snapshot_time,
|
||||
polygon_wkt (str|None), center_wkt (str|None),
|
||||
area_sq_nm, member_count, zone_id, zone_name,
|
||||
members (list[dict]), color
|
||||
"""
|
||||
if not snapshots:
|
||||
return 0
|
||||
|
||||
insert_sql = """
|
||||
INSERT INTO kcg.group_polygon_snapshots (
|
||||
group_type, group_key, group_label, snapshot_time,
|
||||
polygon, center_point, area_sq_nm, member_count,
|
||||
zone_id, zone_name, members, color
|
||||
) VALUES (
|
||||
%s, %s, %s, %s,
|
||||
ST_GeomFromText(%s, 4326), ST_GeomFromText(%s, 4326),
|
||||
%s, %s, %s, %s, %s::jsonb, %s
|
||||
)
|
||||
"""
|
||||
|
||||
inserted = 0
|
||||
try:
|
||||
with get_conn() as conn:
|
||||
with conn.cursor() as cur:
|
||||
for s in snapshots:
|
||||
cur.execute(
|
||||
insert_sql,
|
||||
(
|
||||
s['group_type'],
|
||||
s['group_key'],
|
||||
s['group_label'],
|
||||
s['snapshot_time'],
|
||||
s.get('polygon_wkt'),
|
||||
s.get('center_wkt'),
|
||||
s.get('area_sq_nm'),
|
||||
s.get('member_count'),
|
||||
s.get('zone_id'),
|
||||
s.get('zone_name'),
|
||||
json.dumps(s.get('members', []), ensure_ascii=False),
|
||||
s.get('color'),
|
||||
),
|
||||
)
|
||||
inserted += 1
|
||||
conn.commit()
|
||||
logger.info('saved %d group polygon snapshots', inserted)
|
||||
return inserted
|
||||
except Exception as e:
|
||||
logger.error('failed to save group snapshots: %s', e)
|
||||
return 0
|
||||
|
||||
|
||||
def cleanup_group_snapshots(days: int = 7) -> int:
|
||||
"""오래된 그룹 폴리곤 스냅샷 삭제."""
|
||||
try:
|
||||
with get_conn() as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
f"DELETE FROM kcg.group_polygon_snapshots WHERE snapshot_time < NOW() - INTERVAL '{days} days'",
|
||||
)
|
||||
deleted = cur.rowcount
|
||||
conn.commit()
|
||||
if deleted > 0:
|
||||
logger.info('cleaned up %d old group snapshots (older than %dd)', deleted, days)
|
||||
return deleted
|
||||
except Exception as e:
|
||||
logger.error('failed to cleanup group snapshots: %s', e)
|
||||
return 0
|
||||
|
||||
@ -317,6 +317,19 @@ class FleetTracker:
|
||||
cur.close()
|
||||
logger.info('fleet snapshot saved: %d companies', len(company_vessels))
|
||||
|
||||
def get_company_vessels(self, vessel_dfs: dict[str, 'pd.DataFrame']) -> dict[int, list[str]]:
|
||||
"""현재 AIS 수신 중인 등록 선단의 회사별 MMSI 목록 반환.
|
||||
|
||||
Returns: {company_id: [mmsi, ...]}
|
||||
"""
|
||||
result: dict[int, list[str]] = {}
|
||||
for mmsi, vid in self._mmsi_to_vid.items():
|
||||
v = self._vessels.get(vid)
|
||||
if not v or mmsi not in vessel_dfs:
|
||||
continue
|
||||
result.setdefault(v['company_id'], []).append(mmsi)
|
||||
return result
|
||||
|
||||
|
||||
# 싱글턴
|
||||
fleet_tracker = FleetTracker()
|
||||
|
||||
@ -6,3 +6,4 @@ numpy>=1.26
|
||||
pandas>=2.2
|
||||
scikit-learn>=1.5
|
||||
apscheduler>=3.10
|
||||
shapely>=2.0
|
||||
|
||||
@ -99,6 +99,23 @@ def run_analysis_cycle():
|
||||
|
||||
fleet_tracker.save_snapshot(vessel_dfs, kcg_conn)
|
||||
|
||||
# 4.5 그룹 폴리곤 생성 + 저장
|
||||
try:
|
||||
from algorithms.polygon_builder import detect_gear_groups, build_all_group_snapshots
|
||||
|
||||
company_vessels = fleet_tracker.get_company_vessels(vessel_dfs)
|
||||
gear_groups = detect_gear_groups(vessel_dfs, vessel_store)
|
||||
group_snapshots = build_all_group_snapshots(
|
||||
vessel_dfs, vessel_store, company_vessels,
|
||||
fleet_tracker._companies,
|
||||
)
|
||||
saved = kcgdb.save_group_snapshots(group_snapshots)
|
||||
cleaned = kcgdb.cleanup_group_snapshots(days=7)
|
||||
logger.info('group polygons: %d saved, %d cleaned, %d gear groups',
|
||||
saved, cleaned, len(gear_groups))
|
||||
except Exception as e:
|
||||
logger.warning('group polygon generation failed: %s', e)
|
||||
|
||||
# 5. 선박별 추가 알고리즘 → AnalysisResult 생성
|
||||
results = []
|
||||
for c in classifications:
|
||||
|
||||
불러오는 중...
Reference in New Issue
Block a user