From 812a78f636caabe9332f0c3fc29945bf2c75056e Mon Sep 17 00:00:00 2001 From: htlee Date: Mon, 30 Mar 2026 10:36:43 +0900 Subject: [PATCH 01/27] =?UTF-8?q?feat:=20=EC=96=B4=EA=B5=AC=20=EC=97=B0?= =?UTF-8?q?=EA=B4=80=EC=84=B1=20=EB=A9=80=ED=8B=B0=EB=AA=A8=EB=8D=B8=20?= =?UTF-8?q?=ED=8C=A8=ED=84=B4=20=EC=B6=94=EC=A0=81=20=EC=8B=9C=EC=8A=A4?= =?UTF-8?q?=ED=85=9C=20(Phase=201=20Core)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - gear_correlation.py: 적응형 EMA + freeze + shadow + 배치 최적화 - 5개 글로벌 모델 병렬 추적 (default/aggressive/conservative/proximity-heavy/visit-pattern) - 어구 중심 점수 체계: 어구 비활성 시 FREEZE, 선박 shadow 추적 - 유형별 메트릭: 어구-선박(proximity+visit+activity), 선박-선박(DTW+SOG+COG) - DB: correlation_param_models + raw_metrics(일별 파티션) + scores + system_config - partition_manager: 일별 파티션 생성/정리 (system_config hot-reload) - track_similarity: SOG상관 + COG동조 + 근접비 3개 메트릭 추가 - scheduler Step 4.7 통합, fleet_tracker MMSI 점수 이전 - chat/tools: query_gear_correlation 도구 Co-Authored-By: Claude Opus 4.6 (1M context) --- database/migration/010_gear_correlation.sql | 146 ++++ prediction/algorithms/gear_correlation.py | 783 ++++++++++++++++++++ prediction/algorithms/track_similarity.py | 84 +++ prediction/chat/tools.py | 54 ++ prediction/db/partition_manager.py | 136 ++++ prediction/fleet_tracker.py | 16 + prediction/scheduler.py | 28 + 7 files changed, 1247 insertions(+) create mode 100644 database/migration/010_gear_correlation.sql create mode 100644 prediction/algorithms/gear_correlation.py create mode 100644 prediction/db/partition_manager.py diff --git a/database/migration/010_gear_correlation.sql b/database/migration/010_gear_correlation.sql new file mode 100644 index 0000000..a36c5b5 --- /dev/null +++ b/database/migration/010_gear_correlation.sql @@ -0,0 +1,146 @@ +-- 010: 어구 연관성 추적 시스템 +-- - correlation_param_models: 파라미터 모델 마스터 +-- - gear_correlation_raw_metrics: raw 메트릭 (타임스탬프 파티셔닝, 7일 보존) +-- - gear_correlation_scores: 모델별 어피니티 점수 (상태 테이블) +-- - system_config: 런타임 설정 (파티션 보관기간 등) + +SET search_path TO kcg, public; + +-- ── 파라미터 모델 ── +CREATE TABLE IF NOT EXISTS kcg.correlation_param_models ( + id SERIAL PRIMARY KEY, + name VARCHAR(100) NOT NULL UNIQUE, + is_default BOOLEAN DEFAULT FALSE, + is_active BOOLEAN DEFAULT TRUE, + params JSONB NOT NULL, + description TEXT, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() +); + +-- default 모델 삽입 +INSERT INTO kcg.correlation_param_models (name, is_default, is_active, params, description) +VALUES ('default', TRUE, TRUE, + '{"alpha_base":0.30,"alpha_min":0.08,"alpha_decay_per_streak":0.005,"track_threshold":0.50,"polygon_threshold":0.70,"w_proximity":0.45,"w_visit":0.35,"w_activity":0.20,"w_dtw":0.30,"w_sog_corr":0.20,"w_heading":0.25,"w_prox_vv":0.25,"w_prox_persist":0.50,"w_drift":0.30,"w_signal_sync":0.20,"group_quiet_ratio":0.30,"normal_gap_hours":1.0,"decay_slow":0.015,"decay_fast":0.08,"stale_hours":6.0,"shadow_stay_bonus":0.10,"shadow_return_bonus":0.15,"candidate_radius_factor":3.0,"proximity_threshold_nm":5.0,"visit_threshold_nm":5.0,"night_bonus":1.3,"long_decay_days":7.0}', + '기본 추적 모델') +ON CONFLICT (name) DO NOTHING; + +-- ── Raw 메트릭 (모델 독립, 5분마다 기록, 타임스탬프 파티셔닝) ── +CREATE TABLE IF NOT EXISTS kcg.gear_correlation_raw_metrics ( + id BIGSERIAL, + observed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + group_key VARCHAR(100) NOT NULL, + target_mmsi VARCHAR(20) NOT NULL, + target_type VARCHAR(10) NOT NULL, + target_name VARCHAR(200), + + -- Raw 메트릭 (모든 모델이 공유) + proximity_ratio DOUBLE PRECISION, + visit_score DOUBLE PRECISION, + activity_sync DOUBLE PRECISION, + dtw_similarity DOUBLE PRECISION, + speed_correlation DOUBLE PRECISION, + heading_coherence DOUBLE PRECISION, + drift_similarity DOUBLE PRECISION, + + -- Shadow + shadow_stay BOOLEAN DEFAULT FALSE, + shadow_return BOOLEAN DEFAULT FALSE, + + -- 상태 + gear_group_active_ratio DOUBLE PRECISION, + + PRIMARY KEY (id, observed_at) +) PARTITION BY RANGE (observed_at); + +-- 일별 파티션 생성 함수 +CREATE OR REPLACE FUNCTION kcg.create_raw_metric_partitions(days_ahead INT DEFAULT 3) +RETURNS void AS $$ +DECLARE + d DATE; + partition_name TEXT; +BEGIN + FOR i IN 0..days_ahead LOOP + d := CURRENT_DATE + i; + partition_name := 'gear_correlation_raw_metrics_' || TO_CHAR(d, 'YYYYMMDD'); + IF NOT EXISTS ( + SELECT 1 FROM pg_class c + JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE c.relname = partition_name AND n.nspname = 'kcg' + ) THEN + EXECUTE format( + 'CREATE TABLE IF NOT EXISTS kcg.%I PARTITION OF kcg.gear_correlation_raw_metrics + FOR VALUES FROM (%L) TO (%L)', + partition_name, d, d + 1 + ); + END IF; + END LOOP; +END; +$$ LANGUAGE plpgsql; + +-- 초기 파티션 생성 (오늘 + 3일) +SELECT kcg.create_raw_metric_partitions(3); + +-- raw_metrics 인덱스 +CREATE INDEX IF NOT EXISTS idx_raw_metrics_group_time + ON kcg.gear_correlation_raw_metrics (group_key, observed_at DESC); +CREATE INDEX IF NOT EXISTS idx_raw_metrics_target + ON kcg.gear_correlation_raw_metrics (target_mmsi, observed_at DESC); + +-- ── 어피니티 점수 (모델별 독립, 상태 테이블) ── +CREATE TABLE IF NOT EXISTS kcg.gear_correlation_scores ( + id BIGSERIAL PRIMARY KEY, + model_id INT NOT NULL REFERENCES kcg.correlation_param_models(id) ON DELETE CASCADE, + + group_key VARCHAR(100) NOT NULL, + target_mmsi VARCHAR(20) NOT NULL, + target_type VARCHAR(10) NOT NULL, + target_name VARCHAR(200), + + -- 모델별 점수 (EMA 결과) + current_score DOUBLE PRECISION DEFAULT 0, + streak_count INT DEFAULT 0, + observation_count INT DEFAULT 0, + + -- Shadow 축적 + shadow_bonus_total DOUBLE PRECISION DEFAULT 0, + shadow_stay_count INT DEFAULT 0, + shadow_return_count INT DEFAULT 0, + + -- 상태 + freeze_state VARCHAR(20) DEFAULT 'ACTIVE', + + -- 시간 + first_observed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + last_observed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + UNIQUE (model_id, group_key, target_mmsi) +); + +CREATE INDEX IF NOT EXISTS idx_gc_model_group + ON kcg.gear_correlation_scores (model_id, group_key, current_score DESC); +CREATE INDEX IF NOT EXISTS idx_gc_active + ON kcg.gear_correlation_scores (current_score DESC) + WHERE current_score >= 0.5; + +-- ── 시스템 설정 (런타임 변경 가능, 재시작 불필요) ── +CREATE TABLE IF NOT EXISTS kcg.system_config ( + key VARCHAR(100) PRIMARY KEY, + value JSONB NOT NULL, + description TEXT, + updated_at TIMESTAMPTZ DEFAULT NOW(), + updated_by VARCHAR(100) DEFAULT 'system' +); + +INSERT INTO kcg.system_config (key, value, description) VALUES + ('partition.raw_metrics.retention_days', '7', + 'raw_metrics 파티션 보관 기간 (일). 초과 시 파티션 DROP.'), + ('partition.raw_metrics.create_ahead_days', '3', + '미래 파티션 미리 생성 일수.'), + ('partition.scores.cleanup_days', '30', + '미관측 점수 레코드 정리 기간 (일).'), + ('correlation.max_active_models', '5', + '동시 활성 모델 최대 수.') +ON CONFLICT (key) DO NOTHING; diff --git a/prediction/algorithms/gear_correlation.py b/prediction/algorithms/gear_correlation.py new file mode 100644 index 0000000..edae548 --- /dev/null +++ b/prediction/algorithms/gear_correlation.py @@ -0,0 +1,783 @@ +"""어구 그룹 다단계 연관성 분석 — 멀티모델 패턴 추적. + +Phase 1: default 모델 1개로 동작 (DB에서 is_active=true 모델 로드). +Phase 2: 글로벌 모델 max 5개 병렬 실행. + +어구 중심 점수 체계: + - 어구 신호 기준 관측 윈도우 (어구 비활성 시 FREEZE) + - 선박 shadow 추적 (비활성 → 활성 전환 시 보너스) + - 적응형 EMA + streak 자기강화 + - 퍼센트 기반 무제한 추적 (50%+) +""" + +from __future__ import annotations + +import logging +import math +from dataclasses import dataclass, field +from datetime import datetime, timezone +from typing import Optional + +logger = logging.getLogger(__name__) + + +# ── 상수 ────────────────────────────────────────────────────────── +_EARTH_RADIUS_NM = 3440.065 +_NM_TO_M = 1852.0 + + +# ── 파라미터 모델 ───────────────────────────────────────────────── + +@dataclass +class ModelParams: + """추적 모델의 전체 파라미터셋.""" + + model_id: int = 1 + name: str = 'default' + + # EMA + alpha_base: float = 0.30 + alpha_min: float = 0.08 + alpha_decay_per_streak: float = 0.005 + + # 임계값 + track_threshold: float = 0.50 + polygon_threshold: float = 0.70 + + # 메트릭 가중치 — 어구-선박 + w_proximity: float = 0.45 + w_visit: float = 0.35 + w_activity: float = 0.20 + + # 메트릭 가중치 — 선박-선박 + w_dtw: float = 0.30 + w_sog_corr: float = 0.20 + w_heading: float = 0.25 + w_prox_vv: float = 0.25 + + # 메트릭 가중치 — 어구-어구 + w_prox_persist: float = 0.50 + w_drift: float = 0.30 + w_signal_sync: float = 0.20 + + # Freeze 기준 + group_quiet_ratio: float = 0.30 + normal_gap_hours: float = 1.0 + + # 감쇠 + decay_slow: float = 0.015 + decay_fast: float = 0.08 + stale_hours: float = 6.0 + + # Shadow + shadow_stay_bonus: float = 0.10 + shadow_return_bonus: float = 0.15 + + # 거리 + candidate_radius_factor: float = 3.0 + proximity_threshold_nm: float = 5.0 + visit_threshold_nm: float = 5.0 + + # 야간 + night_bonus: float = 1.3 + + # 장기 감쇠 + long_decay_days: float = 7.0 + + @classmethod + def from_db_row(cls, row: dict) -> ModelParams: + """DB correlation_param_models 행에서 생성.""" + params_json = row.get('params', {}) + return cls( + model_id=row['id'], + name=row['name'], + **{k: v for k, v in params_json.items() if hasattr(cls, k)}, + ) + + +# ── Haversine 거리 ──────────────────────────────────────────────── + +def _haversine_nm(lat1: float, lon1: float, lat2: float, lon2: float) -> float: + """두 좌표 간 거리 (해리).""" + phi1 = math.radians(lat1) + phi2 = math.radians(lat2) + dphi = math.radians(lat2 - lat1) + dlam = math.radians(lon2 - lon1) + a = math.sin(dphi / 2) ** 2 + math.cos(phi1) * math.cos(phi2) * math.sin(dlam / 2) ** 2 + return _EARTH_RADIUS_NM * 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a)) + + +# ── Freeze 판단 ─────────────────────────────────────────────────── + +def should_freeze( + gear_group_active_ratio: float, + target_last_observed: Optional[datetime], + now: datetime, + params: ModelParams, +) -> tuple[bool, str]: + """감쇠 적용 여부 판단. 어구 그룹이 기준.""" + # 1. 어구 그룹 비활성 → 비교 불가 + if gear_group_active_ratio < params.group_quiet_ratio: + return True, 'GROUP_QUIET' + + # 2. 개별 부재가 정상 범위 + if target_last_observed is not None: + hours_absent = (now - target_last_observed).total_seconds() / 3600 + if hours_absent < params.normal_gap_hours: + return True, 'NORMAL_GAP' + + return False, 'ACTIVE' + + +# ── EMA 업데이트 ────────────────────────────────────────────────── + +def update_score( + prev_score: Optional[float], + raw_score: Optional[float], + streak: int, + last_observed: Optional[datetime], + now: datetime, + gear_group_active_ratio: float, + shadow_bonus: float, + params: ModelParams, +) -> tuple[float, int, str]: + """적응형 EMA 점수 업데이트. + + Returns: (new_score, new_streak, state) + """ + # 관측 불가 + if raw_score is None: + frz, reason = should_freeze( + gear_group_active_ratio, last_observed, now, params, + ) + if frz: + return (prev_score or 0.0), streak, reason + + # 실제 이탈 → 감쇠 + hours_absent = 0.0 + if last_observed is not None: + hours_absent = (now - last_observed).total_seconds() / 3600 + decay = params.decay_fast if hours_absent > params.stale_hours else params.decay_slow + return max(0.0, (prev_score or 0.0) - decay), 0, 'SIGNAL_LOSS' + + # Shadow 보너스 + adjusted = min(1.0, raw_score + shadow_bonus) + + # Case 1: 임계값 이상 → streak 보상 + if adjusted >= params.track_threshold: + streak += 1 + alpha = max(params.alpha_min, + params.alpha_base - streak * params.alpha_decay_per_streak) + if prev_score is None: + return adjusted, streak, 'ACTIVE' + return alpha * adjusted + (1.0 - alpha) * prev_score, streak, 'ACTIVE' + + # Case 2: 패턴 이탈 + alpha = params.alpha_base + if prev_score is None: + return adjusted, 0, 'PATTERN_DIVERGE' + return alpha * adjusted + (1.0 - alpha) * prev_score, 0, 'PATTERN_DIVERGE' + + +# ── 어구-선박 메트릭 ────────────────────────────────────────────── + +def _compute_gear_vessel_metrics( + gear_center_lat: float, + gear_center_lon: float, + gear_radius_nm: float, + vessel_track: list[dict], + params: ModelParams, +) -> dict: + """어구 그룹 중심 vs 선박 궤적 메트릭. + + vessel_track: [{lat, lon, sog, cog, timestamp}, ...] + """ + if not vessel_track: + return {'proximity_ratio': 0, 'visit_score': 0, 'activity_sync': 0, 'composite': 0} + + threshold_nm = max(gear_radius_nm * 2, params.proximity_threshold_nm) + + # 1. proximity_ratio — 근접 지속비 + close_count = 0 + for p in vessel_track: + d = _haversine_nm(gear_center_lat, gear_center_lon, p['lat'], p['lon']) + if d < threshold_nm: + close_count += 1 + proximity_ratio = close_count / len(vessel_track) + + # 2. visit_score — 방문 패턴 + visit_threshold = params.visit_threshold_nm + in_zone = False + visits = 0 + stay_points = 0 + away_points = 0 + + for p in vessel_track: + d = _haversine_nm(gear_center_lat, gear_center_lon, p['lat'], p['lon']) + if d < visit_threshold: + if not in_zone: + visits += 1 + in_zone = True + stay_points += 1 + else: + in_zone = False + away_points += 1 + + visit_count_norm = min(1.0, visits / 5.0) if visits > 0 else 0.0 + total = stay_points + away_points + stay_ratio = stay_points / total if total > 0 else 0.0 + visit_score = 0.5 * visit_count_norm + 0.5 * stay_ratio + + # 3. activity_sync — 영역 내 저속 비율 (조업/관리 행위) + in_zone_count = 0 + in_zone_slow = 0 + for p in vessel_track: + d = _haversine_nm(gear_center_lat, gear_center_lon, p['lat'], p['lon']) + if d < visit_threshold: + in_zone_count += 1 + if p.get('sog', 0) < 2.0: + in_zone_slow += 1 + activity_sync = in_zone_slow / in_zone_count if in_zone_count > 0 else 0.0 + + # 가중 합산 + composite = ( + params.w_proximity * proximity_ratio + + params.w_visit * visit_score + + params.w_activity * activity_sync + ) + + return { + 'proximity_ratio': round(proximity_ratio, 4), + 'visit_score': round(visit_score, 4), + 'activity_sync': round(activity_sync, 4), + 'composite': round(composite, 4), + } + + +# ── 선박-선박 메트릭 ────────────────────────────────────────────── + +def _compute_vessel_vessel_metrics( + track_a: list[dict], + track_b: list[dict], + params: ModelParams, +) -> dict: + """두 선박 궤적 간 메트릭.""" + from algorithms.track_similarity import ( + compute_heading_coherence, + compute_proximity_ratio, + compute_sog_correlation, + compute_track_similarity, + ) + + if not track_a or not track_b: + return { + 'dtw_similarity': 0, 'speed_correlation': 0, + 'heading_coherence': 0, 'proximity_ratio': 0, 'composite': 0, + } + + # DTW + pts_a = [(p['lat'], p['lon']) for p in track_a] + pts_b = [(p['lat'], p['lon']) for p in track_b] + dtw_sim = compute_track_similarity(pts_a, pts_b) + + # SOG 상관 + sog_a = [p.get('sog', 0) for p in track_a] + sog_b = [p.get('sog', 0) for p in track_b] + sog_corr = compute_sog_correlation(sog_a, sog_b) + + # COG 동조 + cog_a = [p.get('cog', 0) for p in track_a] + cog_b = [p.get('cog', 0) for p in track_b] + heading = compute_heading_coherence(cog_a, cog_b) + + # 근접비 + prox = compute_proximity_ratio(pts_a, pts_b, params.proximity_threshold_nm) + + composite = ( + params.w_dtw * dtw_sim + + params.w_sog_corr * sog_corr + + params.w_heading * heading + + params.w_prox_vv * prox + ) + + return { + 'dtw_similarity': round(dtw_sim, 4), + 'speed_correlation': round(sog_corr, 4), + 'heading_coherence': round(heading, 4), + 'proximity_ratio': round(prox, 4), + 'composite': round(composite, 4), + } + + +# ── 어구-어구 메트릭 ────────────────────────────────────────────── + +def _compute_gear_gear_metrics( + center_a: tuple[float, float], + center_b: tuple[float, float], + center_history_a: list[dict], + center_history_b: list[dict], + params: ModelParams, +) -> dict: + """두 어구 그룹 간 메트릭.""" + if not center_history_a or not center_history_b: + return { + 'proximity_ratio': 0, 'drift_similarity': 0, + 'composite': 0, + } + + # 1. 근접 지속성 — 현재 중심 간 거리의 안정성 + dist_nm = _haversine_nm(center_a[0], center_a[1], center_b[0], center_b[1]) + prox_persist = max(0.0, 1.0 - dist_nm / 20.0) # 20NM 이상이면 0 + + # 2. 표류 유사도 — 중심 이동 벡터 코사인 유사도 + drift_sim = 0.0 + n = min(len(center_history_a), len(center_history_b)) + if n >= 2: + # 마지막 2점으로 이동 벡터 계산 + da_lat = center_history_a[-1].get('lat', 0) - center_history_a[-2].get('lat', 0) + da_lon = center_history_a[-1].get('lon', 0) - center_history_a[-2].get('lon', 0) + db_lat = center_history_b[-1].get('lat', 0) - center_history_b[-2].get('lat', 0) + db_lon = center_history_b[-1].get('lon', 0) - center_history_b[-2].get('lon', 0) + + dot = da_lat * db_lat + da_lon * db_lon + mag_a = (da_lat ** 2 + da_lon ** 2) ** 0.5 + mag_b = (db_lat ** 2 + db_lon ** 2) ** 0.5 + if mag_a > 1e-10 and mag_b > 1e-10: + cos_sim = dot / (mag_a * mag_b) + drift_sim = max(0.0, (cos_sim + 1.0) / 2.0) + + composite = ( + params.w_prox_persist * prox_persist + + params.w_drift * drift_sim + ) + + return { + 'proximity_ratio': round(prox_persist, 4), + 'drift_similarity': round(drift_sim, 4), + 'composite': round(composite, 4), + } + + +# ── Shadow 보너스 계산 ──────────────────────────────────────────── + +def compute_shadow_bonus( + vessel_positions_during_inactive: list[dict], + last_known_gear_center: tuple[float, float], + group_radius_nm: float, + params: ModelParams, +) -> tuple[float, bool, bool]: + """어구 비활성 동안 선박이 어구 근처에 머물렀는지 평가. + + Returns: (bonus, stayed_nearby, returned_before_resume) + """ + if not vessel_positions_during_inactive or last_known_gear_center is None: + return 0.0, False, False + + gc_lat, gc_lon = last_known_gear_center + threshold_nm = max(group_radius_nm * 2, params.proximity_threshold_nm) + + # 1. 평균 거리 + dists = [ + _haversine_nm(gc_lat, gc_lon, p['lat'], p['lon']) + for p in vessel_positions_during_inactive + ] + avg_dist = sum(dists) / len(dists) + stayed = avg_dist < threshold_nm + + # 2. 마지막 위치가 근처인지 (복귀 판단) + returned = dists[-1] < threshold_nm if dists else False + + bonus = 0.0 + if stayed: + bonus += params.shadow_stay_bonus + if returned: + bonus += params.shadow_return_bonus + + return bonus, stayed, returned + + +# ── 후보 필터링 ─────────────────────────────────────────────────── + +def _compute_group_radius(members: list[dict]) -> float: + """그룹 멤버 간 최대 거리의 절반 (NM).""" + if len(members) < 2: + return 1.0 # 최소 1NM + + max_dist = 0.0 + for i in range(len(members)): + for j in range(i + 1, len(members)): + d = _haversine_nm( + members[i]['lat'], members[i]['lon'], + members[j]['lat'], members[j]['lon'], + ) + if d > max_dist: + max_dist = d + + return max(1.0, max_dist / 2.0) + + +def find_candidates( + gear_center_lat: float, + gear_center_lon: float, + group_radius_nm: float, + group_mmsis: set[str], + all_positions: dict[str, dict], + params: ModelParams, +) -> list[str]: + """어구 그룹 주변 후보 MMSI 필터링.""" + search_radius = group_radius_nm * params.candidate_radius_factor + candidates = [] + + for mmsi, pos in all_positions.items(): + if mmsi in group_mmsis: + continue + d = _haversine_nm(gear_center_lat, gear_center_lon, pos['lat'], pos['lon']) + if d < search_radius: + candidates.append(mmsi) + + return candidates + + +# ── 메인 실행 ───────────────────────────────────────────────────── + +def _get_vessel_track(vessel_store, mmsi: str, hours: int = 6) -> list[dict]: + """vessel_store에서 특정 MMSI의 최근 N시간 궤적 추출 (벡터화).""" + df = vessel_store._tracks.get(mmsi) + if df is None or len(df) == 0: + return [] + + import pandas as pd + now = datetime.now(timezone.utc) + cutoff = now - pd.Timedelta(hours=hours) + + ts_col = df['timestamp'] + if hasattr(ts_col.dtype, 'tz') and ts_col.dtype.tz is not None: + mask = ts_col >= pd.Timestamp(cutoff) + else: + mask = ts_col >= pd.Timestamp(cutoff.replace(tzinfo=None)) + + recent = df.loc[mask] + if recent.empty: + return [] + + # 벡터화 추출 (iterrows 대신) + lats = recent['lat'].values + lons = recent['lon'].values + sogs = (recent['sog'] if 'sog' in recent.columns + else recent.get('raw_sog', pd.Series(dtype=float))).fillna(0).values + cogs = (recent['cog'] if 'cog' in recent.columns + else pd.Series(0, index=recent.index)).fillna(0).values + + return [ + {'lat': float(lats[i]), 'lon': float(lons[i]), + 'sog': float(sogs[i]), 'cog': float(cogs[i])} + for i in range(len(lats)) + ] + + +def _compute_gear_active_ratio( + gear_members: list[dict], + all_positions: dict[str, dict], + now: datetime, + stale_sec: float = 21600, +) -> float: + """어구 그룹의 활성 멤버 비율.""" + if not gear_members: + return 0.0 + + active = 0 + for m in gear_members: + pos = all_positions.get(m['mmsi']) + if pos is None: + continue + ts = pos.get('timestamp') + if ts is None: + continue + if isinstance(ts, datetime): + last_dt = ts if ts.tzinfo is not None else ts.replace(tzinfo=timezone.utc) + else: + 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 = (now - last_dt).total_seconds() + if age < stale_sec: + active += 1 + + return active / len(gear_members) + + +def _is_gear_pattern(name: str) -> bool: + """어구 이름 패턴 판별.""" + import re + return bool(re.match(r'^.+_\d+_\d*$', name or '')) + + +_MAX_CANDIDATES_PER_GROUP = 30 # 후보 수 상한 (성능 보호) + + +def run_gear_correlation( + vessel_store, + gear_groups: list[dict], + conn, +) -> dict: + """어구 연관성 분석 메인 실행 (배치 최적화). + + Args: + vessel_store: VesselStore 인스턴스 + gear_groups: detect_gear_groups() 결과 + conn: kcgdb 커넥션 + + Returns: + {'updated': int, 'models': int, 'raw_inserted': int} + """ + import time as _time + import re as _re + + _gear_re = _re.compile(r'^.+_\d+_\d*$') + + t0 = _time.time() + now = datetime.now(timezone.utc) + all_positions = vessel_store.get_all_latest_positions() + + # 활성 모델 로드 + models = _load_active_models(conn) + if not models: + logger.warning('no active correlation models found') + return {'updated': 0, 'models': 0, 'raw_inserted': 0} + + # 기존 점수 전체 사전 로드 (건별 쿼리 대신 벌크) + all_scores = _load_all_scores(conn) + + raw_batch: list[tuple] = [] + score_batch: list[tuple] = [] + total_updated = 0 + total_raw = 0 + + default_params = models[0] + + for gear_group in gear_groups: + parent_name = gear_group['parent_name'] + members = gear_group['members'] + if not members: + continue + + # 그룹 중심 + 반경 + center_lat = sum(m['lat'] for m in members) / len(members) + center_lon = sum(m['lon'] for m in members) / len(members) + group_radius = _compute_group_radius(members) + + # 어구 활성도 + active_ratio = _compute_gear_active_ratio(members, all_positions, now) + + # 그룹 멤버 MMSI 셋 + group_mmsis = {m['mmsi'] for m in members} + if gear_group.get('parent_mmsi'): + group_mmsis.add(gear_group['parent_mmsi']) + + # 후보 필터링 + 수 제한 + candidates = find_candidates( + center_lat, center_lon, group_radius, + group_mmsis, all_positions, default_params, + ) + if not candidates: + continue + if len(candidates) > _MAX_CANDIDATES_PER_GROUP: + # 가까운 순서로 제한 + candidates.sort(key=lambda m: _haversine_nm( + center_lat, center_lon, + all_positions[m]['lat'], all_positions[m]['lon'], + )) + candidates = candidates[:_MAX_CANDIDATES_PER_GROUP] + + for target_mmsi in candidates: + target_pos = all_positions.get(target_mmsi) + if target_pos is None: + continue + + target_name = target_pos.get('name', '') + target_is_gear = bool(_gear_re.match(target_name or '')) + target_type = 'GEAR_BUOY' if target_is_gear else 'VESSEL' + + # 메트릭 계산 (어구는 단순 거리, 선박은 track 기반) + if target_is_gear: + d = _haversine_nm(center_lat, center_lon, + target_pos['lat'], target_pos['lon']) + prox = max(0.0, 1.0 - d / 20.0) + metrics = {'proximity_ratio': prox, 'composite': prox} + else: + vessel_track = _get_vessel_track(vessel_store, target_mmsi, hours=6) + metrics = _compute_gear_vessel_metrics( + center_lat, center_lon, group_radius, + vessel_track, default_params, + ) + + # raw 메트릭 배치 수집 + raw_batch.append(( + now, parent_name, target_mmsi, target_type, target_name, + metrics.get('proximity_ratio'), metrics.get('visit_score'), + metrics.get('activity_sync'), metrics.get('dtw_similarity'), + metrics.get('speed_correlation'), metrics.get('heading_coherence'), + metrics.get('drift_similarity'), False, False, active_ratio, + )) + total_raw += 1 + + # 모델별 EMA 업데이트 + for model in models: + if target_is_gear: + composite = metrics.get('proximity_ratio', 0) * model.w_prox_persist + else: + composite = ( + model.w_proximity * (metrics.get('proximity_ratio') or 0) + + model.w_visit * (metrics.get('visit_score') or 0) + + model.w_activity * (metrics.get('activity_sync') or 0) + ) + + # 사전 로드된 점수에서 조회 (DB 쿼리 없음) + score_key = (model.model_id, parent_name, target_mmsi) + prev = all_scores.get(score_key) + prev_score = prev['current_score'] if prev else None + streak = prev['streak_count'] if prev else 0 + last_obs = prev['last_observed_at'] if prev else None + + new_score, new_streak, state = update_score( + prev_score, composite, streak, + last_obs, now, active_ratio, + 0.0, model, + ) + + if new_score >= model.track_threshold or prev is not None: + score_batch.append(( + model.model_id, parent_name, target_mmsi, + target_type, target_name, + round(new_score, 6), new_streak, state, + now, now, now, + )) + total_updated += 1 + + # 배치 DB 저장 + _batch_insert_raw(conn, raw_batch) + _batch_upsert_scores(conn, score_batch) + conn.commit() + + elapsed = round(_time.time() - t0, 2) + logger.info( + 'gear correlation internals: %.2fs, %d groups, %d raw, %d scores, %d models', + elapsed, len(gear_groups), total_raw, total_updated, len(models), + ) + + return { + 'updated': total_updated, + 'models': len(models), + 'raw_inserted': total_raw, + } + + +# ── DB 헬퍼 (배치 최적화) ───────────────────────────────────────── + +def _load_active_models(conn) -> list[ModelParams]: + """활성 모델 로드.""" + cur = conn.cursor() + try: + cur.execute( + "SELECT id, name, params FROM kcg.correlation_param_models " + "WHERE is_active = TRUE ORDER BY is_default DESC, id ASC" + ) + rows = cur.fetchall() + models = [] + for row in rows: + import json + params = row[2] if isinstance(row[2], dict) else json.loads(row[2]) + models.append(ModelParams.from_db_row({ + 'id': row[0], 'name': row[1], 'params': params, + })) + return models + except Exception as e: + logger.error('failed to load models: %s', e) + return [ModelParams()] + finally: + cur.close() + + +def _load_all_scores(conn) -> dict[tuple, dict]: + """모든 점수를 사전 로드. {(model_id, group_key, target_mmsi): {...}}""" + cur = conn.cursor() + try: + cur.execute( + "SELECT model_id, group_key, target_mmsi, " + "current_score, streak_count, last_observed_at " + "FROM kcg.gear_correlation_scores" + ) + result = {} + for row in cur.fetchall(): + key = (row[0], row[1], row[2]) + result[key] = { + 'current_score': row[3], + 'streak_count': row[4], + 'last_observed_at': row[5], + } + return result + except Exception as e: + logger.warning('failed to load all scores: %s', e) + return {} + finally: + cur.close() + + +def _batch_insert_raw(conn, batch: list[tuple]): + """raw 메트릭 배치 INSERT.""" + if not batch: + return + cur = conn.cursor() + try: + from psycopg2.extras import execute_values + execute_values( + cur, + """INSERT INTO kcg.gear_correlation_raw_metrics + (observed_at, group_key, target_mmsi, target_type, target_name, + proximity_ratio, visit_score, activity_sync, + dtw_similarity, speed_correlation, heading_coherence, + drift_similarity, shadow_stay, shadow_return, + gear_group_active_ratio) + VALUES %s""", + batch, + page_size=500, + ) + except Exception as e: + logger.warning('batch insert raw failed: %s', e) + finally: + cur.close() + + +def _batch_upsert_scores(conn, batch: list[tuple]): + """점수 배치 UPSERT.""" + if not batch: + return + cur = conn.cursor() + try: + from psycopg2.extras import execute_values + execute_values( + cur, + """INSERT INTO kcg.gear_correlation_scores + (model_id, group_key, target_mmsi, target_type, target_name, + current_score, streak_count, freeze_state, + first_observed_at, last_observed_at, updated_at) + VALUES %s + ON CONFLICT (model_id, group_key, target_mmsi) + DO UPDATE SET + current_score = EXCLUDED.current_score, + streak_count = EXCLUDED.streak_count, + freeze_state = EXCLUDED.freeze_state, + observation_count = kcg.gear_correlation_scores.observation_count + 1, + last_observed_at = EXCLUDED.last_observed_at, + updated_at = EXCLUDED.updated_at""", + batch, + page_size=500, + ) + except Exception as e: + logger.warning('batch upsert scores failed: %s', e) + finally: + cur.close() diff --git a/prediction/algorithms/track_similarity.py b/prediction/algorithms/track_similarity.py index 0212f98..6a4b24a 100644 --- a/prediction/algorithms/track_similarity.py +++ b/prediction/algorithms/track_similarity.py @@ -158,3 +158,87 @@ def match_gear_by_track( }) return results + + +def compute_sog_correlation( + sog_a: list[float], + sog_b: list[float], +) -> float: + """두 SOG 시계열의 피어슨 상관계수 (0~1 정규화). + + 시계열 길이가 다르면 짧은 쪽 기준으로 자름. + 데이터 부족(< 3점)이면 0.0 반환. + """ + n = min(len(sog_a), len(sog_b)) + if n < 3: + return 0.0 + + a = sog_a[:n] + b = sog_b[:n] + + mean_a = sum(a) / n + mean_b = sum(b) / n + + cov = sum((a[i] - mean_a) * (b[i] - mean_b) for i in range(n)) + var_a = sum((x - mean_a) ** 2 for x in a) + var_b = sum((x - mean_b) ** 2 for x in b) + + denom = (var_a * var_b) ** 0.5 + if denom < 1e-12: + return 0.0 + + corr = cov / denom # -1 ~ 1 + return max(0.0, (corr + 1.0) / 2.0) # 0 ~ 1 정규화 + + +def compute_heading_coherence( + cog_a: list[float], + cog_b: list[float], + threshold_deg: float = 30.0, +) -> float: + """두 COG 시계열의 방향 동조율 (0~1). + + angular diff < threshold_deg 인 비율. + 시계열 길이가 다르면 짧은 쪽 기준. + 데이터 부족(< 3점)이면 0.0 반환. + """ + n = min(len(cog_a), len(cog_b)) + if n < 3: + return 0.0 + + coherent = 0 + for i in range(n): + diff = abs(cog_a[i] - cog_b[i]) + if diff > 180.0: + diff = 360.0 - diff + if diff < threshold_deg: + coherent += 1 + + return coherent / n + + +def compute_proximity_ratio( + track_a: list[tuple[float, float]], + track_b: list[tuple[float, float]], + threshold_nm: float = 10.0, +) -> float: + """두 궤적의 근접 지속비 (0~1). + + 시간 정렬된 포인트 쌍에서 haversine < threshold_nm 비율. + 시계열 길이가 다르면 짧은 쪽 기준. + 데이터 부족(< 2점)이면 0.0 반환. + """ + n = min(len(track_a), len(track_b)) + if n < 2: + return 0.0 + + close = 0 + threshold_m = threshold_nm * 1852.0 + + for i in range(n): + dist = haversine_m(track_a[i][0], track_a[i][1], + track_b[i][0], track_b[i][1]) + if dist < threshold_m: + close += 1 + + return close / n diff --git a/prediction/chat/tools.py b/prediction/chat/tools.py index 766c260..f863ed4 100644 --- a/prediction/chat/tools.py +++ b/prediction/chat/tools.py @@ -197,6 +197,9 @@ def execute_tool_call(call: dict) -> str: if tool == 'get_knowledge': return _get_knowledge(params) + if tool == 'query_gear_correlation': + return _query_gear_correlation(params) + return f'(알 수 없는 도구: {tool})' @@ -357,3 +360,54 @@ def _query_vessel_static(params: dict) -> str: except Exception as e: logger.error('vessel static query failed: %s', e) return f'\n(정적정보 조회 실패: {e})\n' + + +def _query_gear_correlation(params: dict) -> str: + """어구 그룹의 연관 선박/어구 조회.""" + from db import kcgdb + + group_key = params.get('group_key', '') + limit = int(params.get('limit', 10)) + + with kcgdb.get_conn() as conn: + cur = conn.cursor() + try: + cur.execute( + 'SELECT target_name, target_mmsi, target_type, current_score, ' + 'streak_count, observation_count, proximity_ratio, visit_score, ' + 'heading_coherence, freeze_state ' + 'FROM kcg.gear_correlation_scores s ' + 'JOIN kcg.correlation_param_models m ON s.model_id = m.id ' + 'WHERE s.group_key = %s AND m.is_default = TRUE AND s.current_score >= 0.3 ' + 'ORDER BY s.current_score DESC LIMIT %s', + (group_key, limit), + ) + rows = cur.fetchall() + except Exception: + return f'어구 그룹 "{group_key}"에 대한 연관성 데이터가 없습니다 (테이블 미생성).' + finally: + cur.close() + + if not rows: + return f'어구 그룹 "{group_key}"에 대한 연관성 데이터가 없습니다.' + + lines = [f'## {group_key} 연관 분석 (상위 {len(rows)}개, default 모델)'] + for r in rows: + name, mmsi, ttype, score, streak, obs, prox, visit, heading, state = r + pct = score * 100 + disp_name = name or mmsi + detail_parts = [] + if prox is not None: + detail_parts.append(f'근접 {prox*100:.0f}%') + if visit is not None: + detail_parts.append(f'방문 {visit*100:.0f}%') + if heading is not None: + detail_parts.append(f'COG동조 {heading*100:.0f}%') + detail = ', '.join(detail_parts) if detail_parts else '' + + lines.append( + f'- **{disp_name}** ({mmsi}, {ttype}): ' + f'일치율 {pct:.1f}% (연속 {streak}회, 관측 {obs}회) ' + f'[{detail}] 상태: {state}' + ) + return '\n'.join(lines) diff --git a/prediction/db/partition_manager.py b/prediction/db/partition_manager.py new file mode 100644 index 0000000..eb5dec8 --- /dev/null +++ b/prediction/db/partition_manager.py @@ -0,0 +1,136 @@ +"""gear_correlation_raw_metrics 파티션 유지보수. + +APScheduler 일별 작업으로 실행: +- system_config에서 설정 읽기 (hot-reload, 프로세스 재시작 불필요) +- 미래 파티션 미리 생성 +- 만료 파티션 DROP +- 미관측 점수 레코드 정리 +""" + +import logging +from datetime import date, datetime, timedelta + +logger = logging.getLogger(__name__) + + +def _get_config_int(conn, key: str, default: int) -> int: + """system_config에서 설정값 조회. 없으면 default.""" + cur = conn.cursor() + try: + cur.execute( + "SELECT value::text FROM kcg.system_config WHERE key = %s", + (key,), + ) + row = cur.fetchone() + return int(row[0].strip('"')) if row else default + except Exception: + return default + finally: + cur.close() + + +def _create_future_partitions(conn, days_ahead: int) -> int: + """미래 N일 파티션 생성. 반환: 생성된 파티션 수.""" + cur = conn.cursor() + created = 0 + try: + for i in range(days_ahead + 1): + d = date.today() + timedelta(days=i) + partition_name = f'gear_correlation_raw_metrics_{d.strftime("%Y%m%d")}' + cur.execute( + "SELECT 1 FROM pg_class c " + "JOIN pg_namespace n ON n.oid = c.relnamespace " + "WHERE c.relname = %s AND n.nspname = 'kcg'", + (partition_name,), + ) + if cur.fetchone() is None: + next_d = d + timedelta(days=1) + cur.execute( + f"CREATE TABLE IF NOT EXISTS kcg.{partition_name} " + f"PARTITION OF kcg.gear_correlation_raw_metrics " + f"FOR VALUES FROM ('{d.isoformat()}') TO ('{next_d.isoformat()}')" + ) + created += 1 + logger.info('created partition: kcg.%s', partition_name) + conn.commit() + except Exception as e: + conn.rollback() + logger.error('failed to create partitions: %s', e) + finally: + cur.close() + return created + + +def _drop_expired_partitions(conn, retention_days: int) -> int: + """retention_days 초과 파티션 DROP. 반환: 삭제된 파티션 수.""" + cutoff = date.today() - timedelta(days=retention_days) + cur = conn.cursor() + dropped = 0 + try: + cur.execute( + "SELECT c.relname FROM pg_class c " + "JOIN pg_namespace n ON n.oid = c.relnamespace " + "WHERE c.relname LIKE 'gear_correlation_raw_metrics_%%' " + "AND n.nspname = 'kcg' AND c.relkind = 'r'" + ) + for (name,) in cur.fetchall(): + date_str = name.rsplit('_', 1)[-1] + try: + partition_date = datetime.strptime(date_str, '%Y%m%d').date() + except ValueError: + continue + if partition_date < cutoff: + cur.execute(f'DROP TABLE IF EXISTS kcg.{name}') + dropped += 1 + logger.info('dropped expired partition: kcg.%s', name) + conn.commit() + except Exception as e: + conn.rollback() + logger.error('failed to drop partitions: %s', e) + finally: + cur.close() + return dropped + + +def _cleanup_stale_scores(conn, cleanup_days: int) -> int: + """cleanup_days 이상 미관측 점수 레코드 삭제.""" + cur = conn.cursor() + try: + cur.execute( + "DELETE FROM kcg.gear_correlation_scores " + "WHERE last_observed_at < NOW() - make_interval(days => %s)", + (cleanup_days,), + ) + deleted = cur.rowcount + conn.commit() + return deleted + except Exception as e: + conn.rollback() + logger.error('failed to cleanup stale scores: %s', e) + return 0 + finally: + cur.close() + + +def maintain_partitions(): + """일별 파티션 유지보수 — 스케줄러에서 호출. + + system_config에서 설정을 매번 읽으므로 + API를 통한 설정 변경이 다음 실행 시 즉시 반영됨. + """ + from db import kcgdb + + with kcgdb.get_conn() as conn: + retention = _get_config_int(conn, 'partition.raw_metrics.retention_days', 7) + ahead = _get_config_int(conn, 'partition.raw_metrics.create_ahead_days', 3) + cleanup_days = _get_config_int(conn, 'partition.scores.cleanup_days', 30) + + created = _create_future_partitions(conn, ahead) + dropped = _drop_expired_partitions(conn, retention) + cleaned = _cleanup_stale_scores(conn, cleanup_days) + + logger.info( + 'partition maintenance: %d created, %d dropped, %d stale scores cleaned ' + '(retention=%dd, ahead=%dd, cleanup=%dd)', + created, dropped, cleaned, retention, ahead, cleanup_days, + ) diff --git a/prediction/fleet_tracker.py b/prediction/fleet_tracker.py index 26788f3..f1c83d7 100644 --- a/prediction/fleet_tracker.py +++ b/prediction/fleet_tracker.py @@ -210,6 +210,22 @@ class FleetTracker: ) logger.info('gear MMSI change: %s → %s (name=%s)', old_mmsi_row[1], mmsi, name) + # 어피니티 점수 이전 (이전 MMSI → 새 MMSI) + try: + cur.execute( + "UPDATE kcg.gear_correlation_scores " + "SET target_mmsi = %s, updated_at = NOW() " + "WHERE target_mmsi = %s", + (mmsi, old_mmsi_row[1]), + ) + if cur.rowcount > 0: + logger.info( + 'transferred %d affinity scores: %s → %s', + cur.rowcount, old_mmsi_row[1], mmsi, + ) + except Exception as e: + logger.warning('affinity score transfer failed: %s', e) + cur.execute( """INSERT INTO kcg.gear_identity_log (mmsi, name, parent_name, parent_mmsi, parent_vessel_id, diff --git a/prediction/scheduler.py b/prediction/scheduler.py index d463098..8cecb17 100644 --- a/prediction/scheduler.py +++ b/prediction/scheduler.py @@ -99,6 +99,8 @@ def run_analysis_cycle(): fleet_tracker.save_snapshot(vessel_dfs, kcg_conn) + gear_groups = [] + # 4.5 그룹 폴리곤 생성 + 저장 try: from algorithms.polygon_builder import detect_gear_groups, build_all_group_snapshots @@ -116,6 +118,23 @@ def run_analysis_cycle(): except Exception as e: logger.warning('group polygon generation failed: %s', e) + # 4.7 어구 연관성 분석 (멀티모델 패턴 추적) + try: + from algorithms.gear_correlation import run_gear_correlation + + corr_result = run_gear_correlation( + vessel_store=vessel_store, + gear_groups=gear_groups, + conn=kcg_conn, + ) + logger.info( + 'gear correlation: %d scores updated, %d raw metrics, %d models', + corr_result['updated'], corr_result['raw_inserted'], + corr_result['models'], + ) + except Exception as e: + logger.warning('gear correlation failed: %s', e) + # 5. 선박별 추가 알고리즘 → AnalysisResult 생성 results = [] for c in classifications: @@ -329,6 +348,15 @@ def start_scheduler(): max_instances=1, replace_existing=True, ) + # 파티션 유지보수 (매일 04:00) + from db.partition_manager import maintain_partitions + _scheduler.add_job( + maintain_partitions, + 'cron', hour=4, minute=0, + id='partition_maintenance', + max_instances=1, + replace_existing=True, + ) _scheduler.start() logger.info('scheduler started (interval=%dm)', settings.SCHEDULER_INTERVAL_MIN) From d0258097936ead1a6f64fb22b8662a83a603e65b Mon Sep 17 00:00:00 2001 From: htlee Date: Mon, 30 Mar 2026 11:33:56 +0900 Subject: [PATCH 02/27] =?UTF-8?q?feat:=20=EC=96=B4=EA=B5=AC=20=EC=97=B0?= =?UTF-8?q?=EA=B4=80=EC=84=B1=20=ED=94=84=EB=A1=A0=ED=8A=B8=EC=97=94?= =?UTF-8?q?=EB=93=9C=20=ED=91=9C=EC=8B=9C=20=E2=80=94=20Backend=20API=20+?= =?UTF-8?q?=20=ED=8C=9D=EC=97=85=20UI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Backend: GET /api/vessel-analysis/groups/{groupKey}/correlations 엔드포인트 - GroupPolygonService: gear_correlation_scores JOIN correlation_param_models 쿼리 - Frontend: fetchGroupCorrelations API 클라이언트 + GearCorrelationItem 타입 - FleetClusterLayer: 어구 그룹 선택 시 연관 선박/어구 목록 팝업에 표시 - default 모델 기준 일치율 % + 바 그래프 - 선박(⛴)/어구(◆) 유형 구분, 상위 8건 표시 Co-Authored-By: Claude Opus 4.6 (1M context) --- .../domain/fleet/GroupPolygonController.java | 15 +++++ .../kcg/domain/fleet/GroupPolygonService.java | 40 ++++++++++++ .../components/korea/FleetClusterLayer.tsx | 62 ++++++++++++++++++- frontend/src/services/vesselAnalysis.ts | 36 +++++++++++ 4 files changed, 151 insertions(+), 2 deletions(-) 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 index 97b9e9e..5786155 100644 --- a/backend/src/main/java/gc/mda/kcg/domain/fleet/GroupPolygonController.java +++ b/backend/src/main/java/gc/mda/kcg/domain/fleet/GroupPolygonController.java @@ -48,4 +48,19 @@ public class GroupPolygonController { List history = groupPolygonService.getGroupHistory(groupKey, hours); return ResponseEntity.ok(history); } + + /** + * 특정 어구 그룹의 연관성 점수 (멀티모델) + */ + @GetMapping("/{groupKey}/correlations") + public ResponseEntity> getGroupCorrelations( + @PathVariable String groupKey, + @RequestParam(defaultValue = "0.3") double minScore) { + List> correlations = groupPolygonService.getGroupCorrelations(groupKey, minScore); + return ResponseEntity.ok(Map.of( + "groupKey", groupKey, + "count", correlations.size(), + "items", correlations + )); + } } 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 index 7dcb2c6..d232a21 100644 --- a/backend/src/main/java/gc/mda/kcg/domain/fleet/GroupPolygonService.java +++ b/backend/src/main/java/gc/mda/kcg/domain/fleet/GroupPolygonService.java @@ -58,6 +58,18 @@ public class GroupPolygonService { ORDER BY snapshot_time DESC """; + private static final String GROUP_CORRELATIONS_SQL = """ + SELECT s.target_mmsi, s.target_type, s.target_name, + s.current_score, s.streak_count, s.observation_count, + s.freeze_state, + s.proximity_ratio, s.visit_score, s.heading_coherence, + m.id AS model_id, m.name AS model_name, m.is_default + FROM kcg.gear_correlation_scores s + JOIN kcg.correlation_param_models m ON s.model_id = m.id + WHERE s.group_key = ? AND s.current_score >= ? AND m.is_active = TRUE + ORDER BY m.is_default DESC, s.current_score DESC + """; + private static final String GEAR_STATS_SQL = """ SELECT COUNT(*) AS gear_groups, COALESCE(SUM(member_count), 0) AS gear_count @@ -81,6 +93,34 @@ public class GroupPolygonService { } } + /** + * 특정 어구 그룹의 연관성 점수 (멀티모델). + */ + public List> getGroupCorrelations(String groupKey, double minScore) { + try { + return jdbcTemplate.query(GROUP_CORRELATIONS_SQL, (rs, rowNum) -> { + Map row = new java.util.LinkedHashMap<>(); + row.put("targetMmsi", rs.getString("target_mmsi")); + row.put("targetType", rs.getString("target_type")); + row.put("targetName", rs.getString("target_name")); + row.put("score", rs.getDouble("current_score")); + row.put("streak", rs.getInt("streak_count")); + row.put("observations", rs.getInt("observation_count")); + row.put("freezeState", rs.getString("freeze_state")); + row.put("proximityRatio", rs.getObject("proximity_ratio")); + row.put("visitScore", rs.getObject("visit_score")); + row.put("headingCoherence", rs.getObject("heading_coherence")); + row.put("modelId", rs.getInt("model_id")); + row.put("modelName", rs.getString("model_name")); + row.put("isDefault", rs.getBoolean("is_default")); + return row; + }, groupKey, minScore); + } catch (Exception e) { + log.warn("getGroupCorrelations failed for {}: {}", groupKey, e.getMessage()); + return List.of(); + } + } + /** * 최신 스냅샷의 전체 그룹 폴리곤 목록 (5분 캐시). */ diff --git a/frontend/src/components/korea/FleetClusterLayer.tsx b/frontend/src/components/korea/FleetClusterLayer.tsx index 83e3c0e..cdfde40 100644 --- a/frontend/src/components/korea/FleetClusterLayer.tsx +++ b/frontend/src/components/korea/FleetClusterLayer.tsx @@ -4,8 +4,8 @@ import { FONT_MONO } from '../../styles/fonts'; import type { GeoJSON } from 'geojson'; import type { MapLayerMouseEvent } from 'maplibre-gl'; import type { Ship, VesselAnalysisDto } from '../../types'; -import { fetchFleetCompanies, fetchGroupHistory } from '../../services/vesselAnalysis'; -import type { FleetCompany, GroupPolygonDto } from '../../services/vesselAnalysis'; +import { fetchFleetCompanies, fetchGroupHistory, fetchGroupCorrelations } from '../../services/vesselAnalysis'; +import type { FleetCompany, GroupPolygonDto, GearCorrelationItem } from '../../services/vesselAnalysis'; import type { UseGroupPolygonsResult } from '../../hooks/useGroupPolygons'; /** @@ -241,6 +241,9 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipS candidates: { name: string; count: number; inZone: boolean; isFleet: boolean; clusterId?: number }[]; } | null>(null); const [pickerHoveredGroup, setPickerHoveredGroup] = useState(null); + // 어구 연관성 데이터 + const [correlationData, setCorrelationData] = useState([]); + const [correlationLoading, setCorrelationLoading] = useState(false); // 히스토리 애니메이션 — 12시간 실시간 타임라인 const [historyData, setHistoryData] = useState(null); const [, setHistoryGroupKey] = useState(null); @@ -537,6 +540,26 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipS }); }, [selectedGearGroup, groupPolygons, onSelectedGearChange, historyData]); + // 선택된 어구 그룹의 연관성 데이터 로드 + useEffect(() => { + if (!selectedGearGroup) { + setCorrelationData([]); + return; + } + let cancelled = false; + setCorrelationLoading(true); + fetchGroupCorrelations(selectedGearGroup, 0.3) + .then(res => { + if (!cancelled) { + // default 모델 결과만 팝업에 표시 + setCorrelationData(res.items.filter(i => i.isDefault)); + } + }) + .catch(() => { if (!cancelled) setCorrelationData([]); }) + .finally(() => { if (!cancelled) setCorrelationLoading(false); }); + return () => { cancelled = true; }; + }, [selectedGearGroup]); + // 선택된 선단 데이터를 부모에 전달 (deck.gl 강조 렌더링용) — 히스토리 모드에서는 null useEffect(() => { if (expandedFleet === null || historyData) { @@ -1119,6 +1142,41 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipS · {m.name || m.mmsi} ))} + {/* 연관 선박/어구 (선택된 그룹만) */} + {selectedGearGroup === name && correlationData.length > 0 && ( +
+
+ 연관 {correlationData.length}건 +
+ {correlationData.slice(0, 8).map(c => { + const pct = (c.score * 100).toFixed(0); + const barW = Math.max(2, c.score * 60); + const barColor = c.score >= 0.7 ? '#22c55e' : c.score >= 0.5 ? '#eab308' : '#94a3b8'; + return ( +
+ + {c.targetType === 'VESSEL' ? '⛴' : '◆'} + + + {c.targetName || c.targetMmsi} + +
+
+
+
+ {pct}% +
+
+ ); + })} + {correlationData.length > 8 && ( +
+{correlationData.length - 8}건 더
+ )} +
+ )} + {selectedGearGroup === name && correlationLoading && ( +
연관 분석 로딩...
+ )}
클릭하여 선택/해제
diff --git a/frontend/src/services/vesselAnalysis.ts b/frontend/src/services/vesselAnalysis.ts index b3072be..dc66deb 100644 --- a/frontend/src/services/vesselAnalysis.ts +++ b/frontend/src/services/vesselAnalysis.ts @@ -95,6 +95,42 @@ export async function fetchGroupHistory(groupKey: string, hours = 24): Promise { + const res = await fetch( + `${API_BASE}/vessel-analysis/groups/${encodeURIComponent(groupKey)}/correlations?minScore=${minScore}`, + { headers: { accept: 'application/json' } }, + ); + if (!res.ok) return { groupKey, count: 0, items: [] }; + return res.json(); +} + /* ── Fleet Companies ─────────────────────────────────────────── */ // 캐시 (세션 중 1회 로드) From 82fb6fbfff89523d79bc801e5fbeefd72971f92e Mon Sep 17 00:00:00 2001 From: htlee Date: Mon, 30 Mar 2026 11:56:07 +0900 Subject: [PATCH 03/27] =?UTF-8?q?fix:=20correlations=20API=20SQL=20?= =?UTF-8?q?=E2=80=94=20raw=5Fmetrics=20LATERAL=20JOIN=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EC=84=B8=EB=B6=80=20=EB=A9=94=ED=8A=B8=EB=A6=AD=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit scores 테이블에는 composite 점수만, 세부 메트릭(proximity/visit/heading)은 raw_metrics에 있으므로 LATERAL JOIN으로 최신 raw 메트릭 결합 Co-Authored-By: Claude Opus 4.6 (1M context) --- .../gc/mda/kcg/domain/fleet/GroupPolygonService.java | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) 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 index d232a21..ccd496d 100644 --- a/backend/src/main/java/gc/mda/kcg/domain/fleet/GroupPolygonService.java +++ b/backend/src/main/java/gc/mda/kcg/domain/fleet/GroupPolygonService.java @@ -61,11 +61,17 @@ public class GroupPolygonService { private static final String GROUP_CORRELATIONS_SQL = """ SELECT s.target_mmsi, s.target_type, s.target_name, s.current_score, s.streak_count, s.observation_count, - s.freeze_state, - s.proximity_ratio, s.visit_score, s.heading_coherence, + s.freeze_state, s.shadow_bonus_total, + r.proximity_ratio, r.visit_score, r.heading_coherence, m.id AS model_id, m.name AS model_name, m.is_default FROM kcg.gear_correlation_scores s JOIN kcg.correlation_param_models m ON s.model_id = m.id + LEFT JOIN LATERAL ( + SELECT proximity_ratio, visit_score, heading_coherence + FROM kcg.gear_correlation_raw_metrics + WHERE group_key = s.group_key AND target_mmsi = s.target_mmsi + ORDER BY observed_at DESC LIMIT 1 + ) r ON TRUE WHERE s.group_key = ? AND s.current_score >= ? AND m.is_active = TRUE ORDER BY m.is_default DESC, s.current_score DESC """; @@ -107,6 +113,7 @@ public class GroupPolygonService { row.put("streak", rs.getInt("streak_count")); row.put("observations", rs.getInt("observation_count")); row.put("freezeState", rs.getString("freeze_state")); + row.put("shadowBonus", rs.getDouble("shadow_bonus_total")); row.put("proximityRatio", rs.getObject("proximity_ratio")); row.put("visitScore", rs.getObject("visit_score")); row.put("headingCoherence", rs.getObject("heading_coherence")); From 4c994e277a1cbad67d6524ad138388d9e5a73f9b Mon Sep 17 00:00:00 2001 From: htlee Date: Mon, 30 Mar 2026 12:38:09 +0900 Subject: [PATCH 04/27] =?UTF-8?q?feat:=20=EC=96=B4=EA=B5=AC=20=EA=B7=B8?= =?UTF-8?q?=EB=A3=B9=20=EB=A9=80=ED=8B=B0=EB=AA=A8=EB=8D=B8=20=ED=8F=B4?= =?UTF-8?q?=EB=A6=AC=EA=B3=A4=20=EC=98=A4=EB=B2=84=EB=A0=88=EC=9D=B4=20+?= =?UTF-8?q?=20=ED=86=A0=EA=B8=80=20UI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 어구 그룹 선택 시 전체 모델(5개) 연관성 데이터 로드 - enabledModels 상태: 'identity'(이름 기반) + 'default' 기본 ON - 모델별 오퍼레이셔널 폴리곤 클라이언트 생성 (70%+ 연관 대상 합산 convex hull) - Source+Layer 오버레이: 모델별 고유 색상, 대시 라인 구분 - 팝업 UI: 모델 토글 체크박스 (최대 5개), 색상 인디케이터 + 70%+ 대상 수 - 연관 선박 상위 8건 바 그래프 (default 모델 기준) - 선택 시 팝업 maxWidth 280px로 확장 Co-Authored-By: Claude Opus 4.6 (1M context) --- .../components/korea/FleetClusterLayer.tsx | 205 +++++++++++++++--- 1 file changed, 174 insertions(+), 31 deletions(-) diff --git a/frontend/src/components/korea/FleetClusterLayer.tsx b/frontend/src/components/korea/FleetClusterLayer.tsx index cdfde40..e4c2e7b 100644 --- a/frontend/src/components/korea/FleetClusterLayer.tsx +++ b/frontend/src/components/korea/FleetClusterLayer.tsx @@ -241,9 +241,11 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipS candidates: { name: string; count: number; inZone: boolean; isFleet: boolean; clusterId?: number }[]; } | null>(null); const [pickerHoveredGroup, setPickerHoveredGroup] = useState(null); - // 어구 연관성 데이터 + // 어구 연관성 데이터 (전체 모델) const [correlationData, setCorrelationData] = useState([]); const [correlationLoading, setCorrelationLoading] = useState(false); + // 활성화된 모델 ('identity' = 이름기반, 'default' = 기본모델, 나머지 = 추가모델) + const [enabledModels, setEnabledModels] = useState>(new Set(['identity', 'default'])); // 히스토리 애니메이션 — 12시간 실시간 타임라인 const [historyData, setHistoryData] = useState(null); const [, setHistoryGroupKey] = useState(null); @@ -551,8 +553,7 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipS fetchGroupCorrelations(selectedGearGroup, 0.3) .then(res => { if (!cancelled) { - // default 모델 결과만 팝업에 표시 - setCorrelationData(res.items.filter(i => i.isDefault)); + setCorrelationData(res.items); } }) .catch(() => { if (!cancelled) setCorrelationData([]); }) @@ -630,6 +631,90 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipS }; }, [hoveredFleetId, groupPolygons?.fleetGroups]); + // 모델별 연관성 데이터 그룹핑 + const correlationByModel = useMemo(() => { + const map = new Map(); + for (const c of correlationData) { + const list = map.get(c.modelName) ?? []; + list.push(c); + map.set(c.modelName, list); + } + return map; + }, [correlationData]); + + // 사용 가능한 모델 목록 (데이터가 있는 모델만) + const availableModels = useMemo(() => { + const models: { name: string; count: number; isDefault: boolean }[] = []; + for (const [name, items] of correlationByModel) { + models.push({ name, count: items.length, isDefault: items[0]?.isDefault ?? false }); + } + models.sort((a, b) => (a.isDefault ? -1 : 0) - (b.isDefault ? -1 : 0)); + return models; + }, [correlationByModel]); + + // 모델별 오퍼레이셔널 폴리곤 GeoJSON (identity 제외, correlation 모델만) + const MODEL_COLORS: Record = { + 'default': '#3b82f6', // 파랑 + 'aggressive': '#22c55e', // 초록 + 'conservative': '#a855f7', // 보라 + 'proximity-heavy': '#06b6d4', // 시안 + 'visit-pattern': '#f43f5e', // 로즈 + }; + + const operationalPolygons = useMemo(() => { + if (!selectedGearGroup || !groupPolygons) return []; + + const allGroups = [...groupPolygons.gearInZoneGroups, ...groupPolygons.gearOutZoneGroups]; + const group = allGroups.find(g => g.groupKey === selectedGearGroup); + if (!group) return []; + + // 이름 기반 멤버 위치 + const baseMemberPositions: [number, number][] = group.members.map(m => [m.lon, m.lat]); + + // ships prop에서 위치 조회용 맵 + const posMap = new Map(); + for (const s of ships) { + posMap.set(s.mmsi, { lat: s.lat, lon: s.lng }); + } + + const result: { modelName: string; color: string; geojson: GeoJSON.FeatureCollection }[] = []; + + for (const [modelName, items] of correlationByModel) { + if (!enabledModels.has(modelName)) continue; + + // 70%+ 점수 대상의 위치 수집 + const extraPositions: [number, number][] = []; + for (const c of items) { + if (c.score < 0.7) continue; + const pos = posMap.get(c.targetMmsi); + if (pos) extraPositions.push([pos.lon, pos.lat]); + } + + if (extraPositions.length === 0) continue; + + // 이름 기반 + 연관 대상 합산 + const allPoints = [...baseMemberPositions, ...extraPositions]; + const polygon = buildInterpPolygon(allPoints); + if (!polygon) continue; + + const color = MODEL_COLORS[modelName] ?? '#94a3b8'; + result.push({ + modelName, + color, + geojson: { + type: 'FeatureCollection', + features: [{ + type: 'Feature', + properties: { modelName, color }, + geometry: polygon, + }], + }, + }); + } + + return result; + }, [selectedGearGroup, groupPolygons, correlationByModel, enabledModels, ships]); + // 어구 클러스터 GeoJSON (서버 제공) const gearClusterGeoJson = useMemo((): GeoJSON => { const features: GeoJSON.Feature[] = []; @@ -957,8 +1042,8 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipS /> - {/* 선택된 어구 그룹 하이라이트 폴리곤 — 히스토리 모드에서는 숨김 */} - {selectedGearGroup && !historyData && (() => { + {/* 선택된 어구 그룹 — 이름 기반 하이라이트 폴리곤 */} + {selectedGearGroup && !historyData && enabledModels.has('identity') && (() => { const allGroups = groupPolygons ? [...groupPolygons.gearInZoneGroups, ...groupPolygons.gearOutZoneGroups] : []; @@ -980,6 +1065,19 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipS ); })()} + {/* 선택된 어구 그룹 — 모델별 오퍼레이셔널 폴리곤 오버레이 */} + {selectedGearGroup && !historyData && operationalPolygons.map(op => ( + + + + + ))} + {/* 비허가 어구 클러스터 폴리곤 */}
@@ -1142,38 +1240,83 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipS · {m.name || m.mmsi}
))} - {/* 연관 선박/어구 (선택된 그룹만) */} - {selectedGearGroup === name && correlationData.length > 0 && ( + {/* 모델 폴리곤 토글 (선택된 그룹만) */} + {selectedGearGroup === name && !correlationLoading && availableModels.length > 0 && (
-
- 연관 {correlationData.length}건 +
+ 폴리곤 오버레이
- {correlationData.slice(0, 8).map(c => { - const pct = (c.score * 100).toFixed(0); - const barW = Math.max(2, c.score * 60); - const barColor = c.score >= 0.7 ? '#22c55e' : c.score >= 0.5 ? '#eab308' : '#94a3b8'; + {/* 이름 기반 (항상 표시) */} + + {/* 모델별 체크박스 */} + {availableModels.map(m => { + const color = MODEL_COLORS[m.name] ?? '#94a3b8'; + const modelItems = correlationByModel.get(m.name) ?? []; + const above70 = modelItems.filter(c => c.score >= 0.7).length; return ( -
- - {c.targetType === 'VESSEL' ? '⛴' : '◆'} - - - {c.targetName || c.targetMmsi} - -
-
-
-
- {pct}% -
-
+ ); })} - {correlationData.length > 8 && ( -
+{correlationData.length - 8}건 더
- )}
)} + {/* 연관 선박 상위 목록 (default 모델) */} + {selectedGearGroup === name && (() => { + const defaultItems = correlationData.filter(c => c.isDefault); + if (defaultItems.length === 0) return null; + return ( +
+
연관 선박 (default 상위 8)
+ {defaultItems.slice(0, 8).map(c => { + const pct = (c.score * 100).toFixed(0); + const barW = Math.max(2, c.score * 60); + const barColor = c.score >= 0.7 ? '#22c55e' : c.score >= 0.5 ? '#eab308' : '#94a3b8'; + return ( +
+ + {c.targetType === 'VESSEL' ? '⛴' : '◆'} + + + {c.targetName || c.targetMmsi} + +
+
+
+
+ {pct}% +
+
+ ); + })} + {defaultItems.length > 8 && ( +
+{defaultItems.length - 8}건 더
+ )} +
+ ); + })()} {selectedGearGroup === name && correlationLoading && (
연관 분석 로딩...
)} From dc6070d6198ebbec3987d9c40c97bf09f23c486e Mon Sep 17 00:00:00 2001 From: htlee Date: Mon, 30 Mar 2026 12:45:30 +0900 Subject: [PATCH 05/27] =?UTF-8?q?fix:=20=EB=AA=A8=EB=8D=B8=20=ED=86=A0?= =?UTF-8?q?=EA=B8=80=EC=9D=84=20=ED=98=B8=EB=B2=84=20=ED=8C=9D=EC=97=85?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=ED=95=98=EB=8B=A8=20=EA=B3=A0=EC=A0=95=20?= =?UTF-8?q?=ED=8C=A8=EB=84=90=EB=A1=9C=20=EC=9D=B4=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 호버 팝업은 마우스 이동 시 사라져서 토글 조작 불가 → 어구 그룹 선택 시 하단 중앙에 고정 패널 배치: - 좌측: 그룹 정보 + 폴리곤 오버레이 토글 (이름 기반 + 5개 모델) - 우측: 연관 선박 목록 (default 모델 상위 12건, 스크롤) - ✕ 버튼으로 선택 해제 - 히스토리 재생 컨트롤러와 동일 위치/스타일 Co-Authored-By: Claude Opus 4.6 (1M context) --- .../components/korea/FleetClusterLayer.tsx | 184 ++++++++++-------- 1 file changed, 104 insertions(+), 80 deletions(-) diff --git a/frontend/src/components/korea/FleetClusterLayer.tsx b/frontend/src/components/korea/FleetClusterLayer.tsx index e4c2e7b..86fc406 100644 --- a/frontend/src/components/korea/FleetClusterLayer.tsx +++ b/frontend/src/components/korea/FleetClusterLayer.tsx @@ -1240,86 +1240,6 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipS · {m.name || m.mmsi}
))} - {/* 모델 폴리곤 토글 (선택된 그룹만) */} - {selectedGearGroup === name && !correlationLoading && availableModels.length > 0 && ( -
-
- 폴리곤 오버레이 -
- {/* 이름 기반 (항상 표시) */} - - {/* 모델별 체크박스 */} - {availableModels.map(m => { - const color = MODEL_COLORS[m.name] ?? '#94a3b8'; - const modelItems = correlationByModel.get(m.name) ?? []; - const above70 = modelItems.filter(c => c.score >= 0.7).length; - return ( - - ); - })} -
- )} - {/* 연관 선박 상위 목록 (default 모델) */} - {selectedGearGroup === name && (() => { - const defaultItems = correlationData.filter(c => c.isDefault); - if (defaultItems.length === 0) return null; - return ( -
-
연관 선박 (default 상위 8)
- {defaultItems.slice(0, 8).map(c => { - const pct = (c.score * 100).toFixed(0); - const barW = Math.max(2, c.score * 60); - const barColor = c.score >= 0.7 ? '#22c55e' : c.score >= 0.5 ? '#eab308' : '#94a3b8'; - return ( -
- - {c.targetType === 'VESSEL' ? '⛴' : '◆'} - - - {c.targetName || c.targetMmsi} - -
-
-
-
- {pct}% -
-
- ); - })} - {defaultItems.length > 8 && ( -
+{defaultItems.length - 8}건 더
- )} -
- ); - })()} - {selectedGearGroup === name && correlationLoading && ( -
연관 분석 로딩...
- )}
클릭하여 선택/해제
@@ -1414,6 +1334,110 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipS )} + {/* ── 어구 연관성 패널 (선택된 그룹, 하단 고정) ── */} + {selectedGearGroup && !historyData && (() => { + const allGroups = groupPolygons + ? [...groupPolygons.gearInZoneGroups, ...groupPolygons.gearOutZoneGroups] + : []; + const group = allGroups.find(g => g.groupKey === selectedGearGroup); + if (!group) return null; + const defaultItems = correlationData.filter(c => c.isDefault); + return ( +
+ {/* 좌: 그룹 정보 + 모델 토글 */} +
+
+ {selectedGearGroup} + 어구 {group.memberCount}개 + +
+ {/* 폴리곤 오버레이 토글 */} +
폴리곤 오버레이
+ + {correlationLoading &&
로딩...
} + {availableModels.map(m => { + const color = MODEL_COLORS[m.name] ?? '#94a3b8'; + const modelItems = correlationByModel.get(m.name) ?? []; + const above70 = modelItems.filter(c => c.score >= 0.7).length; + return ( + + ); + })} +
+ {/* 우: 연관 선박 목록 (default 모델) */} + {defaultItems.length > 0 && ( +
+
+ 연관 선박 ({defaultItems.length}건) +
+
+ {defaultItems.slice(0, 12).map(c => { + const pct = (c.score * 100).toFixed(0); + const barW = Math.max(2, c.score * 60); + const barColor = c.score >= 0.7 ? '#22c55e' : c.score >= 0.5 ? '#eab308' : '#94a3b8'; + return ( +
+ + {c.targetType === 'VESSEL' ? '⛴' : '◆'} + + + {c.targetName || c.targetMmsi} + +
+
+
+
+ {pct}% +
+
+ ); + })} + {defaultItems.length > 12 && ( +
+{defaultItems.length - 12}건 더
+ )} +
+
+ )} +
+ ); + })()} + {/* 히스토리 재생 컨트롤러 */} {historyData && (() => { const curTime = new Date(currentTimeMs); From 2fb08425237372cd9bd757b6facc3e9c495ccb07 Mon Sep 17 00:00:00 2001 From: htlee Date: Mon, 30 Mar 2026 12:56:22 +0900 Subject: [PATCH 06/27] =?UTF-8?q?fix:=20=EC=97=B0=EA=B4=80=EC=84=B1=20?= =?UTF-8?q?=ED=8C=A8=EB=84=90=20historyData=20=EC=A1=B0=EA=B1=B4=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0=20+=20=EC=9E=AC=EC=83=9D=20=EC=BB=A8?= =?UTF-8?q?=ED=8A=B8=EB=A1=A4=EB=9F=AC=20=EC=9C=84=20=EB=B0=B0=EC=B9=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - !historyData 조건 제거 — 어구 클릭 시 히스토리 자동 로딩되므로 항상 표시 - 히스토리 모드: bottom 80px (재생 컨트롤러 위), 비히스토리: bottom 20px - z-index 21 (재생 컨트롤러 20 위) - 오퍼레이셔널 폴리곤/이름 기반 하이라이트도 히스토리 조건 조정 Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/components/korea/FleetClusterLayer.tsx | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/frontend/src/components/korea/FleetClusterLayer.tsx b/frontend/src/components/korea/FleetClusterLayer.tsx index 86fc406..7f967e5 100644 --- a/frontend/src/components/korea/FleetClusterLayer.tsx +++ b/frontend/src/components/korea/FleetClusterLayer.tsx @@ -1043,7 +1043,7 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipS {/* 선택된 어구 그룹 — 이름 기반 하이라이트 폴리곤 */} - {selectedGearGroup && !historyData && enabledModels.has('identity') && (() => { + {selectedGearGroup && enabledModels.has('identity') && !historyData && (() => { const allGroups = groupPolygons ? [...groupPolygons.gearInZoneGroups, ...groupPolygons.gearOutZoneGroups] : []; @@ -1066,7 +1066,7 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipS })()} {/* 선택된 어구 그룹 — 모델별 오퍼레이셔널 폴리곤 오버레이 */} - {selectedGearGroup && !historyData && operationalPolygons.map(op => ( + {selectedGearGroup && operationalPolygons.map(op => ( { + {selectedGearGroup && (() => { const allGroups = groupPolygons ? [...groupPolygons.gearInZoneGroups, ...groupPolygons.gearOutZoneGroups] : []; const group = allGroups.find(g => g.groupKey === selectedGearGroup); - if (!group) return null; + const memberCount = group?.memberCount ?? 0; const defaultItems = correlationData.filter(c => c.isDefault); return (
@@ -1356,7 +1356,7 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipS
{selectedGearGroup} - 어구 {group.memberCount}개 + 어구 {memberCount}개 -
- {/* 폴리곤 오버레이 토글 */} -
폴리곤 오버레이
- - {correlationLoading &&
로딩...
} - {availableModels.map(m => { - const color = MODEL_COLORS[m.name] ?? '#94a3b8'; - const modelItems = correlationByModel.get(m.name) ?? []; - const above70 = modelItems.filter(c => c.score >= 0.7).length; - return ( - - ); - })} -
- {/* 우: 연관 선박 목록 (default 모델) */} - {defaultItems.length > 0 && ( -
-
- 연관 선박 ({defaultItems.length}건) -
-
- {defaultItems.slice(0, 12).map(c => { - const pct = (c.score * 100).toFixed(0); - const barW = Math.max(2, c.score * 60); - const barColor = c.score >= 0.7 ? '#22c55e' : c.score >= 0.5 ? '#eab308' : '#94a3b8'; - return ( -
- - {c.targetType === 'VESSEL' ? '⛴' : '◆'} - - - {c.targetName || c.targetMmsi} - -
-
-
-
- {pct}% -
-
- ); - })} - {defaultItems.length > 12 && ( -
+{defaultItems.length - 12}건 더
- )} -
-
- )} -
- ); - })()} - - {/* 히스토리 재생 컨트롤러 */} - {historyData && (() => { - const curTime = new Date(currentTimeMs); - const timeStr = curTime.toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit' }); - const hasSnap = currentSnapIdx >= 0; - return ( -
- {/* 프로그레스 바 — 갭 표시 */} -
- {/* 스냅샷 존재 구간 표시 */} - {snapshotRanges.map((pos, i) => ( -
- ))} - {/* 현재 위치 */} -
-
- {/* 컨트롤 행 */} -
- - - {timeStr} - - { setIsPlaying(false); setTimelinePos(Number(e.target.value) / 1000); }} - style={{ flex: 1, cursor: 'pointer', accentColor: '#fbbf24' }} - title="히스토리 타임라인" aria-label="히스토리 타임라인" - /> - - {historyData.length}건 - - -
-
- ); - })()} - - {/* 선단 목록 패널 */} -
- {/* ── 선단 현황 섹션 ── */} -
toggleSection('fleet')}> - - 선단 현황 ({fleetList.length}개) - - -
- {activeSection === 'fleet' && ( -
- {fleetList.length === 0 ? ( -
- 선단 데이터 없음 -
- ) : ( - fleetList.map(({ id, mmsiList, label, color, members }) => { - const company = companies.get(id); - const companyName = company?.nameCn ?? label ?? `선단 #${id}`; - const isOpen = expandedFleet === id; - const isHovered = hoveredFleetId === id; - - const mainMembers = members.filter(m => { - const dto = analysisMap.get(m.mmsi); - return dto?.algorithms.fleetRole.role === 'LEADER' || dto?.algorithms.fleetRole.role === 'MEMBER'; - }); - const displayMembers = mainMembers.length > 0 ? mainMembers : members; - - return ( -
- {/* 선단 행 */} -
setHoveredFleetId(id)} - onMouseLeave={() => setHoveredFleetId(null)} - style={{ - display: 'flex', - alignItems: 'center', - gap: 4, - padding: '4px 10px', - cursor: 'pointer', - backgroundColor: isHovered ? 'rgba(255,255,255,0.06)' : 'transparent', - borderLeft: isOpen ? `2px solid ${color}` : '2px solid transparent', - transition: 'background-color 0.1s', - }} - > - {/* 펼침 토글 */} - setExpandedFleet(prev => (prev === id ? null : id))} - style={{ color: '#94a3b8', fontSize: 9, flexShrink: 0, cursor: 'pointer' }} - > - {isOpen ? '▾' : '▸'} - - {/* 색상 인디케이터 */} - - {/* 회사명 */} - setExpandedFleet(prev => (prev === id ? null : id))} - style={{ - flex: 1, - color: '#e2e8f0', - overflow: 'hidden', - textOverflow: 'ellipsis', - whiteSpace: 'nowrap', - cursor: 'pointer', - }} - title={company ? `${company.nameCn} / ${company.nameEn}` : companyName} - > - {companyName} - - {/* 선박 수 */} - - ({mmsiList.length}척) - - {/* zoom 버튼 */} - -
- - {/* 선단 상세 */} - {isOpen && ( -
- {/* 선박 목록 */} -
선박:
- {displayMembers.map(m => { - const dto = analysisMap.get(m.mmsi); - const role = dto?.algorithms.fleetRole.role ?? m.role; - const displayName = m.name || m.mmsi; - return ( -
- - {displayName} - - - ({role === 'LEADER' ? 'MAIN' : 'SUB'}) - - -
- ); - })} -
- )} -
- ); - }) - )} - -
- )} - - {/* ── 조업구역내 어구 그룹 섹션 ── */} -
toggleSection('inZone')}> - - 조업구역내 어구 ({inZoneGearGroups.length}개) - - -
- {activeSection === 'inZone' && ( -
- {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 ( -
-
{ (e.currentTarget as HTMLDivElement).style.backgroundColor = 'rgba(220,38,38,0.06)'; }} - onMouseLeave={e => { (e.currentTarget as HTMLDivElement).style.backgroundColor = 'transparent'; }} - > - 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} — ${zoneName}`}>{name} - {parentMember && } - {zoneName} - ({gearMembers.length}) - -
- {isOpen && ( -
- {parentMember &&
모선: {parentMember.name || parentMember.mmsi}
} -
어구 목록:
- {gearMembers.map(m => ( -
- {m.name || m.mmsi} - -
- ))} -
- )} -
- ); - })} -
- )} - - {/* ── 비허가 어구 그룹 섹션 ── */} -
toggleSection('outZone')}> - - 비허가 어구 ({outZoneGearGroups.length}개) - - -
- {activeSection === 'outZone' && ( -
- {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 ( -
-
{ (e.currentTarget as HTMLDivElement).style.backgroundColor = 'rgba(255,255,255,0.04)'; }} - onMouseLeave={e => { (e.currentTarget as HTMLDivElement).style.backgroundColor = 'transparent'; }} - > - 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} - > - {name} - - {parentMember && } - - ({gearMembers.length}개) - - -
- - {isOpen && ( -
- {parentMember && ( -
- 모선: {parentMember.name || parentMember.mmsi} -
- )} -
어구 목록:
- {gearMembers.map(m => ( -
- - {m.name || m.mmsi} - - -
- ))} -
- )} -
- ); - })} -
- )} -
+ {/* ── 좌측 목록 패널 ── */} + onShipSelect?.(mmsi)} + /> ); } - -export default FleetClusterLayer; diff --git a/frontend/src/components/korea/FleetClusterMapLayers.tsx b/frontend/src/components/korea/FleetClusterMapLayers.tsx new file mode 100644 index 0000000..be47930 --- /dev/null +++ b/frontend/src/components/korea/FleetClusterMapLayers.tsx @@ -0,0 +1,492 @@ +import { Source, Layer, Popup } from 'react-map-gl/maplibre'; +import { FONT_MONO } from '../../styles/fonts'; +import type { FleetCompany } from '../../services/vesselAnalysis'; +import type { VesselAnalysisDto } from '../../types'; +import type { UseGroupPolygonsResult } from '../../hooks/useGroupPolygons'; +import type { + HistoryFrame, + HoverTooltipState, + GearPickerPopupState, + PickerCandidate, +} from './fleetClusterTypes'; +import type { FleetClusterGeoJsonResult } from './useFleetClusterGeoJson'; +import { MODEL_ORDER, MODEL_COLORS } from './fleetClusterConstants'; + +interface FleetClusterMapLayersProps { + geo: FleetClusterGeoJsonResult; + selectedGearGroup: string | null; + hoveredMmsi: string | null; + enabledModels: Set; + expandedFleet: number | null; + historyData: HistoryFrame[] | null; + effectiveSnapIdx: number; + // Popup/tooltip state + hoverTooltip: HoverTooltipState | null; + gearPickerPopup: GearPickerPopupState | null; + pickerHoveredGroup: string | null; + // Data for tooltip rendering + groupPolygons: UseGroupPolygonsResult | undefined; + companies: Map; + analysisMap: Map; + // Whether any correlation trails exist (drives conditional render) + hasCorrelationTracks: boolean; + // Callbacks + onPickerHover: (group: string | null) => void; + onPickerSelect: (candidate: PickerCandidate) => void; + onPickerClose: () => void; +} + +const FleetClusterMapLayers = ({ + geo, + selectedGearGroup, + hoveredMmsi, + enabledModels, + expandedFleet, + historyData, + effectiveSnapIdx, + hoverTooltip, + gearPickerPopup, + pickerHoveredGroup, + groupPolygons, + companies, + analysisMap, + hasCorrelationTracks, + onPickerHover, + onPickerSelect, + onPickerClose, +}: FleetClusterMapLayersProps) => { + const { + fleetPolygonGeoJSON, + lineGeoJSON, + hoveredGeoJSON, + gearClusterGeoJson, + memberMarkersGeoJson, + pickerHighlightGeoJson, + operationalPolygons, + memberTrailsGeoJson, + centerTrailGeoJson, + currentCenterGeoJson, + animPolygonGeoJson, + animMembersGeoJson, + correlationVesselGeoJson, + correlationTrailGeoJson, + modelBadgesGeoJson, + hoverHighlightGeoJson, + hoverHighlightTrailGeoJson, + isStale, + } = geo; + + return ( + <> + {/* 선단 폴리곤 레이어 */} + + + + + + {/* 2척 선단 라인 (서버측 Polygon 처리로 빈 컬렉션) */} + + + + + {/* 호버 하이라이트 (별도 Source) */} + + + + + {/* 선택된 어구 그룹 — 이름 기반 하이라이트 폴리곤 */} + {selectedGearGroup && enabledModels.has('identity') && !historyData && (() => { + 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: group.polygon, + }], + }; + return ( + + + + + ); + })()} + + {/* 선택된 어구 그룹 — 모델별 오퍼레이셔널 폴리곤 오버레이 */} + {selectedGearGroup && operationalPolygons.map(op => ( + + + + + ))} + + {/* 비허가 어구 클러스터 폴리곤 */} + + + + + + {/* 가상 선박 마커 — 히스토리 재생 모드에서는 숨김 (애니메이션 아이콘으로 대체) */} + + + + + {/* 어구 picker 호버 하이라이트 */} + + + + + + {/* 어구 다중 선택 팝업 */} + {gearPickerPopup && ( + { onPickerClose(); }} + closeOnClick={false} className="gl-popup" maxWidth="220px"> +
+
+ 겹친 그룹 ({gearPickerPopup.candidates.length}) +
+ {gearPickerPopup.candidates.map(c => ( +
onPickerHover(c.isFleet ? String(c.clusterId) : c.name)} + onMouseLeave={() => onPickerHover(null)} + onClick={() => { + onPickerSelect(c); + onPickerClose(); + }} + style={{ + cursor: 'pointer', padding: '3px 6px', + borderLeft: `3px solid ${c.isFleet ? '#63b3ed' : c.inZone ? '#dc2626' : '#f97316'}`, + marginBottom: 2, borderRadius: 2, + backgroundColor: pickerHoveredGroup === (c.isFleet ? String(c.clusterId) : c.name) ? 'rgba(255,255,255,0.1)' : 'transparent', + }}> + {c.isFleet ? '⚓ ' : ''}{c.name} + ({c.count}{c.isFleet ? '척' : '개'}) +
+ ))} +
+
+ )} + + {/* 폴리곤 호버 툴팁 */} + {hoverTooltip && (() => { + if (hoverTooltip.type === 'fleet') { + const cid = hoverTooltip.id as number; + const group = groupPolygons?.fleetGroups.find(g => Number(g.groupKey) === cid); + const company = companies.get(cid); + const memberCount = group?.memberCount ?? 0; + return ( + +
+
+ {company?.nameCn || group?.groupLabel || `선단 #${cid}`} +
+
선박 {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 +
+ ); + })} +
클릭하여 상세 보기
+
+
+ ); + } + if (hoverTooltip.type === 'gear') { + const name = hoverTooltip.id as string; + 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} 어구 {gearMembers.length}개 +
+ {parentMember && ( +
모선: {parentMember.name || parentMember.mmsi}
+ )} + {selectedGearGroup === name && gearMembers.slice(0, 5).map(m => ( +
+ · {m.name || m.mmsi} +
+ ))} +
클릭하여 선택/해제
+
+
+ ); + } + return null; + })()} + + {/* ── 연관 대상 트레일 + 마커 (활성 모델 전체) ── */} + {selectedGearGroup && hasCorrelationTracks && ( + + + + )} + {selectedGearGroup && ( + + + + + )} + + {/* ── 모델 배지 (아이콘 우측 컬러 dot) ── */} + {selectedGearGroup && ( + + {MODEL_ORDER.map((model, i) => ( + enabledModels.has(model) ? ( + + ) : null + ))} + + )} + + {/* ── 호버 하이라이트 (글로우 + 항적 강조) ── */} + {hoveredMmsi && ( + + + + + )} + {hoveredMmsi && ( + + + + )} + + {/* ── 히스토리 애니메이션 레이어 (최상위) ── */} + {historyData && ( + + + + )} + {historyData && ( + + + + + )} + {/* 보간 경로는 centerTrailGeoJson에 통합됨 (interpolated=1 세그먼트) */} + {/* 현재 재생 위치 포인트 (실데이터=빨강, 보간=주황) */} + {historyData && effectiveSnapIdx >= 0 && ( + + + + )} + {historyData && ( + + + + + )} + {/* 가상 아이콘 — 현재 프레임 멤버 위치 (최상위) */} + {historyData && ( + + + + + )} + + ); +}; + +export default FleetClusterMapLayers; diff --git a/frontend/src/components/korea/FleetGearListPanel.tsx b/frontend/src/components/korea/FleetGearListPanel.tsx new file mode 100644 index 0000000..72182c0 --- /dev/null +++ b/frontend/src/components/korea/FleetGearListPanel.tsx @@ -0,0 +1,171 @@ +import type { FleetCompany } from '../../services/vesselAnalysis'; +import type { VesselAnalysisDto } from '../../types'; +import type { UseGroupPolygonsResult } from '../../hooks/useGroupPolygons'; +import type { FleetListItem } from './fleetClusterTypes'; +import { panelStyle, headerStyle, toggleButtonStyle } from './fleetClusterConstants'; +import GearGroupSection from './GearGroupSection'; + +interface FleetGearListPanelProps { + fleetList: FleetListItem[]; + companies: Map; + analysisMap: Map; + inZoneGearGroups: UseGroupPolygonsResult['gearInZoneGroups']; + outZoneGearGroups: UseGroupPolygonsResult['gearOutZoneGroups']; + activeSection: string | null; + expandedFleet: number | null; + expandedGearGroup: string | null; + hoveredFleetId: number | null; + onToggleSection: (key: string) => void; + onExpandFleet: (id: number | null) => void; + onHoverFleet: (id: number | null) => void; + onFleetZoom: (id: number) => void; + onGearGroupZoom: (name: string) => void; + onExpandGearGroup: (name: string | null) => void; + onShipSelect: (mmsi: string) => void; +} + +const FleetGearListPanel = ({ + fleetList, + companies, + analysisMap, + inZoneGearGroups, + outZoneGearGroups, + activeSection, + expandedFleet, + expandedGearGroup, + hoveredFleetId, + onToggleSection, + onExpandFleet, + onHoverFleet, + onFleetZoom, + onGearGroupZoom, + onExpandGearGroup, + onShipSelect, +}: FleetGearListPanelProps) => { + return ( +
+ {/* ── 선단 현황 섹션 ── */} +
onToggleSection('fleet')}> + + 선단 현황 ({fleetList.length}개) + + +
+ {activeSection === 'fleet' && ( +
+ {fleetList.length === 0 ? ( +
+ 선단 데이터 없음 +
+ ) : ( + fleetList.map(({ id, mmsiList, label, color, members }) => { + const company = companies.get(id); + const companyName = company?.nameCn ?? label ?? `선단 #${id}`; + const isOpen = expandedFleet === id; + const isHovered = hoveredFleetId === id; + + const mainMembers = members.filter(m => { + const dto = analysisMap.get(m.mmsi); + return dto?.algorithms.fleetRole.role === 'LEADER' || dto?.algorithms.fleetRole.role === 'MEMBER'; + }); + const displayMembers = mainMembers.length > 0 ? mainMembers : members; + + return ( +
+
onHoverFleet(id)} + onMouseLeave={() => onHoverFleet(null)} + style={{ + display: 'flex', alignItems: 'center', gap: 4, padding: '4px 10px', + cursor: 'pointer', + backgroundColor: isHovered ? 'rgba(255,255,255,0.06)' : 'transparent', + borderLeft: isOpen ? `2px solid ${color}` : '2px solid transparent', + transition: 'background-color 0.1s', + }} + > + onExpandFleet(isOpen ? null : id)} + style={{ color: '#94a3b8', fontSize: 9, flexShrink: 0, cursor: 'pointer' }}> + {isOpen ? '▾' : '▸'} + + + onExpandFleet(isOpen ? null : id)} + style={{ flex: 1, color: '#e2e8f0', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', cursor: 'pointer' }} + title={company ? `${company.nameCn} / ${company.nameEn}` : companyName}> + {companyName} + + ({mmsiList.length}척) + +
+ + {isOpen && ( +
+
선박:
+ {displayMembers.map(m => { + const dto = analysisMap.get(m.mmsi); + const role = dto?.algorithms.fleetRole.role ?? m.role; + const displayName = m.name || m.mmsi; + return ( +
+ + {displayName} + + + ({role === 'LEADER' ? 'MAIN' : 'SUB'}) + + +
+ ); + })} +
+ )} +
+ ); + }) + )} +
+ )} + + {/* ── 조업구역내 어구 ── */} + onToggleSection('inZone')} + onToggleGroup={(name) => onExpandGearGroup(expandedGearGroup === name ? null : name)} + onGroupZoom={onGearGroupZoom} + onShipSelect={onShipSelect} + /> + + {/* ── 비허가 어구 ── */} + onToggleSection('outZone')} + onToggleGroup={(name) => onExpandGearGroup(expandedGearGroup === name ? null : name)} + onGroupZoom={onGearGroupZoom} + onShipSelect={onShipSelect} + /> +
+ ); +}; + +export default FleetGearListPanel; diff --git a/frontend/src/components/korea/GearGroupSection.tsx b/frontend/src/components/korea/GearGroupSection.tsx new file mode 100644 index 0000000..16081d6 --- /dev/null +++ b/frontend/src/components/korea/GearGroupSection.tsx @@ -0,0 +1,211 @@ +import type { GroupPolygonDto } from '../../services/vesselAnalysis'; +import { FONT_MONO } from '../../styles/fonts'; +import { headerStyle, toggleButtonStyle } from './fleetClusterConstants'; + +interface GearGroupSectionProps { + groups: GroupPolygonDto[]; + sectionKey: string; + sectionLabel: string; + accentColor: string; + hoverBgColor: string; + isActive: boolean; + expandedGroup: string | null; + onToggleSection: () => void; + onToggleGroup: (name: string) => void; + onGroupZoom: (name: string) => void; + onShipSelect: (mmsi: string) => void; +} + +const GearGroupSection = ({ + groups, + sectionKey, + sectionLabel, + accentColor, + hoverBgColor, + isActive, + expandedGroup, + onToggleSection, + onToggleGroup, + onGroupZoom, + onShipSelect, +}: GearGroupSectionProps) => { + const isInZoneSection = sectionKey === 'inZone'; + + return ( + <> +
+ + {sectionLabel} ({groups.length}개) + + +
+ + {isActive && ( +
+ {groups.map(g => { + const name = g.groupKey; + const isOpen = expandedGroup === name; + const parentMember = g.members.find(m => m.isParent); + const gearMembers = g.members.filter(m => !m.isParent); + const zoneName = g.zoneName ?? ''; + + return ( +
+
{ + (e.currentTarget as HTMLDivElement).style.backgroundColor = hoverBgColor; + }} + onMouseLeave={e => { + (e.currentTarget as HTMLDivElement).style.backgroundColor = 'transparent'; + }} + > + onToggleGroup(name)} + style={{ color: '#94a3b8', fontSize: 9, flexShrink: 0, cursor: 'pointer' }} + > + {isOpen ? '▾' : '▸'} + + + onToggleGroup(name)} + style={{ + flex: 1, + color: '#e2e8f0', + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + cursor: 'pointer', + }} + title={isInZoneSection ? `${name} — ${zoneName}` : name} + > + {name} + + {parentMember && ( + + ⚓ + + )} + {isInZoneSection && zoneName && ( + {zoneName} + )} + + ({gearMembers.length}{isInZoneSection ? '' : '개'}) + + +
+ + {isOpen && ( +
+ {parentMember && ( +
+ 모선: {parentMember.name || parentMember.mmsi} +
+ )} +
어구 목록:
+ {gearMembers.map(m => ( +
+ + {m.name || m.mmsi} + + +
+ ))} +
+ )} +
+ ); + })} +
+ )} + + ); +}; + +export default GearGroupSection; diff --git a/frontend/src/components/korea/HistoryReplayController.tsx b/frontend/src/components/korea/HistoryReplayController.tsx new file mode 100644 index 0000000..875df93 --- /dev/null +++ b/frontend/src/components/korea/HistoryReplayController.tsx @@ -0,0 +1,171 @@ +import React from 'react'; +import { FONT_MONO } from '../../styles/fonts'; +import type { HistoryFrame } from './fleetClusterTypes'; +import { TIMELINE_DURATION_MS } from './fleetClusterTypes'; + +interface HistoryReplayControllerProps { + historyData: HistoryFrame[]; + effectiveSnapIdx: number; + isPlaying: boolean; + snapshotRanges: number[]; + progressBarRef: React.RefObject; + progressIndicatorRef: React.RefObject; + timeDisplayRef: React.RefObject; + historyStartRef: React.RefObject; + timelinePosRef: React.MutableRefObject; + frameTimesRef: React.RefObject; + onTogglePlay: () => void; + onFrameChange: (idx: number) => void; + onClose: () => void; +} + +const HistoryReplayController = ({ + historyData, + effectiveSnapIdx, + isPlaying, + snapshotRanges, + progressBarRef, + progressIndicatorRef, + timeDisplayRef, + historyStartRef, + timelinePosRef, + frameTimesRef, + onTogglePlay, + onFrameChange, + onClose, +}: HistoryReplayControllerProps) => { + const hasSnap = effectiveSnapIdx >= 0; + + return ( +
+ {/* 프로그레스 바 — 갭 표시 */} +
+ {snapshotRanges.map((pos, i) => ( +
+ ))} + {/* 현재 위치 인디케이터 (DOM ref로 업데이트) */} +
+
+ + {/* 컨트롤 행 */} +
+ + + + --:-- + + + { + timelinePosRef.current = Number(e.target.value) / 1000; + // 수동 드래그 시 즉시 프레임 계산 + const t = historyStartRef.current + timelinePosRef.current * TIMELINE_DURATION_MS; + const ft = frameTimesRef.current ?? []; + let best = 0, bestDiff = Infinity; + for (let i = 0; i < ft.length; i++) { + const d = Math.abs(ft[i] - t); + if (d < bestDiff) { bestDiff = d; best = i; } + } + onFrameChange(bestDiff < 1_800_000 ? best : -1); + if (timeDisplayRef.current) { + timeDisplayRef.current.textContent = new Date(t).toLocaleTimeString('ko-KR', { + hour: '2-digit', + minute: '2-digit', + }); + } + }} + style={{ flex: 1, cursor: 'pointer', accentColor: '#fbbf24' }} + title="히스토리 타임라인" + aria-label="히스토리 타임라인" + /> + + + {historyData.length}건 + + + +
+
+ ); +}; + +export default HistoryReplayController; diff --git a/frontend/src/components/korea/fleetClusterConstants.ts b/frontend/src/components/korea/fleetClusterConstants.ts new file mode 100644 index 0000000..8824618 --- /dev/null +++ b/frontend/src/components/korea/fleetClusterConstants.ts @@ -0,0 +1,116 @@ +import { FONT_MONO } from '../../styles/fonts'; + +// ── 모델 순서/색상/설명 ── +export const MODEL_ORDER = ['identity', 'default', 'aggressive', 'conservative', 'proximity-heavy', 'visit-pattern'] as const; + +export const MODEL_COLORS: Record = { + 'identity': '#f97316', + 'default': '#3b82f6', + 'aggressive': '#22c55e', + 'conservative': '#a855f7', + 'proximity-heavy': '#06b6d4', + 'visit-pattern': '#f43f5e', +}; + +export const MODEL_DESC: Record = { + 'identity': { + summary: '이름 패턴매칭 — 동일 모선명 기반 어구 그룹', + details: [ + '패턴: NAME_인덱스 (_ 필수, 공백만은 선박)', + '거리제한: ~10NM 이내 연결 클러스터링', + '모선연결: 어구와 ~20NM 이내 시 포함', + ], + }, + 'default': { + summary: '기본 모델 — 균형 가중치', + details: [ + '어구-선박: 근접도 45% · 방문 35% · 활동동기화 20%', + '선박-선박: DTW 30% · SOG 20% · COG 25% · 근접비 25%', + 'EMA: α 0.30→0.08 · 추적시작 50% · 폴리곤 70%', + '감쇠: 정상 0.015/5분 · 장기(6h+) 0.08/5분', + '근접판정: 5NM · 후보반경: 그룹×3배', + ], + }, + 'aggressive': { + summary: '공격적 추적 — 빠른 상승, 약한 감쇠', + details: [ + '어구-선박: 근접도 55% · 방문 25% · 활동동기화 20%', + 'EMA: α 0.40→0.10 · 추적시작 40% · 폴리곤 60%', + '감쇠: 정상 0.010/5분 · 장기(8h+) 0.06/5분', + '근접판정: 7NM · 후보반경: 그룹×4배', + '야간보너스: ×1.5 · shadow: 체류+0.15 복귀+0.20', + ], + }, + 'conservative': { + summary: '보수적 추적 — 높은 임계값, 강한 감쇠', + details: [ + '어구-선박: 근접도 40% · 방문 40% · 활동동기화 20%', + 'EMA: α 0.20→0.05 · 추적시작 60% · 폴리곤 80%', + '감쇠: 정상 0.020/5분 · 장기(4h+) 0.10/5분', + '근접판정: 4NM · 후보반경: 그룹×2.5배', + '야간보너스: ×1.2 · shadow: 체류+0.08 복귀+0.12', + ], + }, + 'proximity-heavy': { + summary: '근접 중심 — 거리 기반 판단 우선', + details: [ + '어구-선박: 근접도 70% · 방문 20% · 활동동기화 10%', + '선박-선박: 근접비 50% · DTW 20% · SOG 15% · COG 15%', + 'EMA: α 0.30→0.08 · 추적시작 50% · 폴리곤 70%', + '근접판정: 5NM · 후보반경: 그룹×3배', + 'shadow: 체류+0.12 복귀+0.18', + ], + }, + 'visit-pattern': { + summary: '방문 패턴 — 반복 접근 추적', + details: [ + '어구-선박: 근접도 25% · 방문 55% · 활동동기화 20%', + 'EMA: α 0.30→0.08 · 추적시작 50% · 폴리곤 70%', + '근접판정: 6NM · 후보반경: 그룹×3.5배', + '야간보너스: ×1.4', + ], + }, +}; + +// ── 패널 스타일 상수 ── +export const panelStyle: React.CSSProperties = { + position: 'absolute', + bottom: 60, + left: 10, + zIndex: 10, + minWidth: 220, + maxWidth: 300, + backgroundColor: 'rgba(12, 24, 37, 0.92)', + border: '1px solid rgba(99, 179, 237, 0.25)', + borderRadius: 8, + color: '#e2e8f0', + fontFamily: FONT_MONO, + fontSize: 11, + boxShadow: '0 4px 16px rgba(0, 0, 0, 0.5)', + overflow: 'hidden', + display: 'flex', + flexDirection: 'column', + pointerEvents: 'auto', + maxHeight: 'min(45vh, 400px)', +}; + +export const headerStyle: React.CSSProperties = { + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + padding: '6px 10px', + borderBottom: 'none', + cursor: 'default', + userSelect: 'none', + flexShrink: 0, +}; + +export const toggleButtonStyle: React.CSSProperties = { + background: 'none', + border: 'none', + color: '#94a3b8', + cursor: 'pointer', + fontSize: 10, + padding: '0 2px', + lineHeight: 1, +}; diff --git a/frontend/src/components/korea/fleetClusterTypes.ts b/frontend/src/components/korea/fleetClusterTypes.ts new file mode 100644 index 0000000..57211c3 --- /dev/null +++ b/frontend/src/components/korea/fleetClusterTypes.ts @@ -0,0 +1,58 @@ +import type { Ship, VesselAnalysisDto } from '../../types'; +import type { MemberInfo } from '../../services/vesselAnalysis'; + +// ── 히스토리 스냅샷 + 보간 플래그 ── +export type HistoryFrame = GroupPolygonDto & { _interp?: boolean; _longGap?: boolean }; + +// ── 외부 노출 타입 (KoreaMap에서 import) ── +export interface SelectedGearGroupData { + parent: Ship | null; + gears: Ship[]; + groupName: string; +} + +export interface SelectedFleetData { + clusterId: number; + ships: Ship[]; + companyName: string; +} + +// ── 내부 공유 타입 ── +export interface HoverTooltipState { + lng: number; + lat: number; + type: 'fleet' | 'gear'; + id: number | string; +} + +export interface PickerCandidate { + name: string; + count: number; + inZone: boolean; + isFleet: boolean; + clusterId?: number; +} + +export interface GearPickerPopupState { + lng: number; + lat: number; + candidates: PickerCandidate[]; +} + +export interface FleetListItem { + id: number; + mmsiList: string[]; + label: string; + memberCount: number; + areaSqNm: number; + color: string; + members: MemberInfo[]; +} + +// ── 상수 ── +export const GEAR_BUFFER_DEG = 0.01; +export const CIRCLE_SEGMENTS = 16; +export const TIMELINE_DURATION_MS = 12 * 60 * 60_000; // 12시간 +export const PLAYBACK_CYCLE_SEC = 30; // 30초에 12시간 전체 재생 +export const TICK_MS = 50; // 50ms 간격 업데이트 +export const EMPTY_ANALYSIS = new globalThis.Map(); diff --git a/frontend/src/components/korea/fleetClusterUtils.ts b/frontend/src/components/korea/fleetClusterUtils.ts new file mode 100644 index 0000000..6f281bc --- /dev/null +++ b/frontend/src/components/korea/fleetClusterUtils.ts @@ -0,0 +1,204 @@ +import type { GeoJSON } from 'geojson'; +import type { GroupPolygonDto } from '../../services/vesselAnalysis'; +import type { HistoryFrame } from './fleetClusterTypes'; +import { GEAR_BUFFER_DEG, CIRCLE_SEGMENTS } from './fleetClusterTypes'; + +/** 트랙 포인트에서 주어진 시각의 보간 위치 반환 */ +export function interpolateTrackPosition( + track: { ts: number; lat: number; lon: number; cog: number }[], + timeMs: number, +): { lat: number; lon: number; cog: number } | null { + if (track.length === 0) return null; + if (track.length === 1) return { lat: track[0].lat, lon: track[0].lon, cog: track[0].cog }; + if (timeMs <= track[0].ts) return { lat: track[0].lat, lon: track[0].lon, cog: track[0].cog }; + if (timeMs >= track[track.length - 1].ts) { + const last = track[track.length - 1]; + return { lat: last.lat, lon: last.lon, cog: last.cog }; + } + // Binary search for surrounding points + let lo = 0, hi = track.length - 1; + while (lo < hi - 1) { + const mid = (lo + hi) >> 1; + if (track[mid].ts <= timeMs) lo = mid; else hi = mid; + } + const a = track[lo], b = track[hi]; + const t = (timeMs - a.ts) / (b.ts - a.ts); + return { + lat: a.lat + t * (b.lat - a.lat), + lon: a.lon + t * (b.lon - a.lon), + cog: b.cog, + }; +} + +/** + * Convex hull + buffer 기반 폴리곤 생성 (Python polygon_builder.py 동일 로직) + * - 1점: 원형 버퍼 (GEAR_BUFFER_DEG=0.01 ~ 1.1km) + * - 2점: 두 점 잇는 직선 양쪽 버퍼 + * - 3점+: convex hull + 버퍼 + */ +export function buildInterpPolygon(points: [number, number][]): GeoJSON.Polygon | null { + if (points.length === 0) return null; + + if (points.length === 1) { + const [cx, cy] = points[0]; + const ring: [number, number][] = []; + for (let i = 0; i <= CIRCLE_SEGMENTS; i++) { + const angle = (2 * Math.PI * i) / CIRCLE_SEGMENTS; + ring.push([cx + GEAR_BUFFER_DEG * Math.cos(angle), cy + GEAR_BUFFER_DEG * Math.sin(angle)]); + } + return { type: 'Polygon', coordinates: [ring] }; + } + + if (points.length === 2) { + const [p1, p2] = points; + const dx = p2[0] - p1[0]; + const dy = p2[1] - p1[1]; + const len = Math.sqrt(dx * dx + dy * dy) || 1e-10; + const nx = (-dy / len) * GEAR_BUFFER_DEG; + const ny = (dx / len) * GEAR_BUFFER_DEG; + const ring: [number, number][] = []; + const half = CIRCLE_SEGMENTS / 2; + ring.push([p1[0] + nx, p1[1] + ny]); + ring.push([p2[0] + nx, p2[1] + ny]); + const a2 = Math.atan2(ny, nx); + for (let i = 0; i <= half; i++) { + const angle = a2 - Math.PI * i / half; + ring.push([p2[0] + GEAR_BUFFER_DEG * Math.cos(angle), p2[1] + GEAR_BUFFER_DEG * Math.sin(angle)]); + } + ring.push([p1[0] - nx, p1[1] - ny]); + const a1 = Math.atan2(-ny, -nx); + for (let i = 0; i <= half; i++) { + const angle = a1 - Math.PI * i / half; + ring.push([p1[0] + GEAR_BUFFER_DEG * Math.cos(angle), p1[1] + GEAR_BUFFER_DEG * Math.sin(angle)]); + } + ring.push(ring[0]); + return { type: 'Polygon', coordinates: [ring] }; + } + + const hull = convexHull(points); + return bufferPolygon(hull, GEAR_BUFFER_DEG); +} + +/** 단순 convex hull (Graham scan) */ +export function convexHull(points: [number, number][]): [number, number][] { + const pts = [...points].sort((a, b) => a[0] - b[0] || a[1] - b[1]); + if (pts.length <= 2) return pts; + + const cross = (o: [number, number], a: [number, number], b: [number, number]) => + (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0]); + + const lower: [number, number][] = []; + for (const p of pts) { + while (lower.length >= 2 && cross(lower[lower.length - 2], lower[lower.length - 1], p) <= 0) lower.pop(); + lower.push(p); + } + const upper: [number, number][] = []; + for (let i = pts.length - 1; i >= 0; i--) { + while (upper.length >= 2 && cross(upper[upper.length - 2], upper[upper.length - 1], pts[i]) <= 0) upper.pop(); + upper.push(pts[i]); + } + lower.pop(); + upper.pop(); + return lower.concat(upper); +} + +/** 폴리곤 외곽에 buffer 적용 (각 변의 오프셋 + 꼭짓점 라운드) */ +export function bufferPolygon(hull: [number, number][], buf: number): GeoJSON.Polygon { + const ring: [number, number][] = []; + const n = hull.length; + for (let i = 0; i < n; i++) { + const p = hull[i]; + const prev = hull[(i - 1 + n) % n]; + const next = hull[(i + 1) % n]; + const a1 = Math.atan2(p[1] - prev[1], p[0] - prev[0]) - Math.PI / 2; + const a2 = Math.atan2(next[1] - p[1], next[0] - p[0]) - Math.PI / 2; + const startA = a1; + let endA = a2; + if (endA < startA) endA += 2 * Math.PI; + const steps = Math.max(2, Math.round((endA - startA) / (Math.PI / 8))); + for (let s = 0; s <= steps; s++) { + const a = startA + (endA - startA) * s / steps; + ring.push([p[0] + buf * Math.cos(a), p[1] + buf * Math.sin(a)]); + } + } + ring.push(ring[0]); + return { type: 'Polygon', coordinates: [ring] }; +} + +/** + * 히스토리 데이터의 gap 구간에 보간 프레임을 삽입하여 완성 데이터셋 반환. + * - gap <= 30분: 5분 간격 직선 보간 (이전 폴리곤 유지, 중심만 직선 이동) + * - gap > 30분: 30분 간격으로 멤버 위치 보간 + 가상 폴리곤 생성 + */ +export function fillGapFrames(snapshots: GroupPolygonDto[]): HistoryFrame[] { + if (snapshots.length < 2) return snapshots; + const STEP_SHORT_MS = 300_000; + const STEP_LONG_MS = 1_800_000; + const THRESHOLD_MS = 1_800_000; + const result: GroupPolygonDto[] = []; + + for (let i = 0; i < snapshots.length; i++) { + result.push(snapshots[i]); + if (i >= snapshots.length - 1) continue; + + const prev = snapshots[i]; + const next = snapshots[i + 1]; + const t0 = new Date(prev.snapshotTime).getTime(); + const t1 = new Date(next.snapshotTime).getTime(); + const gap = t1 - t0; + if (gap <= STEP_SHORT_MS) continue; + + const nextMap = new Map(next.members.map(m => [m.mmsi, m])); + const common = prev.members.filter(m => nextMap.has(m.mmsi)); + if (common.length === 0) continue; + + if (gap <= THRESHOLD_MS) { + for (let t = t0 + STEP_SHORT_MS; t < t1; t += STEP_SHORT_MS) { + const ratio = (t - t0) / gap; + const cLon = prev.centerLon + (next.centerLon - prev.centerLon) * ratio; + const cLat = prev.centerLat + (next.centerLat - prev.centerLat) * ratio; + result.push({ + ...prev, + snapshotTime: new Date(t).toISOString(), + centerLon: cLon, + centerLat: cLat, + _interp: true, + }); + } + } else { + for (let t = t0 + STEP_LONG_MS; t < t1; t += STEP_LONG_MS) { + const ratio = (t - t0) / gap; + const positions: [number, number][] = []; + const members: typeof prev.members = []; + + for (const pm of common) { + const nm = nextMap.get(pm.mmsi)!; + const lon = pm.lon + (nm.lon - pm.lon) * ratio; + const lat = pm.lat + (nm.lat - pm.lat) * ratio; + const dLon = nm.lon - pm.lon; + const dLat = nm.lat - pm.lat; + const cog = (dLon === 0 && dLat === 0) ? pm.cog : (Math.atan2(dLon, dLat) * 180 / Math.PI + 360) % 360; + members.push({ mmsi: pm.mmsi, name: pm.name, lon, lat, sog: pm.sog, cog, role: pm.role, isParent: pm.isParent }); + positions.push([lon, lat]); + } + + const cLon = positions.reduce((s, p) => s + p[0], 0) / positions.length; + const cLat = positions.reduce((s, p) => s + p[1], 0) / positions.length; + const polygon = buildInterpPolygon(positions); + + result.push({ + ...prev, + snapshotTime: new Date(t).toISOString(), + polygon, + centerLon: cLon, + centerLat: cLat, + memberCount: members.length, + members, + _interp: true, + _longGap: true, + }); + } + } + } + return result; +} diff --git a/frontend/src/components/korea/useFleetClusterGeoJson.ts b/frontend/src/components/korea/useFleetClusterGeoJson.ts new file mode 100644 index 0000000..c17b341 --- /dev/null +++ b/frontend/src/components/korea/useFleetClusterGeoJson.ts @@ -0,0 +1,618 @@ +import { useMemo } from 'react'; +import type { GeoJSON } from 'geojson'; +import type { Ship, VesselAnalysisDto } from '../../types'; +import type { GearCorrelationItem, CorrelationVesselTrack } from '../../services/vesselAnalysis'; +import type { UseGroupPolygonsResult } from '../../hooks/useGroupPolygons'; +import type { HistoryFrame, FleetListItem } from './fleetClusterTypes'; +import { TIMELINE_DURATION_MS } from './fleetClusterTypes'; +import { interpolateTrackPosition, buildInterpPolygon } from './fleetClusterUtils'; +import { MODEL_ORDER, MODEL_COLORS } from './fleetClusterConstants'; + +export interface UseFleetClusterGeoJsonParams { + ships: Ship[]; + shipMap: Map; + groupPolygons: UseGroupPolygonsResult | undefined; + analysisMap: Map; + hoveredFleetId: number | null; + selectedGearGroup: string | null; + pickerHoveredGroup: string | null; + historyData: HistoryFrame[] | null; + effectiveSnapIdx: number; + correlationData: GearCorrelationItem[]; + correlationTracks: CorrelationVesselTrack[]; + enabledModels: Set; + enabledVessels: Set; + hoveredMmsi: string | null; + historyStartMs: number; +} + +export interface FleetClusterGeoJsonResult { + // static/base GeoJSON + fleetPolygonGeoJSON: GeoJSON; + lineGeoJSON: GeoJSON; + hoveredGeoJSON: GeoJSON; + gearClusterGeoJson: GeoJSON; + memberMarkersGeoJson: GeoJSON; + pickerHighlightGeoJson: GeoJSON; + selectedGearHighlightGeoJson: GeoJSON.FeatureCollection | null; + // history animation GeoJSON + memberTrailsGeoJson: GeoJSON; + centerTrailGeoJson: GeoJSON; + currentCenterGeoJson: GeoJSON; + animPolygonGeoJson: GeoJSON; + animMembersGeoJson: GeoJSON; + // correlation GeoJSON + correlationVesselGeoJson: GeoJSON; + correlationTrailGeoJson: GeoJSON; + modelBadgesGeoJson: GeoJSON; + hoverHighlightGeoJson: GeoJSON; + hoverHighlightTrailGeoJson: GeoJSON; + // operational polygons (per model) + operationalPolygons: { modelName: string; color: string; geojson: GeoJSON.FeatureCollection }[]; + // derived values + fleetList: FleetListItem[]; + currentFrame: HistoryFrame | null; + showGray: boolean; + isStale: boolean; + snapshotRanges: number[]; + correlationByModel: Map; + availableModels: { name: string; count: number; isDefault: boolean }[]; +} + +const EMPTY_FC: GeoJSON = { type: 'FeatureCollection', features: [] }; + +export function useFleetClusterGeoJson(params: UseFleetClusterGeoJsonParams): FleetClusterGeoJsonResult { + const { + ships, + shipMap, + groupPolygons, + hoveredFleetId, + selectedGearGroup, + pickerHoveredGroup, + historyData, + effectiveSnapIdx, + correlationData, + correlationTracks, + enabledModels, + enabledVessels, + hoveredMmsi, + historyStartMs, + } = params; + + // ── 선단 폴리곤 GeoJSON (서버 제공) ── + const fleetPolygonGeoJSON = useMemo((): GeoJSON => { + const features: GeoJSON.Feature[] = []; + if (!groupPolygons) return { type: 'FeatureCollection', features }; + for (const g of groupPolygons.fleetGroups) { + if (!g.polygon) continue; + features.push({ + type: 'Feature', + properties: { clusterId: Number(g.groupKey), color: g.color }, + geometry: g.polygon, + }); + } + return { type: 'FeatureCollection', features }; + }, [groupPolygons]); + + // 2척 선단 라인은 서버에서 Shapely buffer로 이미 Polygon 처리되므로 빈 컬렉션 + const lineGeoJSON = useMemo((): GeoJSON => ({ + type: 'FeatureCollection', features: [], + }), []); + + // 호버 하이라이트용 단일 폴리곤 + const hoveredGeoJSON = useMemo((): GeoJSON => { + if (hoveredFleetId === null || !groupPolygons) return EMPTY_FC; + const g = groupPolygons.fleetGroups.find(f => Number(f.groupKey) === hoveredFleetId); + if (!g?.polygon) return EMPTY_FC; + return { + type: 'FeatureCollection', + features: [{ + type: 'Feature', + properties: { clusterId: hoveredFleetId, color: g.color }, + geometry: g.polygon, + }], + }; + }, [hoveredFleetId, groupPolygons]); + + // 모델별 연관성 데이터 그룹핑 + const correlationByModel = useMemo(() => { + const map = new Map(); + for (const c of correlationData) { + const list = map.get(c.modelName) ?? []; + list.push(c); + map.set(c.modelName, list); + } + return map; + }, [correlationData]); + + // 사용 가능한 모델 목록 (데이터가 있는 모델만) + const availableModels = useMemo(() => { + const models: { name: string; count: number; isDefault: boolean }[] = []; + for (const [name, items] of correlationByModel) { + models.push({ name, count: items.length, isDefault: items[0]?.isDefault ?? false }); + } + models.sort((a, b) => (a.isDefault ? -1 : 0) - (b.isDefault ? -1 : 0)); + return models; + }, [correlationByModel]); + + // 현재 프레임 및 파생 상태 + const currentFrame = historyData && effectiveSnapIdx >= 0 ? historyData[effectiveSnapIdx] : null; + const showGray = !!currentFrame?._longGap || !!currentFrame?._interp; + const isStale = showGray; + + // 스냅샷 존재 구간 맵 (프로그레스 바 갭 표시용) + const snapshotRanges = useMemo(() => { + if (!historyData) return []; + return historyData + .filter(h => !h._interp) + .map(h => { + const t = new Date(h.snapshotTime).getTime(); + return (t - historyStartMs) / TIMELINE_DURATION_MS; + }); + }, [historyData, historyStartMs]); + + // ── 사전계산: 각 프레임별 연관 대상 보간 위치 ── + const correlationPosMap = useMemo(() => { + if (!historyData || correlationTracks.length === 0) return null; + const trackMap = new Map(correlationTracks.map(v => [v.mmsi, v.track])); + return historyData.map(snap => { + const t = new Date(snap.snapshotTime).getTime(); + const m = new Map(); + for (const [mmsi, track] of trackMap) { + const p = interpolateTrackPosition(track, t); + if (p) m.set(mmsi, p); + } + return m; + }); + }, [historyData, correlationTracks]); + + // 사전계산: 각 프레임별 트레일 클립 인덱스 + const trailClipMap = useMemo(() => { + if (!historyData || correlationTracks.length === 0) return null; + return historyData.map(snap => { + const t = new Date(snap.snapshotTime).getTime(); + const m = new Map(); + for (const vt of correlationTracks) { + let idx = vt.track.length; + for (let i = 0; i < vt.track.length; i++) { + if (vt.track[i].ts > t) { idx = i; break; } + } + m.set(vt.mmsi, idx); + } + return m; + }); + }, [historyData, correlationTracks]); + + // 사전계산: 각 프레임별 오퍼레이셔널 폴리곤 + const operationalPolygonsByFrame = useMemo(() => { + if (!historyData || !selectedGearGroup || !groupPolygons) return null; + const allGroups = [...groupPolygons.gearInZoneGroups, ...groupPolygons.gearOutZoneGroups]; + const group = allGroups.find(g => g.groupKey === selectedGearGroup); + if (!group) return null; + + return historyData.map((snap, fi) => { + const basePts: [number, number][] = snap.members.map(m => [m.lon, m.lat]); + const positions = correlationPosMap?.[fi]; + const result: { modelName: string; color: string; geojson: GeoJSON.FeatureCollection }[] = []; + + for (const [mn, items] of correlationByModel) { + if (!enabledModels.has(mn)) continue; + const extra: [number, number][] = []; + for (const c of items) { + if (c.score < 0.7) continue; + const p = positions?.get(c.targetMmsi); + if (p) extra.push([p.lon, p.lat]); + } + if (extra.length === 0) continue; + const polygon = buildInterpPolygon([...basePts, ...extra]); + if (!polygon) continue; + const color = MODEL_COLORS[mn] ?? '#94a3b8'; + result.push({ + modelName: mn, + color, + geojson: { + type: 'FeatureCollection', + features: [{ type: 'Feature', properties: { modelName: mn, color }, geometry: polygon }], + }, + }); + } + return result; + }); + }, [historyData, selectedGearGroup, groupPolygons, correlationByModel, enabledModels, correlationPosMap]); + + // 재생 시 O(1) 룩업, 비재생 시 기존 로직 + const operationalPolygons = useMemo(() => { + if (operationalPolygonsByFrame && effectiveSnapIdx >= 0) { + return operationalPolygonsByFrame[effectiveSnapIdx] ?? []; + } + if (!selectedGearGroup || !groupPolygons) return []; + const allGroups = [...groupPolygons.gearInZoneGroups, ...groupPolygons.gearOutZoneGroups]; + const group = allGroups.find(g => g.groupKey === selectedGearGroup); + if (!group) return []; + const basePts: [number, number][] = group.members.map(m => [m.lon, m.lat]); + const result: { modelName: string; color: string; geojson: GeoJSON.FeatureCollection }[] = []; + for (const [mn, items] of correlationByModel) { + if (!enabledModels.has(mn)) continue; + const extra: [number, number][] = []; + for (const c of items) { + if (c.score < 0.7) continue; + const s = ships.find(x => x.mmsi === c.targetMmsi); + if (s) extra.push([s.lng, s.lat]); + } + if (extra.length === 0) continue; + const polygon = buildInterpPolygon([...basePts, ...extra]); + if (!polygon) continue; + const color = MODEL_COLORS[mn] ?? '#94a3b8'; + result.push({ + modelName: mn, + color, + geojson: { + type: 'FeatureCollection', + features: [{ type: 'Feature', properties: { modelName: mn, color }, geometry: polygon }], + }, + }); + } + return result; + }, [operationalPolygonsByFrame, effectiveSnapIdx, selectedGearGroup, groupPolygons, correlationByModel, enabledModels, ships]); + + // 어구 클러스터 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]); + + // 가상 선박 마커 GeoJSON (API members + shipMap heading 보정) + const memberMarkersGeoJson = useMemo((): GeoJSON => { + const features: GeoJSON.Feature[] = []; + if (!groupPolygons) return { type: 'FeatureCollection', features }; + + const addMember = ( + m: { mmsi: string; name: string; lat: number; lon: number; cog: number; isParent: boolean; role: string }, + groupKey: string, + groupType: string, + color: string, + ) => { + const realShip = shipMap.get(m.mmsi); + const heading = realShip?.heading ?? m.cog ?? 0; + const lat = realShip?.lat ?? m.lat; + const lon = realShip?.lng ?? m.lon; + features.push({ + type: 'Feature', + properties: { + mmsi: m.mmsi, + name: m.name, + groupKey, + groupType, + role: m.role, + isParent: m.isParent ? 1 : 0, + isGear: (groupType !== 'FLEET' && !m.isParent) ? 1 : 0, + color, + cog: heading, + baseSize: (groupType !== 'FLEET' && !m.isParent) ? 0.11 : m.isParent ? 0.18 : 0.14, + }, + geometry: { type: 'Point', coordinates: [lon, lat] }, + }); + }; + + for (const g of groupPolygons.fleetGroups) { + for (const m of g.members) addMember(m, g.groupKey, 'FLEET', g.color); + } + for (const g of [...groupPolygons.gearInZoneGroups, ...groupPolygons.gearOutZoneGroups]) { + const color = g.groupType === 'GEAR_IN_ZONE' ? '#dc2626' : '#f97316'; + for (const m of g.members) addMember(m, g.groupKey, g.groupType, color); + } + return { type: 'FeatureCollection', features }; + }, [groupPolygons, shipMap]); + + // picker 호버 하이라이트 (선단 + 어구 통합) + const pickerHighlightGeoJson = useMemo((): GeoJSON => { + if (!pickerHoveredGroup || !groupPolygons) return EMPTY_FC; + const fleet = groupPolygons.fleetGroups.find(x => String(x.groupKey) === pickerHoveredGroup); + if (fleet?.polygon) return { type: 'FeatureCollection', features: [{ type: 'Feature', properties: {}, geometry: fleet.polygon }] }; + const all = [...groupPolygons.gearInZoneGroups, ...groupPolygons.gearOutZoneGroups]; + const g = all.find(x => x.groupKey === pickerHoveredGroup); + if (!g?.polygon) return EMPTY_FC; + return { type: 'FeatureCollection', features: [{ type: 'Feature', properties: {}, geometry: g.polygon }] }; + }, [pickerHoveredGroup, groupPolygons]); + + // 선택된 어구 그룹 하이라이트 폴리곤 (JSX IIFE → useMemo) + const selectedGearHighlightGeoJson = useMemo((): GeoJSON.FeatureCollection | null => { + if (!selectedGearGroup || !enabledModels.has('identity') || historyData) return null; + const allGroups = groupPolygons + ? [...groupPolygons.gearInZoneGroups, ...groupPolygons.gearOutZoneGroups] + : []; + const group = allGroups.find(g => g.groupKey === selectedGearGroup); + if (!group?.polygon) return null; + return { + type: 'FeatureCollection', + features: [{ + type: 'Feature', + properties: {}, + geometry: group.polygon, + }], + }; + }, [selectedGearGroup, enabledModels, historyData, groupPolygons]); + + // ── 히스토리 애니메이션 GeoJSON ── + const memberTrailsGeoJson = useMemo((): GeoJSON => { + if (!historyData) return EMPTY_FC; + const tracks = new Map(); + for (const snap of historyData) { + for (const m of snap.members) { + const arr = tracks.get(m.mmsi) ?? []; + arr.push([m.lon, m.lat]); + tracks.set(m.mmsi, arr); + } + } + const features: GeoJSON.Feature[] = []; + for (const [, coords] of tracks) { + if (coords.length < 2) continue; + features.push({ type: 'Feature', properties: {}, geometry: { type: 'LineString', coordinates: coords } }); + } + return { type: 'FeatureCollection', features }; + }, [historyData]); + + // center trail: historyData에 이미 보간 프레임 포함 → 전체 좌표 연결 + const centerTrailGeoJson = useMemo((): GeoJSON => { + if (!historyData || historyData.length === 0) return EMPTY_FC; + + const features: GeoJSON.Feature[] = []; + + let segStart = 0; + for (let i = 1; i <= historyData.length; i++) { + const curInterp = i < historyData.length && !!historyData[i]._longGap; + const startInterp = !!historyData[segStart]._longGap; + if (i < historyData.length && curInterp === startInterp) continue; + + const from = segStart > 0 ? segStart - 1 : segStart; + const seg = historyData.slice(from, i); + if (seg.length >= 2) { + features.push({ + type: 'Feature', + properties: { interpolated: startInterp ? 1 : 0 }, + geometry: { type: 'LineString', coordinates: seg.map(s => [s.centerLon, s.centerLat]) }, + }); + } + segStart = i; + } + + for (const h of historyData) { + if (h.color === '#94a3b8') continue; + features.push({ + type: 'Feature', properties: { interpolated: 0 }, + geometry: { type: 'Point', coordinates: [h.centerLon, h.centerLat] }, + }); + } + + return { type: 'FeatureCollection', features }; + }, [historyData]); + + // 현재 재생 위치 포인트 + const currentCenterGeoJson = useMemo((): GeoJSON => { + if (!historyData || effectiveSnapIdx < 0) return EMPTY_FC; + const snap = historyData[effectiveSnapIdx]; + if (!snap) return EMPTY_FC; + return { + type: 'FeatureCollection', + features: [{ + type: 'Feature', + properties: { interpolated: showGray ? 1 : 0 }, + geometry: { type: 'Point', coordinates: [snap.centerLon, snap.centerLat] }, + }], + }; + }, [historyData, effectiveSnapIdx, showGray]); + + const animPolygonGeoJson = useMemo((): GeoJSON => { + if (!historyData || effectiveSnapIdx < 0) return EMPTY_FC; + const snap = historyData[effectiveSnapIdx]; + if (!snap?.polygon) return EMPTY_FC; + return { + type: 'FeatureCollection', + features: [{ type: 'Feature', properties: { stale: isStale ? 1 : 0, interpolated: showGray ? 1 : 0 }, geometry: snap.polygon }], + }; + }, [historyData, effectiveSnapIdx, isStale, showGray]); + + // 현재 프레임의 멤버 위치 (가상 아이콘) + const animMembersGeoJson = useMemo((): GeoJSON => { + if (!historyData || effectiveSnapIdx < 0) return EMPTY_FC; + const snap = historyData[effectiveSnapIdx]; + if (!snap) return EMPTY_FC; + return { + type: 'FeatureCollection', + features: snap.members.map(m => ({ + type: 'Feature' as const, + properties: { mmsi: m.mmsi, name: m.name, cog: m.cog ?? 0, role: m.role, isGear: m.role === 'GEAR' ? 1 : 0, stale: isStale ? 1 : 0, interpolated: showGray ? 1 : 0 }, + geometry: { type: 'Point' as const, coordinates: [m.lon, m.lat] }, + })), + }; + }, [historyData, effectiveSnapIdx, isStale, showGray]); + + // ── 연관 대상 마커 (사전계산 룩업 or ships fallback) ── + const correlationVesselGeoJson = useMemo((): GeoJSON => { + if (!selectedGearGroup || correlationByModel.size === 0) return EMPTY_FC; + const positions = correlationPosMap && effectiveSnapIdx >= 0 ? correlationPosMap[effectiveSnapIdx] : null; + const features: GeoJSON.Feature[] = []; + const seen = new Set(); + + for (const [mn, items] of correlationByModel) { + if (!enabledModels.has(mn)) continue; + const color = MODEL_COLORS[mn] ?? '#94a3b8'; + for (const c of items) { + if (seen.has(c.targetMmsi)) continue; + let lon: number | undefined, lat: number | undefined, cog = 0; + const cached = positions?.get(c.targetMmsi); + if (cached) { lon = cached.lon; lat = cached.lat; cog = cached.cog; } + if (lon === undefined) { + const s = ships.find(x => x.mmsi === c.targetMmsi); + if (s) { lon = s.lng; lat = s.lat; cog = s.course ?? 0; } + } + if (lon === undefined || lat === undefined) continue; + seen.add(c.targetMmsi); + features.push({ + type: 'Feature', + properties: { mmsi: c.targetMmsi, name: c.targetName || c.targetMmsi, score: c.score, cog, color, isVessel: c.targetType === 'VESSEL' ? 1 : 0 }, + geometry: { type: 'Point', coordinates: [lon, lat] }, + }); + } + } + return { type: 'FeatureCollection', features }; + }, [selectedGearGroup, correlationByModel, enabledModels, correlationPosMap, effectiveSnapIdx, ships]); + + // 연관 대상 트레일 (사전계산 클립 인덱스 룩업) + const correlationTrailGeoJson = useMemo((): GeoJSON => { + if (correlationTracks.length === 0) return EMPTY_FC; + const clips = trailClipMap && effectiveSnapIdx >= 0 ? trailClipMap[effectiveSnapIdx] : null; + const features: GeoJSON.Feature[] = []; + const vesselColor = new Map(); + for (const [mn, items] of correlationByModel) { + if (!enabledModels.has(mn)) continue; + for (const c of items) { + if (!vesselColor.has(c.targetMmsi)) vesselColor.set(c.targetMmsi, MODEL_COLORS[mn] ?? '#60a5fa'); + } + } + for (const vt of correlationTracks) { + if (!enabledVessels.has(vt.mmsi)) continue; + const color = vesselColor.get(vt.mmsi) ?? '#60a5fa'; + const clipIdx = clips?.get(vt.mmsi) ?? vt.track.length; + const coords: [number, number][] = vt.track.slice(0, clipIdx).map(pt => [pt.lon, pt.lat]); + if (coords.length >= 2) { + features.push({ type: 'Feature', properties: { mmsi: vt.mmsi, color }, geometry: { type: 'LineString', coordinates: coords } }); + } + } + return { type: 'FeatureCollection', features }; + }, [correlationTracks, enabledVessels, correlationByModel, enabledModels, trailClipMap, effectiveSnapIdx]); + + // 모델 배지 GeoJSON (사전계산 위치 룩업) + const modelBadgesGeoJson = useMemo((): GeoJSON => { + if (!selectedGearGroup) return EMPTY_FC; + const positions = correlationPosMap && effectiveSnapIdx >= 0 ? correlationPosMap[effectiveSnapIdx] : null; + const targets = new Map }>(); + + if (enabledModels.has('identity')) { + const members = (historyData && effectiveSnapIdx >= 0) + ? historyData[effectiveSnapIdx].members + : (() => { + if (!groupPolygons) return []; + const all = [...groupPolygons.gearInZoneGroups, ...groupPolygons.gearOutZoneGroups]; + return all.find(g => g.groupKey === selectedGearGroup)?.members ?? []; + })(); + for (const m of members) { + const e = targets.get(m.mmsi) ?? { lon: m.lon, lat: m.lat, models: new Set() }; + e.lon = m.lon; e.lat = m.lat; e.models.add('identity'); + targets.set(m.mmsi, e); + } + } + for (const [mn, items] of correlationByModel) { + if (!enabledModels.has(mn)) continue; + for (const c of items) { + if (c.score < 0.3) continue; + let lon: number | undefined, lat: number | undefined; + const cached = positions?.get(c.targetMmsi); + if (cached) { lon = cached.lon; lat = cached.lat; } + if (lon === undefined) { const s = ships.find(x => x.mmsi === c.targetMmsi); if (s) { lon = s.lng; lat = s.lat; } } + if (lon !== undefined && lat !== undefined) { + const e = targets.get(c.targetMmsi) ?? { lon, lat, models: new Set() }; + e.lon = lon; e.lat = lat; e.models.add(mn); + targets.set(c.targetMmsi, e); + } + } + } + const features: GeoJSON.Feature[] = []; + for (const [mmsi, t] of targets) { + if (t.models.size === 0) continue; + const props: Record = { mmsi }; + for (let i = 0; i < MODEL_ORDER.length; i++) props[`m${i}`] = t.models.has(MODEL_ORDER[i]) ? 1 : 0; + features.push({ type: 'Feature', properties: props, geometry: { type: 'Point', coordinates: [t.lon, t.lat] } }); + } + return { type: 'FeatureCollection', features }; + }, [selectedGearGroup, enabledModels, historyData, effectiveSnapIdx, groupPolygons, + correlationByModel, correlationPosMap, ships]); + + // 호버 하이라이트 — 대상 위치 (사전계산 룩업) + const hoverHighlightGeoJson = useMemo((): GeoJSON => { + if (!hoveredMmsi || !selectedGearGroup) return EMPTY_FC; + const positions = correlationPosMap && effectiveSnapIdx >= 0 ? correlationPosMap[effectiveSnapIdx] : null; + if (historyData && effectiveSnapIdx >= 0) { + const m = historyData[effectiveSnapIdx].members.find(x => x.mmsi === hoveredMmsi); + if (m) return { type: 'FeatureCollection', features: [{ type: 'Feature', properties: {}, geometry: { type: 'Point', coordinates: [m.lon, m.lat] } }] }; + } + const cached = positions?.get(hoveredMmsi); + if (cached) return { type: 'FeatureCollection', features: [{ type: 'Feature', properties: {}, geometry: { type: 'Point', coordinates: [cached.lon, cached.lat] } }] }; + if (groupPolygons) { + const all = [...groupPolygons.gearInZoneGroups, ...groupPolygons.gearOutZoneGroups]; + const m = all.find(g => g.groupKey === selectedGearGroup)?.members.find(x => x.mmsi === hoveredMmsi); + if (m) return { type: 'FeatureCollection', features: [{ type: 'Feature', properties: {}, geometry: { type: 'Point', coordinates: [m.lon, m.lat] } }] }; + } + const s = ships.find(x => x.mmsi === hoveredMmsi); + if (s) return { type: 'FeatureCollection', features: [{ type: 'Feature', properties: {}, geometry: { type: 'Point', coordinates: [s.lng, s.lat] } }] }; + return EMPTY_FC; + }, [hoveredMmsi, selectedGearGroup, historyData, effectiveSnapIdx, correlationPosMap, groupPolygons, ships]); + + // 호버 하이라이트 — 대상 항적 (사전계산 클립 룩업) + const hoverHighlightTrailGeoJson = useMemo((): GeoJSON => { + if (!hoveredMmsi) return EMPTY_FC; + const vt = correlationTracks.find(v => v.mmsi === hoveredMmsi); + if (!vt) return EMPTY_FC; + const clipIdx = trailClipMap && effectiveSnapIdx >= 0 + ? (trailClipMap[effectiveSnapIdx]?.get(hoveredMmsi) ?? vt.track.length) + : vt.track.length; + const coords: [number, number][] = vt.track.slice(0, clipIdx).map(pt => [pt.lon, pt.lat]); + if (coords.length < 2) return EMPTY_FC; + return { type: 'FeatureCollection', features: [{ type: 'Feature', properties: {}, geometry: { type: 'LineString', coordinates: coords } }] }; + }, [hoveredMmsi, correlationTracks, trailClipMap, effectiveSnapIdx]); + + // 선단 목록 (멤버 수 내림차순) + const fleetList = useMemo((): FleetListItem[] => { + if (!groupPolygons) return []; + return groupPolygons.fleetGroups.map(g => ({ + id: Number(g.groupKey), + mmsiList: g.members.map(m => m.mmsi), + label: g.groupLabel, + memberCount: g.memberCount, + areaSqNm: g.areaSqNm, + color: g.color, + members: g.members, + })).sort((a, b) => b.memberCount - a.memberCount); + }, [groupPolygons]); + + return { + fleetPolygonGeoJSON, + lineGeoJSON, + hoveredGeoJSON, + gearClusterGeoJson, + memberMarkersGeoJson, + pickerHighlightGeoJson, + selectedGearHighlightGeoJson, + memberTrailsGeoJson, + centerTrailGeoJson, + currentCenterGeoJson, + animPolygonGeoJson, + animMembersGeoJson, + correlationVesselGeoJson, + correlationTrailGeoJson, + modelBadgesGeoJson, + hoverHighlightGeoJson, + hoverHighlightTrailGeoJson, + operationalPolygons, + fleetList, + currentFrame, + showGray, + isStale, + snapshotRanges, + correlationByModel, + availableModels, + }; +} diff --git a/frontend/src/hooks/useGearReplayLayers.ts b/frontend/src/hooks/useGearReplayLayers.ts new file mode 100644 index 0000000..c7a8f50 --- /dev/null +++ b/frontend/src/hooks/useGearReplayLayers.ts @@ -0,0 +1,452 @@ +import { useEffect, useRef, useCallback } from 'react'; +import type { Layer } from '@deck.gl/core'; +import { TripsLayer } from '@deck.gl/geo-layers'; +import { ScatterplotLayer, PathLayer, PolygonLayer, TextLayer } from '@deck.gl/layers'; +import { useGearReplayStore } from '../stores/gearReplayStore'; +import { findFrameAtTime, interpolateMemberPositions } from '../stores/gearReplayPreprocess'; +import type { MemberPosition } from '../stores/gearReplayPreprocess'; +import { MODEL_COLORS } from '../components/korea/fleetClusterConstants'; +import { buildInterpPolygon } from '../components/korea/fleetClusterUtils'; +import type { GearCorrelationItem } from '../services/vesselAnalysis'; + +// ── Constants ───────────────────────────────────────────────────────────────── + +const TRAIL_LENGTH_MS = 3_600_000; // 1 hour trail +const RENDER_INTERVAL_MS = 100; // 10fps throttle during playback + +// ── Helper ─────────────────────────────────────────────────────────────────── + +function hexToRgb(hex: string): [number, number, number] { + const h = hex.replace('#', ''); + return [ + parseInt(h.substring(0, 2), 16), + parseInt(h.substring(2, 4), 16), + parseInt(h.substring(4, 6), 16), + ]; +} + +// ── Types ───────────────────────────────────────────────────────────────────── + +interface CorrPosition { + mmsi: string; + name: string; + lon: number; + lat: number; + color: [number, number, number, number]; + isVessel: boolean; +} + +// ── Hook ────────────────────────────────────────────────────────────────────── + +/** + * Gear group replay animation layers for deck.gl. + * + * Performance: + * - currentTime changes are subscribed via zustand.subscribe (NOT React selectors). + * React never re-renders during playback. + * - Layer objects are built imperatively and written to replayLayerRef. + * - The parent calls overlay.setProps() to push layers to WebGL. + */ +export function useGearReplayLayers( + replayLayerRef: React.MutableRefObject, + requestRender: () => void, +): void { + // ── React selectors (infrequent changes only) ──────────────────────────── + const historyFrames = useGearReplayStore(s => s.historyFrames); + const memberTripsData = useGearReplayStore(s => s.memberTripsData); + const correlationTripsData = useGearReplayStore(s => s.correlationTripsData); + const centerTrailSegments = useGearReplayStore(s => s.centerTrailSegments); + const centerDotsPositions = useGearReplayStore(s => s.centerDotsPositions); + const enabledModels = useGearReplayStore(s => s.enabledModels); + const enabledVessels = useGearReplayStore(s => s.enabledVessels); + const hoveredMmsi = useGearReplayStore(s => s.hoveredMmsi); + const correlationByModel = useGearReplayStore(s => s.correlationByModel); + + // ── Refs ───────────────────────────────────────────────────────────────── + const cursorRef = useRef(0); // frame cursor for O(1) forward lookup + + // ── renderFrame ────────────────────────────────────────────────────────── + + const renderFrame = useCallback(() => { + if (historyFrames.length === 0) { + replayLayerRef.current = []; + requestRender(); + return; + } + + const state = useGearReplayStore.getState(); + const ct = state.currentTime; + const st = state.startTime; + + // Find current frame + const { index: frameIdx, cursor } = findFrameAtTime(state.frameTimes, ct, cursorRef.current); + cursorRef.current = cursor; + + const layers: Layer[] = []; + + // ── Static layers (center trail + dots) ─────────────────────────────── + + // Center trail segments (PathLayer) + for (let i = 0; i < centerTrailSegments.length; i++) { + const seg = centerTrailSegments[i]; + if (seg.path.length < 2) continue; + layers.push(new PathLayer({ + id: `replay-center-trail-${i}`, + data: [{ path: seg.path }], + getPath: (d: { path: [number, number][] }) => d.path, + getColor: seg.isInterpolated + ? [249, 115, 22, 200] + : [251, 191, 36, 180], + widthMinPixels: 2, + })); + } + + // Center dots (real data only) + if (centerDotsPositions.length > 0) { + layers.push(new ScatterplotLayer({ + id: 'replay-center-dots', + data: centerDotsPositions, + getPosition: (d: [number, number]) => d, + getFillColor: [251, 191, 36, 150], + getRadius: 80, + radiusUnits: 'meters', + radiusMinPixels: 2.5, + })); + } + + // ── Dynamic layers (depend on currentTime) ──────────────────────────── + + if (frameIdx < 0) { + // No valid frame at this time — only show static layers + replayLayerRef.current = layers; + requestRender(); + return; + } + + const frame = state.historyFrames[frameIdx]; + const isStale = !!frame._longGap || !!frame._interp; + + // Member positions (interpolated) + const members = interpolateMemberPositions(state.historyFrames, frameIdx, ct); + + // 1. TripsLayer — member trails (GPU animated) + if (memberTripsData.length > 0) { + layers.push(new TripsLayer({ + id: 'replay-member-trails', + data: memberTripsData, + getPath: d => d.path, + getTimestamps: d => d.timestamps, + getColor: d => d.color, + widthMinPixels: 1.5, + fadeTrail: true, + trailLength: TRAIL_LENGTH_MS, + currentTime: ct - st, + })); + } + + // 2. TripsLayer — correlation trails (GPU animated) + if (correlationTripsData.length > 0) { + const enabledTrips = correlationTripsData.filter(d => enabledVessels.has(d.id)); + if (enabledTrips.length > 0) { + layers.push(new TripsLayer({ + id: 'replay-corr-trails', + data: enabledTrips, + getPath: d => d.path, + getTimestamps: d => d.timestamps, + getColor: d => d.color, + widthMinPixels: 2, + fadeTrail: true, + trailLength: TRAIL_LENGTH_MS, + currentTime: ct - st, + })); + } + } + + // 3. Current animated polygon (convex hull of members) + const memberPts: [number, number][] = members.map(m => [m.lon, m.lat]); + const polygon = buildInterpPolygon(memberPts); + if (polygon) { + layers.push(new PolygonLayer({ + id: 'replay-polygon', + data: [{ polygon: polygon.coordinates }], + getPolygon: (d: { polygon: number[][][] }) => d.polygon, + getFillColor: isStale ? [148, 163, 184, 30] : [251, 191, 36, 40], + getLineColor: isStale ? [148, 163, 184, 100] : [251, 191, 36, 180], + getLineWidth: isStale ? 1 : 2, + lineWidthMinPixels: 1, + filled: true, + stroked: true, + })); + } + + // 4. Current center point + layers.push(new ScatterplotLayer({ + id: 'replay-center', + data: [{ position: [frame.centerLon, frame.centerLat] as [number, number] }], + getPosition: (d: { position: [number, number] }) => d.position, + getFillColor: isStale ? [249, 115, 22, 255] : [239, 68, 68, 255], + getRadius: 200, + radiusUnits: 'meters', + radiusMinPixels: 7, + stroked: true, + getLineColor: [255, 255, 255, 255], + lineWidthMinPixels: 2, + })); + + // 5. Member position markers + if (members.length > 0) { + layers.push(new ScatterplotLayer({ + id: 'replay-members', + data: members, + getPosition: d => [d.lon, d.lat], + getFillColor: d => { + if (d.stale) return [100, 116, 139, 150]; + if (d.isGear) return [168, 184, 200, 230]; + return [251, 191, 36, 230]; + }, + getRadius: d => d.isParent ? 150 : d.isGear ? 80 : 120, + radiusUnits: 'meters', + radiusMinPixels: 3, + stroked: true, + getLineColor: [0, 0, 0, 150], + lineWidthMinPixels: 0.5, + })); + + // Member labels + layers.push(new TextLayer({ + id: 'replay-member-labels', + data: members, + getPosition: d => [d.lon, d.lat], + getText: d => d.name || d.mmsi, + getColor: d => d.stale + ? [148, 163, 184, 200] + : d.isGear + ? [226, 232, 240, 255] + : [251, 191, 36, 255], + getSize: 10, + getPixelOffset: [0, 14], + background: true, + getBackgroundColor: [0, 0, 0, 200], + backgroundPadding: [2, 1], + fontFamily: '"Fira Code Variable", monospace', + })); + } + + // 6. Correlation vessel positions (interpolated from correlationTripsData) + const corrPositions: CorrPosition[] = []; + const corrTrackMap = new Map(correlationTripsData.map(d => [d.id, d])); + + for (const [mn, items] of correlationByModel) { + if (!enabledModels.has(mn)) continue; + const color = MODEL_COLORS[mn] ?? '#94a3b8'; + const [r, g, b] = hexToRgb(color); + + for (const c of items as GearCorrelationItem[]) { + if (corrPositions.some(p => p.mmsi === c.targetMmsi)) continue; + + const tripData = corrTrackMap.get(c.targetMmsi); + if (!tripData) continue; + + const relTime = ct - st; + const ts = tripData.timestamps; + const path = tripData.path; + if (ts.length === 0) continue; + if (relTime < ts[0] || relTime > ts[ts.length - 1]) continue; + + // Binary search in timestamps + let lo = 0; + let hi = ts.length - 1; + while (lo < hi - 1) { + const mid = (lo + hi) >> 1; + if (ts[mid] <= relTime) lo = mid; else hi = mid; + } + const ratio = ts[hi] !== ts[lo] ? (relTime - ts[lo]) / (ts[hi] - ts[lo]) : 0; + const lon = path[lo][0] + (path[hi][0] - path[lo][0]) * ratio; + const lat = path[lo][1] + (path[hi][1] - path[lo][1]) * ratio; + + corrPositions.push({ + mmsi: c.targetMmsi, + name: c.targetName || c.targetMmsi, + lon, + lat, + color: [r, g, b, 230], + isVessel: c.targetType === 'VESSEL', + }); + } + } + + if (corrPositions.length > 0) { + layers.push(new ScatterplotLayer({ + id: 'replay-corr-vessels', + data: corrPositions, + getPosition: d => [d.lon, d.lat], + getFillColor: d => d.color, + getRadius: d => d.isVessel ? 130 : 80, + radiusUnits: 'meters', + radiusMinPixels: 3, + stroked: true, + getLineColor: [0, 0, 0, 150], + lineWidthMinPixels: 1, + })); + + layers.push(new TextLayer({ + id: 'replay-corr-labels', + data: corrPositions, + getPosition: d => [d.lon, d.lat], + getText: d => d.name, + getColor: d => d.color, + getSize: 8, + getPixelOffset: [0, 15], + background: true, + getBackgroundColor: [0, 0, 0, 200], + backgroundPadding: [2, 1], + })); + } + + // 7. Hover highlight + if (hoveredMmsi) { + const hoveredMember = members.find(m => m.mmsi === hoveredMmsi); + const hoveredCorr = corrPositions.find(c => c.mmsi === hoveredMmsi); + const hoveredPos: [number, number] | null = hoveredMember + ? [hoveredMember.lon, hoveredMember.lat] + : hoveredCorr + ? [hoveredCorr.lon, hoveredCorr.lat] + : null; + + if (hoveredPos) { + layers.push(new ScatterplotLayer({ + id: 'replay-hover-glow', + data: [{ position: hoveredPos }], + getPosition: (d: { position: [number, number] }) => d.position, + getFillColor: [255, 255, 255, 60], + getRadius: 400, + radiusUnits: 'meters', + radiusMinPixels: 14, + })); + layers.push(new ScatterplotLayer({ + id: 'replay-hover-ring', + data: [{ position: hoveredPos }], + getPosition: (d: { position: [number, number] }) => d.position, + getFillColor: [0, 0, 0, 0], + getRadius: 250, + radiusUnits: 'meters', + radiusMinPixels: 8, + stroked: true, + getLineColor: [255, 255, 255, 255], + lineWidthMinPixels: 2, + })); + } + + // Hover trail (from correlation track) + const hoveredTrack = correlationTripsData.find(d => d.id === hoveredMmsi); + if (hoveredTrack) { + const relTime = ct - st; + let clipIdx = hoveredTrack.timestamps.length; + for (let i = 0; i < hoveredTrack.timestamps.length; i++) { + if (hoveredTrack.timestamps[i] > relTime) { + clipIdx = i; + break; + } + } + const clippedPath = hoveredTrack.path.slice(0, clipIdx); + if (clippedPath.length >= 2) { + layers.push(new PathLayer({ + id: 'replay-hover-trail', + data: [{ path: clippedPath }], + getPath: (d: { path: [number, number][] }) => d.path, + getColor: [255, 255, 255, 180], + widthMinPixels: 3, + })); + } + } + } + + // 8. Operational polygons (per model — union of member positions + high-score correlation vessels) + for (const [mn, items] of correlationByModel) { + if (!enabledModels.has(mn)) continue; + const color = MODEL_COLORS[mn] ?? '#94a3b8'; + const [r, g, b] = hexToRgb(color); + + const extraPts: [number, number][] = []; + for (const c of items as GearCorrelationItem[]) { + if (c.score < 0.7) continue; + const cp = corrPositions.find(p => p.mmsi === c.targetMmsi); + if (cp) extraPts.push([cp.lon, cp.lat]); + } + if (extraPts.length === 0) continue; + + const opPolygon = buildInterpPolygon([...memberPts, ...extraPts]); + if (!opPolygon) continue; + + layers.push(new PolygonLayer({ + id: `replay-op-${mn}`, + data: [{ polygon: opPolygon.coordinates }], + getPolygon: (d: { polygon: number[][][] }) => d.polygon, + getFillColor: [r, g, b, 30], + getLineColor: [r, g, b, 200], + getLineWidth: 2, + lineWidthMinPixels: 2, + filled: true, + stroked: true, + })); + } + + replayLayerRef.current = layers; + requestRender(); + }, [ + historyFrames, memberTripsData, correlationTripsData, + centerTrailSegments, centerDotsPositions, + enabledModels, enabledVessels, hoveredMmsi, correlationByModel, + replayLayerRef, requestRender, + ]); + + // ── zustand.subscribe effect (currentTime → renderFrame) ───────────────── + + useEffect(() => { + if (historyFrames.length === 0) return; + + // Initial render + renderFrame(); + + let lastRenderTime = 0; + let pendingRafId: number | null = null; + + const unsub = useGearReplayStore.subscribe( + s => s.currentTime, + () => { + const isPlaying = useGearReplayStore.getState().isPlaying; + if (!isPlaying) { + // Seek/pause — immediate render for responsiveness + renderFrame(); + return; + } + const now = performance.now(); + if (now - lastRenderTime >= RENDER_INTERVAL_MS) { + lastRenderTime = now; + renderFrame(); + } else if (!pendingRafId) { + pendingRafId = requestAnimationFrame(() => { + pendingRafId = null; + lastRenderTime = performance.now(); + renderFrame(); + }); + } + }, + ); + + return () => { + unsub(); + if (pendingRafId) cancelAnimationFrame(pendingRafId); + }; + }, [historyFrames, renderFrame]); + + // ── Cleanup on unmount ──────────────────────────────────────────────────── + + useEffect(() => { + return () => { + replayLayerRef.current = []; + requestRender(); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps -- intentional: run only on unmount + }, []); +} diff --git a/frontend/src/services/vesselAnalysis.ts b/frontend/src/services/vesselAnalysis.ts index dc66deb..5eea1b7 100644 --- a/frontend/src/services/vesselAnalysis.ts +++ b/frontend/src/services/vesselAnalysis.ts @@ -131,6 +131,41 @@ export async function fetchGroupCorrelations( return res.json(); } +/* ── Correlation Tracks (Prediction API) ──────────────────────── */ + +export interface CorrelationTrackPoint { + ts: number; // epoch ms + lat: number; + lon: number; + sog: number; + cog: number; +} + +export interface CorrelationVesselTrack { + mmsi: string; + name: string; + score: number; + modelName: string; + track: CorrelationTrackPoint[]; +} + +export interface CorrelationTracksResponse { + groupKey: string; + vessels: CorrelationVesselTrack[]; +} + +export async function fetchCorrelationTracks( + groupKey: string, + hours = 24, + minScore = 0.3, +): Promise { + const res = await fetch( + `/api/prediction/v1/correlation/${encodeURIComponent(groupKey)}/tracks?hours=${hours}&minScore=${minScore}`, + ); + if (!res.ok) return { groupKey, vessels: [] }; + return res.json(); +} + /* ── Fleet Companies ─────────────────────────────────────────── */ // 캐시 (세션 중 1회 로드) diff --git a/frontend/src/stores/gearReplayPreprocess.ts b/frontend/src/stores/gearReplayPreprocess.ts new file mode 100644 index 0000000..9e17969 --- /dev/null +++ b/frontend/src/stores/gearReplayPreprocess.ts @@ -0,0 +1,235 @@ +import type { HistoryFrame } from '../components/korea/fleetClusterTypes'; +import type { CorrelationVesselTrack } from '../services/vesselAnalysis'; + +export interface TripsLayerDatum { + id: string; + path: [number, number][]; // [lon, lat][] + timestamps: number[]; // relative ms from startTime (TripsLayer requirement) + color: [number, number, number, number]; +} + +export interface MemberPosition { + mmsi: string; + name: string; + lon: number; + lat: number; + cog: number; + role: string; + isParent: boolean; + isGear: boolean; + stale: boolean; +} + +export interface CenterTrailSegment { + path: [number, number][]; + isInterpolated: boolean; +} + +/** + * Walk all frames and collect per-MMSI tracks for TripsLayer rendering. + * timestamps are relative ms from startTime (deck.gl TripsLayer requirement). + */ +export function buildMemberTripsData(frames: HistoryFrame[], startTime: number): TripsLayerDatum[] { + const memberMap = new Map(); + + for (const frame of frames) { + const t = new Date(frame.snapshotTime).getTime() - startTime; + for (const member of frame.members) { + const entry = memberMap.get(member.mmsi) ?? { path: [], timestamps: [] }; + entry.path.push([member.lon, member.lat]); + entry.timestamps.push(t); + memberMap.set(member.mmsi, entry); + } + } + + const result: TripsLayerDatum[] = []; + for (const [mmsi, data] of memberMap) { + if (data.path.length >= 2) { + result.push({ + id: mmsi, + path: data.path, + timestamps: data.timestamps, + color: [200, 200, 200, 180], + }); + } + } + return result; +} + +/** + * Convert correlation vessel tracks to TripsLayer format. + * timestamps are relative ms from startTime (deck.gl TripsLayer requirement). + */ +export function buildCorrelationTripsData( + tracks: CorrelationVesselTrack[], + startTime: number, +): TripsLayerDatum[] { + const result: TripsLayerDatum[] = []; + for (const vt of tracks) { + if (vt.track.length >= 2) { + result.push({ + id: vt.mmsi, + path: vt.track.map(pt => [pt.lon, pt.lat]), + timestamps: vt.track.map(pt => pt.ts - startTime), + color: [96, 165, 250, 150], + }); + } + } + return result; +} + +/** + * Split center trail into real/interpolated segments and collect real-data dot positions. + * Consecutive frames with the same _longGap flag form one segment. + */ +export function buildCenterTrailData( + frames: HistoryFrame[], +): { segments: CenterTrailSegment[]; dots: [number, number][] } { + const segments: CenterTrailSegment[] = []; + const dots: [number, number][] = []; + + if (frames.length === 0) return { segments, dots }; + + let segStart = 0; + + for (let i = 1; i <= frames.length; i++) { + const curInterp = i < frames.length ? !!frames[i]._longGap : null; + const startInterp = !!frames[segStart]._longGap; + + if (i < frames.length && curInterp === startInterp) continue; + + const from = segStart > 0 ? segStart - 1 : segStart; + const seg = frames.slice(from, i); + if (seg.length >= 2) { + segments.push({ + path: seg.map(s => [s.centerLon, s.centerLat]), + isInterpolated: startInterp, + }); + } + segStart = i; + } + + for (const frame of frames) { + if (!frame._longGap && !frame._interp) { + dots.push([frame.centerLon, frame.centerLat]); + } + } + + return { segments, dots }; +} + +/** + * Map real (non-interpolated) frames to normalized [0, 1] positions + * along the timeline, for progress bar gap indicators. + */ +export function buildSnapshotRanges( + frames: HistoryFrame[], + startTime: number, + endTime: number, +): number[] { + const duration = endTime - startTime; + if (duration <= 0) return []; + return frames + .filter(h => !h._interp) + .map(h => (new Date(h.snapshotTime).getTime() - startTime) / duration); +} + +/** + * Cursor-based frame index lookup. + * Uses forward linear scan from cursorHint during normal playback (O(1–2)), + * falls back to binary search when time goes backward or hint is invalid. + * Returns { index: -1 } when the closest frame is more than 30 minutes away. + */ +export function findFrameAtTime( + frameTimes: number[], + timeMs: number, + cursorHint: number, +): { index: number; cursor: number } { + if (frameTimes.length === 0) return { index: -1, cursor: 0 }; + + // Forward linear scan from cursor + if (cursorHint >= 0 && cursorHint < frameTimes.length) { + if (frameTimes[cursorHint] <= timeMs) { + let i = cursorHint; + while (i < frameTimes.length - 1 && frameTimes[i + 1] <= timeMs) { + i++; + } + return { index: i, cursor: i }; + } + // Time went backward — fall through to binary search + } + + // Binary search fallback + let lo = 0; + let hi = frameTimes.length - 1; + while (lo < hi) { + const mid = (lo + hi + 1) >> 1; + if (frameTimes[mid] <= timeMs) { + lo = mid; + } else { + hi = mid - 1; + } + } + + if (Math.abs(frameTimes[lo] - timeMs) > 1_800_000) { + return { index: -1, cursor: lo }; + } + return { index: lo, cursor: lo }; +} + +/** + * Interpolate member positions between frameIdx and frameIdx+1 at timeMs. + * Returns stale=true for frames marked as _longGap or _interp. + */ +export function interpolateMemberPositions( + frames: HistoryFrame[], + frameIdx: number, + timeMs: number, +): MemberPosition[] { + if (frameIdx < 0 || frameIdx >= frames.length) return []; + + const frame = frames[frameIdx]; + const isStale = !!frame._longGap || !!frame._interp; + + const toPosition = ( + m: { mmsi: string; name: string; lon: number; lat: number; cog: number; role: string; isParent: boolean }, + lon: number, + lat: number, + cog: number, + ): MemberPosition => ({ + mmsi: m.mmsi, + name: m.name, + lon, + lat, + cog, + role: m.role, + isParent: m.isParent, + isGear: m.role === 'GEAR' || !m.isParent, + stale: isStale, + }); + + // No next frame — return current positions as-is + if (frameIdx >= frames.length - 1) { + return frame.members.map(m => toPosition(m, m.lon, m.lat, m.cog)); + } + + const nextFrame = frames[frameIdx + 1]; + const t0 = new Date(frame.snapshotTime).getTime(); + const t1 = new Date(nextFrame.snapshotTime).getTime(); + const ratio = t1 > t0 ? Math.max(0, Math.min(1, (timeMs - t0) / (t1 - t0))) : 0; + + const nextMap = new Map(nextFrame.members.map(m => [m.mmsi, m])); + + return frame.members.map(m => { + const nm = nextMap.get(m.mmsi); + if (!nm) { + return toPosition(m, m.lon, m.lat, m.cog); + } + return toPosition( + m, + m.lon + (nm.lon - m.lon) * ratio, + m.lat + (nm.lat - m.lat) * ratio, + nm.cog, + ); + }); +} diff --git a/frontend/src/stores/gearReplayStore.ts b/frontend/src/stores/gearReplayStore.ts new file mode 100644 index 0000000..eab5e8d --- /dev/null +++ b/frontend/src/stores/gearReplayStore.ts @@ -0,0 +1,245 @@ +import { create } from 'zustand'; +import { subscribeWithSelector } from 'zustand/middleware'; +import type { HistoryFrame } from '../components/korea/fleetClusterTypes'; +import type { GearCorrelationItem, CorrelationVesselTrack } from '../services/vesselAnalysis'; +import { + buildMemberTripsData, + buildCorrelationTripsData, + buildCenterTrailData, + buildSnapshotRanges, +} from './gearReplayPreprocess'; + +// ── Pre-processed data types for deck.gl layers ────────────────── + +export interface TripsLayerDatum { + id: string; + path: [number, number][]; + timestamps: number[]; + color: [number, number, number, number]; +} + +export interface MemberPosition { + mmsi: string; + name: string; + lon: number; + lat: number; + cog: number; + role: string; + isParent: boolean; + isGear: boolean; + stale: boolean; +} + +export interface CenterTrailSegment { + path: [number, number][]; + isInterpolated: boolean; +} + +// ── Speed factor: 1x = 30 real seconds covers 12 timeline hours ── +const SPEED_FACTOR = (12 * 60 * 60 * 1000) / (30 * 1000); // 1440 + +// ── Module-level rAF state (outside React) ─────────────────────── +let animationFrameId: number | null = null; +let lastFrameTime: number | null = null; + +// ── Store interface ─────────────────────────────────────────────── + +interface GearReplayState { + // Playback state + isPlaying: boolean; + currentTime: number; + startTime: number; + endTime: number; + playbackSpeed: number; + + // Source data + historyFrames: HistoryFrame[]; + frameTimes: number[]; + selectedGroupKey: string | null; + + // Pre-computed layer data + memberTripsData: TripsLayerDatum[]; + correlationTripsData: TripsLayerDatum[]; + centerTrailSegments: CenterTrailSegment[]; + centerDotsPositions: [number, number][]; + snapshotRanges: number[]; + + // Filter state + enabledModels: Set; + enabledVessels: Set; + hoveredMmsi: string | null; + correlationByModel: Map; + + // Actions + loadHistory: ( + frames: HistoryFrame[], + corrTracks: CorrelationVesselTrack[], + corrData: GearCorrelationItem[], + enabledModels: Set, + enabledVessels: Set, + ) => void; + play: () => void; + pause: () => void; + seek: (timeMs: number) => void; + setPlaybackSpeed: (speed: number) => void; + setEnabledModels: (models: Set) => void; + setEnabledVessels: (vessels: Set) => void; + setHoveredMmsi: (mmsi: string | null) => void; + reset: () => void; +} + +// ── Store ───────────────────────────────────────────────────────── + +export const useGearReplayStore = create()( + subscribeWithSelector((set, get) => { + const animate = (): void => { + const state = get(); + if (!state.isPlaying) return; + + const now = performance.now(); + if (lastFrameTime === null) lastFrameTime = now; + + const delta = now - lastFrameTime; + lastFrameTime = now; + + const newTime = state.currentTime + delta * SPEED_FACTOR * state.playbackSpeed; + + if (newTime >= state.endTime) { + set({ currentTime: state.startTime }); + animationFrameId = requestAnimationFrame(animate); + return; + } + + set({ currentTime: newTime }); + animationFrameId = requestAnimationFrame(animate); + }; + + return { + // Playback state + isPlaying: false, + currentTime: 0, + startTime: 0, + endTime: 0, + playbackSpeed: 1, + + // Source data + historyFrames: [], + frameTimes: [], + selectedGroupKey: null, + + // Pre-computed layer data + memberTripsData: [], + correlationTripsData: [], + centerTrailSegments: [], + centerDotsPositions: [], + snapshotRanges: [], + + // Filter state + enabledModels: new Set(), + enabledVessels: new Set(), + hoveredMmsi: null, + correlationByModel: new Map(), + + // ── Actions ──────────────────────────────────────────────── + + loadHistory: (frames, corrTracks, corrData, enabledModels, enabledVessels) => { + const startTime = Date.now() - 12 * 60 * 60 * 1000; + const endTime = Date.now(); + const frameTimes = frames.map(f => new Date(f.snapshotTime).getTime()); + + const memberTrips = buildMemberTripsData(frames, startTime); + const corrTrips = buildCorrelationTripsData(corrTracks, startTime); + const { segments, dots } = buildCenterTrailData(frames); + const ranges = buildSnapshotRanges(frames, startTime, endTime); + + const byModel = new Map(); + for (const c of corrData) { + const list = byModel.get(c.modelName) ?? []; + list.push(c); + byModel.set(c.modelName, list); + } + + set({ + historyFrames: frames, + frameTimes, + startTime, + endTime, + currentTime: startTime, + memberTripsData: memberTrips, + correlationTripsData: corrTrips, + centerTrailSegments: segments, + centerDotsPositions: dots, + snapshotRanges: ranges, + enabledModels, + enabledVessels, + correlationByModel: byModel, + selectedGroupKey: frames[0]?.groupKey ?? null, + }); + }, + + play: () => { + const state = get(); + if (state.endTime <= state.startTime) return; + + lastFrameTime = null; + + if (state.currentTime >= state.endTime) { + set({ isPlaying: true, currentTime: state.startTime }); + } else { + set({ isPlaying: true }); + } + + animationFrameId = requestAnimationFrame(animate); + }, + + pause: () => { + if (animationFrameId !== null) { + cancelAnimationFrame(animationFrameId); + animationFrameId = null; + } + lastFrameTime = null; + set({ isPlaying: false }); + }, + + seek: (timeMs) => { + const { startTime, endTime } = get(); + set({ currentTime: Math.max(startTime, Math.min(endTime, timeMs)) }); + }, + + setPlaybackSpeed: (speed) => set({ playbackSpeed: speed }), + + setEnabledModels: (models) => set({ enabledModels: models }), + + setEnabledVessels: (vessels) => set({ enabledVessels: vessels }), + + setHoveredMmsi: (mmsi) => set({ hoveredMmsi: mmsi }), + + reset: () => { + if (animationFrameId !== null) { + cancelAnimationFrame(animationFrameId); + animationFrameId = null; + } + lastFrameTime = null; + set({ + isPlaying: false, + currentTime: 0, + startTime: 0, + endTime: 0, + playbackSpeed: 1, + historyFrames: [], + frameTimes: [], + selectedGroupKey: null, + memberTripsData: [], + correlationTripsData: [], + centerTrailSegments: [], + centerDotsPositions: [], + snapshotRanges: [], + enabledModels: new Set(), + enabledVessels: new Set(), + hoveredMmsi: null, + correlationByModel: new Map(), + }); + }, + }; + }), +); diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index c0c411e..928304b 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -115,6 +115,11 @@ export default defineConfig(({ mode }): UserConfig => ({ changeOrigin: true, secure: true, }, + '/api/prediction': { + target: 'http://localhost:8001', + changeOrigin: true, + rewrite: (path) => path.replace(/^\/api\/prediction/, ''), + }, '/ollama': { target: 'http://localhost:11434', changeOrigin: true, diff --git a/prediction/algorithms/gear_correlation.py b/prediction/algorithms/gear_correlation.py index edae548..21c5b95 100644 --- a/prediction/algorithms/gear_correlation.py +++ b/prediction/algorithms/gear_correlation.py @@ -537,7 +537,7 @@ def run_gear_correlation( import time as _time import re as _re - _gear_re = _re.compile(r'^.+_\d+_\d*$') + _gear_re = _re.compile(r'^.+_(?=\S*\d)\S+(?:[_ ]\S*)*[_ ]*$|^.+%$|^\d+$') t0 = _time.time() now = datetime.now(timezone.utc) @@ -768,6 +768,8 @@ def _batch_upsert_scores(conn, batch: list[tuple]): VALUES %s ON CONFLICT (model_id, group_key, target_mmsi) DO UPDATE SET + target_type = EXCLUDED.target_type, + target_name = EXCLUDED.target_name, current_score = EXCLUDED.current_score, streak_count = EXCLUDED.streak_count, freeze_state = EXCLUDED.freeze_state, diff --git a/prediction/algorithms/polygon_builder.py b/prediction/algorithms/polygon_builder.py index 75f0e15..0e592cf 100644 --- a/prediction/algorithms/polygon_builder.py +++ b/prediction/algorithms/polygon_builder.py @@ -23,8 +23,8 @@ from algorithms.location import classify_zone logger = logging.getLogger(__name__) -# 프론트 FleetClusterLayer.tsx gearGroupMap 패턴과 동일 -GEAR_PATTERN = re.compile(r'^(.+?)_\d+_\d+_?$') +# 어구 이름 패턴 — _ 필수 (공백만으로는 어구 미판정, fleet_tracker.py와 동일) +GEAR_PATTERN = re.compile(r'^(.+?)_(?=\S*\d)\S+(?:[_ ]\S*)*[_ ]*$|^(\d+)$') MAX_DIST_DEG = 0.15 # ~10NM STALE_SEC = 21600 # 6시간 (어구 P75 갭 3.5h, P90 갭 8h 커버) FLEET_BUFFER_DEG = 0.02 @@ -130,14 +130,21 @@ def detect_gear_groups( all_positions = vessel_store.get_all_latest_positions() # 선박명 → mmsi 맵 (모선 탐색용, 어구 패턴이 아닌 선박만) + # 정규화 키(공백 제거) + 원본 이름 모두 등록 name_to_mmsi: dict[str, str] = {} for mmsi, pos in all_positions.items(): name = (pos.get('name') or '').strip() if name and not GEAR_PATTERN.match(name): name_to_mmsi[name] = mmsi + name_to_mmsi[name.replace(' ', '')] = mmsi - # 1단계: 같은 모선명 어구 수집 (60분 이내만) + # parent 이름 정규화 — 공백 제거 후 같은 모선은 하나로 통합 + def _normalize_parent(raw: str) -> str: + return raw.replace(' ', '') + + # 1단계: 같은 모선명 어구 수집 (60분 이내만, 공백 정규화) raw_groups: dict[str, list[dict]] = {} + parent_display: dict[str, str] = {} # normalized → 대표 원본 이름 for mmsi, pos in all_positions.items(): name = (pos.get('name') or '').strip() if not name: @@ -164,7 +171,11 @@ def detect_gear_groups( if not m: continue - parent_name = m.group(1).strip() + parent_raw = (m.group(1) or name).strip() + parent_key = _normalize_parent(parent_raw) + # 대표 이름: 공백 없는 버전 우선 (더 정규화된 형태) + if parent_key not in parent_display or ' ' not in parent_raw: + parent_display[parent_key] = parent_raw entry = { 'mmsi': mmsi, 'name': name, @@ -173,61 +184,121 @@ def detect_gear_groups( 'sog': pos.get('sog', 0), 'cog': pos.get('cog', 0), } - raw_groups.setdefault(parent_name, []).append(entry) + raw_groups.setdefault(parent_key, []).append(entry) - # 2단계: 거리 기반 서브 클러스터링 (anchor 기준 MAX_DIST_DEG 이내만) + # 2단계: 연결 기반 서브 클러스터링 (각 어구가 클러스터 내 최소 1개와 MAX_DIST_DEG 이내) + # 같은 parent 이름이라도 거리가 먼 어구들은 별도 서브그룹으로 분리 results: list[dict] = [] - for parent_name, gears in raw_groups.items(): - parent_mmsi = name_to_mmsi.get(parent_name) + for parent_key, gears in raw_groups.items(): + parent_mmsi = name_to_mmsi.get(parent_key) + display_name = parent_display.get(parent_key, parent_key) - # 기준점(anchor): 모선 있으면 모선 위치, 없으면 첫 어구 - anchor_lat: Optional[float] = None - anchor_lon: Optional[float] = None + if not gears: + continue + # 모선 위치 (있으면 시드 포인트로 활용) + seed_lat: Optional[float] = None + seed_lon: Optional[float] = None if parent_mmsi and parent_mmsi in all_positions: - parent_pos = all_positions[parent_mmsi] - anchor_lat = parent_pos['lat'] - anchor_lon = parent_pos['lon'] + p = all_positions[parent_mmsi] + seed_lat, seed_lon = p['lat'], p['lon'] - if anchor_lat is None and gears: - anchor_lat = gears[0]['lat'] - anchor_lon = gears[0]['lon'] + # 연결 기반 클러스터링 (Union-Find 방식) + n = len(gears) + parent_uf = list(range(n)) - if anchor_lat is None or anchor_lon is None: + def find(x: int) -> int: + while parent_uf[x] != x: + parent_uf[x] = parent_uf[parent_uf[x]] + x = parent_uf[x] + return x + + def union(a: int, b: int) -> None: + ra, rb = find(a), find(b) + if ra != rb: + parent_uf[ra] = rb + + for i in range(n): + for j in range(i + 1, n): + if (abs(gears[i]['lat'] - gears[j]['lat']) <= MAX_DIST_DEG + and abs(gears[i]['lon'] - gears[j]['lon']) <= MAX_DIST_DEG): + union(i, j) + + # 클러스터별 그룹화 + clusters: dict[int, list[int]] = {} + for i in range(n): + clusters.setdefault(find(i), []).append(i) + + # 모선이 있으면 모선과 가장 가까운 클러스터에 연결 (MAX_DIST_DEG 이내만) + seed_cluster_root: Optional[int] = None + if seed_lat is not None and seed_lon is not None: + best_dist = float('inf') + for root, idxs in clusters.items(): + for i in idxs: + d = abs(gears[i]['lat'] - seed_lat) + abs(gears[i]['lon'] - seed_lon) + if d < best_dist: + best_dist = d + seed_cluster_root = root + # 모선이 어느 클러스터와도 MAX_DIST_DEG 초과 → 연결하지 않음 + if best_dist > MAX_DIST_DEG * 2: + seed_cluster_root = None + + # 클러스터마다 서브그룹 생성 (최소 2개 이상이거나 모선 포함) + for ci, (root, idxs) in enumerate(clusters.items()): + has_seed = (root == seed_cluster_root) + if len(idxs) < 2 and not has_seed: + continue + + members = [ + {'mmsi': gears[i]['mmsi'], 'name': gears[i]['name'], + 'lat': gears[i]['lat'], 'lon': gears[i]['lon'], + 'sog': gears[i]['sog'], 'cog': gears[i]['cog']} + for i in idxs + ] + + # 서브그룹 이름: 1개면 원본, 2개 이상이면 #1, #2 + sub_name = display_name if len(clusters) == 1 else f'{display_name}#{ci + 1}' + sub_mmsi = parent_mmsi if has_seed else None + + results.append({ + 'parent_name': sub_name, + 'parent_key': parent_key, + 'parent_mmsi': sub_mmsi, + 'members': members, + }) + + # 3단계: 동일 parent_key 서브그룹 간 근접 병합 (거리 이내 시) + # prefix 기반 병합은 과도한 그룹화 유발 → 동일 키만 병합 + def _groups_nearby(a: dict, b: dict) -> bool: + for ma in a['members']: + for mb in b['members']: + if abs(ma['lat'] - mb['lat']) <= MAX_DIST_DEG and abs(ma['lon'] - mb['lon']) <= MAX_DIST_DEG: + return True + return False + + merged: list[dict] = [] + skip: set[int] = set() + results.sort(key=lambda g: len(g['members']), reverse=True) + for i, big in enumerate(results): + if i in skip: continue + for j, small in enumerate(results): + if j <= i or j in skip: + continue + # 동일 parent_key만 병합 (prefix 매칭 제거 — 과도한 병합 방지) + if big['parent_key'] == small['parent_key'] and _groups_nearby(big, small): + existing_mmsis = {m['mmsi'] for m in big['members']} + for m in small['members']: + if m['mmsi'] not in existing_mmsis: + big['members'].append(m) + existing_mmsis.add(m['mmsi']) + if not big['parent_mmsi'] and small['parent_mmsi']: + big['parent_mmsi'] = small['parent_mmsi'] + skip.add(j) + del big['parent_key'] + merged.append(big) - # 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 + return merged def build_all_group_snapshots( @@ -340,13 +411,18 @@ def build_all_group_snapshots( if not in_zone and len(gear_members) < MIN_GEAR_GROUP_SIZE: continue - # 폴리곤 points: 어구 좌표 + 모선 좌표 + # 폴리곤 points: 어구 좌표 + 모선 좌표 (근접 시에만) points = [(g['lon'], g['lat']) for g in gear_members] + parent_nearby = False if parent_mmsi and parent_mmsi in all_positions: parent_pos = all_positions[parent_mmsi] p_lon, p_lat = parent_pos['lon'], parent_pos['lat'] - if (p_lon, p_lat) not in points: - points.append((p_lon, p_lat)) + # 모선이 어구 클러스터 내 최소 1개와 MAX_DIST_DEG*2 이내일 때만 포함 + if any(abs(g['lat'] - p_lat) <= MAX_DIST_DEG * 2 + and abs(g['lon'] - p_lon) <= MAX_DIST_DEG * 2 for g in gear_members): + if (p_lon, p_lat) not in points: + points.append((p_lon, p_lat)) + parent_nearby = True polygon_wkt, center_wkt, area_sq_nm, _clat, _clon = build_group_polygon( points, GEAR_BUFFER_DEG @@ -354,8 +430,8 @@ def build_all_group_snapshots( # members JSONB 구성 members_out: list[dict] = [] - # 모선 먼저 - if parent_mmsi and parent_mmsi in all_positions: + # 모선 먼저 (근접 시에만) + if parent_nearby and parent_mmsi and parent_mmsi in all_positions: parent_pos = all_positions[parent_mmsi] members_out.append({ 'mmsi': parent_mmsi, diff --git a/prediction/cache/vessel_store.py b/prediction/cache/vessel_store.py index 8f67bee..00b82e7 100644 --- a/prediction/cache/vessel_store.py +++ b/prediction/cache/vessel_store.py @@ -349,6 +349,66 @@ class VesselStore: } return result + def get_vessel_tracks(self, mmsis: list[str], hours: int = 24) -> dict[str, list[dict]]: + """Return track points for given MMSIs within the specified hours window. + + Returns dict mapping mmsi to list of {ts, lat, lon, sog, cog} dicts, + sorted by timestamp ascending. + """ + import datetime as _dt + + now = datetime.now(timezone.utc) + cutoff_aware = now - _dt.timedelta(hours=hours) + cutoff_naive = cutoff_aware.replace(tzinfo=None) + + result: dict[str, list[dict]] = {} + for mmsi in mmsis: + df = self._tracks.get(mmsi) + if df is None or len(df) == 0: + continue + + ts_col = df['timestamp'] + if hasattr(ts_col.dtype, 'tz') and ts_col.dtype.tz is not None: + mask = ts_col >= pd.Timestamp(cutoff_aware) + else: + mask = ts_col >= pd.Timestamp(cutoff_naive) + + filtered = df[mask].sort_values('timestamp') + if filtered.empty: + continue + + # Compute SOG/COG for this vessel's track + if len(filtered) >= 2: + track_with_sog = _compute_sog_cog(filtered.copy()) + else: + track_with_sog = filtered.copy() + if 'sog' not in track_with_sog.columns: + track_with_sog['sog'] = track_with_sog.get('raw_sog', 0) + if 'cog' not in track_with_sog.columns: + track_with_sog['cog'] = 0 + + points = [] + for _, row in track_with_sog.iterrows(): + ts = row['timestamp'] + # Convert to epoch ms + if hasattr(ts, 'timestamp'): + epoch_ms = int(ts.timestamp() * 1000) + else: + epoch_ms = int(pd.Timestamp(ts).timestamp() * 1000) + + points.append({ + 'ts': epoch_ms, + 'lat': float(row['lat']), + 'lon': float(row['lon']), + 'sog': float(row.get('sog', 0) or 0), + 'cog': float(row.get('cog', 0) or 0), + }) + + if points: + result[mmsi] = points + + return result + def get_chinese_mmsis(self) -> set: """Return the set of all Chinese vessel MMSIs (412*) currently in the store.""" return {m for m in self._tracks if m.startswith(_CHINESE_MMSI_PREFIX)} diff --git a/prediction/fleet_tracker.py b/prediction/fleet_tracker.py index f1c83d7..db85628 100644 --- a/prediction/fleet_tracker.py +++ b/prediction/fleet_tracker.py @@ -9,8 +9,8 @@ import pandas as pd logger = logging.getLogger(__name__) -# 어구 이름 패턴 -GEAR_PATTERN = re.compile(r'^(.+?)_(\d+)_(\d*)$') +# 어구 이름 패턴 — 공백/영숫자 인덱스/끝_ 허용 +GEAR_PATTERN = re.compile(r'^(.+?)_(?=\S*\d)\S+(?:[_ ]\S*)*[_ ]*$|^(\d+)$') GEAR_PATTERN_PCT = re.compile(r'^(.+?)%$') _REGISTRY_CACHE_SEC = 3600 @@ -139,9 +139,16 @@ class FleetTracker: m = GEAR_PATTERN.match(name) if m: - parent_name = m.group(1).strip() - idx1 = int(m.group(2)) - idx2 = int(m.group(3)) if m.group(3) else None + # group(1): parent+index 패턴, group(2): 순수 숫자 패턴 + if m.group(1): + parent_name = m.group(1).strip() + suffix = name[m.end(1):].strip(' _') + digits = re.findall(r'\d+', suffix) + idx1 = int(digits[0]) if len(digits) >= 1 else None + idx2 = int(digits[1]) if len(digits) >= 2 else None + else: + # 순수 숫자 이름 (예: 12345) — parent 없음, 인덱스만 + idx1 = int(m.group(2)) else: m2 = GEAR_PATTERN_PCT.match(name) if m2: diff --git a/prediction/main.py b/prediction/main.py index 2e1a9dc..30ae293 100644 --- a/prediction/main.py +++ b/prediction/main.py @@ -68,3 +68,75 @@ def analysis_status(): def trigger_analysis(background_tasks: BackgroundTasks): background_tasks.add_task(run_analysis_cycle) return {'message': 'analysis cycle triggered'} + + +@app.get('/api/v1/correlation/{group_key:path}/tracks') +def get_correlation_tracks( + group_key: str, + hours: int = 24, + min_score: float = 0.3, +): + """Return correlated vessels with their track history for map rendering. + + Queries gear_correlation_scores (default model) and enriches with + 24h track data from in-memory vessel_store. + """ + from cache.vessel_store import vessel_store + + try: + conn = kcgdb.get_conn() + cur = conn.cursor() + + # Get correlated vessels from default model + cur.execute(""" + SELECT s.target_mmsi, s.target_type, s.target_name, + s.current_score, m.name AS model_name + FROM kcg.gear_correlation_scores s + JOIN kcg.correlation_param_models m ON s.model_id = m.id + WHERE s.group_key = %s + AND s.current_score >= %s + AND m.is_default = TRUE + AND m.is_active = TRUE + ORDER BY s.current_score DESC + """, (group_key, min_score)) + + rows = cur.fetchall() + cur.close() + conn.close() + + if not rows: + return {'groupKey': group_key, 'vessels': []} + + # Collect target MMSIs + vessel_info = [] + mmsis = [] + for row in rows: + vessel_info.append({ + 'mmsi': row[0], + 'type': row[1], + 'name': row[2] or '', + 'score': float(row[3]), + 'modelName': row[4], + }) + mmsis.append(row[0]) + + # Get tracks from vessel_store + tracks = vessel_store.get_vessel_tracks(mmsis, hours) + + # Build response + vessels = [] + for info in vessel_info: + track = tracks.get(info['mmsi'], []) + vessels.append({ + 'mmsi': info['mmsi'], + 'name': info['name'], + 'score': info['score'], + 'modelName': info['modelName'], + 'track': track, + }) + + return {'groupKey': group_key, 'vessels': vessels} + + except Exception as e: + logger.warning('get_correlation_tracks failed for %s: %s', group_key, e) + return {'groupKey': group_key, 'vessels': []} diff --git a/prediction/scheduler.py b/prediction/scheduler.py index 8cecb17..10eba03 100644 --- a/prediction/scheduler.py +++ b/prediction/scheduler.py @@ -75,7 +75,7 @@ def run_analysis_cycle(): return # 4. 등록 선단 기반 fleet 분석 - _gear_re = _re.compile(r'^.+_\d+_\d*$|%$') + _gear_re = _re.compile(r'^.+_(?=\S*\d)\S+(?:[_ ]\S*)*[_ ]*$|^\d+$|^.+%$') with kcgdb.get_conn() as kcg_conn: fleet_tracker.load_registry(kcg_conn) From 87d1b31ef3a640bdc766dc7eea8091dc39b7604c Mon Sep 17 00:00:00 2001 From: htlee Date: Tue, 31 Mar 2026 07:54:50 +0900 Subject: [PATCH 08/27] =?UTF-8?q?feat:=20=EC=96=B4=EA=B5=AC=20=EB=A6=AC?= =?UTF-8?q?=ED=94=8C=EB=A0=88=EC=9D=B4=20deck.gl=20+=20Zustand=20=EC=A0=84?= =?UTF-8?q?=ED=99=98=20=EC=99=84=EB=A3=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 3: DeckGLOverlay에 overlayRef 추가, KoreaMap에서 리플레이 레이어 합성 (imperative setProps → React 렌더 우회) Phase 4: 기존 MapLibre 리플레이 레이어 → deck.gl 전환 - FleetClusterLayer: 애니메이션 state/ref/timer 제거 → Zustand 스토어 - useFleetClusterGeoJson: 리플레이 useMemo 15개 제거 (618→389줄) - FleetClusterMapLayers: MapLibre 재생 레이어 6개 제거 (492→397줄) - HistoryReplayController: React refs → Zustand subscribe 바인딩 성능: React re-render 20회/초 → 0회/초 (재생 중) GeoJSON 직렬화 15개/프레임 → 0 (raw 배열 → deck.gl) 트레일: 매 프레임 재생성 → TripsLayer GPU 셰이더 Co-Authored-By: Claude Opus 4.6 (1M context) --- .../components/korea/FleetClusterLayer.tsx | 117 ++----- .../korea/FleetClusterMapLayers.tsx | 123 +------ .../korea/HistoryReplayController.tsx | 92 +++--- frontend/src/components/korea/KoreaMap.tsx | 42 ++- .../korea/useFleetClusterGeoJson.ts | 309 +++--------------- .../src/components/layers/DeckGLOverlay.tsx | 6 +- 6 files changed, 163 insertions(+), 526 deletions(-) diff --git a/frontend/src/components/korea/FleetClusterLayer.tsx b/frontend/src/components/korea/FleetClusterLayer.tsx index 1b9d198..20c71e7 100644 --- a/frontend/src/components/korea/FleetClusterLayer.tsx +++ b/frontend/src/components/korea/FleetClusterLayer.tsx @@ -5,10 +5,11 @@ import type { Ship, VesselAnalysisDto } from '../../types'; import { fetchFleetCompanies, fetchGroupHistory, fetchGroupCorrelations, fetchCorrelationTracks } from '../../services/vesselAnalysis'; import type { FleetCompany, GearCorrelationItem, CorrelationVesselTrack } from '../../services/vesselAnalysis'; import type { UseGroupPolygonsResult } from '../../hooks/useGroupPolygons'; +import { useGearReplayStore } from '../../stores/gearReplayStore'; // ── 분리된 모듈 ── -import type { HistoryFrame, PickerCandidate, HoverTooltipState, GearPickerPopupState } from './fleetClusterTypes'; -import { TIMELINE_DURATION_MS, PLAYBACK_CYCLE_SEC, TICK_MS, EMPTY_ANALYSIS } from './fleetClusterTypes'; +import type { PickerCandidate, HoverTooltipState, GearPickerPopupState } from './fleetClusterTypes'; +import { EMPTY_ANALYSIS } from './fleetClusterTypes'; import { fillGapFrames } from './fleetClusterUtils'; import { useFleetClusterGeoJson } from './useFleetClusterGeoJson'; import FleetClusterMapLayers from './FleetClusterMapLayers'; @@ -55,19 +56,8 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipS const [enabledModels, setEnabledModels] = useState>(new Set(['identity', 'default', 'aggressive', 'conservative', 'proximity-heavy', 'visit-pattern'])); const [hoveredTarget, setHoveredTarget] = useState<{ mmsi: string; model: string } | null>(null); - // ── 히스토리 애니메이션 상태 ── - const [historyData, setHistoryData] = useState(null); - const [, setHistoryGroupKey] = useState(null); - const [isPlaying, setIsPlaying] = useState(true); - const [displayFrameIdx, setDisplayFrameIdx] = useState(-1); - const timelinePosRef = useRef(0); - const progressBarRef = useRef(null); - const progressIndicatorRef = useRef(null); - const timeDisplayRef = useRef(null); - const animTimerRef = useRef>(); - const historyStartRef = useRef(0); - const historyEndRef = useRef(0); - const frameTimesRef = useRef([]); + // ── Zustand store (히스토리 재생) ── + const historyActive = useGearReplayStore(s => s.historyFrames.length > 0); // ── 맵 + ref ── const { current: mapRef } = useMap(); @@ -81,68 +71,37 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipS // ── 히스토리 로드/닫기 ── const loadHistory = async (groupKey: string) => { - setHistoryGroupKey(groupKey); - timelinePosRef.current = 0; - setDisplayFrameIdx(-1); - setIsPlaying(true); const history = await fetchGroupHistory(groupKey, 12); const sorted = history.reverse(); const filled = fillGapFrames(sorted); - const now = Date.now(); - historyStartRef.current = now - TIMELINE_DURATION_MS; - historyEndRef.current = now; - frameTimesRef.current = filled.map(h => new Date(h.snapshotTime).getTime()); - setHistoryData(filled); + useGearReplayStore.getState().loadHistory( + filled, correlationTracks, correlationData, enabledModels, enabledVessels, + ); }; const closeHistory = useCallback(() => { - setHistoryData(null); - setHistoryGroupKey(null); - timelinePosRef.current = 0; - setDisplayFrameIdx(-1); - setIsPlaying(true); + useGearReplayStore.getState().reset(); setSelectedGearGroup(null); - clearInterval(animTimerRef.current); }, []); - // ── 재생 타이머 (ref 기반, 프레임 변경 시에만 setState) ── + // ── enabledModels/enabledVessels/hoveredTarget → store 동기화 ── useEffect(() => { - if (!historyData || !isPlaying) { - clearInterval(animTimerRef.current); - return; - } - const step = TICK_MS / (PLAYBACK_CYCLE_SEC * 1000); - const ft = frameTimesRef.current; - animTimerRef.current = setInterval(() => { - timelinePosRef.current += step; - if (timelinePosRef.current >= 1) timelinePosRef.current = 0; + useGearReplayStore.getState().setEnabledModels(enabledModels); + }, [enabledModels]); - const pos = timelinePosRef.current; - if (progressBarRef.current) progressBarRef.current.value = String(Math.round(pos * 1000)); - if (progressIndicatorRef.current) progressIndicatorRef.current.style.left = `${pos * 100}%`; - const t = historyStartRef.current + timelinePosRef.current * TIMELINE_DURATION_MS; - if (timeDisplayRef.current) { - timeDisplayRef.current.textContent = new Date(t).toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit' }); - } + useEffect(() => { + useGearReplayStore.getState().setEnabledVessels(enabledVessels); + }, [enabledVessels]); - let best = 0, bestDiff = Infinity; - for (let i = 0; i < ft.length; i++) { - const d = Math.abs(ft[i] - t); - if (d < bestDiff) { bestDiff = d; best = i; } - } - const fi = bestDiff < 1_800_000 ? best : -1; - setDisplayFrameIdx(prev => prev === fi ? prev : fi); - }, TICK_MS); - return () => clearInterval(animTimerRef.current); - }, [historyData, isPlaying]); - - const effectiveSnapIdx = displayFrameIdx; + useEffect(() => { + useGearReplayStore.getState().setHoveredMmsi(hoveredTarget?.mmsi ?? null); + }, [hoveredTarget]); // ── ESC 키 ── useEffect(() => { const onKeyDown = (e: KeyboardEvent) => { if (e.key === 'Escape') { - if (historyData) closeHistory(); + if (historyActive) closeHistory(); setSelectedGearGroup(null); setExpandedFleet(null); setExpandedGearGroup(null); @@ -150,7 +109,7 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipS }; window.addEventListener('keydown', onKeyDown); return () => window.removeEventListener('keydown', onKeyDown); - }, [historyData, closeHistory]); + }, [historyActive, closeHistory]); // ── 맵 이벤트 등록 ── useEffect(() => { @@ -299,9 +258,9 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipS // ── 부모 콜백 동기화: 어구 그룹 선택 ── useEffect(() => { - if (!selectedGearGroup || historyData) { + if (!selectedGearGroup || historyActive) { onSelectedGearChange?.(null); - if (historyData) return; + if (historyActive) return; return; } const allGroups = groupPolygons ? [...groupPolygons.gearInZoneGroups, ...groupPolygons.gearOutZoneGroups] : []; @@ -315,7 +274,7 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipS category: 'fishing', lastSeen: Date.now(), }); onSelectedGearChange?.({ parent: parent ? toShip(parent) : null, gears: gears.map(toShip), groupName: selectedGearGroup }); - }, [selectedGearGroup, groupPolygons, onSelectedGearChange, historyData]); + }, [selectedGearGroup, groupPolygons, onSelectedGearChange, historyActive]); // ── 연관성 데이터 로드 ── useEffect(() => { @@ -346,9 +305,9 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipS // ── 부모 콜백 동기화: 선단 선택 ── useEffect(() => { - if (expandedFleet === null || historyData) { + if (expandedFleet === null || historyActive) { onSelectedFleetChange?.(null); - if (historyData) return; + if (historyActive) return; return; } const group = groupPolygons?.fleetGroups.find(g => Number(g.groupKey) === expandedFleet); @@ -360,17 +319,16 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipS category: 'fishing', lastSeen: Date.now(), })); onSelectedFleetChange?.({ clusterId: expandedFleet, ships: fleetShips, companyName: company?.nameCn || group.groupLabel || `선단 #${expandedFleet}` }); - }, [expandedFleet, groupPolygons, companies, onSelectedFleetChange, historyData]); + }, [expandedFleet, groupPolygons, companies, onSelectedFleetChange, historyActive]); // ── GeoJSON 훅 ── const hoveredMmsi = hoveredTarget?.mmsi ?? null; const geo = useFleetClusterGeoJson({ ships, shipMap, groupPolygons, analysisMap, hoveredFleetId, selectedGearGroup, pickerHoveredGroup, - historyData, effectiveSnapIdx, + historyActive, correlationData, correlationTracks, enabledModels, enabledVessels, hoveredMmsi, - historyStartMs: historyStartRef.current, }); // ── 어구 그룹 데이터 ── @@ -446,8 +404,7 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipS hoveredMmsi={hoveredMmsi} enabledModels={enabledModels} expandedFleet={expandedFleet} - historyData={historyData} - effectiveSnapIdx={effectiveSnapIdx} + historyActive={historyActive} hoverTooltip={hoverTooltip} gearPickerPopup={gearPickerPopup} pickerHoveredGroup={pickerHoveredGroup} @@ -472,8 +429,8 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipS enabledModels={enabledModels} enabledVessels={enabledVessels} correlationLoading={correlationLoading} - historyData={historyData} - effectiveSnapIdx={effectiveSnapIdx} + historyData={null} + effectiveSnapIdx={-1} hoveredTarget={hoveredTarget} onEnabledModelsChange={(updater) => setEnabledModels(updater)} onEnabledVesselsChange={(updater) => setEnabledVessels(updater)} @@ -482,20 +439,8 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipS )} {/* ── 재생 컨트롤러 ── */} - {historyData && ( + {historyActive && ( setIsPlaying(p => !p)} - onFrameChange={(idx) => { setIsPlaying(false); setDisplayFrameIdx(idx); }} onClose={closeHistory} /> )} diff --git a/frontend/src/components/korea/FleetClusterMapLayers.tsx b/frontend/src/components/korea/FleetClusterMapLayers.tsx index be47930..b73f5ab 100644 --- a/frontend/src/components/korea/FleetClusterMapLayers.tsx +++ b/frontend/src/components/korea/FleetClusterMapLayers.tsx @@ -4,7 +4,6 @@ import type { FleetCompany } from '../../services/vesselAnalysis'; import type { VesselAnalysisDto } from '../../types'; import type { UseGroupPolygonsResult } from '../../hooks/useGroupPolygons'; import type { - HistoryFrame, HoverTooltipState, GearPickerPopupState, PickerCandidate, @@ -18,8 +17,7 @@ interface FleetClusterMapLayersProps { hoveredMmsi: string | null; enabledModels: Set; expandedFleet: number | null; - historyData: HistoryFrame[] | null; - effectiveSnapIdx: number; + historyActive: boolean; // Popup/tooltip state hoverTooltip: HoverTooltipState | null; gearPickerPopup: GearPickerPopupState | null; @@ -42,8 +40,7 @@ const FleetClusterMapLayers = ({ hoveredMmsi, enabledModels, expandedFleet, - historyData, - effectiveSnapIdx, + historyActive, hoverTooltip, gearPickerPopup, pickerHoveredGroup, @@ -63,17 +60,11 @@ const FleetClusterMapLayers = ({ memberMarkersGeoJson, pickerHighlightGeoJson, operationalPolygons, - memberTrailsGeoJson, - centerTrailGeoJson, - currentCenterGeoJson, - animPolygonGeoJson, - animMembersGeoJson, correlationVesselGeoJson, correlationTrailGeoJson, modelBadgesGeoJson, hoverHighlightGeoJson, hoverHighlightTrailGeoJson, - isStale, } = geo; return ( @@ -126,7 +117,7 @@ const FleetClusterMapLayers = ({ {/* 선택된 어구 그룹 — 이름 기반 하이라이트 폴리곤 */} - {selectedGearGroup && enabledModels.has('identity') && !historyData && (() => { + {selectedGearGroup && enabledModels.has('identity') && !historyActive && (() => { const allGroups = groupPolygons ? [...groupPolygons.gearInZoneGroups, ...groupPolygons.gearOutZoneGroups] : []; @@ -149,7 +140,7 @@ const FleetClusterMapLayers = ({ })()} {/* 선택된 어구 그룹 — 모델별 오퍼레이셔널 폴리곤 오버레이 */} - {selectedGearGroup && operationalPolygons.map(op => ( + {selectedGearGroup && !historyActive && operationalPolygons.map(op => ( - {/* 가상 선박 마커 — 히스토리 재생 모드에서는 숨김 (애니메이션 아이콘으로 대체) */} - + {/* 가상 선박 마커 — 히스토리 재생 모드에서는 숨김 (deck.gl 레이어로 대체) */} + )} - {selectedGearGroup && ( + {selectedGearGroup && !historyActive && ( )} - {/* ── 모델 배지 (아이콘 우측 컬러 dot) ── */} - {selectedGearGroup && ( + {/* ── 모델 배지 (비재생 모드) ── */} + {selectedGearGroup && !historyActive && ( {MODEL_ORDER.map((model, i) => ( enabledModels.has(model) ? ( @@ -379,8 +370,8 @@ const FleetClusterMapLayers = ({ )} - {/* ── 호버 하이라이트 (글로우 + 항적 강조) ── */} - {hoveredMmsi && ( + {/* ── 호버 하이라이트 (비재생 모드) ── */} + {hoveredMmsi && !historyActive && ( )} - {hoveredMmsi && ( + {hoveredMmsi && !historyActive && ( )} - - {/* ── 히스토리 애니메이션 레이어 (최상위) ── */} - {historyData && ( - - - - )} - {historyData && ( - - - - - )} - {/* 보간 경로는 centerTrailGeoJson에 통합됨 (interpolated=1 세그먼트) */} - {/* 현재 재생 위치 포인트 (실데이터=빨강, 보간=주황) */} - {historyData && effectiveSnapIdx >= 0 && ( - - - - )} - {historyData && ( - - - - - )} - {/* 가상 아이콘 — 현재 프레임 멤버 위치 (최상위) */} - {historyData && ( - - - - - )} ); }; diff --git a/frontend/src/components/korea/HistoryReplayController.tsx b/frontend/src/components/korea/HistoryReplayController.tsx index 875df93..6cdc819 100644 --- a/frontend/src/components/korea/HistoryReplayController.tsx +++ b/frontend/src/components/korea/HistoryReplayController.tsx @@ -1,40 +1,41 @@ -import React from 'react'; +import { useRef, useEffect } from 'react'; import { FONT_MONO } from '../../styles/fonts'; -import type { HistoryFrame } from './fleetClusterTypes'; -import { TIMELINE_DURATION_MS } from './fleetClusterTypes'; +import { useGearReplayStore } from '../../stores/gearReplayStore'; interface HistoryReplayControllerProps { - historyData: HistoryFrame[]; - effectiveSnapIdx: number; - isPlaying: boolean; - snapshotRanges: number[]; - progressBarRef: React.RefObject; - progressIndicatorRef: React.RefObject; - timeDisplayRef: React.RefObject; - historyStartRef: React.RefObject; - timelinePosRef: React.MutableRefObject; - frameTimesRef: React.RefObject; - onTogglePlay: () => void; - onFrameChange: (idx: number) => void; onClose: () => void; } -const HistoryReplayController = ({ - historyData, - effectiveSnapIdx, - isPlaying, - snapshotRanges, - progressBarRef, - progressIndicatorRef, - timeDisplayRef, - historyStartRef, - timelinePosRef, - frameTimesRef, - onTogglePlay, - onFrameChange, - onClose, -}: HistoryReplayControllerProps) => { - const hasSnap = effectiveSnapIdx >= 0; +const HistoryReplayController = ({ onClose }: HistoryReplayControllerProps) => { + // React selectors (infrequent changes) + const isPlaying = useGearReplayStore(s => s.isPlaying); + const snapshotRanges = useGearReplayStore(s => s.snapshotRanges); + const frameCount = useGearReplayStore(s => s.historyFrames.length); + + // DOM refs for imperative updates + const progressBarRef = useRef(null); + const progressIndicatorRef = useRef(null); + const timeDisplayRef = useRef(null); + + // Subscribe to currentTime for DOM updates (no React re-render) + useEffect(() => { + const unsub = useGearReplayStore.subscribe( + s => s.currentTime, + (currentTime) => { + const { startTime, endTime } = useGearReplayStore.getState(); + if (endTime <= startTime) return; + const progress = (currentTime - startTime) / (endTime - startTime); + if (progressBarRef.current) progressBarRef.current.value = String(Math.round(progress * 1000)); + if (progressIndicatorRef.current) progressIndicatorRef.current.style.left = `${progress * 100}%`; + if (timeDisplayRef.current) { + timeDisplayRef.current.textContent = new Date(currentTime).toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit' }); + } + }, + ); + return unsub; + }, []); + + const store = useGearReplayStore; return (
@@ -92,7 +93,7 @@ const HistoryReplayController = ({
- - - --:-- - - - --:-- + { const { startTime, endTime } = store.getState(); const progress = Number(e.target.value) / 1000; - const seekTime = startTime + progress * (endTime - startTime); store.getState().pause(); - store.getState().seek(seekTime); + store.getState().seek(startTime + progress * (endTime - startTime)); }} style={{ flex: 1, cursor: 'pointer', accentColor: '#fbbf24' }} - title="히스토리 타임라인" - aria-label="히스토리 타임라인" - /> - - - {frameCount}건 - - -
+ + {/* 컨트롤 행 2: 표시 옵션 */} +
+ + + | + 일치율 + +
); }; diff --git a/frontend/src/hooks/useGearReplayLayers.ts b/frontend/src/hooks/useGearReplayLayers.ts index d7b0c82..2019bf0 100644 --- a/frontend/src/hooks/useGearReplayLayers.ts +++ b/frontend/src/hooks/useGearReplayLayers.ts @@ -64,6 +64,8 @@ export function useGearReplayLayers( const enabledVessels = useGearReplayStore(s => s.enabledVessels); const hoveredMmsi = useGearReplayStore(s => s.hoveredMmsi); const correlationByModel = useGearReplayStore(s => s.correlationByModel); + const showTrails = useGearReplayStore(s => s.showTrails); + const showLabels = useGearReplayStore(s => s.showLabels); // ── Refs ───────────────────────────────────────────────────────────────── const cursorRef = useRef(0); // frame cursor for O(1) forward lookup @@ -95,23 +97,25 @@ export function useGearReplayLayers( // ── Static layers (center trail + dots) ─────────────────────────────── - // Center trail segments (PathLayer) - for (let i = 0; i < centerTrailSegments.length; i++) { - const seg = centerTrailSegments[i]; - if (seg.path.length < 2) continue; - layers.push(new PathLayer({ - id: `replay-center-trail-${i}`, - data: [{ path: seg.path }], - getPath: (d: { path: [number, number][] }) => d.path, - getColor: seg.isInterpolated - ? [249, 115, 22, 200] - : [251, 191, 36, 180], - widthMinPixels: 2, - })); + // Center trail segments (PathLayer) — showTrails 제어 + if (showTrails) { + for (let i = 0; i < centerTrailSegments.length; i++) { + const seg = centerTrailSegments[i]; + if (seg.path.length < 2) continue; + layers.push(new PathLayer({ + id: `replay-center-trail-${i}`, + data: [{ path: seg.path }], + getPath: (d: { path: [number, number][] }) => d.path, + getColor: seg.isInterpolated + ? [249, 115, 22, 200] + : [251, 191, 36, 180], + widthMinPixels: 2, + })); + } } - // Center dots (real data only) - if (centerDotsPositions.length > 0) { + // Center dots (real data only) — showTrails 제어 + if (showTrails && centerDotsPositions.length > 0) { layers.push(new ScatterplotLayer({ id: 'replay-center-dots', data: centerDotsPositions, @@ -141,8 +145,8 @@ export function useGearReplayLayers( // 1. Identity 모델: 멤버 트레일 + 폴리곤 + 마커 (enabledModels 체크) if (enabledModels.has('identity')) { - // TripsLayer — member trails (GPU animated) - if (memberTripsData.length > 0) { + // TripsLayer — member trails (GPU animated, showTrails 제어) + if (showTrails && memberTripsData.length > 0) { layers.push(new TripsLayer({ id: 'replay-member-trails', data: memberTripsData, @@ -173,8 +177,8 @@ export function useGearReplayLayers( } } - // 2. Correlation trails (GPU animated, enabledModels 체크) - if (correlationTripsData.length > 0) { + // 2. Correlation trails (GPU animated, showTrails + enabledModels 체크) + if (showTrails && correlationTripsData.length > 0) { // 활성 모델에 속하는 선박의 트랙만 표시 const activeMmsis = new Set(); for (const [mn, items] of correlationByModel) { @@ -231,8 +235,8 @@ export function useGearReplayLayers( billboard: false, })); - // Member labels - layers.push(new TextLayer({ + // Member labels — showLabels 제어 + if (showLabels) layers.push(new TextLayer({ id: 'replay-member-labels', data: members, getPosition: d => [d.lon, d.lat], @@ -263,6 +267,7 @@ export function useGearReplayLayers( const [r, g, b] = hexToRgb(color); for (const c of items as GearCorrelationItem[]) { + if (!enabledVessels.has(c.targetMmsi)) continue; // OFF → 아이콘+트레일+폴리곤 모두 제외 if (corrPositions.some(p => p.mmsi === c.targetMmsi)) continue; let lon: number | undefined; @@ -373,7 +378,7 @@ export function useGearReplayLayers( billboard: false, })); - layers.push(new TextLayer({ + if (showLabels) layers.push(new TextLayer({ id: 'replay-corr-labels', data: corrPositions, getPosition: d => [d.lon, d.lat], @@ -541,6 +546,7 @@ export function useGearReplayLayers( historyFrames, memberTripsData, correlationTripsData, centerTrailSegments, centerDotsPositions, enabledModels, enabledVessels, hoveredMmsi, correlationByModel, + showTrails, showLabels, replayLayerRef, requestRender, ]); diff --git a/frontend/src/stores/gearReplayStore.ts b/frontend/src/stores/gearReplayStore.ts index b1e63c6..83ee7bd 100644 --- a/frontend/src/stores/gearReplayStore.ts +++ b/frontend/src/stores/gearReplayStore.ts @@ -64,11 +64,13 @@ interface GearReplayState { centerDotsPositions: [number, number][]; snapshotRanges: number[]; - // Filter state + // Filter / display state enabledModels: Set; enabledVessels: Set; hoveredMmsi: string | null; correlationByModel: Map; + showTrails: boolean; + showLabels: boolean; // Actions loadHistory: ( @@ -85,6 +87,8 @@ interface GearReplayState { setEnabledModels: (models: Set) => void; setEnabledVessels: (vessels: Set) => void; setHoveredMmsi: (mmsi: string | null) => void; + setShowTrails: (show: boolean) => void; + setShowLabels: (show: boolean) => void; updateCorrelation: (corrData: GearCorrelationItem[], corrTracks: CorrelationVesselTrack[]) => void; reset: () => void; } @@ -135,10 +139,12 @@ export const useGearReplayStore = create()( centerDotsPositions: [], snapshotRanges: [], - // Filter state + // Filter / display state enabledModels: new Set(), enabledVessels: new Set(), hoveredMmsi: null, + showTrails: true, + showLabels: true, correlationByModel: new Map(), // ── Actions ──────────────────────────────────────────────── @@ -214,6 +220,8 @@ export const useGearReplayStore = create()( setEnabledVessels: (vessels) => set({ enabledVessels: vessels }), setHoveredMmsi: (mmsi) => set({ hoveredMmsi: mmsi }), + setShowTrails: (show) => set({ showTrails: show }), + setShowLabels: (show) => set({ showLabels: show }), updateCorrelation: (corrData, corrTracks) => { const state = get(); @@ -260,6 +268,8 @@ export const useGearReplayStore = create()( enabledModels: new Set(), enabledVessels: new Set(), hoveredMmsi: null, + showTrails: true, + showLabels: true, correlationByModel: new Map(), }); }, From 6789f82e3b8c766e63cafed549a92e320fb38840 Mon Sep 17 00:00:00 2001 From: htlee Date: Tue, 31 Mar 2026 09:40:06 +0900 Subject: [PATCH 20/27] =?UTF-8?q?fix:=20=ED=95=AD=EC=A0=81=20=ED=86=A0?= =?UTF-8?q?=EA=B8=80=20+=20=ED=8C=A8=EB=84=90=20=EB=A0=88=EC=9D=B4?= =?UTF-8?q?=EC=95=84=EC=9B=83=20+=20TripsLayer=20=EC=83=89=EC=83=81=20?= =?UTF-8?q?=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 항적 토글 역할 변경: - 항상 ON: TripsLayer (애니메이션 트레일) + 센터 트레일 + 도트 - "항적" 토글: 전체 24h 정적 항적 PathLayer (멤버 + 연관 선박) ON 시 회색/연파랑 배경 경로 위에 고채도 TripsLayer 애니메이션 색상 계층: - 정적 항적: 회색 [180,180,180,80] / 연파랑 [100,140,200,60] - TripsLayer: 고채도 노랑 [255,200,60,220] / 고채도 파랑 [100,180,255,220] 패널 레이아웃: - 토글 패널: position: sticky left: 0 (항상 좌측 고정) - 모델 카드: 가로 스크롤 (maxWidth: calc(100vw - 340px)) - 다중 토글 유지, 화면 초과 시 스크롤 Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/components/korea/CorrelationPanel.tsx | 18 ++-- frontend/src/hooks/useGearReplayLayers.ts | 99 +++++++++++++------ 2 files changed, 76 insertions(+), 41 deletions(-) diff --git a/frontend/src/components/korea/CorrelationPanel.tsx b/frontend/src/components/korea/CorrelationPanel.tsx index b3be94e..de2d050 100644 --- a/frontend/src/components/korea/CorrelationPanel.tsx +++ b/frontend/src/components/korea/CorrelationPanel.tsx @@ -203,7 +203,7 @@ const CorrelationPanel = ({
{/* 고정: 토글 패널 */}
c.targetType !== 'VESSEL').length; return (