diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index 8c8a0fd..17c92b1 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -148,75 +148,10 @@ jobs: sleep 10 done - # ═══ Prediction (FastAPI → redis-211) ═══ - - name: Deploy prediction via SSH - env: - DEPLOY_KEY: ${{ secrets.DEPLOY_SSH_KEY }} - PRED_HOST: 192.168.1.18 - PRED_PORT: 32023 - run: | - mkdir -p ~/.ssh - printf '%s\n' "$DEPLOY_KEY" > ~/.ssh/id_deploy - chmod 600 ~/.ssh/id_deploy - - SSH_OPTS="-i ~/.ssh/id_deploy -o StrictHostKeyChecking=no -o ConnectTimeout=10 -o ServerAliveInterval=15 -p $PRED_PORT" - SCP_OPTS="-i ~/.ssh/id_deploy -o StrictHostKeyChecking=no -P $PRED_PORT" - - REMOTE_DIR=/home/apps/kcg-prediction - - # 코드 전송 (rsync 대체: tar + scp) - tar czf /tmp/prediction.tar.gz -C prediction --exclude='__pycache__' --exclude='venv' --exclude='.env' . - for attempt in 1 2 3; do - echo "SCP prediction attempt $attempt/3..." - if scp $SCP_OPTS /tmp/prediction.tar.gz root@$PRED_HOST:/tmp/prediction.tar.gz; then break; fi - [ "$attempt" -eq 3 ] && { echo "ERROR: SCP failed"; exit 1; } - sleep 10 - done - - # systemd 서비스 파일 전송 - scp $SCP_OPTS deploy/kcg-prediction.service root@$PRED_HOST:/tmp/kcg-prediction.service - - # 원격 설치 + 재시작 (단일 SSH — tar.gz는 SCP에서 이미 전송됨) - ssh $SSH_OPTS root@$PRED_HOST bash -s << 'SCRIPT' - set -e - REMOTE_DIR=/home/apps/kcg-prediction - mkdir -p $REMOTE_DIR - cd $REMOTE_DIR - - # 코드 배포 - tar xzf /tmp/prediction.tar.gz -C $REMOTE_DIR - - # venv + 의존성 - python3 -m venv venv 2>/dev/null || true - venv/bin/pip install -r requirements.txt -q - - # SELinux 컨텍스트 (Rocky Linux) - chcon -R -t bin_t venv/bin/ 2>/dev/null || true - - # systemd 서비스 갱신 - if ! diff -q /tmp/kcg-prediction.service /etc/systemd/system/kcg-prediction.service >/dev/null 2>&1; then - cp /tmp/kcg-prediction.service /etc/systemd/system/kcg-prediction.service - systemctl daemon-reload - systemctl enable kcg-prediction - fi - - # 재시작 - systemctl restart kcg-prediction - - # health 확인 (60초 — 초기 로드에 ~30초 소요) - for i in $(seq 1 12); do - if curl -sf http://localhost:8001/health > /dev/null 2>&1; then - echo "Prediction healthy (attempt ${i})" - rm -f /tmp/prediction.tar.gz /tmp/kcg-prediction.service - exit 0 - fi - sleep 5 - done - echo "WARNING: Prediction health timeout (서비스는 시작됨, 초기 로드 진행 중)" - systemctl is-active kcg-prediction && echo "Service is active" - rm -f /tmp/prediction.tar.gz /tmp/kcg-prediction.service - SCRIPT - echo "Prediction deployment completed" + # ═══ Prediction (FastAPI) — CI/CD 제외, 수동 배포 ═══ + # ssh redis-211 에서 수동 배포: + # scp prediction/*.py redis-211:/home/apps/kcg-prediction/ + # ssh redis-211 "sudo systemctl restart kcg-prediction" - name: Cleanup if: always() 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..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 @@ -58,6 +58,24 @@ 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.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 + """; + private static final String GEAR_STATS_SQL = """ SELECT COUNT(*) AS gear_groups, COALESCE(SUM(member_count), 0) AS gear_count @@ -81,6 +99,35 @@ 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("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")); + 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/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/docs/RELEASE-NOTES.md b/docs/RELEASE-NOTES.md index 97170d8..388cf2b 100644 --- a/docs/RELEASE-NOTES.md +++ b/docs/RELEASE-NOTES.md @@ -4,6 +4,31 @@ ## [Unreleased] +### 추가 +- 어구 연관성 프론트엔드 표시 — Backend API + 모델별 팝업/토글 UI +- 어구 그룹 멀티모델 폴리곤 오버레이 + 토글 패널 +- 어구 리플레이 deck.gl + Zustand 전환 (TripsLayer GPU 트레일 + rAF 10fps) +- 리플레이 IconLayer (SVG ship-triangle/gear-diamond, COG 회전) +- 재생 컨트롤 확장: 항적/이름 토글, 일치율 드롭다운(50~90%), 개별 on/off +- 트랙 API 전체 모델 확장 — 모델별 점수 + 24h 트랙 반환 +- 모델별 폴리곤 중심 경로 + 현재 중심점 렌더링 (모델명 라벨) + +### 변경 +- FleetClusterLayer 2357줄 → 10파일 리팩토링 (오케스트레이터 + 서브컴포넌트) +- 리플레이 렌더링: MapLibre GeoJSON → deck.gl (React re-render 20회/초 → 0회) +- 연관 선박 위치: 트랙 보간 우선, live 선박 fallback +- 토글 패널 위치 고정 + 모델 카드 가로 스크롤 +- enabledVessels 토글 시 폴리곤 + 중심경로 동시 재계산 + +### 수정 +- Prediction API DB 접속 context manager 누락 +- Prediction proxy rewrite 경로 불일치 (/api/prediction → /api) +- nginx prediction API 라우팅 추가 + +### 기타 +- CI/CD: Prediction 자동 배포 제거 → 수동 배포 전환 +- zustand 5.0.12, @deck.gl/geo-layers 9.2.11 의존성 추가 + ## [2026-03-26] ### 추가 diff --git a/frontend/package-lock.json b/frontend/package-lock.json index c29507d..3016ed2 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -9,6 +9,7 @@ "version": "0.0.0", "dependencies": { "@deck.gl/core": "^9.2.11", + "@deck.gl/geo-layers": "^9.2.11", "@deck.gl/layers": "^9.2.11", "@deck.gl/mapbox": "^9.2.11", "@fontsource-variable/fira-code": "^5.2.7", @@ -30,7 +31,8 @@ "react-map-gl": "^8.1.0", "recharts": "^3.8.0", "satellite.js": "^6.0.2", - "tailwindcss": "^4.2.1" + "tailwindcss": "^4.2.1", + "zustand": "^5.0.12" }, "devDependencies": { "@eslint/js": "^9.39.1", @@ -325,6 +327,57 @@ "mjolnir.js": "^3.0.0" } }, + "node_modules/@deck.gl/extensions": { + "version": "9.2.11", + "resolved": "https://registry.npmjs.org/@deck.gl/extensions/-/extensions-9.2.11.tgz", + "integrity": "sha512-zlpM4Bg1ifBziW1Juiii9NY5gyW2rEhyVTWnhagH/bpTCZ2E73OhnToYt1ouqmoxL6lMtIjhRXz6LPb7tJbHHQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "@luma.gl/constants": "~9.2.6", + "@luma.gl/shadertools": "~9.2.6", + "@math.gl/core": "^4.1.0" + }, + "peerDependencies": { + "@deck.gl/core": "~9.2.0", + "@luma.gl/core": "~9.2.6", + "@luma.gl/engine": "~9.2.6" + } + }, + "node_modules/@deck.gl/geo-layers": { + "version": "9.2.11", + "resolved": "https://registry.npmjs.org/@deck.gl/geo-layers/-/geo-layers-9.2.11.tgz", + "integrity": "sha512-Mr3yvKyZMPmQ3ho0hSqcJu1p7a881RqQaq/dRaPs2VP56UAkfk1e10zxXnrZ9/Dmo2MR5PH0j8tkOoGR3zKbfA==", + "license": "MIT", + "dependencies": { + "@loaders.gl/3d-tiles": "~4.3.4", + "@loaders.gl/gis": "~4.3.4", + "@loaders.gl/loader-utils": "~4.3.4", + "@loaders.gl/mvt": "~4.3.4", + "@loaders.gl/schema": "~4.3.4", + "@loaders.gl/terrain": "~4.3.4", + "@loaders.gl/tiles": "~4.3.4", + "@loaders.gl/wms": "~4.3.4", + "@luma.gl/gltf": "~9.2.6", + "@luma.gl/shadertools": "~9.2.6", + "@math.gl/core": "^4.1.0", + "@math.gl/culling": "^4.1.0", + "@math.gl/web-mercator": "^4.1.0", + "@types/geojson": "^7946.0.8", + "a5-js": "^0.5.0", + "h3-js": "^4.1.0", + "long": "^3.2.0" + }, + "peerDependencies": { + "@deck.gl/core": "~9.2.0", + "@deck.gl/extensions": "~9.2.0", + "@deck.gl/layers": "~9.2.0", + "@deck.gl/mesh-layers": "~9.2.0", + "@loaders.gl/core": "~4.3.4", + "@luma.gl/core": "~9.2.6", + "@luma.gl/engine": "~9.2.6" + } + }, "node_modules/@deck.gl/layers": { "version": "9.2.11", "resolved": "https://registry.npmjs.org/@deck.gl/layers/-/layers-9.2.11.tgz", @@ -369,6 +422,26 @@ "@math.gl/web-mercator": "^4.1.0" } }, + "node_modules/@deck.gl/mesh-layers": { + "version": "9.2.11", + "resolved": "https://registry.npmjs.org/@deck.gl/mesh-layers/-/mesh-layers-9.2.11.tgz", + "integrity": "sha512-zPB7TtnPXB3tOEoOfcOkNZo7coIq/ukIQa8HIUQLLiOE8AVSQfz3kbMmMK6rUabXlQbgSw/I/j3kFSYRHg3NGg==", + "license": "MIT", + "peer": true, + "dependencies": { + "@loaders.gl/gltf": "~4.3.4", + "@loaders.gl/schema": "~4.3.4", + "@luma.gl/gltf": "~9.2.6", + "@luma.gl/shadertools": "~9.2.6" + }, + "peerDependencies": { + "@deck.gl/core": "~9.2.0", + "@luma.gl/core": "~9.2.6", + "@luma.gl/engine": "~9.2.6", + "@luma.gl/gltf": "~9.2.6", + "@luma.gl/shadertools": "~9.2.6" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.27.4", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.4.tgz", @@ -1024,6 +1097,61 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@loaders.gl/3d-tiles": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/@loaders.gl/3d-tiles/-/3d-tiles-4.3.4.tgz", + "integrity": "sha512-JQ3y3p/KlZP7lfobwON5t7H9WinXEYTvuo3SRQM8TBKhM+koEYZhvI2GwzoXx54MbBbY+s3fm1dq5UAAmaTsZw==", + "license": "MIT", + "dependencies": { + "@loaders.gl/compression": "4.3.4", + "@loaders.gl/crypto": "4.3.4", + "@loaders.gl/draco": "4.3.4", + "@loaders.gl/gltf": "4.3.4", + "@loaders.gl/images": "4.3.4", + "@loaders.gl/loader-utils": "4.3.4", + "@loaders.gl/math": "4.3.4", + "@loaders.gl/tiles": "4.3.4", + "@loaders.gl/zip": "4.3.4", + "@math.gl/core": "^4.1.0", + "@math.gl/culling": "^4.1.0", + "@math.gl/geospatial": "^4.1.0", + "@probe.gl/log": "^4.0.4", + "long": "^5.2.1" + }, + "peerDependencies": { + "@loaders.gl/core": "^4.3.0" + } + }, + "node_modules/@loaders.gl/3d-tiles/node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" + }, + "node_modules/@loaders.gl/compression": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/@loaders.gl/compression/-/compression-4.3.4.tgz", + "integrity": "sha512-+o+5JqL9Sx8UCwdc2MTtjQiUHYQGJALHbYY/3CT+b9g/Emzwzez2Ggk9U9waRfdHiBCzEgRBivpWZEOAtkimXQ==", + "license": "MIT", + "dependencies": { + "@loaders.gl/loader-utils": "4.3.4", + "@loaders.gl/worker-utils": "4.3.4", + "@types/brotli": "^1.3.0", + "@types/pako": "^1.0.1", + "fflate": "0.7.4", + "lzo-wasm": "^0.0.4", + "pako": "1.0.11", + "snappyjs": "^0.6.1" + }, + "optionalDependencies": { + "brotli": "^1.3.2", + "lz4js": "^0.2.0", + "zstd-codec": "^0.1" + }, + "peerDependencies": { + "@loaders.gl/core": "^4.3.0" + } + }, "node_modules/@loaders.gl/core": { "version": "4.3.4", "resolved": "https://registry.npmjs.org/@loaders.gl/core/-/core-4.3.4.tgz", @@ -1037,6 +1165,96 @@ "@probe.gl/log": "^4.0.2" } }, + "node_modules/@loaders.gl/crypto": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/@loaders.gl/crypto/-/crypto-4.3.4.tgz", + "integrity": "sha512-3VS5FgB44nLOlAB9Q82VOQnT1IltwfRa1miE0mpHCe1prYu1M/dMnEyynusbrsp+eDs3EKbxpguIS9HUsFu5dQ==", + "license": "MIT", + "dependencies": { + "@loaders.gl/loader-utils": "4.3.4", + "@loaders.gl/worker-utils": "4.3.4", + "@types/crypto-js": "^4.0.2" + }, + "peerDependencies": { + "@loaders.gl/core": "^4.3.0" + } + }, + "node_modules/@loaders.gl/draco": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/@loaders.gl/draco/-/draco-4.3.4.tgz", + "integrity": "sha512-4Lx0rKmYENGspvcgV5XDpFD9o+NamXoazSSl9Oa3pjVVjo+HJuzCgrxTQYD/3JvRrolW/QRehZeWD/L/cEC6mw==", + "license": "MIT", + "dependencies": { + "@loaders.gl/loader-utils": "4.3.4", + "@loaders.gl/schema": "4.3.4", + "@loaders.gl/worker-utils": "4.3.4", + "draco3d": "1.5.7" + }, + "peerDependencies": { + "@loaders.gl/core": "^4.3.0" + } + }, + "node_modules/@loaders.gl/gis": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/@loaders.gl/gis/-/gis-4.3.4.tgz", + "integrity": "sha512-8xub38lSWW7+ZXWuUcggk7agRHJUy6RdipLNKZ90eE0ZzLNGDstGD1qiBwkvqH0AkG+uz4B7Kkiptyl7w2Oa6g==", + "license": "MIT", + "dependencies": { + "@loaders.gl/loader-utils": "4.3.4", + "@loaders.gl/schema": "4.3.4", + "@mapbox/vector-tile": "^1.3.1", + "@math.gl/polygon": "^4.1.0", + "pbf": "^3.2.1" + }, + "peerDependencies": { + "@loaders.gl/core": "^4.3.0" + } + }, + "node_modules/@loaders.gl/gis/node_modules/@mapbox/point-geometry": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@mapbox/point-geometry/-/point-geometry-0.1.0.tgz", + "integrity": "sha512-6j56HdLTwWGO0fJPlrZtdU/B13q8Uwmo18Ck2GnGgN9PCFyKTZ3UbXeEdRFh18i9XQ92eH2VdtpJHpBD3aripQ==", + "license": "ISC" + }, + "node_modules/@loaders.gl/gis/node_modules/@mapbox/vector-tile": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@mapbox/vector-tile/-/vector-tile-1.3.1.tgz", + "integrity": "sha512-MCEddb8u44/xfQ3oD+Srl/tNcQoqTw3goGk2oLsrFxOTc3dUp+kAnby3PvAeeBYSMSjSPD1nd1AJA6W49WnoUw==", + "license": "BSD-3-Clause", + "dependencies": { + "@mapbox/point-geometry": "~0.1.0" + } + }, + "node_modules/@loaders.gl/gis/node_modules/pbf": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/pbf/-/pbf-3.3.0.tgz", + "integrity": "sha512-XDF38WCH3z5OV/OVa8GKUNtLAyneuzbCisx7QUCF8Q6Nutx0WnJrQe5O+kOtBlLfRNUws98Y58Lblp+NJG5T4Q==", + "license": "BSD-3-Clause", + "dependencies": { + "ieee754": "^1.1.12", + "resolve-protobuf-schema": "^2.1.0" + }, + "bin": { + "pbf": "bin/pbf" + } + }, + "node_modules/@loaders.gl/gltf": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/@loaders.gl/gltf/-/gltf-4.3.4.tgz", + "integrity": "sha512-EiUTiLGMfukLd9W98wMpKmw+hVRhQ0dJ37wdlXK98XPeGGB+zTQxCcQY+/BaMhsSpYt/OOJleHhTfwNr8RgzRg==", + "license": "MIT", + "dependencies": { + "@loaders.gl/draco": "4.3.4", + "@loaders.gl/images": "4.3.4", + "@loaders.gl/loader-utils": "4.3.4", + "@loaders.gl/schema": "4.3.4", + "@loaders.gl/textures": "4.3.4", + "@math.gl/core": "^4.1.0" + }, + "peerDependencies": { + "@loaders.gl/core": "^4.3.0" + } + }, "node_modules/@loaders.gl/images": { "version": "4.3.4", "resolved": "https://registry.npmjs.org/@loaders.gl/images/-/images-4.3.4.tgz", @@ -1064,6 +1282,51 @@ "@loaders.gl/core": "^4.3.0" } }, + "node_modules/@loaders.gl/math": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/@loaders.gl/math/-/math-4.3.4.tgz", + "integrity": "sha512-UJrlHys1fp9EUO4UMnqTCqvKvUjJVCbYZ2qAKD7tdGzHJYT8w/nsP7f/ZOYFc//JlfC3nq+5ogvmdpq2pyu3TA==", + "license": "MIT", + "dependencies": { + "@loaders.gl/images": "4.3.4", + "@loaders.gl/loader-utils": "4.3.4", + "@math.gl/core": "^4.1.0" + }, + "peerDependencies": { + "@loaders.gl/core": "^4.3.0" + } + }, + "node_modules/@loaders.gl/mvt": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/@loaders.gl/mvt/-/mvt-4.3.4.tgz", + "integrity": "sha512-9DrJX8RQf14htNtxsPIYvTso5dUce9WaJCWCIY/79KYE80Be6dhcEYMknxBS4w3+PAuImaAe66S5xo9B7Erm5A==", + "license": "MIT", + "dependencies": { + "@loaders.gl/gis": "4.3.4", + "@loaders.gl/images": "4.3.4", + "@loaders.gl/loader-utils": "4.3.4", + "@loaders.gl/schema": "4.3.4", + "@math.gl/polygon": "^4.1.0", + "@probe.gl/stats": "^4.0.0", + "pbf": "^3.2.1" + }, + "peerDependencies": { + "@loaders.gl/core": "^4.3.0" + } + }, + "node_modules/@loaders.gl/mvt/node_modules/pbf": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/pbf/-/pbf-3.3.0.tgz", + "integrity": "sha512-XDF38WCH3z5OV/OVa8GKUNtLAyneuzbCisx7QUCF8Q6Nutx0WnJrQe5O+kOtBlLfRNUws98Y58Lblp+NJG5T4Q==", + "license": "BSD-3-Clause", + "dependencies": { + "ieee754": "^1.1.12", + "resolve-protobuf-schema": "^2.1.0" + }, + "bin": { + "pbf": "bin/pbf" + } + }, "node_modules/@loaders.gl/schema": { "version": "4.3.4", "resolved": "https://registry.npmjs.org/@loaders.gl/schema/-/schema-4.3.4.tgz", @@ -1076,6 +1339,74 @@ "@loaders.gl/core": "^4.3.0" } }, + "node_modules/@loaders.gl/terrain": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/@loaders.gl/terrain/-/terrain-4.3.4.tgz", + "integrity": "sha512-JszbRJGnxL5Fh82uA2U8HgjlsIpzYoCNNjy3cFsgCaxi4/dvjz3BkLlBilR7JlbX8Ka+zlb4GAbDDChiXLMJ/g==", + "license": "MIT", + "dependencies": { + "@loaders.gl/images": "4.3.4", + "@loaders.gl/loader-utils": "4.3.4", + "@loaders.gl/schema": "4.3.4", + "@mapbox/martini": "^0.2.0" + }, + "peerDependencies": { + "@loaders.gl/core": "^4.3.0" + } + }, + "node_modules/@loaders.gl/textures": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/@loaders.gl/textures/-/textures-4.3.4.tgz", + "integrity": "sha512-arWIDjlE7JaDS6v9by7juLfxPGGnjT9JjleaXx3wq/PTp+psLOpGUywHXm38BNECos3MFEQK3/GFShWI+/dWPw==", + "license": "MIT", + "dependencies": { + "@loaders.gl/images": "4.3.4", + "@loaders.gl/loader-utils": "4.3.4", + "@loaders.gl/schema": "4.3.4", + "@loaders.gl/worker-utils": "4.3.4", + "@math.gl/types": "^4.1.0", + "ktx-parse": "^0.7.0", + "texture-compressor": "^1.0.2" + }, + "peerDependencies": { + "@loaders.gl/core": "^4.3.0" + } + }, + "node_modules/@loaders.gl/tiles": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/@loaders.gl/tiles/-/tiles-4.3.4.tgz", + "integrity": "sha512-oC0zJfyvGox6Ag9ABF8fxOkx9yEFVyzTa9ryHXl2BqLiQoR1v3p+0tIJcEbh5cnzHfoTZzUis1TEAZluPRsHBQ==", + "license": "MIT", + "dependencies": { + "@loaders.gl/loader-utils": "4.3.4", + "@loaders.gl/math": "4.3.4", + "@math.gl/core": "^4.1.0", + "@math.gl/culling": "^4.1.0", + "@math.gl/geospatial": "^4.1.0", + "@math.gl/web-mercator": "^4.1.0", + "@probe.gl/stats": "^4.0.2" + }, + "peerDependencies": { + "@loaders.gl/core": "^4.3.0" + } + }, + "node_modules/@loaders.gl/wms": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/@loaders.gl/wms/-/wms-4.3.4.tgz", + "integrity": "sha512-yXF0wuYzJUdzAJQrhLIua6DnjOiBJusaY1j8gpvuH1VYs3mzvWlIRuZKeUd9mduQZKK88H2IzHZbj2RGOauq4w==", + "license": "MIT", + "dependencies": { + "@loaders.gl/images": "4.3.4", + "@loaders.gl/loader-utils": "4.3.4", + "@loaders.gl/schema": "4.3.4", + "@loaders.gl/xml": "4.3.4", + "@turf/rewind": "^5.1.5", + "deep-strict-equal": "^0.2.0" + }, + "peerDependencies": { + "@loaders.gl/core": "^4.3.0" + } + }, "node_modules/@loaders.gl/worker-utils": { "version": "4.3.4", "resolved": "https://registry.npmjs.org/@loaders.gl/worker-utils/-/worker-utils-4.3.4.tgz", @@ -1085,11 +1416,42 @@ "@loaders.gl/core": "^4.3.0" } }, + "node_modules/@loaders.gl/xml": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/@loaders.gl/xml/-/xml-4.3.4.tgz", + "integrity": "sha512-p+y/KskajsvyM3a01BwUgjons/j/dUhniqd5y1p6keLOuwoHlY/TfTKd+XluqfyP14vFrdAHCZTnFCWLblN10w==", + "license": "MIT", + "dependencies": { + "@loaders.gl/loader-utils": "4.3.4", + "@loaders.gl/schema": "4.3.4", + "fast-xml-parser": "^4.2.5" + }, + "peerDependencies": { + "@loaders.gl/core": "^4.3.0" + } + }, + "node_modules/@loaders.gl/zip": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/@loaders.gl/zip/-/zip-4.3.4.tgz", + "integrity": "sha512-bHY4XdKYJm3vl9087GMoxnUqSURwTxPPh6DlAGOmz6X9Mp3JyWuA2gk3tQ1UIuInfjXKph3WAUfGe6XRIs1sfw==", + "license": "MIT", + "dependencies": { + "@loaders.gl/compression": "4.3.4", + "@loaders.gl/crypto": "4.3.4", + "@loaders.gl/loader-utils": "4.3.4", + "jszip": "^3.1.5", + "md5": "^2.3.0" + }, + "peerDependencies": { + "@loaders.gl/core": "^4.3.0" + } + }, "node_modules/@luma.gl/constants": { "version": "9.2.6", "resolved": "https://registry.npmjs.org/@luma.gl/constants/-/constants-9.2.6.tgz", "integrity": "sha512-rvFFrJuSm5JIWbDHFuR4Q2s4eudO3Ggsv0TsGKn9eqvO7bBiPm/ANugHredvh3KviEyYuMZZxtfJvBdr3kzldg==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@luma.gl/core": { "version": "9.2.6", @@ -1122,6 +1484,24 @@ "@luma.gl/shadertools": "~9.2.0" } }, + "node_modules/@luma.gl/gltf": { + "version": "9.2.6", + "resolved": "https://registry.npmjs.org/@luma.gl/gltf/-/gltf-9.2.6.tgz", + "integrity": "sha512-is3YkiGsWqWTmwldMz6PRaIUleufQfUKYjJTKpsF5RS1OnN+xdAO0mJq5qJTtOQpppWAU0VrmDFEVZ6R3qvm0A==", + "license": "MIT", + "dependencies": { + "@loaders.gl/core": "^4.2.0", + "@loaders.gl/gltf": "^4.2.0", + "@loaders.gl/textures": "^4.2.0", + "@math.gl/core": "^4.1.0" + }, + "peerDependencies": { + "@luma.gl/constants": "~9.2.0", + "@luma.gl/core": "~9.2.0", + "@luma.gl/engine": "~9.2.0", + "@luma.gl/shadertools": "~9.2.0" + } + }, "node_modules/@luma.gl/shadertools": { "version": "9.2.6", "resolved": "https://registry.npmjs.org/@luma.gl/shadertools/-/shadertools-9.2.6.tgz", @@ -1157,6 +1537,12 @@ "node": ">= 0.6" } }, + "node_modules/@mapbox/martini": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@mapbox/martini/-/martini-0.2.0.tgz", + "integrity": "sha512-7hFhtkb0KTLEls+TRw/rWayq5EeHtTaErgm/NskVoXmtgAQu/9D299aeyj6mzAR/6XUnYRp2lU+4IcrYRFjVsQ==", + "license": "ISC" + }, "node_modules/@mapbox/point-geometry": { "version": "1.1.0", "license": "ISC" @@ -1243,6 +1629,26 @@ "@math.gl/types": "4.1.0" } }, + "node_modules/@math.gl/culling": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@math.gl/culling/-/culling-4.1.0.tgz", + "integrity": "sha512-jFmjFEACnP9kVl8qhZxFNhCyd47qPfSVmSvvjR0/dIL6R9oD5zhR1ub2gN16eKDO/UM7JF9OHKU3EBIfeR7gtg==", + "license": "MIT", + "dependencies": { + "@math.gl/core": "4.1.0", + "@math.gl/types": "4.1.0" + } + }, + "node_modules/@math.gl/geospatial": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@math.gl/geospatial/-/geospatial-4.1.0.tgz", + "integrity": "sha512-BzsUhpVvnmleyYF6qdqJIip6FtIzJmnWuPTGhlBuPzh7VBHLonCFSPtQpbkRuoyAlbSyaGXcVt6p6lm9eK2vtg==", + "license": "MIT", + "dependencies": { + "@math.gl/core": "4.1.0", + "@math.gl/types": "4.1.0" + } + }, "node_modules/@math.gl/polygon": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/@math.gl/polygon/-/polygon-4.1.0.tgz", @@ -1920,6 +2326,31 @@ "vite": "^5.2.0 || ^6 || ^7" } }, + "node_modules/@turf/boolean-clockwise": { + "version": "5.1.5", + "resolved": "https://registry.npmjs.org/@turf/boolean-clockwise/-/boolean-clockwise-5.1.5.tgz", + "integrity": "sha512-FqbmEEOJ4rU4/2t7FKx0HUWmjFEVqR+NJrFP7ymGSjja2SQ7Q91nnBihGuT+yuHHl6ElMjQ3ttsB/eTmyCycxA==", + "license": "MIT", + "dependencies": { + "@turf/helpers": "^5.1.5", + "@turf/invariant": "^5.1.5" + } + }, + "node_modules/@turf/boolean-clockwise/node_modules/@turf/helpers": { + "version": "5.1.5", + "resolved": "https://registry.npmjs.org/@turf/helpers/-/helpers-5.1.5.tgz", + "integrity": "sha512-/lF+JR+qNDHZ8bF9d+Cp58nxtZWJ3sqFe6n3u3Vpj+/0cqkjk4nXKYBSY0azm+GIYB5mWKxUXvuP/m0ZnKj1bw==", + "license": "MIT" + }, + "node_modules/@turf/boolean-clockwise/node_modules/@turf/invariant": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@turf/invariant/-/invariant-5.2.0.tgz", + "integrity": "sha512-28RCBGvCYsajVkw2EydpzLdcYyhSA77LovuOvgCJplJWaNVyJYH6BOR3HR9w50MEkPqb/Vc/jdo6I6ermlRtQA==", + "license": "MIT", + "dependencies": { + "@turf/helpers": "^5.1.5" + } + }, "node_modules/@turf/boolean-point-in-polygon": { "version": "7.3.4", "resolved": "https://registry.npmjs.org/@turf/boolean-point-in-polygon/-/boolean-point-in-polygon-7.3.4.tgz", @@ -1936,6 +2367,21 @@ "url": "https://opencollective.com/turf" } }, + "node_modules/@turf/clone": { + "version": "5.1.5", + "resolved": "https://registry.npmjs.org/@turf/clone/-/clone-5.1.5.tgz", + "integrity": "sha512-//pITsQ8xUdcQ9pVb4JqXiSqG4dos5Q9N4sYFoWghX21tfOV2dhc5TGqYOhnHrQS7RiKQL1vQ48kIK34gQ5oRg==", + "license": "MIT", + "dependencies": { + "@turf/helpers": "^5.1.5" + } + }, + "node_modules/@turf/clone/node_modules/@turf/helpers": { + "version": "5.1.5", + "resolved": "https://registry.npmjs.org/@turf/helpers/-/helpers-5.1.5.tgz", + "integrity": "sha512-/lF+JR+qNDHZ8bF9d+Cp58nxtZWJ3sqFe6n3u3Vpj+/0cqkjk4nXKYBSY0azm+GIYB5mWKxUXvuP/m0ZnKj1bw==", + "license": "MIT" + }, "node_modules/@turf/helpers": { "version": "7.3.4", "resolved": "https://registry.npmjs.org/@turf/helpers/-/helpers-7.3.4.tgz", @@ -1963,6 +2409,49 @@ "url": "https://opencollective.com/turf" } }, + "node_modules/@turf/meta": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@turf/meta/-/meta-5.2.0.tgz", + "integrity": "sha512-ZjQ3Ii62X9FjnK4hhdsbT+64AYRpaI8XMBMcyftEOGSmPMUVnkbvuv3C9geuElAXfQU7Zk1oWGOcrGOD9zr78Q==", + "license": "MIT", + "dependencies": { + "@turf/helpers": "^5.1.5" + } + }, + "node_modules/@turf/meta/node_modules/@turf/helpers": { + "version": "5.1.5", + "resolved": "https://registry.npmjs.org/@turf/helpers/-/helpers-5.1.5.tgz", + "integrity": "sha512-/lF+JR+qNDHZ8bF9d+Cp58nxtZWJ3sqFe6n3u3Vpj+/0cqkjk4nXKYBSY0azm+GIYB5mWKxUXvuP/m0ZnKj1bw==", + "license": "MIT" + }, + "node_modules/@turf/rewind": { + "version": "5.1.5", + "resolved": "https://registry.npmjs.org/@turf/rewind/-/rewind-5.1.5.tgz", + "integrity": "sha512-Gdem7JXNu+G4hMllQHXRFRihJl3+pNl7qY+l4qhQFxq+hiU1cQoVFnyoleIqWKIrdK/i2YubaSwc3SCM7N5mMw==", + "license": "MIT", + "dependencies": { + "@turf/boolean-clockwise": "^5.1.5", + "@turf/clone": "^5.1.5", + "@turf/helpers": "^5.1.5", + "@turf/invariant": "^5.1.5", + "@turf/meta": "^5.1.5" + } + }, + "node_modules/@turf/rewind/node_modules/@turf/helpers": { + "version": "5.1.5", + "resolved": "https://registry.npmjs.org/@turf/helpers/-/helpers-5.1.5.tgz", + "integrity": "sha512-/lF+JR+qNDHZ8bF9d+Cp58nxtZWJ3sqFe6n3u3Vpj+/0cqkjk4nXKYBSY0azm+GIYB5mWKxUXvuP/m0ZnKj1bw==", + "license": "MIT" + }, + "node_modules/@turf/rewind/node_modules/@turf/invariant": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@turf/invariant/-/invariant-5.2.0.tgz", + "integrity": "sha512-28RCBGvCYsajVkw2EydpzLdcYyhSA77LovuOvgCJplJWaNVyJYH6BOR3HR9w50MEkPqb/Vc/jdo6I6ermlRtQA==", + "license": "MIT", + "dependencies": { + "@turf/helpers": "^5.1.5" + } + }, "node_modules/@types/babel__core": { "version": "7.20.5", "dev": true, @@ -2000,6 +2489,21 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/brotli": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@types/brotli/-/brotli-1.3.5.tgz", + "integrity": "sha512-9xoNr+bcxT236/7ZgcWw/6Pb2RRetE13p4bFy1xYSckKwyOiRfmInay8baUWZgH7/284Wl6IPe7+nOI9+OQg/A==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/crypto-js": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@types/crypto-js/-/crypto-js-4.2.2.tgz", + "integrity": "sha512-sDOLlVbHhXpAUAL0YHDUUwDZf3iN4Bwi4W6a0W0b+QcAezUbRtH4FVb+9J4h+XFPW7l/gQ9F8qC7P+Ec4k8QVQ==", + "license": "MIT" + }, "node_modules/@types/d3-array": { "version": "3.2.2", "license": "MIT" @@ -2067,9 +2571,7 @@ }, "node_modules/@types/node": { "version": "24.12.0", - "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -2080,6 +2582,12 @@ "integrity": "sha512-ieXiYmgSRXUDeOntE1InxjWyvEelZGP63M+cGuquuRLuIKKT1osnkXjxev9B7d1nXSug5vpunx+gNlbVxMlC9A==", "license": "MIT" }, + "node_modules/@types/pako": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/@types/pako/-/pako-1.0.7.tgz", + "integrity": "sha512-YBtzT2ztNF6R/9+UXj2wTGFnC9NklAnASt3sC0h2m1bbH7G6FyBIkt4AN8ThZpNfxUo1b2iMVO0UawiJymEt8A==", + "license": "MIT" + }, "node_modules/@types/react": { "version": "19.2.14", "devOptional": true, @@ -2443,6 +2951,15 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/a5-js": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/a5-js/-/a5-js-0.5.0.tgz", + "integrity": "sha512-VAw19sWdYadhdovb0ViOIi1SdKx6H6LwcGMRFKwMfgL5gcmL/1fKJHfgsNgNaJ7xC/eEyjs6VK+VVd4N0a+peg==", + "license": "Apache-2.0", + "dependencies": { + "gl-matrix": "^3.4.3" + } + }, "node_modules/acorn": { "version": "8.16.0", "dev": true, @@ -2516,6 +3033,27 @@ "dev": true, "license": "MIT" }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "optional": true + }, "node_modules/baseline-browser-mapping": { "version": "2.10.8", "dev": true, @@ -2536,6 +3074,16 @@ "concat-map": "0.0.1" } }, + "node_modules/brotli": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/brotli/-/brotli-1.3.3.tgz", + "integrity": "sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg==", + "license": "MIT", + "optional": true, + "dependencies": { + "base64-js": "^1.1.2" + } + }, "node_modules/browserslist": { "version": "4.28.1", "dev": true, @@ -2569,6 +3117,15 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/buf-compare": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buf-compare/-/buf-compare-1.0.1.tgz", + "integrity": "sha512-Bvx4xH00qweepGc43xFvMs5BKASXTbHaHm6+kDYIK9p/4iFwjATQkmPKHQSgJZzKbAymhztRbXUf1Nqhzl73/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/bytewise": { "version": "1.1.0", "license": "MIT", @@ -2626,6 +3183,15 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/charenc": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/charenc/-/charenc-0.0.2.tgz", + "integrity": "sha512-yrLQ/yVUFXkzg7EDQsPieE/53+0RlaWTs+wBrvW36cyilJ2SaDWfl4Yj7MtLTXleV9uEKefbAGUPv2/iWSooRA==", + "license": "BSD-3-Clause", + "engines": { + "node": "*" + } + }, "node_modules/clsx": { "version": "2.1.1", "license": "MIT", @@ -2659,6 +3225,25 @@ "dev": true, "license": "MIT" }, + "node_modules/core-assert": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/core-assert/-/core-assert-0.2.1.tgz", + "integrity": "sha512-IG97qShIP+nrJCXMCgkNZgH7jZQ4n8RpPyPeXX++T6avR/KhLhgLiHKoEn5Rc1KjfycSfA9DMa6m+4C4eguHhw==", + "license": "MIT", + "dependencies": { + "buf-compare": "^1.0.0", + "is-error": "^2.2.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "license": "MIT" + }, "node_modules/cross-spawn": { "version": "7.0.6", "dev": true, @@ -2672,6 +3257,15 @@ "node": ">= 8" } }, + "node_modules/crypt": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/crypt/-/crypt-0.0.2.tgz", + "integrity": "sha512-mCxBlsHFYh9C+HVpiEacem8FEBnMXgU9gy4zmNC+SXAZNB/1idgp/aulFJ4FgCi7GPEVbfyng092GqL2k2rmow==", + "license": "BSD-3-Clause", + "engines": { + "node": "*" + } + }, "node_modules/csstype": { "version": "3.2.3", "devOptional": true, @@ -2809,6 +3403,18 @@ "dev": true, "license": "MIT" }, + "node_modules/deep-strict-equal": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/deep-strict-equal/-/deep-strict-equal-0.2.0.tgz", + "integrity": "sha512-3daSWyvZ/zwJvuMGlzG1O+Ow0YSadGfb3jsh9xoCutv2tWyB9dA4YvR9L9/fSdDZa2dByYQe+TqapSGUrjnkoA==", + "license": "MIT", + "dependencies": { + "core-assert": "^0.2.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/detect-libc": { "version": "2.1.2", "license": "Apache-2.0", @@ -2816,6 +3422,12 @@ "node": ">=8" } }, + "node_modules/draco3d": { + "version": "1.5.7", + "resolved": "https://registry.npmjs.org/draco3d/-/draco3d-1.5.7.tgz", + "integrity": "sha512-m6WCKt/erDXcw+70IJXnG7M3awwQPAsZvJGX5zY7beBqpELw6RDGkYVU0W43AFxye4pDZ5i2Lbyc/NNGqwjUVQ==", + "license": "Apache-2.0" + }, "node_modules/earcut": { "version": "3.0.2", "license": "ISC" @@ -3096,6 +3708,24 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-xml-parser": { + "version": "4.5.5", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-4.5.5.tgz", + "integrity": "sha512-cK9c5I/DwIOI7/Q7AlGN3DuTdwN61gwSfL8rvuVPK+0mcCNHHGxRrpiFtaZZRfRMJL3Gl8B2AFlBG6qXf03w9A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "strnum": "^1.0.5" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } + }, "node_modules/fdir": { "version": "6.5.0", "license": "MIT", @@ -3111,6 +3741,12 @@ } } }, + "node_modules/fflate": { + "version": "0.7.4", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.7.4.tgz", + "integrity": "sha512-5u2V/CDW15QM1XbbgS+0DfPxVB+jUKhWEKuuFuHncbk3tEEqzmoXL+2KyOFuKGqOnmdIy0/davWF1CkuwtibCw==", + "license": "MIT" + }, "node_modules/file-entry-cache": { "version": "8.0.0", "dev": true, @@ -3210,6 +3846,17 @@ "version": "4.2.11", "license": "ISC" }, + "node_modules/h3-js": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/h3-js/-/h3-js-4.4.0.tgz", + "integrity": "sha512-DvJh07MhGgY2KcC4OeZc8SSyA+ZXpdvoh6uCzGpoKvWtZxJB+g6VXXC1+eWYkaMIsLz7J/ErhOalHCpcs1KYog==", + "license": "Apache-2.0", + "engines": { + "node": ">=4", + "npm": ">=3", + "yarn": ">=1.3.0" + } + }, "node_modules/has-flag": { "version": "4.0.0", "dev": true, @@ -3272,6 +3919,26 @@ } } }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/ignore": { "version": "5.3.2", "dev": true, @@ -3280,6 +3947,24 @@ "node": ">= 4" } }, + "node_modules/image-size": { + "version": "0.7.5", + "resolved": "https://registry.npmjs.org/image-size/-/image-size-0.7.5.tgz", + "integrity": "sha512-Hiyv+mXHfFEP7LzUL/llg9RwFxxY+o9N3JVLIeG5E7iFIFAalxvRU9UZthBdYDEVnzHMgjnKJPPpay5BWf1g9g==", + "license": "MIT", + "bin": { + "image-size": "bin/image-size.js" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", + "license": "MIT" + }, "node_modules/immer": { "version": "10.2.0", "license": "MIT", @@ -3311,6 +3996,12 @@ "node": ">=0.8.19" } }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, "node_modules/internmap": { "version": "2.0.3", "license": "ISC", @@ -3318,6 +4009,18 @@ "node": ">=12" } }, + "node_modules/is-buffer": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz", + "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==", + "license": "MIT" + }, + "node_modules/is-error": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/is-error/-/is-error-2.2.2.tgz", + "integrity": "sha512-IOQqts/aHWbiisY5DuPJQ0gcbvaLFCa7fBa9xoLfxBZvQ+ZI/Zh9xoI7Gk+G64N0FdK4AbibytHht2tWgpJWLg==", + "license": "MIT" + }, "node_modules/is-extendable": { "version": "0.1.1", "license": "MIT", @@ -3354,6 +4057,12 @@ "node": ">=0.10.0" } }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, "node_modules/isexe": { "version": "2.0.0", "dev": true, @@ -3430,6 +4139,18 @@ "node": ">=6" } }, + "node_modules/jszip": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", + "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==", + "license": "(MIT OR GPL-3.0-or-later)", + "dependencies": { + "lie": "~3.3.0", + "pako": "~1.0.2", + "readable-stream": "~2.3.6", + "setimmediate": "^1.0.5" + } + }, "node_modules/kdbush": { "version": "4.0.2", "license": "ISC" @@ -3442,6 +4163,12 @@ "json-buffer": "3.0.1" } }, + "node_modules/ktx-parse": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/ktx-parse/-/ktx-parse-0.7.1.tgz", + "integrity": "sha512-FeA3g56ksdFNwjXJJsc1CCc7co+AJYDp6ipIp878zZ2bU8kWROatLYf39TQEd4/XRSUvBXovQ8gaVKWPXsCLEQ==", + "license": "MIT" + }, "node_modules/leaflet": { "version": "1.9.4", "license": "BSD-2-Clause", @@ -3459,6 +4186,15 @@ "node": ">= 0.8.0" } }, + "node_modules/lie": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "license": "MIT", + "dependencies": { + "immediate": "~3.0.5" + } + }, "node_modules/lightningcss": { "version": "1.31.1", "license": "MPL-2.0", @@ -3723,6 +4459,15 @@ "dev": true, "license": "MIT" }, + "node_modules/long": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/long/-/long-3.2.0.tgz", + "integrity": "sha512-ZYvPPOMqUwPoDsbJaR10iQJYnMuZhRTvHYl62ErLIEX7RgFlziSBUUvrt3OVfc47QlHHpzPZYP17g3Fv7oeJkg==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.6" + } + }, "node_modules/lru-cache": { "version": "5.1.1", "dev": true, @@ -3731,6 +4476,19 @@ "yallist": "^3.0.2" } }, + "node_modules/lz4js": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/lz4js/-/lz4js-0.2.0.tgz", + "integrity": "sha512-gY2Ia9Lm7Ep8qMiuGRhvUq0Q7qUereeldZPP1PMEJxPtEWHJLqw9pgX68oHajBH0nzJK4MaZEA/YNV3jT8u8Bg==", + "license": "ISC", + "optional": true + }, + "node_modules/lzo-wasm": { + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/lzo-wasm/-/lzo-wasm-0.0.4.tgz", + "integrity": "sha512-VKlnoJRFrB8SdJhlVKvW5vI1gGwcZ+mvChEXcSX6r2xDNc/Q2FD9esfBmGCuPZdrJ1feO+YcVFd2PTk0c137Gw==", + "license": "BSD-2-Clause" + }, "node_modules/magic-string": { "version": "0.30.21", "license": "MIT", @@ -3771,6 +4529,17 @@ "url": "https://github.com/maplibre/maplibre-gl-js?sponsor=1" } }, + "node_modules/md5": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/md5/-/md5-2.3.0.tgz", + "integrity": "sha512-T1GITYmFaKuO91vxyoQMFETst+O71VUPEU3ze5GNzDm0OWdP8v1ziTaAEPUr/3kLsY3Sftgz242A1SetQiDL7g==", + "license": "BSD-3-Clause", + "dependencies": { + "charenc": "0.0.2", + "crypt": "0.0.2", + "is-buffer": "~1.1.6" + } + }, "node_modules/minimatch": { "version": "3.1.5", "dev": true, @@ -3874,6 +4643,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "license": "(MIT AND Zlib)" + }, "node_modules/parent-module": { "version": "1.0.1", "dev": true, @@ -3973,6 +4748,12 @@ "node": ">= 0.8.0" } }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "license": "MIT" + }, "node_modules/protocol-buffers-schema": { "version": "3.6.0", "license": "MIT" @@ -4102,6 +4883,21 @@ "node": ">=0.10.0" } }, + "node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, "node_modules/recharts": { "version": "3.8.0", "license": "MIT", @@ -4213,6 +5009,12 @@ "version": "1.3.3", "license": "BSD-3-Clause" }, + "node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, "node_modules/satellite.js": { "version": "6.0.2", "license": "MIT" @@ -4242,6 +5044,12 @@ "node": ">=0.10.0" } }, + "node_modules/setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", + "license": "MIT" + }, "node_modules/shebang-command": { "version": "2.0.0", "dev": true, @@ -4261,6 +5069,12 @@ "node": ">=8" } }, + "node_modules/snappyjs": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/snappyjs/-/snappyjs-0.6.1.tgz", + "integrity": "sha512-YIK6I2lsH072UE0aOFxxY1dPDCS43I5ktqHpeAsuLNYWkE5pGxRGWfDM4/vSUfNzXjC1Ivzt3qx31PCLmc9yqg==", + "license": "MIT" + }, "node_modules/sort-asc": { "version": "0.2.0", "license": "MIT", @@ -4328,6 +5142,21 @@ "node": ">=0.10.0" } }, + "node_modules/sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", + "license": "BSD-3-Clause" + }, + "node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/strip-json-comments": { "version": "3.1.1", "dev": true, @@ -4339,6 +5168,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/strnum": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-1.1.2.tgz", + "integrity": "sha512-vrN+B7DBIoTTZjnPNewwhx6cBA/H+IS7rfW68n7XxC1y7uoiGQBxaKzqucGUgavX15dJgiGztLJ8vxuEzwqBdA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, "node_modules/supercluster": { "version": "8.0.1", "license": "ISC", @@ -4372,6 +5213,28 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/texture-compressor": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/texture-compressor/-/texture-compressor-1.0.2.tgz", + "integrity": "sha512-dStVgoaQ11mA5htJ+RzZ51ZxIZqNOgWKAIvtjLrW1AliQQLCmrDqNzQZ8Jh91YealQ95DXt4MEduLzJmbs6lig==", + "license": "MIT", + "dependencies": { + "argparse": "^1.0.10", + "image-size": "^0.7.4" + }, + "bin": { + "texture-compressor": "bin/texture-compressor.js" + } + }, + "node_modules/texture-compressor/node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "license": "MIT", + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, "node_modules/tiny-invariant": { "version": "1.3.3", "license": "MIT" @@ -4470,7 +5333,6 @@ }, "node_modules/undici-types": { "version": "7.16.0", - "devOptional": true, "license": "MIT" }, "node_modules/union-value": { @@ -4530,6 +5392,12 @@ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, "node_modules/victory-vendor": { "version": "37.3.6", "license": "MIT AND ISC", @@ -4693,6 +5561,42 @@ "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } + }, + "node_modules/zstd-codec": { + "version": "0.1.5", + "resolved": "https://registry.npmjs.org/zstd-codec/-/zstd-codec-0.1.5.tgz", + "integrity": "sha512-v3fyjpK8S/dpY/X5WxqTK3IoCnp/ZOLxn144GZVlNUjtwAchzrVo03h+oMATFhCIiJ5KTr4V3vDQQYz4RU684g==", + "license": "MIT", + "optional": true + }, + "node_modules/zustand": { + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.12.tgz", + "integrity": "sha512-i77ae3aZq4dhMlRhJVCYgMLKuSiZAaUPAct2AksxQ+gOtimhGMdXljRT21P5BNpeT4kXlLIckvkPM029OljD7g==", + "license": "MIT", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "immer": ">=9.0.6", + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + }, + "use-sync-external-store": { + "optional": true + } + } } } } diff --git a/frontend/package.json b/frontend/package.json index 40a475d..971200c 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -11,6 +11,7 @@ }, "dependencies": { "@deck.gl/core": "^9.2.11", + "@deck.gl/geo-layers": "^9.2.11", "@deck.gl/layers": "^9.2.11", "@deck.gl/mapbox": "^9.2.11", "@fontsource-variable/fira-code": "^5.2.7", @@ -32,7 +33,8 @@ "react-map-gl": "^8.1.0", "recharts": "^3.8.0", "satellite.js": "^6.0.2", - "tailwindcss": "^4.2.1" + "tailwindcss": "^4.2.1", + "zustand": "^5.0.12" }, "devDependencies": { "@eslint/js": "^9.39.1", diff --git a/frontend/src/components/korea/CorrelationPanel.tsx b/frontend/src/components/korea/CorrelationPanel.tsx new file mode 100644 index 0000000..709bf0a --- /dev/null +++ b/frontend/src/components/korea/CorrelationPanel.tsx @@ -0,0 +1,355 @@ +import { useState } from 'react'; +import type { GearCorrelationItem } from '../../services/vesselAnalysis'; +import type { UseGroupPolygonsResult } from '../../hooks/useGroupPolygons'; +import { FONT_MONO } from '../../styles/fonts'; +import { MODEL_ORDER, MODEL_COLORS, MODEL_DESC } from './fleetClusterConstants'; +import { useGearReplayStore } from '../../stores/gearReplayStore'; + +interface CorrelationPanelProps { + selectedGearGroup: string; + memberCount: number; + groupPolygons: UseGroupPolygonsResult | undefined; + correlationByModel: Map; + availableModels: { name: string; count: number; isDefault: boolean }[]; + enabledModels: Set; + enabledVessels: Set; + correlationLoading: boolean; + hoveredTarget: { mmsi: string; model: string } | null; + onEnabledModelsChange: (updater: (prev: Set) => Set) => void; + onEnabledVesselsChange: (updater: (prev: Set) => Set) => void; + onHoveredTargetChange: (target: { mmsi: string; model: string } | null) => void; +} + +// Ensure MODEL_ORDER is treated as string array for Record lookups +const _MODEL_ORDER: string[] = MODEL_ORDER as unknown as string[]; + +const CorrelationPanel = ({ + selectedGearGroup, + memberCount, + groupPolygons, + correlationByModel, + availableModels, + enabledModels, + enabledVessels, + correlationLoading, + hoveredTarget, + onEnabledModelsChange, + onEnabledVesselsChange, + onHoveredTargetChange, +}: CorrelationPanelProps) => { + const historyActive = useGearReplayStore(s => s.historyFrames.length > 0); + + // Local tooltip state + const [hoveredModelTip, setHoveredModelTip] = useState(null); + const [pinnedModelTip, setPinnedModelTip] = useState(null); + const activeModelTip = pinnedModelTip ?? hoveredModelTip; + + // Compute identity data from groupPolygons + const allGroups = groupPolygons + ? [...groupPolygons.gearInZoneGroups, ...groupPolygons.gearOutZoneGroups] + : []; + const group = allGroups.find(g => g.groupKey === selectedGearGroup); + const identityVessels = group?.members.filter(m => m.isParent) ?? []; + const identityGear = group?.members.filter(m => !m.isParent) ?? []; + + // Suppress unused MODEL_ORDER warning — used for ordering checks + void _MODEL_ORDER; + + // Common card styles + const cardStyle: React.CSSProperties = { + background: 'rgba(12,24,37,0.95)', + borderRadius: 6, + minWidth: 160, + maxWidth: 200, + flexShrink: 0, + border: '1px solid rgba(255,255,255,0.08)', + position: 'relative', + }; + + const cardScrollStyle: React.CSSProperties = { + padding: '6px 8px', + maxHeight: 200, + overflowY: 'auto', + }; + + // Model title tooltip hover/click handlers + const handleTipHover = (model: string) => { + if (!pinnedModelTip) setHoveredModelTip(model); + }; + const handleTipLeave = () => { + if (!pinnedModelTip) setHoveredModelTip(null); + }; + const handleTipClick = (model: string) => { + setPinnedModelTip(prev => prev === model ? null : model); + setHoveredModelTip(null); + }; + + const renderModelTip = (model: string, color: string) => { + if (activeModelTip !== model) return null; + const desc = MODEL_DESC[model]; + if (!desc) return null; + return ( +
+
{desc.summary}
+ {desc.details.map((line, i) => ( +
{line}
+ ))} + {pinnedModelTip === model && ( +
+ 클릭하여 닫기 +
+ )} +
+ ); + }; + + // Common row renderer (correlation target — with score bar, model-independent hover) + const toggleVessel = (mmsi: string) => { + onEnabledVesselsChange(prev => { + const next = new Set(prev); + if (next.has(mmsi)) next.delete(mmsi); else next.add(mmsi); + return next; + }); + }; + + const renderRow = (c: GearCorrelationItem, color: string, modelName: string) => { + const pct = (c.score * 100).toFixed(0); + const barW = Math.max(2, c.score * 30); + const barColor = c.score >= 0.7 ? '#22c55e' : c.score >= 0.5 ? '#eab308' : '#94a3b8'; + const isVessel = c.targetType === 'VESSEL'; + const isEnabled = enabledVessels.has(c.targetMmsi); + const isHovered = hoveredTarget?.mmsi === c.targetMmsi && hoveredTarget?.model === modelName; + return ( +
toggleVessel(c.targetMmsi)} + onMouseEnter={() => onHoveredTargetChange({ mmsi: c.targetMmsi, model: modelName })} + onMouseLeave={() => onHoveredTargetChange(null)} + > + + + {isVessel ? '⛴' : '◆'} + + + {c.targetName || c.targetMmsi} + +
+
+
+
+ {pct}% +
+
+ ); + }; + + // Member row renderer (identity model — no score, independent hover) + const renderMemberRow = (m: { mmsi: string; name: string }, icon: string, iconColor: string) => { + const isHovered = hoveredTarget?.mmsi === m.mmsi && hoveredTarget?.model === 'identity'; + return ( +
onHoveredTargetChange({ mmsi: m.mmsi, model: 'identity' })} + onMouseLeave={() => onHoveredTargetChange(null)} + > + {icon} + + {m.name || m.mmsi} + +
+ ); + }; + + return ( +
+ {/* 고정: 토글 패널 */} +
+
+ {selectedGearGroup} + {memberCount}개 +
+
폴리곤 오버레이
+ + {correlationLoading &&
로딩...
} + {availableModels.map(m => { + const color = MODEL_COLORS[m.name] ?? '#94a3b8'; + const modelItems = correlationByModel.get(m.name) ?? []; + const vc = modelItems.filter(c => c.targetType === 'VESSEL').length; + const gc = modelItems.filter(c => c.targetType !== 'VESSEL').length; + return ( + + ); + })} +
+ + {/* 이름 기반 카드 (체크 시) */} + {enabledModels.has('identity') && (identityVessels.length > 0 || identityGear.length > 0) && ( +
+ {renderModelTip('identity', '#f97316')} +
+
handleTipHover('identity')} + onMouseLeave={handleTipLeave} + onClick={() => handleTipClick('identity')} + > + + 이름 기반 +
+ {identityVessels.length > 0 && ( + <> +
연관 선박 ({identityVessels.length})
+ {identityVessels.map(m => renderMemberRow(m, '⛴', '#60a5fa'))} + + )} + {identityGear.length > 0 && ( + <> +
연관 어구 ({identityGear.length})
+ {identityGear.slice(0, 12).map(m => renderMemberRow(m, '◆', '#f97316'))} + {identityGear.length > 12 && ( +
+{identityGear.length - 12}개 더
+ )} + + )} +
+
+ )} + + {/* 각 Correlation 모델 카드 (체크 시 우측에 추가) */} + {availableModels.filter(m => enabledModels.has(m.name)).map(m => { + const color = MODEL_COLORS[m.name] ?? '#94a3b8'; + const items = correlationByModel.get(m.name) ?? []; + const vessels = items.filter(c => c.targetType === 'VESSEL'); + const gears = items.filter(c => c.targetType !== 'VESSEL'); + if (vessels.length === 0 && gears.length === 0) return null; + return ( +
+ {renderModelTip(m.name, color)} +
+
handleTipHover(m.name)} + onMouseLeave={handleTipLeave} + onClick={() => handleTipClick(m.name)} + > + + {m.name}{m.isDefault ? '*' : ''} +
+ {vessels.length > 0 && ( + <> +
연관 선박 ({vessels.length})
+ {vessels.slice(0, 10).map(c => renderRow(c, color, m.name))} + {vessels.length > 10 && ( +
+{vessels.length - 10}건 더
+ )} + + )} + {gears.length > 0 && ( + <> +
연관 어구 ({gears.length})
+ {gears.slice(0, 10).map(c => renderRow(c, color, m.name))} + {gears.length > 10 && ( +
+{gears.length - 10}건 더
+ )} + + )} +
+
+ ); + })} +
+ ); +}; + +export default CorrelationPanel; diff --git a/frontend/src/components/korea/FleetClusterLayer.tsx b/frontend/src/components/korea/FleetClusterLayer.tsx index 83e3c0e..2ec108d 100644 --- a/frontend/src/components/korea/FleetClusterLayer.tsx +++ b/frontend/src/components/korea/FleetClusterLayer.tsx @@ -1,215 +1,24 @@ import { useState, useEffect, useMemo, useCallback, useRef } from 'react'; -import { Source, Layer, Popup, useMap } from 'react-map-gl/maplibre'; -import { FONT_MONO } from '../../styles/fonts'; -import type { GeoJSON } from 'geojson'; +import { useMap } from 'react-map-gl/maplibre'; 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, fetchCorrelationTracks } from '../../services/vesselAnalysis'; +import type { FleetCompany, GearCorrelationItem, CorrelationVesselTrack } from '../../services/vesselAnalysis'; import type { UseGroupPolygonsResult } from '../../hooks/useGroupPolygons'; +import { useGearReplayStore } from '../../stores/gearReplayStore'; -/** - * Convex hull + buffer 기반 폴리곤 생성 (Python polygon_builder.py 동일 로직) - * - 1점: 원형 버퍼 (GEAR_BUFFER_DEG=0.01 ≈ 1.1km) - * - 2점: 두 점 잇는 직선 양쪽 버퍼 - * - 3점+: convex hull + 버퍼 - */ -const GEAR_BUFFER_DEG = 0.01; -const CIRCLE_SEGMENTS = 16; +// ── 분리된 모듈 ── +import type { PickerCandidate, HoverTooltipState, GearPickerPopupState } from './fleetClusterTypes'; +import { EMPTY_ANALYSIS } from './fleetClusterTypes'; +import { fillGapFrames } from './fleetClusterUtils'; +import { useFleetClusterGeoJson } from './useFleetClusterGeoJson'; +import FleetClusterMapLayers from './FleetClusterMapLayers'; +import CorrelationPanel from './CorrelationPanel'; +import HistoryReplayController from './HistoryReplayController'; +import FleetGearListPanel from './FleetGearListPanel'; -function buildInterpPolygon(points: [number, number][]): GeoJSON.Polygon | null { - if (points.length === 0) return null; - - if (points.length === 1) { - // Point.buffer → 원형 - 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) { - // LineString.buffer → 캡슐 형태 - 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; - // p1→p2 오른쪽 - ring.push([p1[0] + nx, p1[1] + ny]); - ring.push([p2[0] + nx, p2[1] + ny]); - // p2 반원 - 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)]); - } - // p2→p1 왼쪽 - ring.push([p1[0] - nx, p1[1] - ny]); - // p1 반원 - 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] }; - } - - // 3점+: convex hull + buffer - const hull = convexHull(points); - return bufferPolygon(hull, GEAR_BUFFER_DEG); -} - -/** 단순 convex hull (Graham scan) */ -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 적용 (각 변의 오프셋 + 꼭짓점 라운드) */ -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; - // 꼭짓점 라운딩 (a1 → a2) - 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분 간격으로 멤버 위치 보간 + 가상 폴리곤 생성 - */ -function fillGapFrames(snapshots: GroupPolygonDto[]): HistoryFrame[] { - if (snapshots.length < 2) return snapshots; - const STEP_SHORT_MS = 300_000; // 5분 - const STEP_LONG_MS = 1_800_000; // 30분 - const THRESHOLD_MS = 1_800_000; // 30분 경계 - 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) { - // ≤30분: 5분 간격 직선 보간 (중심만 이동, 폴리곤은 이전 것 유지) - 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 { - // >30분: 30분 간격으로 멤버 위치 보간 + 가상 폴리곤 - for (let t = t0 + STEP_LONG_MS; t < t1; t += STEP_LONG_MS) { - const ratio = (t - t0) / gap; - const positions: [number, number][] = []; - const members: MemberInfo[] = []; - - 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; -} - -export interface SelectedGearGroupData { - parent: Ship | null; - gears: Ship[]; - groupName: string; -} - -export interface SelectedFleetData { - clusterId: number; - ships: Ship[]; - companyName: string; -} - -/** 히스토리 스냅샷 + 보간 플래그 */ -type HistoryFrame = GroupPolygonDto & { _interp?: boolean; _longGap?: boolean }; +// ── re-export (KoreaMap 호환) ── +export type { SelectedGearGroupData, SelectedFleetData } from './fleetClusterTypes'; interface Props { ships: Ship[]; @@ -217,15 +26,15 @@ interface Props { clusters?: Map; onShipSelect?: (mmsi: string) => void; onFleetZoom?: (bounds: { minLat: number; maxLat: number; minLng: number; maxLng: number }) => void; - onSelectedGearChange?: (data: SelectedGearGroupData | null) => void; - onSelectedFleetChange?: (data: SelectedFleetData | null) => void; + onSelectedGearChange?: (data: import('./fleetClusterTypes').SelectedGearGroupData | null) => void; + onSelectedFleetChange?: (data: import('./fleetClusterTypes').SelectedFleetData | null) => void; groupPolygons?: UseGroupPolygonsResult; } -const EMPTY_ANALYSIS = new globalThis.Map(); - export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipSelect, onFleetZoom, onSelectedGearChange, onSelectedFleetChange, groupPolygons }: Props) { const analysisMap = analysisMapProp ?? EMPTY_ANALYSIS; + + // ── 선단/어구 패널 상태 ── const [companies, setCompanies] = useState>(new Map()); const [expandedFleet, setExpandedFleet] = useState(null); const [activeSection, setActiveSection] = useState('fleet'); @@ -233,102 +42,95 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipS const [hoveredFleetId, setHoveredFleetId] = useState(null); const [expandedGearGroup, setExpandedGearGroup] = useState(null); const [selectedGearGroup, setSelectedGearGroup] = useState(null); - // 폴리곤 호버 툴팁 - const [hoverTooltip, setHoverTooltip] = useState<{ lng: number; lat: number; type: 'fleet' | 'gear'; id: number | string } | null>(null); - // 어구 다중 선택 팝업 - const [gearPickerPopup, setGearPickerPopup] = useState<{ - lng: number; lat: number; - candidates: { name: string; count: number; inZone: boolean; isFleet: boolean; clusterId?: number }[]; - } | null>(null); + + // ── 맵 팝업/툴팁 상태 ── + const [hoverTooltip, setHoverTooltip] = useState(null); + const [gearPickerPopup, setGearPickerPopup] = useState(null); const [pickerHoveredGroup, setPickerHoveredGroup] = useState(null); - // 히스토리 애니메이션 — 12시간 실시간 타임라인 - const [historyData, setHistoryData] = useState(null); - const [, setHistoryGroupKey] = useState(null); - const [timelinePos, setTimelinePos] = useState(0); // 0~1 (12시간 내 위치) - const [isPlaying, setIsPlaying] = useState(true); - const animTimerRef = useRef>(); - const historyStartRef = useRef(0); // 12시간 전 epoch ms - const historyEndRef = useRef(0); // 현재 epoch ms + + // ── 연관성 데이터 ── + const [correlationData, setCorrelationData] = useState([]); + const [correlationLoading, setCorrelationLoading] = useState(false); + const [correlationTracks, setCorrelationTracks] = useState([]); + const [enabledVessels, setEnabledVessels] = useState>(new Set()); + const [enabledModels, setEnabledModels] = useState>(new Set(['identity', 'default', 'aggressive', 'conservative', 'proximity-heavy', 'visit-pattern'])); + const [hoveredTarget, setHoveredTarget] = useState<{ mmsi: string; model: string } | null>(null); + + // ── Zustand store (히스토리 재생) ── + const historyActive = useGearReplayStore(s => s.historyFrames.length > 0); + + // ── 맵 + ref ── const { current: mapRef } = useMap(); const registeredRef = useRef(false); const dataRef = useRef<{ shipMap: Map; groupPolygons: UseGroupPolygonsResult | undefined; onFleetZoom: Props['onFleetZoom'] }>({ shipMap: new Map(), groupPolygons, onFleetZoom }); + // ── 초기 로드 ── useEffect(() => { fetchFleetCompanies().then(setCompanies).catch(() => {}); }, []); - const TIMELINE_DURATION_MS = 12 * 60 * 60_000; // 12시간 - const PLAYBACK_CYCLE_SEC = 30; // 30초에 12시간 전체 재생 - const TICK_MS = 50; // 50ms 간격 업데이트 - + // ── 히스토리 로드 (3개 API 순차 await → 스토어 초기화 → 재생) ── const loadHistory = async (groupKey: string) => { - setHistoryGroupKey(groupKey); - setTimelinePos(0); - 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; - setHistoryData(filled); + // 1. 모든 데이터를 병렬 fetch + const [history, corrRes, trackRes] = await Promise.all([ + fetchGroupHistory(groupKey, 12), + fetchGroupCorrelations(groupKey, 0.3).catch(() => ({ items: [] as GearCorrelationItem[] })), + fetchCorrelationTracks(groupKey, 24, 0.3).catch(() => ({ vessels: [] as CorrelationVesselTrack[] })), + ]); + + // 2. 데이터 전처리 + const sorted = history.reverse(); + const filled = fillGapFrames(sorted); + const corrData = corrRes.items; + const corrTracks = trackRes.vessels; + + // 디버그: fetch 결과 확인 + const withTrack = corrTracks.filter(v => v.track && v.track.length > 0).length; + console.log('[loadHistory] fetch 완료:', { + history: history.length, + corrData: corrData.length, + corrTracks: corrTracks.length, + withTrack, + sampleTrack: corrTracks[0] ? { mmsi: corrTracks[0].mmsi, trackPts: corrTracks[0].track?.length, score: corrTracks[0].score } : 'none', + }); + + const vessels = new Set(corrTracks.filter(v => v.score >= 0.7).map(v => v.mmsi)); + + // 3. React 상태 동기화 (패널 표시용) + setCorrelationData(corrData); + setCorrelationTracks(corrTracks); + setEnabledVessels(vessels); + setCorrelationLoading(false); + + // 4. 스토어 초기화 (모든 데이터 포함) → 재생 시작 + const store = useGearReplayStore.getState(); + store.loadHistory(filled, corrTracks, corrData, enabledModels, vessels); + store.play(); }; const closeHistory = useCallback(() => { - setHistoryData(null); - setHistoryGroupKey(null); - setTimelinePos(0); - setIsPlaying(true); - clearInterval(animTimerRef.current); + useGearReplayStore.getState().reset(); + setSelectedGearGroup(null); }, []); - // 재생 타이머 — 50ms마다 timelinePos 진행 + // ── enabledModels/enabledVessels/hoveredTarget → store 동기화 ── useEffect(() => { - if (!historyData || !isPlaying) { - clearInterval(animTimerRef.current); - return; - } - const step = TICK_MS / (PLAYBACK_CYCLE_SEC * 1000); // 1틱당 진행량 - animTimerRef.current = setInterval(() => { - setTimelinePos(prev => { - const next = prev + step; - return next >= 1 ? 0 : next; // 순환 - }); - }, TICK_MS); - return () => clearInterval(animTimerRef.current); - }, [historyData, isPlaying]); + useGearReplayStore.getState().setEnabledModels(enabledModels); + }, [enabledModels]); - // timelinePos → 현재 시각 + 가장 가까운 스냅샷 인덱스 - const currentTimeMs = historyStartRef.current + timelinePos * TIMELINE_DURATION_MS; - const currentSnapIdx = useMemo(() => { - if (!historyData || historyData.length === 0) return -1; - let best = 0; - let bestDiff = Infinity; - for (let i = 0; i < historyData.length; i++) { - const t = new Date(historyData[i].snapshotTime).getTime(); - const diff = Math.abs(t - currentTimeMs); - if (diff < bestDiff) { bestDiff = diff; best = i; } - } - // 보간 프레임 포함 데이터셋에서 가장 가까운 프레임 매칭 (최대 gap = 30분) - return bestDiff < 1_800_000 ? best : -1; - }, [historyData, currentTimeMs]); + useEffect(() => { + useGearReplayStore.getState().setEnabledVessels(enabledVessels); + }, [enabledVessels]); - // 스냅샷 존재 구간 맵 (프로그레스 바 갭 표시용) - // 프로그레스 바: 원본 데이터만 표시 (보간 프레임 제외) - const snapshotRanges = useMemo(() => { - if (!historyData) return []; - return historyData - .filter(h => !h._interp) - .map(h => { - const t = new Date(h.snapshotTime).getTime(); - return (t - historyStartRef.current) / TIMELINE_DURATION_MS; - }); - }, [historyData]); + 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); @@ -336,9 +138,9 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipS }; window.addEventListener('keydown', onKeyDown); return () => window.removeEventListener('keydown', onKeyDown); - }, [historyData, closeHistory]); + }, [historyActive, closeHistory]); - // ── 맵 폴리곤 클릭/호버 이벤트 등록 + // ── 맵 이벤트 등록 ── useEffect(() => { const map = mapRef?.getMap(); if (!map || registeredRef.current) return; @@ -346,7 +148,6 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipS const fleetLayers = ['fleet-cluster-fill-layer']; const gearLayers = ['gear-cluster-fill-layer']; const allLayers = [...fleetLayers, ...gearLayers]; - const setCursor = (cursor: string) => { map.getCanvas().style.cursor = cursor; }; const onFleetEnter = (e: MapLayerMouseEvent) => { @@ -364,6 +165,7 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipS setHoveredFleetId(null); setHoverTooltip(prev => prev?.type === 'fleet' ? null : prev); }; + const handleFleetSelect = (cid: number) => { const d = dataRef.current; setExpandedFleet(prev => prev === cid ? null : cid); @@ -381,14 +183,36 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipS loadHistory(String(cid)); }; - // 통합 클릭 핸들러: 선단+어구 모든 폴리곤 겹침 판정 + const handleGearGroupZoomFromMap = (name: string) => { + const d = dataRef.current; + setSelectedGearGroup(prev => prev === name ? null : name); + setExpandedGearGroup(name); + const isInZone = d.groupPolygons?.gearInZoneGroups.some(g => g.groupKey === name); + setActiveSection(isInZone ? 'inZone' : 'outZone'); + requestAnimationFrame(() => { + setTimeout(() => { + document.getElementById(`gear-row-${name}`)?.scrollIntoView({ behavior: 'smooth', block: 'start' }); + }, 50); + }); + const allGroups = d.groupPolygons ? [...d.groupPolygons.gearInZoneGroups, ...d.groupPolygons.gearOutZoneGroups] : []; + const group = allGroups.find(g => g.groupKey === name); + if (!group || group.members.length === 0) return; + let minLat = Infinity, maxLat = -Infinity, minLng = Infinity, maxLng = -Infinity; + for (const m of group.members) { + if (m.lat < minLat) minLat = m.lat; + if (m.lat > maxLat) maxLat = m.lat; + if (m.lon < minLng) minLng = m.lon; + if (m.lon > maxLng) maxLng = m.lon; + } + if (minLat !== Infinity) d.onFleetZoom?.({ minLat, maxLat, minLng, maxLng }); + loadHistory(name); + }; + const onPolygonClick = (e: MapLayerMouseEvent) => { const features = map.queryRenderedFeatures(e.point, { layers: allLayers }); if (features.length === 0) return; - - // 후보 수집 (선단 + 어구 통합) const seen = new Set(); - const candidates: { name: string; count: number; inZone: boolean; isFleet: boolean; clusterId?: number }[] = []; + const candidates: PickerCandidate[] = []; for (const f of features) { const cid = f.properties?.clusterId as number | undefined; const gearName = f.properties?.name as string | undefined; @@ -405,80 +229,40 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipS candidates.push({ name: gearName, count: f.properties?.gearCount ?? 0, inZone: f.properties?.inZone === 1, isFleet: false }); } } - if (candidates.length === 1) { - // 단일 → 바로 선택 const c = candidates[0]; if (c.isFleet && c.clusterId != null) handleFleetSelect(c.clusterId); else handleGearGroupZoomFromMap(c.name); } else if (candidates.length > 1) { - // 다중 → 선택 팝업 setGearPickerPopup({ lng: e.lngLat.lng, lat: e.lngLat.lat, candidates }); } }; - const onFleetClick = onPolygonClick; - const onGearEnter = (e: MapLayerMouseEvent) => { setCursor('pointer'); const feat = e.features?.[0]; if (!feat) return; const name = feat.properties?.name as string | undefined; - if (name) { - setHoverTooltip({ lng: e.lngLat.lng, lat: e.lngLat.lat, type: 'gear', id: name }); - } + if (name) setHoverTooltip({ lng: e.lngLat.lng, lat: e.lngLat.lat, type: 'gear', id: name }); }; const onGearLeave = () => { setCursor(''); setHoverTooltip(prev => prev?.type === 'gear' ? null : prev); }; - const handleGearGroupZoomFromMap = (name: string) => { - const d = dataRef.current; - setSelectedGearGroup(prev => prev === name ? null : name); - setExpandedGearGroup(name); - - // 해당 어구가 속한 섹션으로 아코디언 전환 - const isInZone = d.groupPolygons?.gearInZoneGroups.some(g => g.groupKey === name); - setActiveSection(isInZone ? 'inZone' : 'outZone'); - - // 섹션 전환 후 스크롤 - requestAnimationFrame(() => { - setTimeout(() => { - document.getElementById(`gear-row-${name}`)?.scrollIntoView({ behavior: 'smooth', block: 'start' }); - }, 50); - }); - const allGroups = d.groupPolygons - ? [...d.groupPolygons.gearInZoneGroups, ...d.groupPolygons.gearOutZoneGroups] - : []; - const group = allGroups.find(g => g.groupKey === name); - if (!group || group.members.length === 0) return; - let minLat = Infinity, maxLat = -Infinity, minLng = Infinity, maxLng = -Infinity; - for (const m of group.members) { - if (m.lat < minLat) minLat = m.lat; - if (m.lat > maxLat) maxLat = m.lat; - if (m.lon < minLng) minLng = m.lon; - if (m.lon > maxLng) maxLng = m.lon; - } - if (minLat !== Infinity) d.onFleetZoom?.({ minLat, maxLat, minLng, maxLng }); - loadHistory(name); - }; - - const onGearClick = onPolygonClick; const register = () => { const ready = allLayers.every(id => map.getLayer(id)); if (!ready) return; registeredRef.current = true; - for (const id of fleetLayers) { map.on('mouseenter', id, onFleetEnter); map.on('mouseleave', id, onFleetLeave); - map.on('click', id, onFleetClick); + map.on('click', id, onPolygonClick); } for (const id of gearLayers) { map.on('mouseenter', id, onGearEnter); map.on('mouseleave', id, onGearLeave); - map.on('click', id, onGearClick); + map.on('click', id, onPolygonClick); } }; @@ -492,312 +276,95 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipS } }, [mapRef]); - // ships map (mmsi → Ship) + // ── ships map ── const shipMap = useMemo(() => { const m = new Map(); for (const s of ships) m.set(s.mmsi, s); return m; }, [ships]); - // stale closure 방지 dataRef.current = { shipMap, groupPolygons, onFleetZoom }; - // 선택된 어구 그룹 데이터를 부모에 전달 (deck.gl 렌더링용) — 히스토리 모드에서는 null + // ── 부모 콜백 동기화: 어구 그룹 선택 ── useEffect(() => { - if (!selectedGearGroup || historyData) { + if (!selectedGearGroup || historyActive) { onSelectedGearChange?.(null); - if (historyData) return; // 히스토리 모드: 선택은 유지하되 부모 강조만 숨김 + if (historyActive) return; return; } - const allGroups = groupPolygons - ? [...groupPolygons.gearInZoneGroups, ...groupPolygons.gearOutZoneGroups] - : []; + const allGroups = groupPolygons ? [...groupPolygons.gearInZoneGroups, ...groupPolygons.gearOutZoneGroups] : []; const group = allGroups.find(g => g.groupKey === selectedGearGroup); - if (!group) { - onSelectedGearChange?.(null); - return; - } + if (!group) { onSelectedGearChange?.(null); return; } const parent = group.members.find(m => m.isParent); const gears = group.members.filter(m => !m.isParent); const toShip = (m: typeof group.members[0]): Ship => ({ - mmsi: m.mmsi, - name: m.name, - lat: m.lat, - lng: m.lon, - heading: m.cog, - speed: m.sog, - course: m.cog, - category: 'fishing', - lastSeen: Date.now(), + mmsi: m.mmsi, name: m.name, lat: m.lat, lng: m.lon, + heading: m.cog, speed: m.sog, course: m.cog, + category: 'fishing', lastSeen: Date.now(), }); - onSelectedGearChange?.({ - parent: parent ? toShip(parent) : null, - gears: gears.map(toShip), - groupName: selectedGearGroup, - }); - }, [selectedGearGroup, groupPolygons, onSelectedGearChange, historyData]); + onSelectedGearChange?.({ parent: parent ? toShip(parent) : null, gears: gears.map(toShip), groupName: selectedGearGroup }); + }, [selectedGearGroup, groupPolygons, onSelectedGearChange, historyActive]); - // 선택된 선단 데이터를 부모에 전달 (deck.gl 강조 렌더링용) — 히스토리 모드에서는 null + // ── 연관성 데이터 로드 (loadHistory에서 일괄 처리, 여기서는 비재생 모드만) ── useEffect(() => { - if (expandedFleet === null || historyData) { + if (!selectedGearGroup || historyActive) { if (!selectedGearGroup) { setCorrelationData([]); } return; } + let cancelled = false; + setCorrelationLoading(true); + fetchGroupCorrelations(selectedGearGroup, 0.3) + .then(res => { if (!cancelled) setCorrelationData(res.items); }) + .catch(() => { if (!cancelled) setCorrelationData([]); }) + .finally(() => { if (!cancelled) setCorrelationLoading(false); }); + return () => { cancelled = true; }; + }, [selectedGearGroup, historyActive]); + + // ── 연관 선박 항적 로드 (loadHistory에서 일괄 처리, 여기서는 비재생 모드만) ── + useEffect(() => { + if (!selectedGearGroup || historyActive) { if (!selectedGearGroup) { setCorrelationTracks([]); setEnabledVessels(new Set()); } return; } + let cancelled = false; + fetchCorrelationTracks(selectedGearGroup, 24, 0.3) + .then(res => { + if (!cancelled) { + setCorrelationTracks(res.vessels); + setEnabledVessels(new Set(res.vessels.filter(v => v.score >= 0.7).map(v => v.mmsi))); + } + }) + .catch(() => { if (!cancelled) setCorrelationTracks([]); }); + return () => { cancelled = true; }; + }, [selectedGearGroup, historyActive]); + + // ── 부모 콜백 동기화: 선단 선택 ── + useEffect(() => { + if (expandedFleet === null || historyActive) { onSelectedFleetChange?.(null); - if (historyData) return; + if (historyActive) return; return; } const group = groupPolygons?.fleetGroups.find(g => Number(g.groupKey) === expandedFleet); const company = companies.get(expandedFleet); - if (!group) { - onSelectedFleetChange?.(null); - return; - } + if (!group) { onSelectedFleetChange?.(null); return; } const fleetShips: Ship[] = group.members.map(m => ({ - mmsi: m.mmsi, - name: m.name, - lat: m.lat, - lng: m.lon, - heading: m.cog, - speed: m.sog, - course: m.cog, - category: 'fishing', - lastSeen: Date.now(), + mmsi: m.mmsi, name: m.name, lat: m.lat, lng: m.lon, + heading: m.cog, speed: m.sog, course: m.cog, + category: 'fishing', lastSeen: Date.now(), })); - onSelectedFleetChange?.({ - clusterId: expandedFleet, - ships: fleetShips, - companyName: company?.nameCn || group.groupLabel || `선단 #${expandedFleet}`, - }); - }, [expandedFleet, groupPolygons, companies, onSelectedFleetChange, historyData]); + onSelectedFleetChange?.({ clusterId: expandedFleet, ships: fleetShips, companyName: company?.nameCn || group.groupLabel || `선단 #${expandedFleet}` }); + }, [expandedFleet, groupPolygons, companies, onSelectedFleetChange, historyActive]); - // API 기반 어구 그룹 분류 + // ── GeoJSON 훅 ── + const hoveredMmsi = hoveredTarget?.mmsi ?? null; + const geo = useFleetClusterGeoJson({ + ships, shipMap, groupPolygons, analysisMap, + hoveredFleetId, selectedGearGroup, pickerHoveredGroup, + historyActive, + correlationData, correlationTracks, + enabledModels, enabledVessels, hoveredMmsi, + }); + + // ── 어구 그룹 데이터 ── const inZoneGearGroups = groupPolygons?.gearInZoneGroups ?? []; const outZoneGearGroups = groupPolygons?.gearOutZoneGroups ?? []; - // 선단 폴리곤 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?.fleetGroups]); - - // 2척 선단 라인은 서버에서 Shapely buffer로 이미 Polygon 처리되므로 빈 컬렉션 - const lineGeoJSON = useMemo((): GeoJSON => ({ - type: 'FeatureCollection', features: [], - }), []); - - // 호버 하이라이트용 단일 폴리곤 - const hoveredGeoJSON = useMemo((): GeoJSON => { - if (hoveredFleetId === null || !groupPolygons) return { type: 'FeatureCollection', features: [] }; - const g = groupPolygons.fleetGroups.find(f => Number(f.groupKey) === hoveredFleetId); - if (!g?.polygon) return { type: 'FeatureCollection', features: [] }; - return { - type: 'FeatureCollection', - features: [{ - type: 'Feature', - properties: { clusterId: hoveredFleetId, color: g.color }, - geometry: g.polygon, - }], - }; - }, [hoveredFleetId, groupPolygons?.fleetGroups]); - - // 어구 클러스터 GeoJSON (서버 제공) - const gearClusterGeoJson = useMemo((): GeoJSON => { - const features: GeoJSON.Feature[] = []; - if (!groupPolygons) return { type: 'FeatureCollection', features }; - for (const g of [...groupPolygons.gearInZoneGroups, ...groupPolygons.gearOutZoneGroups]) { - if (!g.polygon) continue; - features.push({ - type: 'Feature', - properties: { - name: g.groupKey, - gearCount: g.memberCount, - inZone: g.groupType === 'GEAR_IN_ZONE' ? 1 : 0, - }, - geometry: g.polygon, - }); - } - return { type: 'FeatureCollection', features }; - }, [groupPolygons?.gearInZoneGroups, groupPolygons?.gearOutZoneGroups]); - - // 가상 선박 마커 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) => { - // shipMap에서 실제 heading 조회 (AIS 하드웨어 값, API cog보다 정확) - 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 { type: 'FeatureCollection', features: [] }; - // 선단에서 찾기 - 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 { type: 'FeatureCollection', features: [] }; - return { type: 'FeatureCollection', features: [{ type: 'Feature', properties: {}, geometry: g.polygon }] }; - }, [pickerHoveredGroup, groupPolygons]); - - // ── 히스토리 애니메이션 GeoJSON ── - const EMPTY_HIST_FC: GeoJSON = { type: 'FeatureCollection', features: [] }; - - const memberTrailsGeoJson = useMemo((): GeoJSON => { - if (!historyData) return EMPTY_HIST_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]); - - // 현재 또는 마지막 유효 스냅샷 (신호없음 구간에서 이전 데이터 유지) - const effectiveSnapIdx = useMemo(() => { - if (!historyData || historyData.length === 0) return -1; - if (currentSnapIdx >= 0) return currentSnapIdx; - // 현재 시각 이전의 가장 가까운 스냅샷 - for (let i = historyData.length - 1; i >= 0; i--) { - if (new Date(historyData[i].snapshotTime).getTime() <= currentTimeMs) return i; - } - return -1; - }, [historyData, currentSnapIdx, currentTimeMs]); - - const isStale = currentSnapIdx < 0 && effectiveSnapIdx >= 0; - - const currentFrame = historyData && effectiveSnapIdx >= 0 ? historyData[effectiveSnapIdx] : null; - const isLongGap = !!currentFrame?._longGap; - const showGray = isLongGap || (isStale && !currentFrame?._interp); - - // center trail: historyData에 이미 보간 프레임 포함 → 전체 좌표 연결 - const centerTrailGeoJson = useMemo((): GeoJSON => { - if (!historyData || historyData.length === 0) return EMPTY_HIST_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_HIST_FC; - const snap = historyData[effectiveSnapIdx]; - if (!snap) return EMPTY_HIST_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_HIST_FC; - const snap = historyData[effectiveSnapIdx]; - if (!snap?.polygon) return EMPTY_HIST_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_HIST_FC; - const snap = historyData[effectiveSnapIdx]; - if (!snap) return EMPTY_HIST_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]); - - // 선단 목록 (멤버 수 내림차순) - const fleetList = useMemo(() => { - if (!groupPolygons) return []; - return groupPolygons.fleetGroups.map(g => ({ - id: Number(g.groupKey), - mmsiList: g.members.map(m => m.mmsi), - label: g.groupLabel, - memberCount: g.memberCount, - areaSqNm: g.areaSqNm, - color: g.color, - members: g.members, - })).sort((a, b) => b.memberCount - a.memberCount); - }, [groupPolygons?.fleetGroups]); - + // ── 핸들러 ── const handleFleetZoom = useCallback((clusterId: number) => { const group = groupPolygons?.fleetGroups.find(g => Number(g.groupKey) === clusterId); if (!group || group.members.length === 0) return; @@ -815,19 +382,14 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipS const handleGearGroupZoom = useCallback((parentName: string) => { setSelectedGearGroup(prev => prev === parentName ? null : parentName); setExpandedGearGroup(parentName); - - // 해당 어구가 속한 섹션으로 아코디언 전환 const isInZone = groupPolygons?.gearInZoneGroups.some(g => g.groupKey === parentName); setActiveSection(isInZone ? 'inZone' : 'outZone'); - requestAnimationFrame(() => { setTimeout(() => { document.getElementById(`gear-row-${parentName}`)?.scrollIntoView({ behavior: 'smooth', block: 'start' }); }, 50); }); - const allGroups = groupPolygons - ? [...groupPolygons.gearInZoneGroups, ...groupPolygons.gearOutZoneGroups] - : []; + const allGroups = groupPolygons ? [...groupPolygons.gearInZoneGroups, ...groupPolygons.gearOutZoneGroups] : []; const group = allGroups.find(g => g.groupKey === parentName); if (!group || group.members.length === 0) return; let minLat = Infinity, maxLat = -Infinity, minLng = Infinity, maxLng = -Infinity; @@ -842,779 +404,111 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipS loadHistory(parentName); }, [groupPolygons, onFleetZoom]); - // 패널 스타일 (AnalysisStatsPanel 패턴) - 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)', - }; + // ── Picker 콜백 ── + const handlePickerSelect = useCallback((c: PickerCandidate) => { + if (c.isFleet && c.clusterId != null) { + setExpandedFleet(prev => prev === c.clusterId! ? null : c.clusterId!); + setActiveSection('fleet'); + handleFleetZoom(c.clusterId); + } else { + handleGearGroupZoom(c.name); + } + setGearPickerPopup(null); + setPickerHoveredGroup(null); + }, [handleFleetZoom, handleGearGroupZoom]); - const headerStyle: React.CSSProperties = { - display: 'flex', - alignItems: 'center', - justifyContent: 'space-between', - padding: '6px 10px', - borderBottom: 'none', - cursor: 'default', - userSelect: 'none', - flexShrink: 0, - }; - - const toggleButtonStyle: React.CSSProperties = { - background: 'none', - border: 'none', - color: '#94a3b8', - cursor: 'pointer', - fontSize: 10, - padding: '0 2px', - lineHeight: 1, - }; + // ── CorrelationPanel용 멤버 수 ── + const selectedGroupMemberCount = useMemo(() => { + if (!selectedGearGroup || !groupPolygons) return 0; + const allGroups = [...groupPolygons.gearInZoneGroups, ...groupPolygons.gearOutZoneGroups]; + return allGroups.find(g => g.groupKey === selectedGearGroup)?.memberCount ?? 0; + }, [selectedGearGroup, groupPolygons]); return ( <> - {/* 선단 폴리곤 레이어 */} - - - - + {/* ── 맵 레이어 ── */} + 0} + onPickerHover={setPickerHoveredGroup} + onPickerSelect={handlePickerSelect} + onPickerClose={() => { setGearPickerPopup(null); setPickerHoveredGroup(null); }} + /> - {/* 2척 선단 라인 (서버측 Polygon 처리로 빈 컬렉션) */} - - setEnabledModels(updater)} + onEnabledVesselsChange={(updater) => setEnabledVessels(updater)} + onHoveredTargetChange={setHoveredTarget} /> - - - {/* 호버 하이라이트 (별도 Source) */} - - - - - {/* 선택된 어구 그룹 하이라이트 폴리곤 — 히스토리 모드에서는 숨김 */} - {selectedGearGroup && !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 ( - - - - - ); - })()} - - {/* 비허가 어구 클러스터 폴리곤 */} - - - - - - {/* 가상 선박 마커 — 히스토리 재생 모드에서는 숨김 (애니메이션 아이콘으로 대체) */} - - - - - {/* 어구 picker 호버 하이라이트 */} - - - - - - {/* 어구 다중 선택 팝업 */} - {gearPickerPopup && ( - { setGearPickerPopup(null); setPickerHoveredGroup(null); }} - closeOnClick={false} className="gl-popup" maxWidth="220px"> -
-
- 겹친 그룹 ({gearPickerPopup.candidates.length}) -
- {gearPickerPopup.candidates.map(c => ( -
setPickerHoveredGroup(c.isFleet ? String(c.clusterId) : c.name)} - onMouseLeave={() => setPickerHoveredGroup(null)} - onClick={() => { - if (c.isFleet && c.clusterId != null) { - setExpandedFleet(prev => prev === c.clusterId! ? null : c.clusterId!); - setActiveSection('fleet'); - handleFleetZoom(c.clusterId); - } else { - handleGearGroupZoom(c.name); - } - setGearPickerPopup(null); - setPickerHoveredGroup(null); - }} - 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; - })()} - - {/* ── 히스토리 애니메이션 레이어 (최상위) ── */} - {historyData && ( - - - - )} - {historyData && ( - - - - - )} - {/* 보간 경로는 centerTrailGeoJson에 통합됨 (interpolated=1 세그먼트) */} - {/* 현재 재생 위치 포인트 (실데이터=빨강, 보간=주황) */} - {historyData && effectiveSnapIdx >= 0 && ( - - - - )} - {historyData && ( - - - - - )} - {/* 가상 아이콘 — 현재 프레임 멤버 위치 (최상위) */} - {historyData && ( - - - - + {/* ── 재생 컨트롤러 ── */} + {historyActive && ( + { + // 전역: 모든 모델의 모든 대상에 적용 (모델 on/off 무관) + if (minPct === null) { + setEnabledVessels(new Set(correlationTracks.map(v => v.mmsi))); + } else { + const threshold = minPct / 100; + const filtered = new Set(); + // correlationData에서 모든 모델의 모든 대상 중 최고 score 기준 + const maxScores = new Map(); + for (const c of correlationData) { + const prev = maxScores.get(c.targetMmsi) ?? 0; + if (c.score > prev) maxScores.set(c.targetMmsi, c.score); + } + for (const [mmsi, score] of maxScores) { + if (score >= threshold) filtered.add(mmsi); + } + setEnabledVessels(filtered); + } + }} + /> )} - {/* 히스토리 재생 컨트롤러 */} - {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..b73f5ab --- /dev/null +++ b/frontend/src/components/korea/FleetClusterMapLayers.tsx @@ -0,0 +1,397 @@ +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 { + 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; + historyActive: boolean; + // 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, + historyActive, + hoverTooltip, + gearPickerPopup, + pickerHoveredGroup, + groupPolygons, + companies, + analysisMap, + hasCorrelationTracks, + onPickerHover, + onPickerSelect, + onPickerClose, +}: FleetClusterMapLayersProps) => { + const { + fleetPolygonGeoJSON, + lineGeoJSON, + hoveredGeoJSON, + gearClusterGeoJson, + memberMarkersGeoJson, + pickerHighlightGeoJson, + operationalPolygons, + correlationVesselGeoJson, + correlationTrailGeoJson, + modelBadgesGeoJson, + hoverHighlightGeoJson, + hoverHighlightTrailGeoJson, + } = geo; + + return ( + <> + {/* 선단 폴리곤 레이어 */} + + + + + + {/* 2척 선단 라인 (서버측 Polygon 처리로 빈 컬렉션) */} + + + + + {/* 호버 하이라이트 (별도 Source) */} + + + + + {/* 선택된 어구 그룹 — 이름 기반 하이라이트 폴리곤 */} + {selectedGearGroup && enabledModels.has('identity') && !historyActive && (() => { + 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 && !historyActive && operationalPolygons.map(op => ( + + + + + ))} + + {/* 비허가 어구 클러스터 폴리곤 */} + + + + + + {/* 가상 선박 마커 — 히스토리 재생 모드에서는 숨김 (deck.gl 레이어로 대체) */} + + + + + {/* 어구 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 && !historyActive && hasCorrelationTracks && ( + + + + )} + {selectedGearGroup && !historyActive && ( + + + + + )} + + {/* ── 모델 배지 (비재생 모드) ── */} + {selectedGearGroup && !historyActive && ( + + {MODEL_ORDER.map((model, i) => ( + enabledModels.has(model) ? ( + + ) : null + ))} + + )} + + {/* ── 호버 하이라이트 (비재생 모드) ── */} + {hoveredMmsi && !historyActive && ( + + + + + )} + {hoveredMmsi && !historyActive && ( + + + + )} + + ); +}; + +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..51236cf --- /dev/null +++ b/frontend/src/components/korea/HistoryReplayController.tsx @@ -0,0 +1,128 @@ +import { useRef, useEffect } from 'react'; +import { FONT_MONO } from '../../styles/fonts'; +import { useGearReplayStore } from '../../stores/gearReplayStore'; + +interface HistoryReplayControllerProps { + onClose: () => void; + onFilterByScore: (minPct: number | null) => void; +} + +const HistoryReplayController = ({ onClose, onFilterByScore }: HistoryReplayControllerProps) => { + const isPlaying = useGearReplayStore(s => s.isPlaying); + const snapshotRanges = useGearReplayStore(s => s.snapshotRanges); + const frameCount = useGearReplayStore(s => s.historyFrames.length); + const showTrails = useGearReplayStore(s => s.showTrails); + const showLabels = useGearReplayStore(s => s.showLabels); + + const progressBarRef = useRef(null); + const progressIndicatorRef = useRef(null); + const timeDisplayRef = useRef(null); + + 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; + const btnStyle: React.CSSProperties = { + background: 'none', border: '1px solid rgba(99,179,237,0.3)', borderRadius: 4, + color: '#e2e8f0', cursor: 'pointer', padding: '2px 6px', fontSize: 10, fontFamily: FONT_MONO, + }; + const btnActiveStyle: React.CSSProperties = { + ...btnStyle, background: 'rgba(99,179,237,0.15)', color: '#93c5fd', + }; + + return ( +
+ {/* 프로그레스 바 */} +
+ {snapshotRanges.map((pos, i) => ( +
+ ))} +
+
+ + {/* 컨트롤 행 1: 재생 + 타임라인 */} +
+ + --:-- + { + const { startTime, endTime } = store.getState(); + const progress = Number(e.target.value) / 1000; + store.getState().pause(); + store.getState().seek(startTime + progress * (endTime - startTime)); + }} + style={{ flex: 1, cursor: 'pointer', accentColor: '#fbbf24' }} + title="히스토리 타임라인" aria-label="히스토리 타임라인" /> + {frameCount}건 + +
+ + {/* 컨트롤 행 2: 표시 옵션 */} +
+ + + | + 일치율 + +
+
+ ); +}; + +export default HistoryReplayController; diff --git a/frontend/src/components/korea/KoreaMap.tsx b/frontend/src/components/korea/KoreaMap.tsx index 9e2c0d3..59c8fa8 100644 --- a/frontend/src/components/korea/KoreaMap.tsx +++ b/frontend/src/components/korea/KoreaMap.tsx @@ -5,9 +5,12 @@ import type { MapRef } from 'react-map-gl/maplibre'; import { ScatterplotLayer, TextLayer } from '@deck.gl/layers'; import { useFontScale } from '../../hooks/useFontScale'; import { FONT_MONO } from '../../styles/fonts'; +import type { MapboxOverlay } from '@deck.gl/mapbox'; +import type { Layer as DeckLayer } from '@deck.gl/core'; import { DeckGLOverlay } from '../layers/DeckGLOverlay'; import { useAnalysisDeckLayers } from '../../hooks/useAnalysisDeckLayers'; import { useStaticDeckLayers } from '../../hooks/useStaticDeckLayers'; +import { useGearReplayLayers } from '../../hooks/useGearReplayLayers'; import type { StaticPickInfo } from '../../hooks/useStaticDeckLayers'; import fishingZonesData from '../../data/zones/fishing-zones-wgs84.json'; import { ShipLayer } from '../layers/ShipLayer'; @@ -210,6 +213,8 @@ const DebugTools = import.meta.env.DEV export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintFeed, currentTime, koreaFilters, transshipSuspects, cableWatchSuspects, dokdoWatchSuspects, dokdoAlerts, vesselAnalysis, groupPolygons, hiddenShipCategories, hiddenNationalities, externalFlyTo, onExternalFlyToDone, opsRoute }: Props) { const { t } = useTranslation(); const mapRef = useRef(null); + const overlayRef = useRef(null); + const replayLayerRef = useRef([]); const [infra, setInfra] = useState([]); const [flyToTarget, setFlyToTarget] = useState<{ lng: number; lat: number; zoom: number } | null>(null); const [selectedAnalysisMmsi, setSelectedAnalysisMmsi] = useState(null); @@ -231,6 +236,24 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF const [analysisPanelOpen, setAnalysisPanelOpen] = useLocalStorage('analysisPanelOpen', false); const [activeBadgeFilter, setActiveBadgeFilter] = useState(null); + // ── deck.gl 리플레이 레이어 (Zustand → imperative setProps, React 렌더 우회) ── + const reactLayersRef = useRef([]); + type ShipPos = { lng: number; lat: number; course?: number }; + const shipsRef = useRef(new globalThis.Map()); + // live 선박 위치를 ref에 동기화 (리플레이 fallback용) + const allShipsList = allShips ?? ships; + const shipPosMap = new globalThis.Map(); + for (const s of allShipsList) shipPosMap.set(s.mmsi, { lng: s.lng, lat: s.lat, course: s.course }); + shipsRef.current = shipPosMap; + + const requestRender = useCallback(() => { + if (!overlayRef.current) return; + overlayRef.current.setProps({ + layers: [...reactLayersRef.current, ...replayLayerRef.current], + }); + }, []); + useGearReplayLayers(replayLayerRef, requestRender, shipsRef); + useEffect(() => { fetchKoreaInfra().then(setInfra).catch(() => {}); }, []); @@ -803,16 +826,23 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF )} - {/* deck.gl GPU 오버레이 — 불법어선 강조 + 분석 위험도 마커 */} - + {/* deck.gl GPU 오버레이 — 정적 + 리플레이 레이어 합성 */} + { + const base = [ + ...staticDeckLayers, + illegalFishingLayer, + illegalFishingLabelLayer, + zoneLabelsLayer, + ...selectedGearLayers, + ...selectedFleetLayers, + ...(analysisPanelOpen ? analysisDeckLayers : []), + ].filter(Boolean); + reactLayersRef.current = base; + return [...base, ...replayLayerRef.current]; + })()} + /> {/* 정적 마커 클릭 Popup — 통합 리치 디자인 */} {staticPickInfo && ( setStaticPickInfo(null)} /> 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..55fc344 --- /dev/null +++ b/frontend/src/components/korea/useFleetClusterGeoJson.ts @@ -0,0 +1,389 @@ +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 { FleetListItem } from './fleetClusterTypes'; +import { 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; + historyActive: boolean; + correlationData: GearCorrelationItem[]; + correlationTracks: CorrelationVesselTrack[]; + enabledModels: Set; + enabledVessels: Set; + hoveredMmsi: string | null; +} + +export interface FleetClusterGeoJsonResult { + // static/base GeoJSON + fleetPolygonGeoJSON: GeoJSON; + lineGeoJSON: GeoJSON; + hoveredGeoJSON: GeoJSON; + gearClusterGeoJson: GeoJSON; + memberMarkersGeoJson: GeoJSON; + pickerHighlightGeoJson: GeoJSON; + selectedGearHighlightGeoJson: GeoJSON.FeatureCollection | null; + // 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[]; + 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, + historyActive, + correlationData, + correlationTracks, + enabledModels, + enabledVessels, + hoveredMmsi, + } = 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 operationalPolygons = useMemo(() => { + 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; + }, [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]); + + // 선택된 어구 그룹 하이라이트 폴리곤 + const selectedGearHighlightGeoJson = useMemo((): GeoJSON.FeatureCollection | null => { + if (!selectedGearGroup || !enabledModels.has('identity') || historyActive) 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, historyActive, groupPolygons]); + + // ── 연관 대상 마커 (ships[] fallback) ── + const correlationVesselGeoJson = useMemo((): GeoJSON => { + if (!selectedGearGroup || correlationByModel.size === 0) return EMPTY_FC; + 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; + const s = ships.find(x => x.mmsi === c.targetMmsi); + if (!s) continue; + seen.add(c.targetMmsi); + features.push({ + type: 'Feature', + properties: { + mmsi: c.targetMmsi, + name: c.targetName || c.targetMmsi, + score: c.score, + cog: s.course ?? 0, + color, + isVessel: c.targetType === 'VESSEL' ? 1 : 0, + }, + geometry: { type: 'Point', coordinates: [s.lng, s.lat] }, + }); + } + } + return { type: 'FeatureCollection', features }; + }, [selectedGearGroup, correlationByModel, enabledModels, ships]); + + // 연관 대상 트레일 (전체 항적) + const correlationTrailGeoJson = useMemo((): GeoJSON => { + if (correlationTracks.length === 0) return EMPTY_FC; + 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 coords: [number, number][] = vt.track.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]); + + // 모델 배지 GeoJSON (groupPolygons 기반) + const modelBadgesGeoJson = useMemo((): GeoJSON => { + if (!selectedGearGroup) return EMPTY_FC; + const targets = new Map }>(); + + if (enabledModels.has('identity') && groupPolygons) { + const all = [...groupPolygons.gearInZoneGroups, ...groupPolygons.gearOutZoneGroups]; + const members = 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; + const s = ships.find(x => x.mmsi === c.targetMmsi); + if (!s) continue; + const e = targets.get(c.targetMmsi) ?? { lon: s.lng, lat: s.lat, models: new Set() }; + e.lon = s.lng; e.lat = s.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, groupPolygons, correlationByModel, ships]); + + // 호버 하이라이트 — 대상 위치 + const hoverHighlightGeoJson = useMemo((): GeoJSON => { + if (!hoveredMmsi || !selectedGearGroup) return EMPTY_FC; + 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, 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 coords: [number, number][] = vt.track.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]); + + // 선단 목록 (멤버 수 내림차순) + 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, + correlationVesselGeoJson, + correlationTrailGeoJson, + modelBadgesGeoJson, + hoverHighlightGeoJson, + hoverHighlightTrailGeoJson, + operationalPolygons, + fleetList, + correlationByModel, + availableModels, + }; +} diff --git a/frontend/src/components/layers/DeckGLOverlay.tsx b/frontend/src/components/layers/DeckGLOverlay.tsx index 9e787db..c40b963 100644 --- a/frontend/src/components/layers/DeckGLOverlay.tsx +++ b/frontend/src/components/layers/DeckGLOverlay.tsx @@ -1,22 +1,26 @@ +import type { MutableRefObject } from 'react'; import { useControl } from 'react-map-gl/maplibre'; import { MapboxOverlay } from '@deck.gl/mapbox'; import type { Layer } from '@deck.gl/core'; interface Props { layers: Layer[]; + overlayRef?: MutableRefObject; } /** * MapLibre Map 내부에서 deck.gl 레이어를 GPU 렌더링하는 오버레이. * interleaved 모드: MapLibre 레이어와 deck.gl 레이어가 z-order로 혼합됨. + * overlayRef: 외부에서 imperative setProps 호출이 필요할 때 전달. */ -export function DeckGLOverlay({ layers }: Props) { +export function DeckGLOverlay({ layers, overlayRef }: Props) { const overlay = useControl( () => new MapboxOverlay({ interleaved: true, getCursor: ({ isHovering }) => isHovering ? 'pointer' : '', }), ); + if (overlayRef) overlayRef.current = overlay; overlay.setProps({ layers }); return null; } diff --git a/frontend/src/hooks/useGearReplayLayers.ts b/frontend/src/hooks/useGearReplayLayers.ts new file mode 100644 index 0000000..1ad21bf --- /dev/null +++ b/frontend/src/hooks/useGearReplayLayers.ts @@ -0,0 +1,706 @@ +import { useEffect, useRef, useCallback } from 'react'; +import type { Layer } from '@deck.gl/core'; +import { TripsLayer } from '@deck.gl/geo-layers'; +import { ScatterplotLayer, IconLayer, 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_ORDER, MODEL_COLORS } from '../components/korea/fleetClusterConstants'; +import { buildInterpPolygon } from '../components/korea/fleetClusterUtils'; +import type { GearCorrelationItem } from '../services/vesselAnalysis'; +import { SHIP_ICON_MAPPING } from '../utils/shipIconSvg'; + +// ── 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; + cog: 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, + shipsRef: React.MutableRefObject>, +): 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); + const modelCenterTrails = useGearReplayStore(s => s.modelCenterTrails); + const showTrails = useGearReplayStore(s => s.showTrails); + const showLabels = useGearReplayStore(s => s.showLabels); + + // ── Refs ───────────────────────────────────────────────────────────────── + const cursorRef = useRef(0); // frame cursor for O(1) forward lookup + + // ── renderFrame ────────────────────────────────────────────────────────── + + // 디버그 로그 (첫 프레임에서만 출력) + const debugLoggedRef = useRef(false); + + const renderFrame = useCallback(() => { + if (historyFrames.length === 0) { + replayLayerRef.current = []; + requestRender(); + return; + } + + const state = useGearReplayStore.getState(); + const ct = state.currentTime; + const st = state.startTime; + + const shouldLog = !debugLoggedRef.current; + if (shouldLog) debugLoggedRef.current = true; + + // Find current frame + const { index: frameIdx, cursor } = findFrameAtTime(state.frameTimes, ct, cursorRef.current); + cursorRef.current = cursor; + + const layers: Layer[] = []; + + // ── 항상 표시: 센터 트레일 + 도트 ────────────────────────────────── + + // Center trail segments (PathLayer) — 항상 ON + 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) — 항상 ON + 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); + const memberPts: [number, number][] = members.map(m => [m.lon, m.lat]); + + // ── "항적" 토글: 전체 24h 경로 PathLayer (정적 배경) ───────────── + if (showTrails) { + // 멤버 전체 항적 (identity) + if (enabledModels.has('identity') && memberTripsData.length > 0) { + for (const trip of memberTripsData) { + if (trip.path.length < 2) continue; + layers.push(new PathLayer({ + id: `replay-member-path-${trip.id}`, + data: [{ path: trip.path }], + getPath: (d: { path: [number, number][] }) => d.path, + getColor: [180, 180, 180, 80], // 낮은 채도 — TripsLayer보다 연하게 + widthMinPixels: 1, + })); + } + } + // 연관 선박 전체 항적 (correlation) + if (correlationTripsData.length > 0) { + const activeMmsis = new Set(); + for (const [mn, items] of correlationByModel) { + if (!enabledModels.has(mn)) continue; + for (const c of items as GearCorrelationItem[]) { + if (enabledVessels.has(c.targetMmsi)) activeMmsis.add(c.targetMmsi); + } + } + for (const trip of correlationTripsData) { + if (!activeMmsis.has(trip.id) || trip.path.length < 2) continue; + layers.push(new PathLayer({ + id: `replay-corr-path-${trip.id}`, + data: [{ path: trip.path }], + getPath: (d: { path: [number, number][] }) => d.path, + getColor: [100, 140, 200, 60], // 연한 파랑 + widthMinPixels: 1, + })); + } + } + } + + // 1. Identity 모델: TripsLayer + 폴리곤 + 마커 (항상 ON) + if (enabledModels.has('identity')) { + // TripsLayer — member trails (GPU animated, 항상 ON, 고채도) + if (memberTripsData.length > 0) { + layers.push(new TripsLayer({ + id: 'replay-member-trails', + data: memberTripsData, + getPath: d => d.path, + getTimestamps: d => d.timestamps, + getColor: [255, 200, 60, 220], // 고채도 노란색 (항적보다 밝게) + widthMinPixels: 2, + fadeTrail: true, + trailLength: TRAIL_LENGTH_MS, + currentTime: ct - st, + })); + } + + // Current animated polygon (convex hull of members) + 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, + })); + } + } + + // 2. Correlation TripsLayer (GPU animated, 항상 ON, 고채도) + if (correlationTripsData.length > 0) { + const activeMmsis = new Set(); + for (const [mn, items] of correlationByModel) { + if (!enabledModels.has(mn)) continue; + for (const c of items as GearCorrelationItem[]) { + if (enabledVessels.has(c.targetMmsi)) activeMmsis.add(c.targetMmsi); + } + } + const enabledTrips = correlationTripsData.filter(d => activeMmsis.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: [100, 180, 255, 220], // 고채도 파랑 (항적보다 밝게) + widthMinPixels: 2.5, + fadeTrail: true, + trailLength: TRAIL_LENGTH_MS, + currentTime: ct - st, + })); + } + } + + // 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 (IconLayer, identity 모델 활성 시만) + if (members.length > 0 && enabledModels.has('identity')) { + layers.push(new IconLayer({ + id: 'replay-members', + data: members, + getPosition: d => [d.lon, d.lat], + getIcon: d => d.isGear ? SHIP_ICON_MAPPING['gear-diamond'] : SHIP_ICON_MAPPING['ship-triangle'], + getSize: d => d.isParent ? 24 : d.isGear ? 14 : 18, + getAngle: d => d.isGear ? 0 : -(d.cog || 0), + getColor: d => { + if (d.stale) return [100, 116, 139, 180]; + if (d.isGear) return [168, 184, 200, 230]; + return [251, 191, 36, 230]; + }, + sizeUnits: 'pixels', + billboard: false, + })); + + // Member labels — showLabels 제어 + if (showLabels) 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 (트랙 보간 → 끝점 clamp → live fallback) + const corrPositions: CorrPosition[] = []; + const corrTrackMap = new Map(correlationTripsData.map(d => [d.id, d])); + const liveShips = shipsRef.current; + const relTime = ct - st; + + 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 (!enabledVessels.has(c.targetMmsi)) continue; // OFF → 아이콘+트레일+폴리곤 모두 제외 + if (corrPositions.some(p => p.mmsi === c.targetMmsi)) continue; + + let lon: number | undefined; + let lat: number | undefined; + let cog = 0; + + // 방법 1: 트랙 데이터 (보간 + 범위 밖은 끝점 clamp) + const tripData = corrTrackMap.get(c.targetMmsi); + if (tripData && tripData.path.length > 0) { + const ts = tripData.timestamps; + const path = tripData.path; + + if (relTime <= ts[0]) { + // 트랙 시작 전 → 첫 점 사용 + lon = path[0][0]; lat = path[0][1]; + if (path.length > 1) { + const dx = path[1][0] - path[0][0]; + const dy = path[1][1] - path[0][1]; + cog = (dx === 0 && dy === 0) ? 0 : (Math.atan2(dx, dy) * 180 / Math.PI + 360) % 360; + } + } else if (relTime >= ts[ts.length - 1]) { + // 트랙 종료 후 → 마지막 점 사용 + const last = path.length - 1; + lon = path[last][0]; lat = path[last][1]; + if (last > 0) { + const dx = path[last][0] - path[last - 1][0]; + const dy = path[last][1] - path[last - 1][1]; + cog = (dx === 0 && dy === 0) ? 0 : (Math.atan2(dx, dy) * 180 / Math.PI + 360) % 360; + } + } else { + // 범위 내 → 보간 + 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; + lon = path[lo][0] + (path[hi][0] - path[lo][0]) * ratio; + lat = path[lo][1] + (path[hi][1] - path[lo][1]) * ratio; + const dx = path[hi][0] - path[lo][0]; + const dy = path[hi][1] - path[lo][1]; + cog = (dx === 0 && dy === 0) ? 0 : (Math.atan2(dx, dy) * 180 / Math.PI + 360) % 360; + } + } + + // 방법 2: live 선박 위치 fallback + if (lon === undefined) { + const ship = liveShips.get(c.targetMmsi); + if (ship) { + lon = ship.lng; + lat = ship.lat; + cog = ship.course ?? 0; + } + } + + if (lon === undefined || lat === undefined) continue; + + corrPositions.push({ + mmsi: c.targetMmsi, + name: c.targetName || c.targetMmsi, + lon, + lat, + cog, + color: [r, g, b, 230], + isVessel: c.targetType === 'VESSEL', + }); + } + } + + // 디버그: 첫 프레임에서 전체 상태 출력 + if (shouldLog) { + const trackHit = corrPositions.filter(p => corrTrackMap.has(p.mmsi)).length; + const liveHit = corrPositions.length - trackHit; + const sampleTrip = memberTripsData[0]; + console.log('[GearReplay] renderFrame:', { + historyFrames: state.historyFrames.length, + memberTripsData: memberTripsData.length, + corrTripsData: correlationTripsData.length, + corrTrackMap: corrTrackMap.size, + showTrails, showLabels, + relTime: Math.round(relTime / 60000) + 'min', + currentTime: Math.round((ct - st) / 60000) + 'min (rel)', + members: members.length, + corrPositions: corrPositions.length, + posSource: `track:${trackHit} live:${liveHit}`, + memberTrip0: sampleTrip ? { id: sampleTrip.id, pts: sampleTrip.path.length, tsRange: `${Math.round(sampleTrip.timestamps[0]/60000)}~${Math.round(sampleTrip.timestamps[sampleTrip.timestamps.length-1]/60000)}min` } : 'none', + }); + // 모델별 상세 + for (const [mn, items] of state.correlationByModel) { + const modEnabled = enabledModels.has(mn); + const modPositions = corrPositions.filter(p => { + return items.some(c => c.targetMmsi === p.mmsi); + }).length; + console.log(` [${mn}] ${modEnabled ? 'ON' : 'OFF'} ${items.length}건 → 위치확인 ${modPositions}`); + } + } + + if (corrPositions.length > 0) { + layers.push(new IconLayer({ + id: 'replay-corr-vessels', + data: corrPositions, + getPosition: d => [d.lon, d.lat], + getIcon: d => d.isVessel ? SHIP_ICON_MAPPING['ship-triangle'] : SHIP_ICON_MAPPING['gear-diamond'], + getSize: d => d.isVessel ? 18 : 12, + getAngle: d => d.isVessel ? -(d.cog || 0) : 0, + getColor: d => d.color, + sizeUnits: 'pixels', + billboard: false, + })); + + if (showLabels) 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 (멤버 위치 + enabledVessels ON인 연관 선박으로 폴리곤 생성) + 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[]) { + // enabledVessels로 개별 on/off 제어 (토글 대응) + if (!enabledVessels.has(c.targetMmsi)) continue; + const cp = corrPositions.find(p => p.mmsi === c.targetMmsi); + if (cp) extraPts.push([cp.lon, cp.lat]); + } + if (extraPts.length === 0) continue; + + const basePts = enabledModels.has('identity') ? memberPts : []; + const opPolygon = buildInterpPolygon([...basePts, ...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, + })); + } + + // 8.5. Model center trails + current center point (모델별 폴리곤 중심 경로) + for (const trail of modelCenterTrails) { + if (!enabledModels.has(trail.modelName)) continue; + if (trail.path.length < 2) continue; + const color = MODEL_COLORS[trail.modelName] ?? '#94a3b8'; + const [r, g, b] = hexToRgb(color); + + // 중심 경로 (PathLayer, 연한 모델 색상) + layers.push(new PathLayer({ + id: `replay-model-trail-${trail.modelName}`, + data: [{ path: trail.path }], + getPath: (d: { path: [number, number][] }) => d.path, + getColor: [r, g, b, 100], + widthMinPixels: 1.5, + })); + + // 현재 중심점 (보간) + const ts = trail.timestamps; + if (ts.length > 0 && relTime >= ts[0] && relTime <= ts[ts.length - 1]) { + let lo = 0, 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 cx = trail.path[lo][0] + (trail.path[hi][0] - trail.path[lo][0]) * ratio; + const cy = trail.path[lo][1] + (trail.path[hi][1] - trail.path[lo][1]) * ratio; + + const centerData = [{ position: [cx, cy] as [number, number] }]; + layers.push(new ScatterplotLayer({ + id: `replay-model-center-${trail.modelName}`, + data: centerData, + getPosition: (d: { position: [number, number] }) => d.position, + getFillColor: [r, g, b, 255], + getRadius: 150, + radiusUnits: 'meters', + radiusMinPixels: 5, + stroked: true, + getLineColor: [255, 255, 255, 200], + lineWidthMinPixels: 1.5, + })); + if (showLabels) { + layers.push(new TextLayer({ + id: `replay-model-center-label-${trail.modelName}`, + data: centerData, + getPosition: (d: { position: [number, number] }) => d.position, + getText: () => trail.modelName, + getColor: [r, g, b, 255], + getSize: 9, + getPixelOffset: [0, -12], + background: true, + getBackgroundColor: [0, 0, 0, 200], + backgroundPadding: [3, 1], + fontFamily: '"Fira Code Variable", monospace', + })); + } + } + } + + // 9. Model badges (small colored dots next to each vessel/gear per model) + { + const badgeTargets = new Map }>(); + + // Identity model: group members + if (enabledModels.has('identity')) { + for (const m of members) { + const e = badgeTargets.get(m.mmsi) ?? { lon: m.lon, lat: m.lat, models: new Set() }; + e.lon = m.lon; e.lat = m.lat; e.models.add('identity'); + badgeTargets.set(m.mmsi, e); + } + } + + // Correlation models + for (const [mn, items] of correlationByModel) { + if (!enabledModels.has(mn)) continue; + for (const c of items as GearCorrelationItem[]) { + if (c.score < 0.3) continue; + const cp = corrPositions.find(p => p.mmsi === c.targetMmsi); + if (!cp) continue; + const e = badgeTargets.get(c.targetMmsi) ?? { lon: cp.lon, lat: cp.lat, models: new Set() }; + e.lon = cp.lon; e.lat = cp.lat; e.models.add(mn); + badgeTargets.set(c.targetMmsi, e); + } + } + + // Render one ScatterplotLayer per model (offset by index) + for (let mi = 0; mi < MODEL_ORDER.length; mi++) { + const model = MODEL_ORDER[mi]; + if (!enabledModels.has(model)) continue; + const color = MODEL_COLORS[model] ?? '#94a3b8'; + const [r, g, b] = hexToRgb(color); + const badgeData: { position: [number, number] }[] = []; + for (const [, t] of badgeTargets) { + if (t.models.has(model)) badgeData.push({ position: [t.lon, t.lat] }); + } + if (badgeData.length === 0) continue; + layers.push(new ScatterplotLayer({ + id: `replay-badge-${model}`, + data: badgeData, + getPosition: (d: { position: [number, number] }) => d.position, + getFillColor: [r, g, b, 255], + getRadius: 3, + radiusUnits: 'pixels', + stroked: true, + getLineColor: [0, 0, 0, 150], + lineWidthMinPixels: 0.5, + // Offset each model's badges to the right + getPixelOffset: [10 + mi * 7, -6] as [number, number], + })); + } + } + + // 디버그: 연관 선박 렌더링 상태 + if (corrPositions.length > 0 && !debugLoggedRef.current) { + console.log('[GearReplay] corrPositions:', corrPositions.length, 'operationalPolygons:', layers.filter(l => l.id?.toString().startsWith('replay-op-')).length); + } + + replayLayerRef.current = layers; + requestRender(); + }, [ + historyFrames, memberTripsData, correlationTripsData, + centerTrailSegments, centerDotsPositions, + enabledModels, enabledVessels, hoveredMmsi, correlationByModel, + modelCenterTrails, showTrails, showLabels, + replayLayerRef, requestRender, + ]); + + // 데이터/필터 변경 시 디버그 로그 리셋 + useEffect(() => { + debugLoggedRef.current = false; + if (correlationByModel.size > 0) { + console.log('[GearReplay] 데이터 갱신:', { + models: [...correlationByModel.keys()], + enabledModels: [...enabledModels], + corrTrips: correlationTripsData.length, + }); + } + }, [correlationByModel, enabledModels, correlationTripsData]); + + // ── 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 b3072be..1a6d493 100644 --- a/frontend/src/services/vesselAnalysis.ts +++ b/frontend/src/services/vesselAnalysis.ts @@ -95,6 +95,84 @@ 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(); +} + +/* ── 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; + type: string; // 'VESSEL' | 'GEAR' + score: number; + models: Record; // { modelName: score } + track: CorrelationTrackPoint[]; +} + +export interface CorrelationTracksResponse { + groupKey: string; + vessels: CorrelationVesselTrack[]; +} + +export async function fetchCorrelationTracks( + groupKey: string, + hours = 24, + minScore = 0.3, +): Promise { + const url = `/api/prediction/v1/correlation/${encodeURIComponent(groupKey)}/tracks?hours=${hours}&minScore=${minScore}`; + console.log('[fetchCorrelationTracks] URL:', url); + const res = await fetch(url); + console.log('[fetchCorrelationTracks] status:', res.status, res.statusText); + if (!res.ok) { + console.warn('[fetchCorrelationTracks] 실패:', res.status); + return { groupKey, vessels: [] }; + } + const data: CorrelationTracksResponse = await res.json(); + console.log('[fetchCorrelationTracks] 응답:', data.vessels.length, '건'); + return data; +} + /* ── Fleet Companies ─────────────────────────────────────────── */ // 캐시 (세션 중 1회 로드) diff --git a/frontend/src/stores/gearReplayPreprocess.ts b/frontend/src/stores/gearReplayPreprocess.ts new file mode 100644 index 0000000..239c938 --- /dev/null +++ b/frontend/src/stores/gearReplayPreprocess.ts @@ -0,0 +1,320 @@ +import type { HistoryFrame } from '../components/korea/fleetClusterTypes'; +import type { CorrelationVesselTrack, GearCorrelationItem } from '../services/vesselAnalysis'; +import { buildInterpPolygon } from '../components/korea/fleetClusterUtils'; + +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, + ); + }); +} + +/** + * 모델별 오퍼레이셔널 폴리곤 중심 경로 사전 계산. + * 각 프레임마다 멤버 위치 + 연관 선박 위치(트랙 보간)로 폴리곤을 만들고 중심점을 기록. + */ +export interface ModelCenterTrail { + modelName: string; + path: [number, number][]; // [lon, lat][] + timestamps: number[]; // relative ms +} + +export function buildModelCenterTrails( + frames: HistoryFrame[], + corrTracks: CorrelationVesselTrack[], + corrByModel: Map, + enabledVessels: Set, + startTime: number, +): ModelCenterTrail[] { + // 연관 선박 트랙 맵: mmsi → {timestampsMs[], geometry[][]} + const trackMap = new Map(); + for (const vt of corrTracks) { + if (vt.track.length < 1) continue; + trackMap.set(vt.mmsi, { + ts: vt.track.map(p => p.ts), + path: vt.track.map(p => [p.lon, p.lat]), + }); + } + + const results: ModelCenterTrail[] = []; + + for (const [mn, items] of corrByModel) { + const enabledItems = items.filter(c => enabledVessels.has(c.targetMmsi)); + if (enabledItems.length === 0) continue; + + const path: [number, number][] = []; + const timestamps: number[] = []; + + for (const frame of frames) { + const t = new Date(frame.snapshotTime).getTime(); + const relT = t - startTime; + + // 멤버 위치 + const allPts: [number, number][] = frame.members.map(m => [m.lon, m.lat]); + + // 연관 선박 위치 (트랙 보간 or 마지막 점 clamp) + for (const c of enabledItems) { + const track = trackMap.get(c.targetMmsi); + if (!track || track.path.length === 0) continue; + + let lon: number, lat: number; + if (t <= track.ts[0]) { + lon = track.path[0][0]; lat = track.path[0][1]; + } else if (t >= track.ts[track.ts.length - 1]) { + const last = track.path.length - 1; + lon = track.path[last][0]; lat = track.path[last][1]; + } else { + let lo = 0, hi = track.ts.length - 1; + while (lo < hi - 1) { const mid = (lo + hi) >> 1; if (track.ts[mid] <= t) lo = mid; else hi = mid; } + const ratio = track.ts[hi] !== track.ts[lo] ? (t - track.ts[lo]) / (track.ts[hi] - track.ts[lo]) : 0; + lon = track.path[lo][0] + (track.path[hi][0] - track.path[lo][0]) * ratio; + lat = track.path[lo][1] + (track.path[hi][1] - track.path[lo][1]) * ratio; + } + allPts.push([lon, lat]); + } + + // 폴리곤 중심 계산 + const poly = buildInterpPolygon(allPts); + if (!poly) continue; + const ring = poly.coordinates[0]; + let cx = 0, cy = 0; + for (const pt of ring) { cx += pt[0]; cy += pt[1]; } + cx /= ring.length; cy /= ring.length; + + path.push([cx, cy]); + timestamps.push(relT); + } + + if (path.length >= 2) { + results.push({ modelName: mn, path, timestamps }); + } + } + + return results; +} diff --git a/frontend/src/stores/gearReplayStore.ts b/frontend/src/stores/gearReplayStore.ts new file mode 100644 index 0000000..d32a066 --- /dev/null +++ b/frontend/src/stores/gearReplayStore.ts @@ -0,0 +1,297 @@ +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, + buildModelCenterTrails, +} from './gearReplayPreprocess'; +import type { ModelCenterTrail } 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; + rawCorrelationTracks: CorrelationVesselTrack[]; + + // Pre-computed layer data + memberTripsData: TripsLayerDatum[]; + correlationTripsData: TripsLayerDatum[]; + centerTrailSegments: CenterTrailSegment[]; + centerDotsPositions: [number, number][]; + snapshotRanges: number[]; + modelCenterTrails: ModelCenterTrail[]; + + // Filter / display state + enabledModels: Set; + enabledVessels: Set; + hoveredMmsi: string | null; + correlationByModel: Map; + showTrails: boolean; + showLabels: boolean; + + // 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; + setShowTrails: (show: boolean) => void; + setShowLabels: (show: boolean) => void; + updateCorrelation: (corrData: GearCorrelationItem[], corrTracks: CorrelationVesselTrack[]) => 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, + rawCorrelationTracks: [], + + // Pre-computed layer data + memberTripsData: [], + correlationTripsData: [], + centerTrailSegments: [], + centerDotsPositions: [], + snapshotRanges: [], + modelCenterTrails: [], + + // Filter / display state + enabledModels: new Set(), + enabledVessels: new Set(), + hoveredMmsi: null, + showTrails: true, + showLabels: true, + 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); + } + + const modelTrails = buildModelCenterTrails(frames, corrTracks, byModel, enabledVessels, startTime); + + set({ + historyFrames: frames, + frameTimes, + startTime, + endTime, + currentTime: startTime, + rawCorrelationTracks: corrTracks, + memberTripsData: memberTrips, + correlationTripsData: corrTrips, + centerTrailSegments: segments, + centerDotsPositions: dots, + snapshotRanges: ranges, + modelCenterTrails: modelTrails, + 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) => { + const state = get(); + const modelTrails = state.historyFrames.length > 0 + ? buildModelCenterTrails(state.historyFrames, state.rawCorrelationTracks, state.correlationByModel, vessels, state.startTime) + : []; + set({ enabledVessels: vessels, modelCenterTrails: modelTrails }); + }, + + setHoveredMmsi: (mmsi) => set({ hoveredMmsi: mmsi }), + setShowTrails: (show) => set({ showTrails: show }), + setShowLabels: (show) => set({ showLabels: show }), + + updateCorrelation: (corrData, corrTracks) => { + const state = get(); + if (state.historyFrames.length === 0) { + console.log('[GearReplayStore] updateCorrelation 스킵: historyFrames 비어있음'); + return; + } + const byModel = new Map(); + for (const c of corrData) { + const list = byModel.get(c.modelName) ?? []; + list.push(c); + byModel.set(c.modelName, list); + } + const corrTrips = buildCorrelationTripsData(corrTracks, state.startTime); + console.log('[GearReplayStore] updateCorrelation:', { + corrData: corrData.length, + models: [...byModel.keys()], + corrTrips: corrTrips.length, + corrTracks: corrTracks.length, + }); + const modelTrails = buildModelCenterTrails(state.historyFrames, corrTracks, byModel, state.enabledVessels, state.startTime); + set({ correlationByModel: byModel, correlationTripsData: corrTrips, modelCenterTrails: modelTrails, rawCorrelationTracks: corrTracks }); + }, + + 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, + rawCorrelationTracks: [], + memberTripsData: [], + correlationTripsData: [], + centerTrailSegments: [], + centerDotsPositions: [], + snapshotRanges: [], + modelCenterTrails: [], + enabledModels: new Set(), + enabledVessels: new Set(), + hoveredMmsi: null, + showTrails: true, + showLabels: true, + correlationByModel: new Map(), + }); + }, + }; + }), +); diff --git a/frontend/src/utils/shipIconSvg.ts b/frontend/src/utils/shipIconSvg.ts new file mode 100644 index 0000000..11c4554 --- /dev/null +++ b/frontend/src/utils/shipIconSvg.ts @@ -0,0 +1,40 @@ +/** + * deck.gl IconLayer용 SVG 아이콘 생성 유틸. + * MapLibre ship-triangle / gear-diamond 형태와 동일. + * Data URI로 캐싱하여 반복 생성 방지. + */ + +const ICON_SIZE = 64; + +/** 선박 삼각형 SVG (heading 0 = north, 위쪽 꼭짓점) */ +function createShipTriangleSvg(): string { + const s = ICON_SIZE; + return ` + + `; +} + +/** 어구 마름모 SVG */ +function createGearDiamondSvg(): string { + const s = ICON_SIZE; + return ` + + `; +} + +function svgToDataUri(svg: string): string { + return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`; +} + +// ── 정적 캐시 (모듈 로드 시 1회 생성) ── +const SHIP_TRIANGLE_URI = svgToDataUri(createShipTriangleSvg()); +const GEAR_DIAMOND_URI = svgToDataUri(createGearDiamondSvg()); + +export const SHIP_ICON_MAPPING = { + 'ship-triangle': { url: SHIP_TRIANGLE_URI, width: ICON_SIZE, height: ICON_SIZE, anchorY: ICON_SIZE * 0.62, mask: true }, + 'gear-diamond': { url: GEAR_DIAMOND_URI, width: ICON_SIZE, height: ICON_SIZE, anchorY: ICON_SIZE / 2, mask: true }, +}; + +export const SHIP_ICON_ATLAS = SHIP_TRIANGLE_URI; +export const GEAR_ICON_ATLAS = GEAR_DIAMOND_URI; +export { ICON_SIZE }; diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index c0c411e..3472b81 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://192.168.1.18:8001', + changeOrigin: true, + rewrite: (path) => path.replace(/^\/api\/prediction/, '/api'), + }, '/ollama': { target: 'http://localhost:11434', changeOrigin: true, diff --git a/prediction/algorithms/gear_correlation.py b/prediction/algorithms/gear_correlation.py new file mode 100644 index 0000000..21c5b95 --- /dev/null +++ b/prediction/algorithms/gear_correlation.py @@ -0,0 +1,785 @@ +"""어구 그룹 다단계 연관성 분석 — 멀티모델 패턴 추적. + +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'^.+_(?=\S*\d)\S+(?:[_ ]\S*)*[_ ]*$|^.+%$|^\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 + 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, + 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/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/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/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/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..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: @@ -210,6 +217,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/main.py b/prediction/main.py index 2e1a9dc..139912f 100644 --- a/prediction/main.py +++ b/prediction/main.py @@ -68,3 +68,90 @@ 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 (ALL active models) and enriches with + 24h track data from in-memory vessel_store. + Each vessel includes which models detected it. + """ + from cache.vessel_store import vessel_store + + try: + with kcgdb.get_conn() as conn: + cur = conn.cursor() + + # Get correlated vessels from ALL active models + 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_active = TRUE + ORDER BY s.current_score DESC + """, (group_key, min_score)) + + rows = cur.fetchall() + cur.close() + + logger.info('correlation tracks: group_key=%r, min_score=%s, rows=%d', + group_key, min_score, len(rows)) + + if not rows: + return {'groupKey': group_key, 'vessels': []} + + # Group by MMSI: collect all models per vessel, keep highest score + vessel_map: dict[str, dict] = {} + for row in rows: + mmsi = row[0] + model_name = row[4] + score = float(row[3]) + if mmsi not in vessel_map: + vessel_map[mmsi] = { + 'mmsi': mmsi, + 'type': row[1], + 'name': row[2] or '', + 'score': score, + 'models': {model_name: score}, + } + else: + entry = vessel_map[mmsi] + entry['models'][model_name] = score + if score > entry['score']: + entry['score'] = score + + mmsis = list(vessel_map.keys()) + + # Get tracks from vessel_store + tracks = vessel_store.get_vessel_tracks(mmsis, hours) + with_tracks = sum(1 for m in mmsis if m in tracks and len(tracks[m]) > 0) + logger.info('correlation tracks: %d unique mmsis, %d with track data, vessel_store._tracks has %d entries', + len(mmsis), with_tracks, len(vessel_store._tracks)) + + # Build response + vessels = [] + for info in vessel_map.values(): + track = tracks.get(info['mmsi'], []) + vessels.append({ + 'mmsi': info['mmsi'], + 'name': info['name'], + 'type': info['type'], + 'score': info['score'], + 'models': info['models'], # {modelName: score, ...} + '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 d463098..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) @@ -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)