From 2441e3068abec3397f36acd67d577df3e3213f69 Mon Sep 17 00:00:00 2001 From: htlee Date: Tue, 24 Mar 2026 13:30:31 +0900 Subject: [PATCH 1/6] =?UTF-8?q?feat(prediction):=20=EC=84=A0=EB=8B=A8/?= =?UTF-8?q?=EC=96=B4=EA=B5=AC=EA=B7=B8=EB=A3=B9=20=ED=8F=B4=EB=A6=AC?= =?UTF-8?q?=EA=B3=A4=20=EC=84=9C=EB=B2=84=EC=82=AC=EC=9D=B4=EB=93=9C=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1=20+=20PostGIS=20=EC=A0=80=EC=9E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DB migration 009: group_polygon_snapshots 테이블 (PostGIS geometry) - polygon_builder.py: Shapely 기반 convex hull + buffer 폴리곤 생성 - scheduler.py: 5분 주기 분석 사이클에 폴리곤 생성 Step 4.5 통합 - fleet_tracker.py: get_company_vessels() 메서드 추가 - kcgdb.py: save_group_snapshots(), cleanup_group_snapshots() 추가 - requirements.txt: shapely>=2.0 추가 --- database/migration/009_group_polygons.sql | 49 +++ prediction/algorithms/polygon_builder.py | 422 ++++++++++++++++++++++ prediction/db/kcgdb.py | 74 ++++ prediction/fleet_tracker.py | 13 + prediction/requirements.txt | 1 + prediction/scheduler.py | 17 + 6 files changed, 576 insertions(+) create mode 100644 database/migration/009_group_polygons.sql create mode 100644 prediction/algorithms/polygon_builder.py 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/prediction/algorithms/polygon_builder.py b/prediction/algorithms/polygon_builder.py new file mode 100644 index 0000000..5f014ac --- /dev/null +++ b/prediction/algorithms/polygon_builder.py @@ -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 diff --git a/prediction/db/kcgdb.py b/prediction/db/kcgdb.py index 12a362b..ba4282f 100644 --- a/prediction/db/kcgdb.py +++ b/prediction/db/kcgdb.py @@ -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 diff --git a/prediction/fleet_tracker.py b/prediction/fleet_tracker.py index 981d7db..26788f3 100644 --- a/prediction/fleet_tracker.py +++ b/prediction/fleet_tracker.py @@ -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() diff --git a/prediction/requirements.txt b/prediction/requirements.txt index 7268415..46d3abc 100644 --- a/prediction/requirements.txt +++ b/prediction/requirements.txt @@ -6,3 +6,4 @@ numpy>=1.26 pandas>=2.2 scikit-learn>=1.5 apscheduler>=3.10 +shapely>=2.0 diff --git a/prediction/scheduler.py b/prediction/scheduler.py index ff48c3b..1cbd9a7 100644 --- a/prediction/scheduler.py +++ b/prediction/scheduler.py @@ -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: From b0fafca8c9a98e1ecd8e68677cfa14f438a259c0 Mon Sep 17 00:00:00 2001 From: htlee Date: Tue, 24 Mar 2026 13:32:36 +0900 Subject: [PATCH 2/6] =?UTF-8?q?feat(backend):=20=EA=B7=B8=EB=A3=B9=20?= =?UTF-8?q?=ED=8F=B4=EB=A6=AC=EA=B3=A4=20API=20=E2=80=94=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D/=EC=83=81=EC=84=B8/=ED=9E=88=EC=8A=A4=ED=86=A0?= =?UTF-8?q?=EB=A6=AC=20=EC=97=94=EB=93=9C=ED=8F=AC=EC=9D=B8=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GroupPolygonController: GET /api/vessel-analysis/groups (목록, 상세, 히스토리) - GroupPolygonService: JdbcTemplate + ST_AsGeoJSON + Caffeine 5분 캐시 - GroupPolygonDto: GeoJSON polygon + members JSONB 응답 구조 - CacheConfig: GROUP_POLYGONS 캐시 키 추가 --- .../java/gc/mda/kcg/config/CacheConfig.java | 4 +- .../domain/fleet/GroupPolygonController.java | 51 +++++++ .../mda/kcg/domain/fleet/GroupPolygonDto.java | 27 ++++ .../kcg/domain/fleet/GroupPolygonService.java | 131 ++++++++++++++++++ 4 files changed, 212 insertions(+), 1 deletion(-) create mode 100644 backend/src/main/java/gc/mda/kcg/domain/fleet/GroupPolygonController.java create mode 100644 backend/src/main/java/gc/mda/kcg/domain/fleet/GroupPolygonDto.java create mode 100644 backend/src/main/java/gc/mda/kcg/domain/fleet/GroupPolygonService.java 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(); + } +} From 9cad89113d6f24404a0043847ad13974ceb1e26f Mon Sep 17 00:00:00 2001 From: htlee Date: Tue, 24 Mar 2026 13:42:14 +0900 Subject: [PATCH 3/6] =?UTF-8?q?feat(frontend):=20FleetClusterLayer=20?= =?UTF-8?q?=EC=84=9C=EB=B2=84=EC=82=AC=EC=9D=B4=EB=93=9C=20=ED=8F=B4?= =?UTF-8?q?=EB=A6=AC=EA=B3=A4=20=EC=A0=84=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - vesselAnalysis.ts: GroupPolygonDto 타입 + fetchGroupPolygons/Detail/History - useGroupPolygons.ts: 5분 폴링 훅 (fleetGroups/gearInZone/gearOutZone) - FleetClusterLayer: 클라이언트 convexHull/padPolygon 제거 → API GeoJSON 렌더링 - KoreaDashboard/KoreaMap: groupPolygons 훅 연결 + props 전달 --- .../components/korea/FleetClusterLayer.tsx | 581 +++++++----------- .../src/components/korea/KoreaDashboard.tsx | 3 + frontend/src/components/korea/KoreaMap.tsx | 5 +- frontend/src/hooks/useGroupPolygons.ts | 69 +++ frontend/src/services/vesselAnalysis.ts | 52 ++ 5 files changed, 365 insertions(+), 345 deletions(-) create mode 100644 frontend/src/hooks/useGroupPolygons.ts diff --git a/frontend/src/components/korea/FleetClusterLayer.tsx b/frontend/src/components/korea/FleetClusterLayer.tsx index 4cfced1..c5e5a29 100644 --- a/frontend/src/components/korea/FleetClusterLayer.tsx +++ b/frontend/src/components/korea/FleetClusterLayer.tsx @@ -6,8 +6,7 @@ import type { MapLayerMouseEvent } from 'maplibre-gl'; import type { Ship, VesselAnalysisDto } from '../../types'; import { fetchFleetCompanies } from '../../services/vesselAnalysis'; import type { FleetCompany } from '../../services/vesselAnalysis'; -import { classifyFishingZone } from '../../utils/fishingAnalysis'; -import { convexHull, padPolygon, clusterColor } from '../../utils/geometry'; +import type { UseGroupPolygonsResult } from '../../hooks/useGroupPolygons'; export interface SelectedGearGroupData { parent: Ship | null; @@ -29,31 +28,13 @@ interface Props { onFleetZoom?: (bounds: { minLat: number; maxLat: number; minLng: number; maxLng: number }) => void; onSelectedGearChange?: (data: SelectedGearGroupData | null) => void; onSelectedFleetChange?: (data: SelectedFleetData | null) => void; + groupPolygons?: UseGroupPolygonsResult; } -// GeoJSON feature에 color 속성으로 주입 -interface ClusterPolygonFeature { - type: 'Feature'; - id: number; - properties: { clusterId: number; color: string }; - geometry: { type: 'Polygon'; coordinates: [number, number][][] }; -} - -interface ClusterLineFeature { - type: 'Feature'; - id: number; - properties: { clusterId: number; color: string }; - geometry: { type: 'LineString'; coordinates: [number, number][] }; -} - -type ClusterFeature = ClusterPolygonFeature | ClusterLineFeature; - const EMPTY_ANALYSIS = new globalThis.Map(); -const EMPTY_CLUSTERS = new globalThis.Map(); -export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, clusters: clustersProp, onShipSelect, onFleetZoom, onSelectedGearChange, onSelectedFleetChange }: Props) { +export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipSelect, onFleetZoom, onSelectedGearChange, onSelectedFleetChange, groupPolygons }: Props) { const analysisMap = analysisMapProp ?? EMPTY_ANALYSIS; - const clusters = clustersProp ?? EMPTY_CLUSTERS; const [companies, setCompanies] = useState>(new Map()); const [expandedFleet, setExpandedFleet] = useState(null); const [sectionExpanded, setSectionExpanded] = useState>({ @@ -67,8 +48,7 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, cluster const [hoverTooltip, setHoverTooltip] = useState<{ lng: number; lat: number; type: 'fleet' | 'gear'; id: number | string } | null>(null); const { current: mapRef } = useMap(); const registeredRef = useRef(false); - // dataRef는 shipMap/gearGroupMap 선언 이후에 갱신 (아래 참조) - const dataRef = useRef<{ clusters: Map; shipMap: Map; gearGroupMap: Map; onFleetZoom: Props['onFleetZoom'] }>({ clusters, shipMap: new Map(), gearGroupMap: new Map(), onFleetZoom }); + const dataRef = useRef<{ shipMap: Map; groupPolygons: UseGroupPolygonsResult | undefined; onFleetZoom: Props['onFleetZoom'] }>({ shipMap: new Map(), groupPolygons, onFleetZoom }); useEffect(() => { fetchFleetCompanies().then(setCompanies).catch(() => {}); @@ -107,17 +87,15 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, cluster if (cid == null) return; const d = dataRef.current; setExpandedFleet(prev => prev === cid ? null : cid); - setExpanded(true); - const mmsiList = d.clusters.get(cid) ?? []; - if (mmsiList.length === 0) return; + setSectionExpanded(prev => ({ ...prev, fleet: true })); + const group = d.groupPolygons?.fleetGroups.find(g => Number(g.groupKey) === cid); + if (!group || group.members.length === 0) return; let minLat = Infinity, maxLat = -Infinity, minLng = Infinity, maxLng = -Infinity; - for (const mmsi of mmsiList) { - const ship = d.shipMap.get(mmsi); - if (!ship) continue; - if (ship.lat < minLat) minLat = ship.lat; - if (ship.lat > maxLat) maxLat = ship.lat; - if (ship.lng < minLng) minLng = ship.lng; - if (ship.lng > maxLng) maxLng = ship.lng; + for (const m of group.members) { + if (m.lat < minLat) minLat = m.lat; + if (m.lat > maxLat) maxLat = m.lat; + if (m.lon < minLng) minLng = m.lon; + if (m.lon > maxLng) maxLng = m.lon; } if (minLat !== Infinity) d.onFleetZoom?.({ minLat, maxLat, minLng, maxLng }); }; @@ -147,16 +125,17 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, cluster requestAnimationFrame(() => { document.getElementById(`gear-row-${name}`)?.scrollIntoView({ behavior: 'smooth', block: 'start' }); }); - const entry = d.gearGroupMap.get(name); - if (!entry) return; - const all: Ship[] = [...entry.gears]; - if (entry.parent) all.push(entry.parent); + const allGroups = d.groupPolygons + ? [...d.groupPolygons.gearInZoneGroups, ...d.groupPolygons.gearOutZoneGroups] + : []; + const group = allGroups.find(g => g.groupKey === name); + if (!group || group.members.length === 0) return; let minLat = Infinity, maxLat = -Infinity, minLng = Infinity, maxLng = -Infinity; - for (const s of all) { - if (s.lat < minLat) minLat = s.lat; - if (s.lat > maxLat) maxLat = s.lat; - if (s.lng < minLng) minLng = s.lng; - if (s.lng > maxLng) maxLng = s.lng; + for (const m of group.members) { + if (m.lat < minLat) minLat = m.lat; + if (m.lat > maxLat) maxLat = m.lat; + if (m.lon < minLng) minLng = m.lon; + if (m.lon > maxLng) maxLng = m.lon; } if (minLat !== Infinity) d.onFleetZoom?.({ minLat, maxLat, minLng, maxLng }); }; @@ -188,29 +167,6 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, cluster } }, [mapRef]); - // 선박명 → mmsi 맵 (어구 매칭용) - const gearsByParent = useMemo(() => { - const map = new Map(); // parent_mmsi → gears - const gearPattern = /^(.+?)_\d+_\d*$/; - const parentNames = new Map(); // name → mmsi - for (const s of ships) { - if (s.name && !gearPattern.test(s.name)) { - parentNames.set(s.name.trim(), s.mmsi); - } - } - for (const s of ships) { - const m = s.name?.match(gearPattern); - if (!m) continue; - const parentMmsi = parentNames.get(m[1].trim()); - if (parentMmsi) { - const arr = map.get(parentMmsi) ?? []; - arr.push(s); - map.set(parentMmsi, arr); - } - } - return map; - }, [ships]); - // ships map (mmsi → Ship) const shipMap = useMemo(() => { const m = new Map(); @@ -218,56 +174,8 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, cluster return m; }, [ships]); - // 비허가 어구 클러스터: parentName → { parent: Ship | null, gears: Ship[] } - const gearGroupMap = useMemo(() => { - const gearPattern = /^(.+?)_\d+_\d+_?$/; - const MAX_DIST_DEG = 0.15; // ~10NM - const STALE_MS = 60 * 60_000; - const now = Date.now(); - - const nameToShip = new Map(); - for (const s of ships) { - const nm = (s.name || '').trim(); - if (nm && !gearPattern.test(nm)) { - nameToShip.set(nm, s); - } - } - - // 1단계: 같은 모선명 어구 수집 (60분 이내만) - const rawGroups = new Map(); - for (const s of ships) { - if (now - s.lastSeen > STALE_MS) continue; - const m = (s.name || '').match(gearPattern); - if (!m) continue; - const parentName = m[1].trim(); - const arr = rawGroups.get(parentName) ?? []; - arr.push(s); - rawGroups.set(parentName, arr); - } - - // 2단계: 거리 기반 서브 클러스터링 (같은 이름이라도 멀면 분리) - const map = new Map(); - for (const [parentName, gears] of rawGroups) { - const parent = nameToShip.get(parentName) ?? null; - - // 기준점: 모선 있으면 모선 위치, 없으면 첫 어구 - const anchor = parent ?? gears[0]; - if (!anchor) continue; - - const nearby = gears.filter(g => { - const dlat = Math.abs(g.lat - anchor.lat); - const dlng = Math.abs(g.lng - anchor.lng); - return dlat <= MAX_DIST_DEG && dlng <= MAX_DIST_DEG; - }); - - if (nearby.length === 0) continue; - map.set(parentName, { parent, gears: nearby }); - } - return map; - }, [ships]); - - // stale closure 방지 — shipMap/gearGroupMap 선언 이후 갱신 - dataRef.current = { clusters, shipMap, gearGroupMap, onFleetZoom }; + // stale closure 방지 + dataRef.current = { shipMap, groupPolygons, onFleetZoom }; // 선택된 어구 그룹 데이터를 부모에 전달 (deck.gl 렌더링용) useEffect(() => { @@ -275,13 +183,33 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, cluster onSelectedGearChange?.(null); return; } - const entry = gearGroupMap.get(selectedGearGroup); - if (entry) { - onSelectedGearChange?.({ parent: entry.parent, gears: entry.gears, groupName: selectedGearGroup }); - } else { + const allGroups = groupPolygons + ? [...groupPolygons.gearInZoneGroups, ...groupPolygons.gearOutZoneGroups] + : []; + const group = allGroups.find(g => g.groupKey === selectedGearGroup); + if (!group) { onSelectedGearChange?.(null); + return; } - }, [selectedGearGroup, gearGroupMap, onSelectedGearChange]); + const parent = group.members.find(m => m.isParent); + const gears = group.members.filter(m => !m.isParent); + const toShip = (m: typeof group.members[0]): Ship => ({ + mmsi: m.mmsi, + name: m.name, + lat: m.lat, + lng: m.lon, + heading: m.cog, + speed: m.sog, + course: m.cog, + category: 'fishing', + lastSeen: Date.now(), + }); + onSelectedGearChange?.({ + parent: parent ? toShip(parent) : null, + gears: gears.map(toShip), + groupName: selectedGearGroup, + }); + }, [selectedGearGroup, groupPolygons, onSelectedGearChange]); // 선택된 선단 데이터를 부모에 전달 (deck.gl 강조 렌더링용) useEffect(() => { @@ -289,64 +217,115 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, cluster onSelectedFleetChange?.(null); return; } - const mmsiList = clusters.get(expandedFleet) ?? []; - const fleetShips = mmsiList.map(mmsi => shipMap.get(mmsi)).filter((s): s is Ship => !!s); + const group = groupPolygons?.fleetGroups.find(g => Number(g.groupKey) === expandedFleet); const company = companies.get(expandedFleet); + if (!group) { + onSelectedFleetChange?.(null); + return; + } + const fleetShips: Ship[] = group.members.map(m => ({ + mmsi: m.mmsi, + name: m.name, + lat: m.lat, + lng: m.lon, + heading: m.cog, + speed: m.sog, + course: m.cog, + category: 'fishing', + lastSeen: Date.now(), + })); onSelectedFleetChange?.({ clusterId: expandedFleet, ships: fleetShips, - companyName: company?.nameCn || `선단 #${expandedFleet}`, + companyName: company?.nameCn || group.groupLabel || `선단 #${expandedFleet}`, }); - }, [expandedFleet, clusters, shipMap, companies, onSelectedFleetChange]); + }, [expandedFleet, groupPolygons, companies, onSelectedFleetChange]); - // 어구 그룹을 수역 내/외로 분류 - const { inZoneGearGroups, outZoneGearGroups } = useMemo(() => { - const inZone: { name: string; parent: Ship | null; gears: Ship[]; zone: string }[] = []; - const outZone: { name: string; parent: Ship | null; gears: Ship[] }[] = []; - for (const [name, { parent, gears }] of gearGroupMap) { - const anchor = parent ?? gears[0]; - if (!anchor) { - // 비허가 어구: 2개 이상일 때만 그룹으로 탐지 - if (gears.length >= 2) outZone.push({ name, parent, gears }); - continue; - } - const zoneInfo = classifyFishingZone(anchor.lat, anchor.lng); - if (zoneInfo.zone !== 'OUTSIDE') { - inZone.push({ name, parent, gears, zone: zoneInfo.name }); - } else { - // 비허가 어구: 2개 이상일 때만 그룹으로 탐지 - if (gears.length >= 2) outZone.push({ name, parent, gears }); - } - } - inZone.sort((a, b) => b.gears.length - a.gears.length); - outZone.sort((a, b) => b.gears.length - a.gears.length); - return { inZoneGearGroups: inZone, outZoneGearGroups: outZone }; - }, [gearGroupMap]); + // API 기반 어구 그룹 분류 + const inZoneGearGroups = groupPolygons?.gearInZoneGroups ?? []; + const outZoneGearGroups = groupPolygons?.gearOutZoneGroups ?? []; - // 어구 클러스터 GeoJSON (수역 내: 붉은색, 수역 외: 오렌지) - // 비허가 어구(outZone)는 2개 이상만 폴리곤 생성 - const gearClusterGeoJson = useMemo((): GeoJSON => { - const inZoneNames = new Set(inZoneGearGroups.map(g => g.name)); - const outZoneNames = new Set(outZoneGearGroups.map(g => g.name)); + // 선단 폴리곤 GeoJSON (서버 제공) + const fleetPolygonGeoJSON = useMemo((): GeoJSON => { const features: GeoJSON.Feature[] = []; - for (const [parentName, { parent, gears }] of gearGroupMap) { - // 비허가(outZone) 1개짜리는 폴리곤에서 제외 - const isInZone = inZoneNames.has(parentName); - if (!isInZone && !outZoneNames.has(parentName)) continue; - const points: [number, number][] = gears.map(g => [g.lng, g.lat]); - if (parent) points.push([parent.lng, parent.lat]); - if (points.length < 3) continue; - const hull = convexHull(points); - const padded = padPolygon(hull, 0.01); - padded.push(padded[0]); + if (!groupPolygons) return { type: 'FeatureCollection', features }; + for (const g of groupPolygons.fleetGroups) { + if (!g.polygon) continue; features.push({ type: 'Feature', - properties: { name: parentName, gearCount: gears.length, inZone: isInZone ? 1 : 0 }, - geometry: { type: 'Polygon', coordinates: [padded] }, + properties: { clusterId: Number(g.groupKey), color: g.color }, + geometry: g.polygon, }); } return { type: 'FeatureCollection', features }; - }, [gearGroupMap, inZoneGearGroups, outZoneGearGroups]); + }, [groupPolygons?.fleetGroups]); + + // 2척 선단 라인은 서버에서 Shapely buffer로 이미 Polygon 처리되므로 빈 컬렉션 + const lineGeoJSON = useMemo((): GeoJSON => ({ + type: 'FeatureCollection', features: [], + }), []); + + // 호버 하이라이트용 단일 폴리곤 + const hoveredGeoJSON = useMemo((): GeoJSON => { + if (hoveredFleetId === null || !groupPolygons) return { type: 'FeatureCollection', features: [] }; + const g = groupPolygons.fleetGroups.find(f => Number(f.groupKey) === hoveredFleetId); + if (!g?.polygon) return { type: 'FeatureCollection', features: [] }; + return { + type: 'FeatureCollection', + features: [{ + type: 'Feature', + properties: { clusterId: hoveredFleetId, color: g.color }, + geometry: g.polygon, + }], + }; + }, [hoveredFleetId, groupPolygons?.fleetGroups]); + + // 어구 클러스터 GeoJSON (서버 제공) + const gearClusterGeoJson = useMemo((): GeoJSON => { + const features: GeoJSON.Feature[] = []; + if (!groupPolygons) return { type: 'FeatureCollection', features }; + for (const g of [...groupPolygons.gearInZoneGroups, ...groupPolygons.gearOutZoneGroups]) { + if (!g.polygon) continue; + features.push({ + type: 'Feature', + properties: { + name: g.groupKey, + gearCount: g.memberCount, + inZone: g.groupType === 'GEAR_IN_ZONE' ? 1 : 0, + }, + geometry: g.polygon, + }); + } + return { type: 'FeatureCollection', features }; + }, [groupPolygons?.gearInZoneGroups, groupPolygons?.gearOutZoneGroups]); + + // 선단 목록 (멤버 수 내림차순) + const fleetList = useMemo(() => { + if (!groupPolygons) return []; + return groupPolygons.fleetGroups.map(g => ({ + id: Number(g.groupKey), + mmsiList: g.members.map(m => m.mmsi), + label: g.groupLabel, + memberCount: g.memberCount, + areaSqNm: g.areaSqNm, + color: g.color, + members: g.members, + })).sort((a, b) => b.memberCount - a.memberCount); + }, [groupPolygons?.fleetGroups]); + + const handleFleetZoom = useCallback((clusterId: number) => { + const group = groupPolygons?.fleetGroups.find(g => Number(g.groupKey) === clusterId); + if (!group || group.members.length === 0) return; + let minLat = Infinity, maxLat = -Infinity, minLng = Infinity, maxLng = -Infinity; + for (const m of group.members) { + if (m.lat < minLat) minLat = m.lat; + if (m.lat > maxLat) maxLat = m.lat; + if (m.lon < minLng) minLng = m.lon; + if (m.lon > maxLng) maxLng = m.lon; + } + if (minLat === Infinity) return; + onFleetZoom?.({ minLat, maxLat, minLng, maxLng }); + }, [groupPolygons?.fleetGroups, onFleetZoom]); const handleGearGroupZoom = useCallback((parentName: string) => { setSelectedGearGroup(prev => prev === parentName ? null : parentName); @@ -354,98 +333,21 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, cluster requestAnimationFrame(() => { document.getElementById(`gear-row-${parentName}`)?.scrollIntoView({ behavior: 'smooth', block: 'start' }); }); - const entry = gearGroupMap.get(parentName); - if (!entry) return; - const all: Ship[] = [...entry.gears]; - if (entry.parent) all.push(entry.parent); - if (all.length === 0) return; + const allGroups = groupPolygons + ? [...groupPolygons.gearInZoneGroups, ...groupPolygons.gearOutZoneGroups] + : []; + const group = allGroups.find(g => g.groupKey === parentName); + if (!group || group.members.length === 0) return; let minLat = Infinity, maxLat = -Infinity, minLng = Infinity, maxLng = -Infinity; - for (const s of all) { - if (s.lat < minLat) minLat = s.lat; - if (s.lat > maxLat) maxLat = s.lat; - if (s.lng < minLng) minLng = s.lng; - if (s.lng > maxLng) maxLng = s.lng; + for (const m of group.members) { + if (m.lat < minLat) minLat = m.lat; + if (m.lat > maxLat) maxLat = m.lat; + if (m.lon < minLng) minLng = m.lon; + if (m.lon > maxLng) maxLng = m.lon; } if (minLat === Infinity) return; onFleetZoom?.({ minLat, maxLat, minLng, maxLng }); - }, [gearGroupMap, onFleetZoom]); - - // GeoJSON 피처 생성 - const polygonFeatures = useMemo((): ClusterFeature[] => { - const features: ClusterFeature[] = []; - for (const [clusterId, mmsiList] of clusters) { - const points: [number, number][] = []; - for (const mmsi of mmsiList) { - const ship = shipMap.get(mmsi); - if (ship) points.push([ship.lng, ship.lat]); - } - if (points.length < 2) continue; - - const color = clusterColor(clusterId); - - if (points.length === 2) { - features.push({ - type: 'Feature', - id: clusterId, - properties: { clusterId, color }, - geometry: { type: 'LineString', coordinates: points }, - }); - continue; - } - - const hull = convexHull(points); - const padded = padPolygon(hull, 0.02); - // 폴리곤 닫기 - const ring = [...padded, padded[0]]; - features.push({ - type: 'Feature', - id: clusterId, - properties: { clusterId, color }, - geometry: { type: 'Polygon', coordinates: [ring] }, - }); - } - return features; - }, [clusters, shipMap]); - - const polygonGeoJSON = useMemo((): GeoJSON => ({ - type: 'FeatureCollection', - features: polygonFeatures.filter(f => f.geometry.type === 'Polygon'), - }), [polygonFeatures]); - - const lineGeoJSON = useMemo((): GeoJSON => ({ - type: 'FeatureCollection', - features: polygonFeatures.filter(f => f.geometry.type === 'LineString'), - }), [polygonFeatures]); - - // 호버 하이라이트용 단일 폴리곤 - const hoveredGeoJSON = useMemo((): GeoJSON => { - if (hoveredFleetId === null) return { type: 'FeatureCollection', features: [] }; - const f = polygonFeatures.find(p => p.properties.clusterId === hoveredFleetId && p.geometry.type === 'Polygon'); - if (!f) return { type: 'FeatureCollection', features: [] }; - return { type: 'FeatureCollection', features: [f] }; - }, [hoveredFleetId, polygonFeatures]); - - const handleFleetZoom = useCallback((clusterId: number) => { - const mmsiList = clusters.get(clusterId) ?? []; - if (mmsiList.length === 0) return; - let minLat = Infinity, maxLat = -Infinity, minLng = Infinity, maxLng = -Infinity; - for (const mmsi of mmsiList) { - const ship = shipMap.get(mmsi); - if (!ship) continue; - if (ship.lat < minLat) minLat = ship.lat; - if (ship.lat > maxLat) maxLat = ship.lat; - if (ship.lng < minLng) minLng = ship.lng; - if (ship.lng > maxLng) maxLng = ship.lng; - } - if (minLat === Infinity) return; - onFleetZoom?.({ minLat, maxLat, minLng, maxLng }); - }, [clusters, shipMap, onFleetZoom]); - - const fleetList = useMemo(() => { - return Array.from(clusters.entries()) - .map(([id, mmsiList]) => ({ id, mmsiList })) - .sort((a, b) => b.mmsiList.length - a.mmsiList.length); - }, [clusters]); + }, [groupPolygons, onFleetZoom]); // 패널 스타일 (AnalysisStatsPanel 패턴) const panelStyle: React.CSSProperties = { @@ -492,7 +394,7 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, cluster return ( <> {/* 선단 폴리곤 레이어 */} - + - {/* 2척 선단 라인 */} + {/* 2척 선단 라인 (서버측 Polygon 처리로 빈 컬렉션) */} - {/* 선택된 어구 그룹 하이라이트 폴리곤 (deck.gl에서 어구 아이콘 + 모선 마커 표시) */} + {/* 선택된 어구 그룹 하이라이트 폴리곤 */} {selectedGearGroup && (() => { - const entry = gearGroupMap.get(selectedGearGroup); - if (!entry) return null; - const points: [number, number][] = entry.gears.map(g => [g.lng, g.lat]); - if (entry.parent) points.push([entry.parent.lng, entry.parent.lat]); - - const hlFeatures: GeoJSON.Feature[] = []; - if (points.length >= 3) { - const hull = convexHull(points); - const padded = padPolygon(hull, 0.01); - padded.push(padded[0]); - hlFeatures.push({ + const allGroups = groupPolygons + ? [...groupPolygons.gearInZoneGroups, ...groupPolygons.gearOutZoneGroups] + : []; + const group = allGroups.find(g => g.groupKey === selectedGearGroup); + if (!group?.polygon) return null; + const hlGeoJson: GeoJSON.FeatureCollection = { + type: 'FeatureCollection', + features: [{ type: 'Feature', properties: {}, - geometry: { type: 'Polygon', coordinates: [padded] }, - }); - } - if (hlFeatures.length === 0) return null; - const hlGeoJson: GeoJSON.FeatureCollection = { type: 'FeatureCollection', features: hlFeatures }; - + geometry: group.polygon, + }], + }; return ( @@ -592,28 +488,27 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, cluster {hoverTooltip && (() => { if (hoverTooltip.type === 'fleet') { const cid = hoverTooltip.id as number; - const mmsiList = clusters.get(cid) ?? []; + const group = groupPolygons?.fleetGroups.find(g => Number(g.groupKey) === cid); const company = companies.get(cid); - const gearCount = mmsiList.reduce((acc, mmsi) => acc + (gearsByParent.get(mmsi)?.length ?? 0), 0); + const memberCount = group?.memberCount ?? 0; return (
-
- {company?.nameCn || `선단 #${cid}`} +
+ {company?.nameCn || group?.groupLabel || `선단 #${cid}`}
-
선박 {mmsiList.length}척 · 어구 {gearCount}개
- {expandedFleet === cid && mmsiList.slice(0, 5).map(mmsi => { - const s = shipMap.get(mmsi); - const dto = analysisMap.get(mmsi); - const role = dto?.algorithms.fleetRole.role ?? ''; - return s ? ( -
- {role === 'LEADER' ? '★' : '·'} {s.name || mmsi} {s.speed?.toFixed(1)}kt +
선박 {memberCount}척
+ {expandedFleet === cid && group?.members.slice(0, 5).map(m => { + const dto = analysisMap.get(m.mmsi); + const role = dto?.algorithms.fleetRole.role ?? m.role; + return ( +
+ {role === 'LEADER' ? '★' : '·'} {m.name || m.mmsi} {m.sog.toFixed(1)}kt
- ) : null; + ); })}
클릭하여 상세 보기
@@ -622,8 +517,13 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, cluster } if (hoverTooltip.type === 'gear') { const name = hoverTooltip.id as string; - const entry = gearGroupMap.get(name); - if (!entry) return null; + const allGroups = groupPolygons + ? [...groupPolygons.gearInZoneGroups, ...groupPolygons.gearOutZoneGroups] + : []; + const group = allGroups.find(g => g.groupKey === name); + if (!group) return null; + const parentMember = group.members.find(m => m.isParent); + const gearMembers = group.members.filter(m => !m.isParent); return (
- {name} 어구 {entry.gears.length}개 + {name} 어구 {gearMembers.length}개
- {entry.parent && ( -
모선: {entry.parent.name || entry.parent.mmsi}
+ {parentMember && ( +
모선: {parentMember.name || parentMember.mmsi}
)} - {selectedGearGroup === name && entry.gears.slice(0, 5).map(g => ( -
- · {g.name || g.mmsi} + {selectedGearGroup === name && gearMembers.slice(0, 5).map(m => ( +
+ · {m.name || m.mmsi}
))}
클릭하여 선택/해제
@@ -657,7 +557,7 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, cluster 선단 현황 ({fleetList.length}개) -
@@ -668,18 +568,17 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, cluster 선단 데이터 없음
) : ( - fleetList.map(({ id, mmsiList }) => { + fleetList.map(({ id, mmsiList, label, color, members }) => { const company = companies.get(id); - const companyName = company?.nameCn ?? `선단 #${id}`; - const color = clusterColor(id); + const companyName = company?.nameCn ?? label ?? `선단 #${id}`; const isOpen = expandedFleet === id; const isHovered = hoveredFleetId === id; - const mainVessels = mmsiList.filter(mmsi => { - const dto = analysisMap.get(mmsi); + const mainMembers = members.filter(m => { + const dto = analysisMap.get(m.mmsi); return dto?.algorithms.fleetRole.role === 'LEADER' || dto?.algorithms.fleetRole.role === 'MEMBER'; }); - const gearCount = mmsiList.reduce((acc, mmsi) => acc + (gearsByParent.get(mmsi)?.length ?? 0), 0); + const displayMembers = mainMembers.length > 0 ? mainMembers : members; return (
@@ -721,7 +620,7 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, cluster whiteSpace: 'nowrap', cursor: 'pointer', }} - title={company ? `${company.nameCn} / ${company.nameEn}` : `선단 #${id}`} + title={company ? `${company.nameCn} / ${company.nameEn}` : companyName} > {companyName} @@ -731,6 +630,7 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, cluster {/* zoom 버튼 */}
); })} - - {/* 어구 목록 */} - {gearCount > 0 && ( - <> -
- 어구: {gearCount}개 -
- {mmsiList.flatMap(mmsi => gearsByParent.get(mmsi) ?? []).map(gear => ( -
- {gear.name || gear.mmsi} -
- ))} - - )}
)}
@@ -838,9 +724,13 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, cluster {sectionExpanded.inZone && (
- {inZoneGearGroups.map(({ name, parent, gears, zone }) => { + {inZoneGearGroups.map(g => { + const name = g.groupKey; const isOpen = expandedGearGroup === name; const accentColor = '#dc2626'; + const parentMember = g.members.find(m => m.isParent); + const gearMembers = g.members.filter(m => !m.isParent); + const zoneName = g.zoneName ?? ''; return (
setExpandedGearGroup(prev => (prev === name ? null : name))} style={{ color: '#94a3b8', fontSize: 9, flexShrink: 0, cursor: 'pointer' }}>{isOpen ? '▾' : '▸'} - setExpandedGearGroup(prev => (prev === name ? null : name))} style={{ flex: 1, color: '#e2e8f0', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', cursor: 'pointer' }} title={`${name} — ${zone}`}>{name} - {zone} - ({gears.length}) + setExpandedGearGroup(prev => (prev === name ? null : name))} style={{ flex: 1, color: '#e2e8f0', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', cursor: 'pointer' }} title={`${name} — ${zoneName}`}>{name} + {zoneName} + ({gearMembers.length})
{isOpen && (
- {parent &&
모선: {parent.name || parent.mmsi}
} + {parentMember &&
모선: {parentMember.name || parentMember.mmsi}
}
어구 목록:
- {gears.map(g => ( -
- {g.name || g.mmsi} - + {gearMembers.map(m => ( +
+ {m.name || m.mmsi} +
))}
@@ -882,14 +772,17 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, cluster 비허가 어구 ({outZoneGearGroups.length}개) -
{sectionExpanded.outZone && (
- {outZoneGearGroups.map(({ name, parent, gears }) => { + {outZoneGearGroups.map(g => { + const name = g.groupKey; const isOpen = expandedGearGroup === name; + const parentMember = g.members.find(m => m.isParent); + const gearMembers = g.members.filter(m => !m.isParent); return (
- ({gears.length}개) + ({gearMembers.length}개) diff --git a/frontend/src/components/korea/KoreaDashboard.tsx b/frontend/src/components/korea/KoreaDashboard.tsx index 2a56df0..b190b72 100644 --- a/frontend/src/components/korea/KoreaDashboard.tsx +++ b/frontend/src/components/korea/KoreaDashboard.tsx @@ -11,6 +11,7 @@ import { ReplayControls } from '../common/ReplayControls'; import { TimelineSlider } from '../common/TimelineSlider'; import { useKoreaData } from '../../hooks/useKoreaData'; import { useVesselAnalysis } from '../../hooks/useVesselAnalysis'; +import { useGroupPolygons } from '../../hooks/useGroupPolygons'; import { useKoreaFilters } from '../../hooks/useKoreaFilters'; import { useSharedFilters } from '../../hooks/useSharedFilters'; import { EAST_ASIA_PORTS } from '../../data/ports'; @@ -161,6 +162,7 @@ export const KoreaDashboard = ({ }); const vesselAnalysis = useVesselAnalysis(true); + const groupPolygons = useGroupPolygons(true); const koreaFiltersResult = useKoreaFilters( koreaData.ships, @@ -329,6 +331,7 @@ export const KoreaDashboard = ({ cnFishingSuspects={koreaFiltersResult.cnFishingSuspects} dokdoAlerts={koreaFiltersResult.dokdoAlerts} vesselAnalysis={vesselAnalysis} + groupPolygons={groupPolygons} hiddenShipCategories={hiddenShipCategories} hiddenNationalities={hiddenNationalities} /> diff --git a/frontend/src/components/korea/KoreaMap.tsx b/frontend/src/components/korea/KoreaMap.tsx index 080517a..62aaba6 100644 --- a/frontend/src/components/korea/KoreaMap.tsx +++ b/frontend/src/components/korea/KoreaMap.tsx @@ -36,6 +36,7 @@ import type { PowerFacility } from '../../services/infra'; import type { Ship, Aircraft, SatellitePosition } from '../../types'; import type { OsintItem } from '../../services/osint'; import type { UseVesselAnalysisResult } from '../../hooks/useVesselAnalysis'; +import type { UseGroupPolygonsResult } from '../../hooks/useGroupPolygons'; import { countryLabelsGeoJSON } from '../../data/countryLabels'; import { useLocalStorage } from '../../hooks/useLocalStorage'; import 'maplibre-gl/dist/maplibre-gl.css'; @@ -65,6 +66,7 @@ interface Props { cnFishingSuspects: Set; dokdoAlerts: { mmsi: string; name: string; dist: number; time: number }[]; vesselAnalysis?: UseVesselAnalysisResult; + groupPolygons?: UseGroupPolygonsResult; hiddenShipCategories?: Set; hiddenNationalities?: Set; } @@ -142,7 +144,7 @@ const FILTER_I18N_KEY: Record = { cnFishing: 'filters.cnFishingMonitor', }; -export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintFeed, currentTime, koreaFilters, transshipSuspects, cableWatchSuspects, dokdoWatchSuspects, dokdoAlerts, vesselAnalysis, hiddenShipCategories, hiddenNationalities }: Props) { +export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintFeed, currentTime, koreaFilters, transshipSuspects, cableWatchSuspects, dokdoWatchSuspects, dokdoAlerts, vesselAnalysis, groupPolygons, hiddenShipCategories, hiddenNationalities }: Props) { const { t } = useTranslation(); const mapRef = useRef(null); const [infra, setInfra] = useState([]); @@ -661,6 +663,7 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF ships={allShips ?? ships} analysisMap={vesselAnalysis ? vesselAnalysis.analysisMap : undefined} clusters={vesselAnalysis ? vesselAnalysis.clusters : undefined} + groupPolygons={groupPolygons} onShipSelect={handleAnalysisShipSelect} onFleetZoom={handleFleetZoom} onSelectedGearChange={setSelectedGearData} diff --git a/frontend/src/hooks/useGroupPolygons.ts b/frontend/src/hooks/useGroupPolygons.ts new file mode 100644 index 0000000..b3cd9af --- /dev/null +++ b/frontend/src/hooks/useGroupPolygons.ts @@ -0,0 +1,69 @@ +import { useState, useEffect, useRef, useMemo, useCallback } from 'react'; +import { fetchGroupPolygons } from '../services/vesselAnalysis'; +import type { GroupPolygonDto } from '../services/vesselAnalysis'; + +const POLL_INTERVAL_MS = 5 * 60_000; // 5분 + +export interface UseGroupPolygonsResult { + fleetGroups: GroupPolygonDto[]; + gearInZoneGroups: GroupPolygonDto[]; + gearOutZoneGroups: GroupPolygonDto[]; + allGroups: GroupPolygonDto[]; + isLoading: boolean; + lastUpdated: number; +} + +const EMPTY: UseGroupPolygonsResult = { + fleetGroups: [], + gearInZoneGroups: [], + gearOutZoneGroups: [], + allGroups: [], + isLoading: false, + lastUpdated: 0, +}; + +export function useGroupPolygons(enabled: boolean): UseGroupPolygonsResult { + const [allGroups, setAllGroups] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [lastUpdated, setLastUpdated] = useState(0); + const timerRef = useRef>(); + + const load = useCallback(async () => { + setIsLoading(true); + try { + const groups = await fetchGroupPolygons(); + setAllGroups(groups); + setLastUpdated(Date.now()); + } catch { + // 네트워크 오류 시 기존 데이터 유지 + } finally { + setIsLoading(false); + } + }, []); + + useEffect(() => { + if (!enabled) return; + load(); + timerRef.current = setInterval(load, POLL_INTERVAL_MS); + return () => clearInterval(timerRef.current); + }, [enabled, load]); + + const fleetGroups = useMemo( + () => allGroups.filter(g => g.groupType === 'FLEET'), + [allGroups], + ); + + const gearInZoneGroups = useMemo( + () => allGroups.filter(g => g.groupType === 'GEAR_IN_ZONE'), + [allGroups], + ); + + const gearOutZoneGroups = useMemo( + () => allGroups.filter(g => g.groupType === 'GEAR_OUT_ZONE'), + [allGroups], + ); + + if (!enabled) return EMPTY; + + return { fleetGroups, gearInZoneGroups, gearOutZoneGroups, allGroups, isLoading, lastUpdated }; +} diff --git a/frontend/src/services/vesselAnalysis.ts b/frontend/src/services/vesselAnalysis.ts index c871c11..257268b 100644 --- a/frontend/src/services/vesselAnalysis.ts +++ b/frontend/src/services/vesselAnalysis.ts @@ -17,6 +17,58 @@ export interface FleetCompany { nameEn: string; } +/* ── Group Polygon Types ─────────────────────────────────────── */ + +export interface MemberInfo { + mmsi: string; + name: string; + lat: number; + lon: number; + sog: number; + cog: number; + role: string; + isParent: boolean; +} + +export interface GroupPolygonDto { + groupType: 'FLEET' | 'GEAR_IN_ZONE' | 'GEAR_OUT_ZONE'; + groupKey: string; + groupLabel: string; + snapshotTime: string; + polygon: GeoJSON.Polygon | null; + centerLat: number; + centerLon: number; + areaSqNm: number; + memberCount: number; + zoneId: string | null; + zoneName: string | null; + members: MemberInfo[]; + color: string; +} + +export async function fetchGroupPolygons(): Promise { + const res = await fetch(`${API_BASE}/vessel-analysis/groups`, { + headers: { accept: 'application/json' }, + }); + if (!res.ok) return []; + const data: { count: number; items: GroupPolygonDto[] } = await res.json(); + return data.items ?? []; +} + +export async function fetchGroupDetail(groupKey: string): Promise { + const res = await fetch(`${API_BASE}/vessel-analysis/groups/${encodeURIComponent(groupKey)}/detail`); + if (!res.ok) return null; + return res.json(); +} + +export async function fetchGroupHistory(groupKey: string, hours = 24): Promise { + const res = await fetch(`${API_BASE}/vessel-analysis/groups/${encodeURIComponent(groupKey)}/history?hours=${hours}`); + if (!res.ok) return []; + return res.json(); +} + +/* ── Fleet Companies ─────────────────────────────────────────── */ + // 캐시 (세션 중 1회 로드) let companyCache: Map | null = null; From 00067fa165d5638e3456711dcf9c5d808c8c4b3a Mon Sep 17 00:00:00 2001 From: htlee Date: Tue, 24 Mar 2026 14:05:50 +0900 Subject: [PATCH 4/6] =?UTF-8?q?fix:=20=EB=B6=88=EB=B2=95=EC=96=B4=EC=84=A0?= =?UTF-8?q?=20=ED=83=AD=20=EB=B3=B5=EC=9B=90=20(=EC=9E=84=EC=8B=9C=20?= =?UTF-8?q?=EC=88=A8=EA=B9=80=20=ED=95=B4=EC=A0=9C)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/components/korea/KoreaDashboard.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/frontend/src/components/korea/KoreaDashboard.tsx b/frontend/src/components/korea/KoreaDashboard.tsx index b190b72..f96c6cf 100644 --- a/frontend/src/components/korea/KoreaDashboard.tsx +++ b/frontend/src/components/korea/KoreaDashboard.tsx @@ -261,12 +261,10 @@ export const KoreaDashboard = ({ <> {headerSlot && createPortal(
- {/* 불법어선 탭 — 준비 중, 임시 숨김 - */}