From bbbc326e3844f79c96d0a7fee4fb9434ecc65f01 Mon Sep 17 00:00:00 2001 From: htlee Date: Tue, 31 Mar 2026 07:44:07 +0900 Subject: [PATCH] =?UTF-8?q?refactor:=20FleetClusterLayer=2010=ED=8C=8C?= =?UTF-8?q?=EC=9D=BC=20=EB=B6=84=EB=A6=AC=20+=20deck.gl=20=EB=A6=AC?= =?UTF-8?q?=ED=94=8C=EB=A0=88=EC=9D=B4=20=EA=B8=B0=EB=B0=98=20=EA=B5=AC?= =?UTF-8?q?=EC=B6=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FleetClusterLayer.tsx 2357줄 → 10개 파일 분리: - fleetClusterTypes/Utils/Constants: 타입, 기하 함수, 모델 상수 - useFleetClusterGeoJson: 27개 useMemo GeoJSON 훅 - FleetClusterMapLayers: MapLibre Source/Layer JSX - CorrelationPanel/HistoryReplayController: 패널 서브컴포넌트 - GearGroupSection/FleetGearListPanel: 좌측 목록 (DRY) - FleetClusterLayer: 오케스트레이터 524줄 deck.gl + Zustand 리플레이 기반 (Phase 0~2): - zustand 5.0.12, @deck.gl/geo-layers 9.2.11 설치 - gearReplayStore: Zustand + rAF 애니메이션 루프 - gearReplayPreprocess: TripsLayer 전처리 + cursor O(1) 보간 - useGearReplayLayers: deck.gl 레이어 빌더 (10fps 스로틀) Co-Authored-By: Claude Opus 4.6 (1M context) --- frontend/package-lock.json | 914 ++++++++- frontend/package.json | 4 +- .../src/components/korea/CorrelationPanel.tsx | 366 ++++ .../components/korea/FleetClusterLayer.tsx | 1807 +++-------------- .../korea/FleetClusterMapLayers.tsx | 492 +++++ .../components/korea/FleetGearListPanel.tsx | 171 ++ .../src/components/korea/GearGroupSection.tsx | 211 ++ .../korea/HistoryReplayController.tsx | 171 ++ .../components/korea/fleetClusterConstants.ts | 116 ++ .../src/components/korea/fleetClusterTypes.ts | 58 + .../src/components/korea/fleetClusterUtils.ts | 204 ++ .../korea/useFleetClusterGeoJson.ts | 618 ++++++ frontend/src/hooks/useGearReplayLayers.ts | 452 +++++ frontend/src/services/vesselAnalysis.ts | 35 + frontend/src/stores/gearReplayPreprocess.ts | 235 +++ frontend/src/stores/gearReplayStore.ts | 245 +++ frontend/vite.config.ts | 5 + prediction/algorithms/gear_correlation.py | 4 +- prediction/algorithms/polygon_builder.py | 186 +- prediction/cache/vessel_store.py | 60 + prediction/fleet_tracker.py | 17 +- prediction/main.py | 72 + prediction/scheduler.py | 2 +- 23 files changed, 4813 insertions(+), 1632 deletions(-) create mode 100644 frontend/src/components/korea/CorrelationPanel.tsx create mode 100644 frontend/src/components/korea/FleetClusterMapLayers.tsx create mode 100644 frontend/src/components/korea/FleetGearListPanel.tsx create mode 100644 frontend/src/components/korea/GearGroupSection.tsx create mode 100644 frontend/src/components/korea/HistoryReplayController.tsx create mode 100644 frontend/src/components/korea/fleetClusterConstants.ts create mode 100644 frontend/src/components/korea/fleetClusterTypes.ts create mode 100644 frontend/src/components/korea/fleetClusterUtils.ts create mode 100644 frontend/src/components/korea/useFleetClusterGeoJson.ts create mode 100644 frontend/src/hooks/useGearReplayLayers.ts create mode 100644 frontend/src/stores/gearReplayPreprocess.ts create mode 100644 frontend/src/stores/gearReplayStore.ts 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..c2077cc --- /dev/null +++ b/frontend/src/components/korea/CorrelationPanel.tsx @@ -0,0 +1,366 @@ +import { useState } from 'react'; +import type { GearCorrelationItem, CorrelationVesselTrack } from '../../services/vesselAnalysis'; +import type { UseGroupPolygonsResult } from '../../hooks/useGroupPolygons'; +import type { HistoryFrame } from './fleetClusterTypes'; +import { FONT_MONO } from '../../styles/fonts'; +import { MODEL_ORDER, MODEL_COLORS, MODEL_DESC } from './fleetClusterConstants'; + +interface CorrelationPanelProps { + selectedGearGroup: string; + memberCount: number; + groupPolygons: UseGroupPolygonsResult | undefined; + correlationByModel: Map; + availableModels: { name: string; count: number; isDefault: boolean }[]; + correlationTracks: CorrelationVesselTrack[]; + enabledModels: Set; + enabledVessels: Set; + correlationLoading: boolean; + historyData: HistoryFrame[] | null; + effectiveSnapIdx: number; + 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, + correlationTracks, + enabledModels, + enabledVessels, + correlationLoading, + historyData, + hoveredTarget, + onEnabledModelsChange, + onEnabledVesselsChange, + onHoveredTargetChange, +}: CorrelationPanelProps) => { + // 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 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 hasTrack = correlationTracks.some(v => v.mmsi === c.targetMmsi); + const isHovered = hoveredTarget?.mmsi === c.targetMmsi && hoveredTarget?.model === modelName; + return ( +
onHoveredTargetChange({ mmsi: c.targetMmsi, model: modelName })} + onMouseLeave={() => onHoveredTargetChange(null)} + > + {hasTrack && ( + onEnabledVesselsChange(prev => { + const next = new Set(prev); + if (next.has(c.targetMmsi)) next.delete(c.targetMmsi); + else next.add(c.targetMmsi); + return next; + })} + style={{ accentColor: color, width: 9, height: 9, flexShrink: 0 }} + title="맵 표시" + /> + )} + + {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 7f967e5..1b9d198 100644 --- a/frontend/src/components/korea/FleetClusterLayer.tsx +++ b/frontend/src/components/korea/FleetClusterLayer.tsx @@ -1,215 +1,23 @@ 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, fetchGroupCorrelations } from '../../services/vesselAnalysis'; -import type { FleetCompany, GroupPolygonDto, GearCorrelationItem } 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'; -/** - * 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 { HistoryFrame, PickerCandidate, HoverTooltipState, GearPickerPopupState } from './fleetClusterTypes'; +import { TIMELINE_DURATION_MS, PLAYBACK_CYCLE_SEC, TICK_MS, 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 +25,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,103 +41,104 @@ 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); - // 어구 연관성 데이터 (전체 모델) + + // ── 연관성 데이터 ── const [correlationData, setCorrelationData] = useState([]); const [correlationLoading, setCorrelationLoading] = useState(false); - // 활성화된 모델 ('identity' = 이름기반, 'default' = 기본모델, 나머지 = 추가모델) - const [enabledModels, setEnabledModels] = useState>(new Set(['identity', 'default'])); - // 히스토리 애니메이션 — 12시간 실시간 타임라인 + 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); + + // ── 히스토리 애니메이션 상태 ── const [historyData, setHistoryData] = useState(null); const [, setHistoryGroupKey] = useState(null); - const [timelinePos, setTimelinePos] = useState(0); // 0~1 (12시간 내 위치) const [isPlaying, setIsPlaying] = useState(true); + const [displayFrameIdx, setDisplayFrameIdx] = useState(-1); + const timelinePosRef = useRef(0); + const progressBarRef = useRef(null); + const progressIndicatorRef = useRef(null); + const timeDisplayRef = useRef(null); const animTimerRef = useRef>(); - const historyStartRef = useRef(0); // 12시간 전 epoch ms - const historyEndRef = useRef(0); // 현재 epoch ms + const historyStartRef = useRef(0); + const historyEndRef = useRef(0); + const frameTimesRef = useRef([]); + + // ── 맵 + 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 간격 업데이트 - + // ── 히스토리 로드/닫기 ── const loadHistory = async (groupKey: string) => { setHistoryGroupKey(groupKey); - setTimelinePos(0); + timelinePosRef.current = 0; + setDisplayFrameIdx(-1); setIsPlaying(true); const history = await fetchGroupHistory(groupKey, 12); - const sorted = history.reverse(); // 시간 오름차순 - const filled = fillGapFrames(sorted); // 빈 구간 보간 삽입 + const sorted = history.reverse(); + const filled = fillGapFrames(sorted); const now = Date.now(); historyStartRef.current = now - TIMELINE_DURATION_MS; historyEndRef.current = now; + frameTimesRef.current = filled.map(h => new Date(h.snapshotTime).getTime()); setHistoryData(filled); }; const closeHistory = useCallback(() => { setHistoryData(null); setHistoryGroupKey(null); - setTimelinePos(0); + timelinePosRef.current = 0; + setDisplayFrameIdx(-1); setIsPlaying(true); + setSelectedGearGroup(null); clearInterval(animTimerRef.current); }, []); - // 재생 타이머 — 50ms마다 timelinePos 진행 + // ── 재생 타이머 (ref 기반, 프레임 변경 시에만 setState) ── useEffect(() => { if (!historyData || !isPlaying) { clearInterval(animTimerRef.current); return; } - const step = TICK_MS / (PLAYBACK_CYCLE_SEC * 1000); // 1틱당 진행량 + const step = TICK_MS / (PLAYBACK_CYCLE_SEC * 1000); + const ft = frameTimesRef.current; animTimerRef.current = setInterval(() => { - setTimelinePos(prev => { - const next = prev + step; - return next >= 1 ? 0 : next; // 순환 - }); + timelinePosRef.current += step; + if (timelinePosRef.current >= 1) timelinePosRef.current = 0; + + const pos = timelinePosRef.current; + if (progressBarRef.current) progressBarRef.current.value = String(Math.round(pos * 1000)); + if (progressIndicatorRef.current) progressIndicatorRef.current.style.left = `${pos * 100}%`; + const t = historyStartRef.current + timelinePosRef.current * TIMELINE_DURATION_MS; + if (timeDisplayRef.current) { + timeDisplayRef.current.textContent = new Date(t).toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit' }); + } + + let best = 0, bestDiff = Infinity; + for (let i = 0; i < ft.length; i++) { + const d = Math.abs(ft[i] - t); + if (d < bestDiff) { bestDiff = d; best = i; } + } + const fi = bestDiff < 1_800_000 ? best : -1; + setDisplayFrameIdx(prev => prev === fi ? prev : fi); }, TICK_MS); return () => clearInterval(animTimerRef.current); }, [historyData, isPlaying]); - // 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]); - - // 스냅샷 존재 구간 맵 (프로그레스 바 갭 표시용) - // 프로그레스 바: 원본 데이터만 표시 (보간 프레임 제외) - 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]); + const effectiveSnapIdx = displayFrameIdx; + // ── ESC 키 ── useEffect(() => { const onKeyDown = (e: KeyboardEvent) => { if (e.key === 'Escape') { @@ -343,7 +152,7 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipS return () => window.removeEventListener('keydown', onKeyDown); }, [historyData, closeHistory]); - // ── 맵 폴리곤 클릭/호버 이벤트 등록 + // ── 맵 이벤트 등록 ── useEffect(() => { const map = mapRef?.getMap(); if (!map || registeredRef.current) return; @@ -351,7 +160,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) => { @@ -369,6 +177,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); @@ -386,14 +195,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; @@ -410,80 +241,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); } }; @@ -497,71 +288,63 @@ 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) { onSelectedGearChange?.(null); - if (historyData) return; // 히스토리 모드: 선택은 유지하되 부모 강조만 숨김 + if (historyData) 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(), - }); - onSelectedGearChange?.({ - parent: parent ? toShip(parent) : null, - gears: gears.map(toShip), - groupName: selectedGearGroup, + 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]); - // 선택된 어구 그룹의 연관성 데이터 로드 + // ── 연관성 데이터 로드 ── useEffect(() => { - if (!selectedGearGroup) { - setCorrelationData([]); - return; - } + if (!selectedGearGroup) { setCorrelationData([]); return; } let cancelled = false; setCorrelationLoading(true); fetchGroupCorrelations(selectedGearGroup, 0.3) - .then(res => { - if (!cancelled) { - setCorrelationData(res.items); - } - }) + .then(res => { if (!cancelled) setCorrelationData(res.items); }) .catch(() => { if (!cancelled) setCorrelationData([]); }) .finally(() => { if (!cancelled) setCorrelationLoading(false); }); return () => { cancelled = true; }; }, [selectedGearGroup]); - // 선택된 선단 데이터를 부모에 전달 (deck.gl 강조 렌더링용) — 히스토리 모드에서는 null + // ── 연관 선박 항적 로드 ── + useEffect(() => { + 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]); + + // ── 부모 콜백 동기화: 선단 선택 ── useEffect(() => { if (expandedFleet === null || historyData) { onSelectedFleetChange?.(null); @@ -570,342 +353,31 @@ export function FleetClusterLayer({ ships, analysisMap: analysisMapProp, onShipS } 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}`, - }); + onSelectedFleetChange?.({ clusterId: expandedFleet, ships: fleetShips, companyName: company?.nameCn || group.groupLabel || `선단 #${expandedFleet}` }); }, [expandedFleet, groupPolygons, companies, onSelectedFleetChange, historyData]); - // API 기반 어구 그룹 분류 + // ── GeoJSON 훅 ── + const hoveredMmsi = hoveredTarget?.mmsi ?? null; + const geo = useFleetClusterGeoJson({ + ships, shipMap, groupPolygons, analysisMap, + hoveredFleetId, selectedGearGroup, pickerHoveredGroup, + historyData, effectiveSnapIdx, + correlationData, correlationTracks, + enabledModels, enabledVessels, hoveredMmsi, + historyStartMs: historyStartRef.current, + }); + + // ── 어구 그룹 데이터 ── 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]); - - // 모델별 연관성 데이터 그룹핑 - const correlationByModel = useMemo(() => { - const map = new Map(); - for (const c of correlationData) { - const list = map.get(c.modelName) ?? []; - list.push(c); - map.set(c.modelName, list); - } - return map; - }, [correlationData]); - - // 사용 가능한 모델 목록 (데이터가 있는 모델만) - const availableModels = useMemo(() => { - const models: { name: string; count: number; isDefault: boolean }[] = []; - for (const [name, items] of correlationByModel) { - models.push({ name, count: items.length, isDefault: items[0]?.isDefault ?? false }); - } - models.sort((a, b) => (a.isDefault ? -1 : 0) - (b.isDefault ? -1 : 0)); - return models; - }, [correlationByModel]); - - // 모델별 오퍼레이셔널 폴리곤 GeoJSON (identity 제외, correlation 모델만) - const MODEL_COLORS: Record = { - 'default': '#3b82f6', // 파랑 - 'aggressive': '#22c55e', // 초록 - 'conservative': '#a855f7', // 보라 - 'proximity-heavy': '#06b6d4', // 시안 - 'visit-pattern': '#f43f5e', // 로즈 - }; - - const operationalPolygons = useMemo(() => { - if (!selectedGearGroup || !groupPolygons) return []; - - const allGroups = [...groupPolygons.gearInZoneGroups, ...groupPolygons.gearOutZoneGroups]; - const group = allGroups.find(g => g.groupKey === selectedGearGroup); - if (!group) return []; - - // 이름 기반 멤버 위치 - const baseMemberPositions: [number, number][] = group.members.map(m => [m.lon, m.lat]); - - // ships prop에서 위치 조회용 맵 - const posMap = new Map(); - for (const s of ships) { - posMap.set(s.mmsi, { lat: s.lat, lon: s.lng }); - } - - const result: { modelName: string; color: string; geojson: GeoJSON.FeatureCollection }[] = []; - - for (const [modelName, items] of correlationByModel) { - if (!enabledModels.has(modelName)) continue; - - // 70%+ 점수 대상의 위치 수집 - const extraPositions: [number, number][] = []; - for (const c of items) { - if (c.score < 0.7) continue; - const pos = posMap.get(c.targetMmsi); - if (pos) extraPositions.push([pos.lon, pos.lat]); - } - - if (extraPositions.length === 0) continue; - - // 이름 기반 + 연관 대상 합산 - const allPoints = [...baseMemberPositions, ...extraPositions]; - const polygon = buildInterpPolygon(allPoints); - if (!polygon) continue; - - const color = MODEL_COLORS[modelName] ?? '#94a3b8'; - result.push({ - modelName, - color, - geojson: { - type: 'FeatureCollection', - features: [{ - type: 'Feature', - properties: { modelName, color }, - geometry: polygon, - }], - }, - }); - } - - return result; - }, [selectedGearGroup, groupPolygons, correlationByModel, enabledModels, ships]); - - // 어구 클러스터 GeoJSON (서버 제공) - const gearClusterGeoJson = useMemo((): GeoJSON => { - const features: GeoJSON.Feature[] = []; - 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; @@ -923,19 +395,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; @@ -950,896 +417,108 @@ 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); }} + /> + + {/* ── 연관성 패널 ── */} + {selectedGearGroup && ( + setEnabledModels(updater)} + onEnabledVesselsChange={(updater) => setEnabledVessels(updater)} + onHoveredTargetChange={setHoveredTarget} /> - - - - {/* 2척 선단 라인 (서버측 Polygon 처리로 빈 컬렉션) */} - - - - - {/* 호버 하이라이트 (별도 Source) */} - - - - - {/* 선택된 어구 그룹 — 이름 기반 하이라이트 폴리곤 */} - {selectedGearGroup && enabledModels.has('identity') && !historyData && (() => { - const allGroups = groupPolygons - ? [...groupPolygons.gearInZoneGroups, ...groupPolygons.gearOutZoneGroups] - : []; - const group = allGroups.find(g => g.groupKey === selectedGearGroup); - if (!group?.polygon) return null; - const hlGeoJson: GeoJSON.FeatureCollection = { - type: 'FeatureCollection', - features: [{ - type: 'Feature', - properties: {}, - geometry: group.polygon, - }], - }; - return ( - - - - - ); - })()} - - {/* 선택된 어구 그룹 — 모델별 오퍼레이셔널 폴리곤 오버레이 */} - {selectedGearGroup && operationalPolygons.map(op => ( - - - - - ))} - - {/* 비허가 어구 클러스터 폴리곤 */} - - - - - - {/* 가상 선박 마커 — 히스토리 재생 모드에서는 숨김 (애니메이션 아이콘으로 대체) */} - - - - - {/* 어구 picker 호버 하이라이트 */} - - - - - - {/* 어구 다중 선택 팝업 */} - {gearPickerPopup && ( - { 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 && ( - - - - + setIsPlaying(p => !p)} + onFrameChange={(idx) => { setIsPlaying(false); setDisplayFrameIdx(idx); }} + onClose={closeHistory} + /> )} - {/* ── 어구 연관성 패널 (선택된 그룹, 하단 고정) ── */} - {selectedGearGroup && (() => { - const allGroups = groupPolygons - ? [...groupPolygons.gearInZoneGroups, ...groupPolygons.gearOutZoneGroups] - : []; - const group = allGroups.find(g => g.groupKey === selectedGearGroup); - const memberCount = group?.memberCount ?? 0; - const defaultItems = correlationData.filter(c => c.isDefault); - return ( -
- {/* 좌: 그룹 정보 + 모델 토글 */} -
-
- {selectedGearGroup} - 어구 {memberCount}개 - -
- {/* 폴리곤 오버레이 토글 */} -
폴리곤 오버레이
- - {correlationLoading &&
로딩...
} - {availableModels.map(m => { - const color = MODEL_COLORS[m.name] ?? '#94a3b8'; - const modelItems = correlationByModel.get(m.name) ?? []; - const above70 = modelItems.filter(c => c.score >= 0.7).length; - return ( - - ); - })} -
- {/* 우: 연관 선박 목록 (default 모델) */} - {defaultItems.length > 0 && ( -
-
- 연관 선박 ({defaultItems.length}건) -
-
- {defaultItems.slice(0, 12).map(c => { - const pct = (c.score * 100).toFixed(0); - const barW = Math.max(2, c.score * 60); - const barColor = c.score >= 0.7 ? '#22c55e' : c.score >= 0.5 ? '#eab308' : '#94a3b8'; - return ( -
- - {c.targetType === 'VESSEL' ? '⛴' : '◆'} - - - {c.targetName || c.targetMmsi} - -
-
-
-
- {pct}% -
-
- ); - })} - {defaultItems.length > 12 && ( -
+{defaultItems.length - 12}건 더
- )} -
-
- )} -
- ); - })()} - - {/* 히스토리 재생 컨트롤러 */} - {historyData && (() => { - const curTime = new Date(currentTimeMs); - const timeStr = curTime.toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit' }); - const hasSnap = currentSnapIdx >= 0; - return ( -
- {/* 프로그레스 바 — 갭 표시 */} -
- {/* 스냅샷 존재 구간 표시 */} - {snapshotRanges.map((pos, i) => ( -
- ))} - {/* 현재 위치 */} -
-
- {/* 컨트롤 행 */} -
- - - {timeStr} - - { setIsPlaying(false); setTimelinePos(Number(e.target.value) / 1000); }} - style={{ flex: 1, cursor: 'pointer', accentColor: '#fbbf24' }} - title="히스토리 타임라인" aria-label="히스토리 타임라인" - /> - - {historyData.length}건 - - -
-
- ); - })()} - - {/* 선단 목록 패널 */} -
- {/* ── 선단 현황 섹션 ── */} -
toggleSection('fleet')}> - - 선단 현황 ({fleetList.length}개) - - -
- {activeSection === 'fleet' && ( -
- {fleetList.length === 0 ? ( -
- 선단 데이터 없음 -
- ) : ( - fleetList.map(({ id, mmsiList, label, color, members }) => { - const company = companies.get(id); - const companyName = company?.nameCn ?? label ?? `선단 #${id}`; - const isOpen = expandedFleet === id; - const isHovered = hoveredFleetId === id; - - const mainMembers = members.filter(m => { - const dto = analysisMap.get(m.mmsi); - return dto?.algorithms.fleetRole.role === 'LEADER' || dto?.algorithms.fleetRole.role === 'MEMBER'; - }); - const displayMembers = mainMembers.length > 0 ? mainMembers : members; - - return ( -
- {/* 선단 행 */} -
setHoveredFleetId(id)} - onMouseLeave={() => setHoveredFleetId(null)} - style={{ - display: 'flex', - alignItems: 'center', - gap: 4, - padding: '4px 10px', - cursor: 'pointer', - backgroundColor: isHovered ? 'rgba(255,255,255,0.06)' : 'transparent', - borderLeft: isOpen ? `2px solid ${color}` : '2px solid transparent', - transition: 'background-color 0.1s', - }} - > - {/* 펼침 토글 */} - setExpandedFleet(prev => (prev === id ? null : id))} - style={{ color: '#94a3b8', fontSize: 9, flexShrink: 0, cursor: 'pointer' }} - > - {isOpen ? '▾' : '▸'} - - {/* 색상 인디케이터 */} - - {/* 회사명 */} - setExpandedFleet(prev => (prev === id ? null : id))} - style={{ - flex: 1, - color: '#e2e8f0', - overflow: 'hidden', - textOverflow: 'ellipsis', - whiteSpace: 'nowrap', - cursor: 'pointer', - }} - title={company ? `${company.nameCn} / ${company.nameEn}` : companyName} - > - {companyName} - - {/* 선박 수 */} - - ({mmsiList.length}척) - - {/* zoom 버튼 */} - -
- - {/* 선단 상세 */} - {isOpen && ( -
- {/* 선박 목록 */} -
선박:
- {displayMembers.map(m => { - const dto = analysisMap.get(m.mmsi); - const role = dto?.algorithms.fleetRole.role ?? m.role; - const displayName = m.name || m.mmsi; - return ( -
- - {displayName} - - - ({role === 'LEADER' ? 'MAIN' : 'SUB'}) - - -
- ); - })} -
- )} -
- ); - }) - )} - -
- )} - - {/* ── 조업구역내 어구 그룹 섹션 ── */} -
toggleSection('inZone')}> - - 조업구역내 어구 ({inZoneGearGroups.length}개) - - -
- {activeSection === 'inZone' && ( -
- {inZoneGearGroups.map(g => { - const name = g.groupKey; - const isOpen = expandedGearGroup === name; - const accentColor = '#dc2626'; - const parentMember = g.members.find(m => m.isParent); - const gearMembers = g.members.filter(m => !m.isParent); - const zoneName = g.zoneName ?? ''; - return ( -
-
{ (e.currentTarget as HTMLDivElement).style.backgroundColor = 'rgba(220,38,38,0.06)'; }} - onMouseLeave={e => { (e.currentTarget as HTMLDivElement).style.backgroundColor = 'transparent'; }} - > - setExpandedGearGroup(prev => (prev === name ? null : name))} style={{ color: '#94a3b8', fontSize: 9, flexShrink: 0, cursor: 'pointer' }}>{isOpen ? '▾' : '▸'} - - setExpandedGearGroup(prev => (prev === name ? null : name))} style={{ flex: 1, color: '#e2e8f0', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', cursor: 'pointer' }} title={`${name} — ${zoneName}`}>{name} - {parentMember && } - {zoneName} - ({gearMembers.length}) - -
- {isOpen && ( -
- {parentMember &&
모선: {parentMember.name || parentMember.mmsi}
} -
어구 목록:
- {gearMembers.map(m => ( -
- {m.name || m.mmsi} - -
- ))} -
- )} -
- ); - })} -
- )} - - {/* ── 비허가 어구 그룹 섹션 ── */} -
toggleSection('outZone')}> - - 비허가 어구 ({outZoneGearGroups.length}개) - - -
- {activeSection === 'outZone' && ( -
- {outZoneGearGroups.map(g => { - const name = g.groupKey; - const isOpen = expandedGearGroup === name; - const parentMember = g.members.find(m => m.isParent); - const gearMembers = g.members.filter(m => !m.isParent); - return ( -
-
{ (e.currentTarget as HTMLDivElement).style.backgroundColor = 'rgba(255,255,255,0.04)'; }} - onMouseLeave={e => { (e.currentTarget as HTMLDivElement).style.backgroundColor = 'transparent'; }} - > - setExpandedGearGroup(prev => (prev === name ? null : name))} - style={{ color: '#94a3b8', fontSize: 9, flexShrink: 0, cursor: 'pointer' }} - > - {isOpen ? '▾' : '▸'} - - - setExpandedGearGroup(prev => (prev === name ? null : name))} - style={{ - flex: 1, - color: '#e2e8f0', - overflow: 'hidden', - textOverflow: 'ellipsis', - whiteSpace: 'nowrap', - cursor: 'pointer', - }} - title={name} - > - {name} - - {parentMember && } - - ({gearMembers.length}개) - - -
- - {isOpen && ( -
- {parentMember && ( -
- 모선: {parentMember.name || parentMember.mmsi} -
- )} -
어구 목록:
- {gearMembers.map(m => ( -
- - {m.name || m.mmsi} - - -
- ))} -
- )} -
- ); - })} -
- )} -
+ {/* ── 좌측 목록 패널 ── */} + onShipSelect?.(mmsi)} + /> ); } - -export default FleetClusterLayer; diff --git a/frontend/src/components/korea/FleetClusterMapLayers.tsx b/frontend/src/components/korea/FleetClusterMapLayers.tsx new file mode 100644 index 0000000..be47930 --- /dev/null +++ b/frontend/src/components/korea/FleetClusterMapLayers.tsx @@ -0,0 +1,492 @@ +import { Source, Layer, Popup } from 'react-map-gl/maplibre'; +import { FONT_MONO } from '../../styles/fonts'; +import type { FleetCompany } from '../../services/vesselAnalysis'; +import type { VesselAnalysisDto } from '../../types'; +import type { UseGroupPolygonsResult } from '../../hooks/useGroupPolygons'; +import type { + HistoryFrame, + HoverTooltipState, + GearPickerPopupState, + PickerCandidate, +} from './fleetClusterTypes'; +import type { FleetClusterGeoJsonResult } from './useFleetClusterGeoJson'; +import { MODEL_ORDER, MODEL_COLORS } from './fleetClusterConstants'; + +interface FleetClusterMapLayersProps { + geo: FleetClusterGeoJsonResult; + selectedGearGroup: string | null; + hoveredMmsi: string | null; + enabledModels: Set; + expandedFleet: number | null; + historyData: HistoryFrame[] | null; + effectiveSnapIdx: number; + // Popup/tooltip state + hoverTooltip: HoverTooltipState | null; + gearPickerPopup: GearPickerPopupState | null; + pickerHoveredGroup: string | null; + // Data for tooltip rendering + groupPolygons: UseGroupPolygonsResult | undefined; + companies: Map; + analysisMap: Map; + // Whether any correlation trails exist (drives conditional render) + hasCorrelationTracks: boolean; + // Callbacks + onPickerHover: (group: string | null) => void; + onPickerSelect: (candidate: PickerCandidate) => void; + onPickerClose: () => void; +} + +const FleetClusterMapLayers = ({ + geo, + selectedGearGroup, + hoveredMmsi, + enabledModels, + expandedFleet, + historyData, + effectiveSnapIdx, + hoverTooltip, + gearPickerPopup, + pickerHoveredGroup, + groupPolygons, + companies, + analysisMap, + hasCorrelationTracks, + onPickerHover, + onPickerSelect, + onPickerClose, +}: FleetClusterMapLayersProps) => { + const { + fleetPolygonGeoJSON, + lineGeoJSON, + hoveredGeoJSON, + gearClusterGeoJson, + memberMarkersGeoJson, + pickerHighlightGeoJson, + operationalPolygons, + memberTrailsGeoJson, + centerTrailGeoJson, + currentCenterGeoJson, + animPolygonGeoJson, + animMembersGeoJson, + correlationVesselGeoJson, + correlationTrailGeoJson, + modelBadgesGeoJson, + hoverHighlightGeoJson, + hoverHighlightTrailGeoJson, + isStale, + } = geo; + + return ( + <> + {/* 선단 폴리곤 레이어 */} + + + + + + {/* 2척 선단 라인 (서버측 Polygon 처리로 빈 컬렉션) */} + + + + + {/* 호버 하이라이트 (별도 Source) */} + + + + + {/* 선택된 어구 그룹 — 이름 기반 하이라이트 폴리곤 */} + {selectedGearGroup && enabledModels.has('identity') && !historyData && (() => { + const allGroups = groupPolygons + ? [...groupPolygons.gearInZoneGroups, ...groupPolygons.gearOutZoneGroups] + : []; + const group = allGroups.find(g => g.groupKey === selectedGearGroup); + if (!group?.polygon) return null; + const hlGeoJson: GeoJSON.FeatureCollection = { + type: 'FeatureCollection', + features: [{ + type: 'Feature', + properties: {}, + geometry: group.polygon, + }], + }; + return ( + + + + + ); + })()} + + {/* 선택된 어구 그룹 — 모델별 오퍼레이셔널 폴리곤 오버레이 */} + {selectedGearGroup && operationalPolygons.map(op => ( + + + + + ))} + + {/* 비허가 어구 클러스터 폴리곤 */} + + + + + + {/* 가상 선박 마커 — 히스토리 재생 모드에서는 숨김 (애니메이션 아이콘으로 대체) */} + + + + + {/* 어구 picker 호버 하이라이트 */} + + + + + + {/* 어구 다중 선택 팝업 */} + {gearPickerPopup && ( + { onPickerClose(); }} + closeOnClick={false} className="gl-popup" maxWidth="220px"> +
+
+ 겹친 그룹 ({gearPickerPopup.candidates.length}) +
+ {gearPickerPopup.candidates.map(c => ( +
onPickerHover(c.isFleet ? String(c.clusterId) : c.name)} + onMouseLeave={() => onPickerHover(null)} + onClick={() => { + onPickerSelect(c); + onPickerClose(); + }} + style={{ + cursor: 'pointer', padding: '3px 6px', + borderLeft: `3px solid ${c.isFleet ? '#63b3ed' : c.inZone ? '#dc2626' : '#f97316'}`, + marginBottom: 2, borderRadius: 2, + backgroundColor: pickerHoveredGroup === (c.isFleet ? String(c.clusterId) : c.name) ? 'rgba(255,255,255,0.1)' : 'transparent', + }}> + {c.isFleet ? '⚓ ' : ''}{c.name} + ({c.count}{c.isFleet ? '척' : '개'}) +
+ ))} +
+
+ )} + + {/* 폴리곤 호버 툴팁 */} + {hoverTooltip && (() => { + if (hoverTooltip.type === 'fleet') { + const cid = hoverTooltip.id as number; + const group = groupPolygons?.fleetGroups.find(g => Number(g.groupKey) === cid); + const company = companies.get(cid); + const memberCount = group?.memberCount ?? 0; + return ( + +
+
+ {company?.nameCn || group?.groupLabel || `선단 #${cid}`} +
+
선박 {memberCount}척
+ {expandedFleet === cid && group?.members.slice(0, 5).map(m => { + const dto = analysisMap.get(m.mmsi); + const role = dto?.algorithms.fleetRole.role ?? m.role; + return ( +
+ {role === 'LEADER' ? '★' : '·'} {m.name || m.mmsi} {m.sog.toFixed(1)}kt +
+ ); + })} +
클릭하여 상세 보기
+
+
+ ); + } + if (hoverTooltip.type === 'gear') { + const name = hoverTooltip.id as string; + const allGroups = groupPolygons + ? [...groupPolygons.gearInZoneGroups, ...groupPolygons.gearOutZoneGroups] + : []; + const group = allGroups.find(g => g.groupKey === name); + if (!group) return null; + const parentMember = group.members.find(m => m.isParent); + const gearMembers = group.members.filter(m => !m.isParent); + return ( + +
+
+ {name} 어구 {gearMembers.length}개 +
+ {parentMember && ( +
모선: {parentMember.name || parentMember.mmsi}
+ )} + {selectedGearGroup === name && gearMembers.slice(0, 5).map(m => ( +
+ · {m.name || m.mmsi} +
+ ))} +
클릭하여 선택/해제
+
+
+ ); + } + return null; + })()} + + {/* ── 연관 대상 트레일 + 마커 (활성 모델 전체) ── */} + {selectedGearGroup && hasCorrelationTracks && ( + + + + )} + {selectedGearGroup && ( + + + + + )} + + {/* ── 모델 배지 (아이콘 우측 컬러 dot) ── */} + {selectedGearGroup && ( + + {MODEL_ORDER.map((model, i) => ( + enabledModels.has(model) ? ( + + ) : null + ))} + + )} + + {/* ── 호버 하이라이트 (글로우 + 항적 강조) ── */} + {hoveredMmsi && ( + + + + + )} + {hoveredMmsi && ( + + + + )} + + {/* ── 히스토리 애니메이션 레이어 (최상위) ── */} + {historyData && ( + + + + )} + {historyData && ( + + + + + )} + {/* 보간 경로는 centerTrailGeoJson에 통합됨 (interpolated=1 세그먼트) */} + {/* 현재 재생 위치 포인트 (실데이터=빨강, 보간=주황) */} + {historyData && effectiveSnapIdx >= 0 && ( + + + + )} + {historyData && ( + + + + + )} + {/* 가상 아이콘 — 현재 프레임 멤버 위치 (최상위) */} + {historyData && ( + + + + + )} + + ); +}; + +export default FleetClusterMapLayers; diff --git a/frontend/src/components/korea/FleetGearListPanel.tsx b/frontend/src/components/korea/FleetGearListPanel.tsx new file mode 100644 index 0000000..72182c0 --- /dev/null +++ b/frontend/src/components/korea/FleetGearListPanel.tsx @@ -0,0 +1,171 @@ +import type { FleetCompany } from '../../services/vesselAnalysis'; +import type { VesselAnalysisDto } from '../../types'; +import type { UseGroupPolygonsResult } from '../../hooks/useGroupPolygons'; +import type { FleetListItem } from './fleetClusterTypes'; +import { panelStyle, headerStyle, toggleButtonStyle } from './fleetClusterConstants'; +import GearGroupSection from './GearGroupSection'; + +interface FleetGearListPanelProps { + fleetList: FleetListItem[]; + companies: Map; + analysisMap: Map; + inZoneGearGroups: UseGroupPolygonsResult['gearInZoneGroups']; + outZoneGearGroups: UseGroupPolygonsResult['gearOutZoneGroups']; + activeSection: string | null; + expandedFleet: number | null; + expandedGearGroup: string | null; + hoveredFleetId: number | null; + onToggleSection: (key: string) => void; + onExpandFleet: (id: number | null) => void; + onHoverFleet: (id: number | null) => void; + onFleetZoom: (id: number) => void; + onGearGroupZoom: (name: string) => void; + onExpandGearGroup: (name: string | null) => void; + onShipSelect: (mmsi: string) => void; +} + +const FleetGearListPanel = ({ + fleetList, + companies, + analysisMap, + inZoneGearGroups, + outZoneGearGroups, + activeSection, + expandedFleet, + expandedGearGroup, + hoveredFleetId, + onToggleSection, + onExpandFleet, + onHoverFleet, + onFleetZoom, + onGearGroupZoom, + onExpandGearGroup, + onShipSelect, +}: FleetGearListPanelProps) => { + return ( +
+ {/* ── 선단 현황 섹션 ── */} +
onToggleSection('fleet')}> + + 선단 현황 ({fleetList.length}개) + + +
+ {activeSection === 'fleet' && ( +
+ {fleetList.length === 0 ? ( +
+ 선단 데이터 없음 +
+ ) : ( + fleetList.map(({ id, mmsiList, label, color, members }) => { + const company = companies.get(id); + const companyName = company?.nameCn ?? label ?? `선단 #${id}`; + const isOpen = expandedFleet === id; + const isHovered = hoveredFleetId === id; + + const mainMembers = members.filter(m => { + const dto = analysisMap.get(m.mmsi); + return dto?.algorithms.fleetRole.role === 'LEADER' || dto?.algorithms.fleetRole.role === 'MEMBER'; + }); + const displayMembers = mainMembers.length > 0 ? mainMembers : members; + + return ( +
+
onHoverFleet(id)} + onMouseLeave={() => onHoverFleet(null)} + style={{ + display: 'flex', alignItems: 'center', gap: 4, padding: '4px 10px', + cursor: 'pointer', + backgroundColor: isHovered ? 'rgba(255,255,255,0.06)' : 'transparent', + borderLeft: isOpen ? `2px solid ${color}` : '2px solid transparent', + transition: 'background-color 0.1s', + }} + > + onExpandFleet(isOpen ? null : id)} + style={{ color: '#94a3b8', fontSize: 9, flexShrink: 0, cursor: 'pointer' }}> + {isOpen ? '▾' : '▸'} + + + onExpandFleet(isOpen ? null : id)} + style={{ flex: 1, color: '#e2e8f0', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', cursor: 'pointer' }} + title={company ? `${company.nameCn} / ${company.nameEn}` : companyName}> + {companyName} + + ({mmsiList.length}척) + +
+ + {isOpen && ( +
+
선박:
+ {displayMembers.map(m => { + const dto = analysisMap.get(m.mmsi); + const role = dto?.algorithms.fleetRole.role ?? m.role; + const displayName = m.name || m.mmsi; + return ( +
+ + {displayName} + + + ({role === 'LEADER' ? 'MAIN' : 'SUB'}) + + +
+ ); + })} +
+ )} +
+ ); + }) + )} +
+ )} + + {/* ── 조업구역내 어구 ── */} + onToggleSection('inZone')} + onToggleGroup={(name) => onExpandGearGroup(expandedGearGroup === name ? null : name)} + onGroupZoom={onGearGroupZoom} + onShipSelect={onShipSelect} + /> + + {/* ── 비허가 어구 ── */} + onToggleSection('outZone')} + onToggleGroup={(name) => onExpandGearGroup(expandedGearGroup === name ? null : name)} + onGroupZoom={onGearGroupZoom} + onShipSelect={onShipSelect} + /> +
+ ); +}; + +export default FleetGearListPanel; diff --git a/frontend/src/components/korea/GearGroupSection.tsx b/frontend/src/components/korea/GearGroupSection.tsx new file mode 100644 index 0000000..16081d6 --- /dev/null +++ b/frontend/src/components/korea/GearGroupSection.tsx @@ -0,0 +1,211 @@ +import type { GroupPolygonDto } from '../../services/vesselAnalysis'; +import { FONT_MONO } from '../../styles/fonts'; +import { headerStyle, toggleButtonStyle } from './fleetClusterConstants'; + +interface GearGroupSectionProps { + groups: GroupPolygonDto[]; + sectionKey: string; + sectionLabel: string; + accentColor: string; + hoverBgColor: string; + isActive: boolean; + expandedGroup: string | null; + onToggleSection: () => void; + onToggleGroup: (name: string) => void; + onGroupZoom: (name: string) => void; + onShipSelect: (mmsi: string) => void; +} + +const GearGroupSection = ({ + groups, + sectionKey, + sectionLabel, + accentColor, + hoverBgColor, + isActive, + expandedGroup, + onToggleSection, + onToggleGroup, + onGroupZoom, + onShipSelect, +}: GearGroupSectionProps) => { + const isInZoneSection = sectionKey === 'inZone'; + + return ( + <> +
+ + {sectionLabel} ({groups.length}개) + + +
+ + {isActive && ( +
+ {groups.map(g => { + const name = g.groupKey; + const isOpen = expandedGroup === name; + const parentMember = g.members.find(m => m.isParent); + const gearMembers = g.members.filter(m => !m.isParent); + const zoneName = g.zoneName ?? ''; + + return ( +
+
{ + (e.currentTarget as HTMLDivElement).style.backgroundColor = hoverBgColor; + }} + onMouseLeave={e => { + (e.currentTarget as HTMLDivElement).style.backgroundColor = 'transparent'; + }} + > + onToggleGroup(name)} + style={{ color: '#94a3b8', fontSize: 9, flexShrink: 0, cursor: 'pointer' }} + > + {isOpen ? '▾' : '▸'} + + + onToggleGroup(name)} + style={{ + flex: 1, + color: '#e2e8f0', + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + cursor: 'pointer', + }} + title={isInZoneSection ? `${name} — ${zoneName}` : name} + > + {name} + + {parentMember && ( + + ⚓ + + )} + {isInZoneSection && zoneName && ( + {zoneName} + )} + + ({gearMembers.length}{isInZoneSection ? '' : '개'}) + + +
+ + {isOpen && ( +
+ {parentMember && ( +
+ 모선: {parentMember.name || parentMember.mmsi} +
+ )} +
어구 목록:
+ {gearMembers.map(m => ( +
+ + {m.name || m.mmsi} + + +
+ ))} +
+ )} +
+ ); + })} +
+ )} + + ); +}; + +export default GearGroupSection; diff --git a/frontend/src/components/korea/HistoryReplayController.tsx b/frontend/src/components/korea/HistoryReplayController.tsx new file mode 100644 index 0000000..875df93 --- /dev/null +++ b/frontend/src/components/korea/HistoryReplayController.tsx @@ -0,0 +1,171 @@ +import React from 'react'; +import { FONT_MONO } from '../../styles/fonts'; +import type { HistoryFrame } from './fleetClusterTypes'; +import { TIMELINE_DURATION_MS } from './fleetClusterTypes'; + +interface HistoryReplayControllerProps { + historyData: HistoryFrame[]; + effectiveSnapIdx: number; + isPlaying: boolean; + snapshotRanges: number[]; + progressBarRef: React.RefObject; + progressIndicatorRef: React.RefObject; + timeDisplayRef: React.RefObject; + historyStartRef: React.RefObject; + timelinePosRef: React.MutableRefObject; + frameTimesRef: React.RefObject; + onTogglePlay: () => void; + onFrameChange: (idx: number) => void; + onClose: () => void; +} + +const HistoryReplayController = ({ + historyData, + effectiveSnapIdx, + isPlaying, + snapshotRanges, + progressBarRef, + progressIndicatorRef, + timeDisplayRef, + historyStartRef, + timelinePosRef, + frameTimesRef, + onTogglePlay, + onFrameChange, + onClose, +}: HistoryReplayControllerProps) => { + const hasSnap = effectiveSnapIdx >= 0; + + return ( +
+ {/* 프로그레스 바 — 갭 표시 */} +
+ {snapshotRanges.map((pos, i) => ( +
+ ))} + {/* 현재 위치 인디케이터 (DOM ref로 업데이트) */} +
+
+ + {/* 컨트롤 행 */} +
+ + + + --:-- + + + { + timelinePosRef.current = Number(e.target.value) / 1000; + // 수동 드래그 시 즉시 프레임 계산 + const t = historyStartRef.current + timelinePosRef.current * TIMELINE_DURATION_MS; + const ft = frameTimesRef.current ?? []; + let best = 0, bestDiff = Infinity; + for (let i = 0; i < ft.length; i++) { + const d = Math.abs(ft[i] - t); + if (d < bestDiff) { bestDiff = d; best = i; } + } + onFrameChange(bestDiff < 1_800_000 ? best : -1); + if (timeDisplayRef.current) { + timeDisplayRef.current.textContent = new Date(t).toLocaleTimeString('ko-KR', { + hour: '2-digit', + minute: '2-digit', + }); + } + }} + style={{ flex: 1, cursor: 'pointer', accentColor: '#fbbf24' }} + title="히스토리 타임라인" + aria-label="히스토리 타임라인" + /> + + + {historyData.length}건 + + + +
+
+ ); +}; + +export default HistoryReplayController; diff --git a/frontend/src/components/korea/fleetClusterConstants.ts b/frontend/src/components/korea/fleetClusterConstants.ts new file mode 100644 index 0000000..8824618 --- /dev/null +++ b/frontend/src/components/korea/fleetClusterConstants.ts @@ -0,0 +1,116 @@ +import { FONT_MONO } from '../../styles/fonts'; + +// ── 모델 순서/색상/설명 ── +export const MODEL_ORDER = ['identity', 'default', 'aggressive', 'conservative', 'proximity-heavy', 'visit-pattern'] as const; + +export const MODEL_COLORS: Record = { + 'identity': '#f97316', + 'default': '#3b82f6', + 'aggressive': '#22c55e', + 'conservative': '#a855f7', + 'proximity-heavy': '#06b6d4', + 'visit-pattern': '#f43f5e', +}; + +export const MODEL_DESC: Record = { + 'identity': { + summary: '이름 패턴매칭 — 동일 모선명 기반 어구 그룹', + details: [ + '패턴: NAME_인덱스 (_ 필수, 공백만은 선박)', + '거리제한: ~10NM 이내 연결 클러스터링', + '모선연결: 어구와 ~20NM 이내 시 포함', + ], + }, + 'default': { + summary: '기본 모델 — 균형 가중치', + details: [ + '어구-선박: 근접도 45% · 방문 35% · 활동동기화 20%', + '선박-선박: DTW 30% · SOG 20% · COG 25% · 근접비 25%', + 'EMA: α 0.30→0.08 · 추적시작 50% · 폴리곤 70%', + '감쇠: 정상 0.015/5분 · 장기(6h+) 0.08/5분', + '근접판정: 5NM · 후보반경: 그룹×3배', + ], + }, + 'aggressive': { + summary: '공격적 추적 — 빠른 상승, 약한 감쇠', + details: [ + '어구-선박: 근접도 55% · 방문 25% · 활동동기화 20%', + 'EMA: α 0.40→0.10 · 추적시작 40% · 폴리곤 60%', + '감쇠: 정상 0.010/5분 · 장기(8h+) 0.06/5분', + '근접판정: 7NM · 후보반경: 그룹×4배', + '야간보너스: ×1.5 · shadow: 체류+0.15 복귀+0.20', + ], + }, + 'conservative': { + summary: '보수적 추적 — 높은 임계값, 강한 감쇠', + details: [ + '어구-선박: 근접도 40% · 방문 40% · 활동동기화 20%', + 'EMA: α 0.20→0.05 · 추적시작 60% · 폴리곤 80%', + '감쇠: 정상 0.020/5분 · 장기(4h+) 0.10/5분', + '근접판정: 4NM · 후보반경: 그룹×2.5배', + '야간보너스: ×1.2 · shadow: 체류+0.08 복귀+0.12', + ], + }, + 'proximity-heavy': { + summary: '근접 중심 — 거리 기반 판단 우선', + details: [ + '어구-선박: 근접도 70% · 방문 20% · 활동동기화 10%', + '선박-선박: 근접비 50% · DTW 20% · SOG 15% · COG 15%', + 'EMA: α 0.30→0.08 · 추적시작 50% · 폴리곤 70%', + '근접판정: 5NM · 후보반경: 그룹×3배', + 'shadow: 체류+0.12 복귀+0.18', + ], + }, + 'visit-pattern': { + summary: '방문 패턴 — 반복 접근 추적', + details: [ + '어구-선박: 근접도 25% · 방문 55% · 활동동기화 20%', + 'EMA: α 0.30→0.08 · 추적시작 50% · 폴리곤 70%', + '근접판정: 6NM · 후보반경: 그룹×3.5배', + '야간보너스: ×1.4', + ], + }, +}; + +// ── 패널 스타일 상수 ── +export const panelStyle: React.CSSProperties = { + position: 'absolute', + bottom: 60, + left: 10, + zIndex: 10, + minWidth: 220, + maxWidth: 300, + backgroundColor: 'rgba(12, 24, 37, 0.92)', + border: '1px solid rgba(99, 179, 237, 0.25)', + borderRadius: 8, + color: '#e2e8f0', + fontFamily: FONT_MONO, + fontSize: 11, + boxShadow: '0 4px 16px rgba(0, 0, 0, 0.5)', + overflow: 'hidden', + display: 'flex', + flexDirection: 'column', + pointerEvents: 'auto', + maxHeight: 'min(45vh, 400px)', +}; + +export const headerStyle: React.CSSProperties = { + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + padding: '6px 10px', + borderBottom: 'none', + cursor: 'default', + userSelect: 'none', + flexShrink: 0, +}; + +export const toggleButtonStyle: React.CSSProperties = { + background: 'none', + border: 'none', + color: '#94a3b8', + cursor: 'pointer', + fontSize: 10, + padding: '0 2px', + lineHeight: 1, +}; diff --git a/frontend/src/components/korea/fleetClusterTypes.ts b/frontend/src/components/korea/fleetClusterTypes.ts new file mode 100644 index 0000000..57211c3 --- /dev/null +++ b/frontend/src/components/korea/fleetClusterTypes.ts @@ -0,0 +1,58 @@ +import type { Ship, VesselAnalysisDto } from '../../types'; +import type { MemberInfo } from '../../services/vesselAnalysis'; + +// ── 히스토리 스냅샷 + 보간 플래그 ── +export type HistoryFrame = GroupPolygonDto & { _interp?: boolean; _longGap?: boolean }; + +// ── 외부 노출 타입 (KoreaMap에서 import) ── +export interface SelectedGearGroupData { + parent: Ship | null; + gears: Ship[]; + groupName: string; +} + +export interface SelectedFleetData { + clusterId: number; + ships: Ship[]; + companyName: string; +} + +// ── 내부 공유 타입 ── +export interface HoverTooltipState { + lng: number; + lat: number; + type: 'fleet' | 'gear'; + id: number | string; +} + +export interface PickerCandidate { + name: string; + count: number; + inZone: boolean; + isFleet: boolean; + clusterId?: number; +} + +export interface GearPickerPopupState { + lng: number; + lat: number; + candidates: PickerCandidate[]; +} + +export interface FleetListItem { + id: number; + mmsiList: string[]; + label: string; + memberCount: number; + areaSqNm: number; + color: string; + members: MemberInfo[]; +} + +// ── 상수 ── +export const GEAR_BUFFER_DEG = 0.01; +export const CIRCLE_SEGMENTS = 16; +export const TIMELINE_DURATION_MS = 12 * 60 * 60_000; // 12시간 +export const PLAYBACK_CYCLE_SEC = 30; // 30초에 12시간 전체 재생 +export const TICK_MS = 50; // 50ms 간격 업데이트 +export const EMPTY_ANALYSIS = new globalThis.Map(); diff --git a/frontend/src/components/korea/fleetClusterUtils.ts b/frontend/src/components/korea/fleetClusterUtils.ts new file mode 100644 index 0000000..6f281bc --- /dev/null +++ b/frontend/src/components/korea/fleetClusterUtils.ts @@ -0,0 +1,204 @@ +import type { GeoJSON } from 'geojson'; +import type { GroupPolygonDto } from '../../services/vesselAnalysis'; +import type { HistoryFrame } from './fleetClusterTypes'; +import { GEAR_BUFFER_DEG, CIRCLE_SEGMENTS } from './fleetClusterTypes'; + +/** 트랙 포인트에서 주어진 시각의 보간 위치 반환 */ +export function interpolateTrackPosition( + track: { ts: number; lat: number; lon: number; cog: number }[], + timeMs: number, +): { lat: number; lon: number; cog: number } | null { + if (track.length === 0) return null; + if (track.length === 1) return { lat: track[0].lat, lon: track[0].lon, cog: track[0].cog }; + if (timeMs <= track[0].ts) return { lat: track[0].lat, lon: track[0].lon, cog: track[0].cog }; + if (timeMs >= track[track.length - 1].ts) { + const last = track[track.length - 1]; + return { lat: last.lat, lon: last.lon, cog: last.cog }; + } + // Binary search for surrounding points + let lo = 0, hi = track.length - 1; + while (lo < hi - 1) { + const mid = (lo + hi) >> 1; + if (track[mid].ts <= timeMs) lo = mid; else hi = mid; + } + const a = track[lo], b = track[hi]; + const t = (timeMs - a.ts) / (b.ts - a.ts); + return { + lat: a.lat + t * (b.lat - a.lat), + lon: a.lon + t * (b.lon - a.lon), + cog: b.cog, + }; +} + +/** + * Convex hull + buffer 기반 폴리곤 생성 (Python polygon_builder.py 동일 로직) + * - 1점: 원형 버퍼 (GEAR_BUFFER_DEG=0.01 ~ 1.1km) + * - 2점: 두 점 잇는 직선 양쪽 버퍼 + * - 3점+: convex hull + 버퍼 + */ +export function buildInterpPolygon(points: [number, number][]): GeoJSON.Polygon | null { + if (points.length === 0) return null; + + if (points.length === 1) { + const [cx, cy] = points[0]; + const ring: [number, number][] = []; + for (let i = 0; i <= CIRCLE_SEGMENTS; i++) { + const angle = (2 * Math.PI * i) / CIRCLE_SEGMENTS; + ring.push([cx + GEAR_BUFFER_DEG * Math.cos(angle), cy + GEAR_BUFFER_DEG * Math.sin(angle)]); + } + return { type: 'Polygon', coordinates: [ring] }; + } + + if (points.length === 2) { + const [p1, p2] = points; + const dx = p2[0] - p1[0]; + const dy = p2[1] - p1[1]; + const len = Math.sqrt(dx * dx + dy * dy) || 1e-10; + const nx = (-dy / len) * GEAR_BUFFER_DEG; + const ny = (dx / len) * GEAR_BUFFER_DEG; + const ring: [number, number][] = []; + const half = CIRCLE_SEGMENTS / 2; + ring.push([p1[0] + nx, p1[1] + ny]); + ring.push([p2[0] + nx, p2[1] + ny]); + const a2 = Math.atan2(ny, nx); + for (let i = 0; i <= half; i++) { + const angle = a2 - Math.PI * i / half; + ring.push([p2[0] + GEAR_BUFFER_DEG * Math.cos(angle), p2[1] + GEAR_BUFFER_DEG * Math.sin(angle)]); + } + ring.push([p1[0] - nx, p1[1] - ny]); + const a1 = Math.atan2(-ny, -nx); + for (let i = 0; i <= half; i++) { + const angle = a1 - Math.PI * i / half; + ring.push([p1[0] + GEAR_BUFFER_DEG * Math.cos(angle), p1[1] + GEAR_BUFFER_DEG * Math.sin(angle)]); + } + ring.push(ring[0]); + return { type: 'Polygon', coordinates: [ring] }; + } + + const hull = convexHull(points); + return bufferPolygon(hull, GEAR_BUFFER_DEG); +} + +/** 단순 convex hull (Graham scan) */ +export function convexHull(points: [number, number][]): [number, number][] { + const pts = [...points].sort((a, b) => a[0] - b[0] || a[1] - b[1]); + if (pts.length <= 2) return pts; + + const cross = (o: [number, number], a: [number, number], b: [number, number]) => + (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0]); + + const lower: [number, number][] = []; + for (const p of pts) { + while (lower.length >= 2 && cross(lower[lower.length - 2], lower[lower.length - 1], p) <= 0) lower.pop(); + lower.push(p); + } + const upper: [number, number][] = []; + for (let i = pts.length - 1; i >= 0; i--) { + while (upper.length >= 2 && cross(upper[upper.length - 2], upper[upper.length - 1], pts[i]) <= 0) upper.pop(); + upper.push(pts[i]); + } + lower.pop(); + upper.pop(); + return lower.concat(upper); +} + +/** 폴리곤 외곽에 buffer 적용 (각 변의 오프셋 + 꼭짓점 라운드) */ +export function bufferPolygon(hull: [number, number][], buf: number): GeoJSON.Polygon { + const ring: [number, number][] = []; + const n = hull.length; + for (let i = 0; i < n; i++) { + const p = hull[i]; + const prev = hull[(i - 1 + n) % n]; + const next = hull[(i + 1) % n]; + const a1 = Math.atan2(p[1] - prev[1], p[0] - prev[0]) - Math.PI / 2; + const a2 = Math.atan2(next[1] - p[1], next[0] - p[0]) - Math.PI / 2; + const startA = a1; + let endA = a2; + if (endA < startA) endA += 2 * Math.PI; + const steps = Math.max(2, Math.round((endA - startA) / (Math.PI / 8))); + for (let s = 0; s <= steps; s++) { + const a = startA + (endA - startA) * s / steps; + ring.push([p[0] + buf * Math.cos(a), p[1] + buf * Math.sin(a)]); + } + } + ring.push(ring[0]); + return { type: 'Polygon', coordinates: [ring] }; +} + +/** + * 히스토리 데이터의 gap 구간에 보간 프레임을 삽입하여 완성 데이터셋 반환. + * - gap <= 30분: 5분 간격 직선 보간 (이전 폴리곤 유지, 중심만 직선 이동) + * - gap > 30분: 30분 간격으로 멤버 위치 보간 + 가상 폴리곤 생성 + */ +export function fillGapFrames(snapshots: GroupPolygonDto[]): HistoryFrame[] { + if (snapshots.length < 2) return snapshots; + const STEP_SHORT_MS = 300_000; + const STEP_LONG_MS = 1_800_000; + const THRESHOLD_MS = 1_800_000; + const result: GroupPolygonDto[] = []; + + for (let i = 0; i < snapshots.length; i++) { + result.push(snapshots[i]); + if (i >= snapshots.length - 1) continue; + + const prev = snapshots[i]; + const next = snapshots[i + 1]; + const t0 = new Date(prev.snapshotTime).getTime(); + const t1 = new Date(next.snapshotTime).getTime(); + const gap = t1 - t0; + if (gap <= STEP_SHORT_MS) continue; + + const nextMap = new Map(next.members.map(m => [m.mmsi, m])); + const common = prev.members.filter(m => nextMap.has(m.mmsi)); + if (common.length === 0) continue; + + if (gap <= THRESHOLD_MS) { + for (let t = t0 + STEP_SHORT_MS; t < t1; t += STEP_SHORT_MS) { + const ratio = (t - t0) / gap; + const cLon = prev.centerLon + (next.centerLon - prev.centerLon) * ratio; + const cLat = prev.centerLat + (next.centerLat - prev.centerLat) * ratio; + result.push({ + ...prev, + snapshotTime: new Date(t).toISOString(), + centerLon: cLon, + centerLat: cLat, + _interp: true, + }); + } + } else { + for (let t = t0 + STEP_LONG_MS; t < t1; t += STEP_LONG_MS) { + const ratio = (t - t0) / gap; + const positions: [number, number][] = []; + const members: typeof prev.members = []; + + for (const pm of common) { + const nm = nextMap.get(pm.mmsi)!; + const lon = pm.lon + (nm.lon - pm.lon) * ratio; + const lat = pm.lat + (nm.lat - pm.lat) * ratio; + const dLon = nm.lon - pm.lon; + const dLat = nm.lat - pm.lat; + const cog = (dLon === 0 && dLat === 0) ? pm.cog : (Math.atan2(dLon, dLat) * 180 / Math.PI + 360) % 360; + members.push({ mmsi: pm.mmsi, name: pm.name, lon, lat, sog: pm.sog, cog, role: pm.role, isParent: pm.isParent }); + positions.push([lon, lat]); + } + + const cLon = positions.reduce((s, p) => s + p[0], 0) / positions.length; + const cLat = positions.reduce((s, p) => s + p[1], 0) / positions.length; + const polygon = buildInterpPolygon(positions); + + result.push({ + ...prev, + snapshotTime: new Date(t).toISOString(), + polygon, + centerLon: cLon, + centerLat: cLat, + memberCount: members.length, + members, + _interp: true, + _longGap: true, + }); + } + } + } + return result; +} diff --git a/frontend/src/components/korea/useFleetClusterGeoJson.ts b/frontend/src/components/korea/useFleetClusterGeoJson.ts new file mode 100644 index 0000000..c17b341 --- /dev/null +++ b/frontend/src/components/korea/useFleetClusterGeoJson.ts @@ -0,0 +1,618 @@ +import { useMemo } from 'react'; +import type { GeoJSON } from 'geojson'; +import type { Ship, VesselAnalysisDto } from '../../types'; +import type { GearCorrelationItem, CorrelationVesselTrack } from '../../services/vesselAnalysis'; +import type { UseGroupPolygonsResult } from '../../hooks/useGroupPolygons'; +import type { HistoryFrame, FleetListItem } from './fleetClusterTypes'; +import { TIMELINE_DURATION_MS } from './fleetClusterTypes'; +import { interpolateTrackPosition, buildInterpPolygon } from './fleetClusterUtils'; +import { MODEL_ORDER, MODEL_COLORS } from './fleetClusterConstants'; + +export interface UseFleetClusterGeoJsonParams { + ships: Ship[]; + shipMap: Map; + groupPolygons: UseGroupPolygonsResult | undefined; + analysisMap: Map; + hoveredFleetId: number | null; + selectedGearGroup: string | null; + pickerHoveredGroup: string | null; + historyData: HistoryFrame[] | null; + effectiveSnapIdx: number; + correlationData: GearCorrelationItem[]; + correlationTracks: CorrelationVesselTrack[]; + enabledModels: Set; + enabledVessels: Set; + hoveredMmsi: string | null; + historyStartMs: number; +} + +export interface FleetClusterGeoJsonResult { + // static/base GeoJSON + fleetPolygonGeoJSON: GeoJSON; + lineGeoJSON: GeoJSON; + hoveredGeoJSON: GeoJSON; + gearClusterGeoJson: GeoJSON; + memberMarkersGeoJson: GeoJSON; + pickerHighlightGeoJson: GeoJSON; + selectedGearHighlightGeoJson: GeoJSON.FeatureCollection | null; + // history animation GeoJSON + memberTrailsGeoJson: GeoJSON; + centerTrailGeoJson: GeoJSON; + currentCenterGeoJson: GeoJSON; + animPolygonGeoJson: GeoJSON; + animMembersGeoJson: GeoJSON; + // correlation GeoJSON + correlationVesselGeoJson: GeoJSON; + correlationTrailGeoJson: GeoJSON; + modelBadgesGeoJson: GeoJSON; + hoverHighlightGeoJson: GeoJSON; + hoverHighlightTrailGeoJson: GeoJSON; + // operational polygons (per model) + operationalPolygons: { modelName: string; color: string; geojson: GeoJSON.FeatureCollection }[]; + // derived values + fleetList: FleetListItem[]; + currentFrame: HistoryFrame | null; + showGray: boolean; + isStale: boolean; + snapshotRanges: number[]; + correlationByModel: Map; + availableModels: { name: string; count: number; isDefault: boolean }[]; +} + +const EMPTY_FC: GeoJSON = { type: 'FeatureCollection', features: [] }; + +export function useFleetClusterGeoJson(params: UseFleetClusterGeoJsonParams): FleetClusterGeoJsonResult { + const { + ships, + shipMap, + groupPolygons, + hoveredFleetId, + selectedGearGroup, + pickerHoveredGroup, + historyData, + effectiveSnapIdx, + correlationData, + correlationTracks, + enabledModels, + enabledVessels, + hoveredMmsi, + historyStartMs, + } = params; + + // ── 선단 폴리곤 GeoJSON (서버 제공) ── + const fleetPolygonGeoJSON = useMemo((): GeoJSON => { + const features: GeoJSON.Feature[] = []; + if (!groupPolygons) return { type: 'FeatureCollection', features }; + for (const g of groupPolygons.fleetGroups) { + if (!g.polygon) continue; + features.push({ + type: 'Feature', + properties: { clusterId: Number(g.groupKey), color: g.color }, + geometry: g.polygon, + }); + } + return { type: 'FeatureCollection', features }; + }, [groupPolygons]); + + // 2척 선단 라인은 서버에서 Shapely buffer로 이미 Polygon 처리되므로 빈 컬렉션 + const lineGeoJSON = useMemo((): GeoJSON => ({ + type: 'FeatureCollection', features: [], + }), []); + + // 호버 하이라이트용 단일 폴리곤 + const hoveredGeoJSON = useMemo((): GeoJSON => { + if (hoveredFleetId === null || !groupPolygons) return EMPTY_FC; + const g = groupPolygons.fleetGroups.find(f => Number(f.groupKey) === hoveredFleetId); + if (!g?.polygon) return EMPTY_FC; + return { + type: 'FeatureCollection', + features: [{ + type: 'Feature', + properties: { clusterId: hoveredFleetId, color: g.color }, + geometry: g.polygon, + }], + }; + }, [hoveredFleetId, groupPolygons]); + + // 모델별 연관성 데이터 그룹핑 + const correlationByModel = useMemo(() => { + const map = new Map(); + for (const c of correlationData) { + const list = map.get(c.modelName) ?? []; + list.push(c); + map.set(c.modelName, list); + } + return map; + }, [correlationData]); + + // 사용 가능한 모델 목록 (데이터가 있는 모델만) + const availableModels = useMemo(() => { + const models: { name: string; count: number; isDefault: boolean }[] = []; + for (const [name, items] of correlationByModel) { + models.push({ name, count: items.length, isDefault: items[0]?.isDefault ?? false }); + } + models.sort((a, b) => (a.isDefault ? -1 : 0) - (b.isDefault ? -1 : 0)); + return models; + }, [correlationByModel]); + + // 현재 프레임 및 파생 상태 + const currentFrame = historyData && effectiveSnapIdx >= 0 ? historyData[effectiveSnapIdx] : null; + const showGray = !!currentFrame?._longGap || !!currentFrame?._interp; + const isStale = showGray; + + // 스냅샷 존재 구간 맵 (프로그레스 바 갭 표시용) + const snapshotRanges = useMemo(() => { + if (!historyData) return []; + return historyData + .filter(h => !h._interp) + .map(h => { + const t = new Date(h.snapshotTime).getTime(); + return (t - historyStartMs) / TIMELINE_DURATION_MS; + }); + }, [historyData, historyStartMs]); + + // ── 사전계산: 각 프레임별 연관 대상 보간 위치 ── + const correlationPosMap = useMemo(() => { + if (!historyData || correlationTracks.length === 0) return null; + const trackMap = new Map(correlationTracks.map(v => [v.mmsi, v.track])); + return historyData.map(snap => { + const t = new Date(snap.snapshotTime).getTime(); + const m = new Map(); + for (const [mmsi, track] of trackMap) { + const p = interpolateTrackPosition(track, t); + if (p) m.set(mmsi, p); + } + return m; + }); + }, [historyData, correlationTracks]); + + // 사전계산: 각 프레임별 트레일 클립 인덱스 + const trailClipMap = useMemo(() => { + if (!historyData || correlationTracks.length === 0) return null; + return historyData.map(snap => { + const t = new Date(snap.snapshotTime).getTime(); + const m = new Map(); + for (const vt of correlationTracks) { + let idx = vt.track.length; + for (let i = 0; i < vt.track.length; i++) { + if (vt.track[i].ts > t) { idx = i; break; } + } + m.set(vt.mmsi, idx); + } + return m; + }); + }, [historyData, correlationTracks]); + + // 사전계산: 각 프레임별 오퍼레이셔널 폴리곤 + const operationalPolygonsByFrame = useMemo(() => { + if (!historyData || !selectedGearGroup || !groupPolygons) return null; + const allGroups = [...groupPolygons.gearInZoneGroups, ...groupPolygons.gearOutZoneGroups]; + const group = allGroups.find(g => g.groupKey === selectedGearGroup); + if (!group) return null; + + return historyData.map((snap, fi) => { + const basePts: [number, number][] = snap.members.map(m => [m.lon, m.lat]); + const positions = correlationPosMap?.[fi]; + const result: { modelName: string; color: string; geojson: GeoJSON.FeatureCollection }[] = []; + + for (const [mn, items] of correlationByModel) { + if (!enabledModels.has(mn)) continue; + const extra: [number, number][] = []; + for (const c of items) { + if (c.score < 0.7) continue; + const p = positions?.get(c.targetMmsi); + if (p) extra.push([p.lon, p.lat]); + } + if (extra.length === 0) continue; + const polygon = buildInterpPolygon([...basePts, ...extra]); + if (!polygon) continue; + const color = MODEL_COLORS[mn] ?? '#94a3b8'; + result.push({ + modelName: mn, + color, + geojson: { + type: 'FeatureCollection', + features: [{ type: 'Feature', properties: { modelName: mn, color }, geometry: polygon }], + }, + }); + } + return result; + }); + }, [historyData, selectedGearGroup, groupPolygons, correlationByModel, enabledModels, correlationPosMap]); + + // 재생 시 O(1) 룩업, 비재생 시 기존 로직 + const operationalPolygons = useMemo(() => { + if (operationalPolygonsByFrame && effectiveSnapIdx >= 0) { + return operationalPolygonsByFrame[effectiveSnapIdx] ?? []; + } + if (!selectedGearGroup || !groupPolygons) return []; + const allGroups = [...groupPolygons.gearInZoneGroups, ...groupPolygons.gearOutZoneGroups]; + const group = allGroups.find(g => g.groupKey === selectedGearGroup); + if (!group) return []; + const basePts: [number, number][] = group.members.map(m => [m.lon, m.lat]); + const result: { modelName: string; color: string; geojson: GeoJSON.FeatureCollection }[] = []; + for (const [mn, items] of correlationByModel) { + if (!enabledModels.has(mn)) continue; + const extra: [number, number][] = []; + for (const c of items) { + if (c.score < 0.7) continue; + const s = ships.find(x => x.mmsi === c.targetMmsi); + if (s) extra.push([s.lng, s.lat]); + } + if (extra.length === 0) continue; + const polygon = buildInterpPolygon([...basePts, ...extra]); + if (!polygon) continue; + const color = MODEL_COLORS[mn] ?? '#94a3b8'; + result.push({ + modelName: mn, + color, + geojson: { + type: 'FeatureCollection', + features: [{ type: 'Feature', properties: { modelName: mn, color }, geometry: polygon }], + }, + }); + } + return result; + }, [operationalPolygonsByFrame, effectiveSnapIdx, selectedGearGroup, groupPolygons, correlationByModel, enabledModels, ships]); + + // 어구 클러스터 GeoJSON (서버 제공) + const gearClusterGeoJson = useMemo((): GeoJSON => { + const features: GeoJSON.Feature[] = []; + if (!groupPolygons) return { type: 'FeatureCollection', features }; + for (const g of [...groupPolygons.gearInZoneGroups, ...groupPolygons.gearOutZoneGroups]) { + if (!g.polygon) continue; + features.push({ + type: 'Feature', + properties: { + name: g.groupKey, + gearCount: g.memberCount, + inZone: g.groupType === 'GEAR_IN_ZONE' ? 1 : 0, + }, + geometry: g.polygon, + }); + } + return { type: 'FeatureCollection', features }; + }, [groupPolygons]); + + // 가상 선박 마커 GeoJSON (API members + shipMap heading 보정) + const memberMarkersGeoJson = useMemo((): GeoJSON => { + const features: GeoJSON.Feature[] = []; + if (!groupPolygons) return { type: 'FeatureCollection', features }; + + const addMember = ( + m: { mmsi: string; name: string; lat: number; lon: number; cog: number; isParent: boolean; role: string }, + groupKey: string, + groupType: string, + color: string, + ) => { + const realShip = shipMap.get(m.mmsi); + const heading = realShip?.heading ?? m.cog ?? 0; + const lat = realShip?.lat ?? m.lat; + const lon = realShip?.lng ?? m.lon; + features.push({ + type: 'Feature', + properties: { + mmsi: m.mmsi, + name: m.name, + groupKey, + groupType, + role: m.role, + isParent: m.isParent ? 1 : 0, + isGear: (groupType !== 'FLEET' && !m.isParent) ? 1 : 0, + color, + cog: heading, + baseSize: (groupType !== 'FLEET' && !m.isParent) ? 0.11 : m.isParent ? 0.18 : 0.14, + }, + geometry: { type: 'Point', coordinates: [lon, lat] }, + }); + }; + + for (const g of groupPolygons.fleetGroups) { + for (const m of g.members) addMember(m, g.groupKey, 'FLEET', g.color); + } + for (const g of [...groupPolygons.gearInZoneGroups, ...groupPolygons.gearOutZoneGroups]) { + const color = g.groupType === 'GEAR_IN_ZONE' ? '#dc2626' : '#f97316'; + for (const m of g.members) addMember(m, g.groupKey, g.groupType, color); + } + return { type: 'FeatureCollection', features }; + }, [groupPolygons, shipMap]); + + // picker 호버 하이라이트 (선단 + 어구 통합) + const pickerHighlightGeoJson = useMemo((): GeoJSON => { + if (!pickerHoveredGroup || !groupPolygons) return EMPTY_FC; + const fleet = groupPolygons.fleetGroups.find(x => String(x.groupKey) === pickerHoveredGroup); + if (fleet?.polygon) return { type: 'FeatureCollection', features: [{ type: 'Feature', properties: {}, geometry: fleet.polygon }] }; + const all = [...groupPolygons.gearInZoneGroups, ...groupPolygons.gearOutZoneGroups]; + const g = all.find(x => x.groupKey === pickerHoveredGroup); + if (!g?.polygon) return EMPTY_FC; + return { type: 'FeatureCollection', features: [{ type: 'Feature', properties: {}, geometry: g.polygon }] }; + }, [pickerHoveredGroup, groupPolygons]); + + // 선택된 어구 그룹 하이라이트 폴리곤 (JSX IIFE → useMemo) + const selectedGearHighlightGeoJson = useMemo((): GeoJSON.FeatureCollection | null => { + if (!selectedGearGroup || !enabledModels.has('identity') || historyData) return null; + const allGroups = groupPolygons + ? [...groupPolygons.gearInZoneGroups, ...groupPolygons.gearOutZoneGroups] + : []; + const group = allGroups.find(g => g.groupKey === selectedGearGroup); + if (!group?.polygon) return null; + return { + type: 'FeatureCollection', + features: [{ + type: 'Feature', + properties: {}, + geometry: group.polygon, + }], + }; + }, [selectedGearGroup, enabledModels, historyData, groupPolygons]); + + // ── 히스토리 애니메이션 GeoJSON ── + const memberTrailsGeoJson = useMemo((): GeoJSON => { + if (!historyData) return EMPTY_FC; + const tracks = new Map(); + for (const snap of historyData) { + for (const m of snap.members) { + const arr = tracks.get(m.mmsi) ?? []; + arr.push([m.lon, m.lat]); + tracks.set(m.mmsi, arr); + } + } + const features: GeoJSON.Feature[] = []; + for (const [, coords] of tracks) { + if (coords.length < 2) continue; + features.push({ type: 'Feature', properties: {}, geometry: { type: 'LineString', coordinates: coords } }); + } + return { type: 'FeatureCollection', features }; + }, [historyData]); + + // center trail: historyData에 이미 보간 프레임 포함 → 전체 좌표 연결 + const centerTrailGeoJson = useMemo((): GeoJSON => { + if (!historyData || historyData.length === 0) return EMPTY_FC; + + const features: GeoJSON.Feature[] = []; + + let segStart = 0; + for (let i = 1; i <= historyData.length; i++) { + const curInterp = i < historyData.length && !!historyData[i]._longGap; + const startInterp = !!historyData[segStart]._longGap; + if (i < historyData.length && curInterp === startInterp) continue; + + const from = segStart > 0 ? segStart - 1 : segStart; + const seg = historyData.slice(from, i); + if (seg.length >= 2) { + features.push({ + type: 'Feature', + properties: { interpolated: startInterp ? 1 : 0 }, + geometry: { type: 'LineString', coordinates: seg.map(s => [s.centerLon, s.centerLat]) }, + }); + } + segStart = i; + } + + for (const h of historyData) { + if (h.color === '#94a3b8') continue; + features.push({ + type: 'Feature', properties: { interpolated: 0 }, + geometry: { type: 'Point', coordinates: [h.centerLon, h.centerLat] }, + }); + } + + return { type: 'FeatureCollection', features }; + }, [historyData]); + + // 현재 재생 위치 포인트 + const currentCenterGeoJson = useMemo((): GeoJSON => { + if (!historyData || effectiveSnapIdx < 0) return EMPTY_FC; + const snap = historyData[effectiveSnapIdx]; + if (!snap) return EMPTY_FC; + return { + type: 'FeatureCollection', + features: [{ + type: 'Feature', + properties: { interpolated: showGray ? 1 : 0 }, + geometry: { type: 'Point', coordinates: [snap.centerLon, snap.centerLat] }, + }], + }; + }, [historyData, effectiveSnapIdx, showGray]); + + const animPolygonGeoJson = useMemo((): GeoJSON => { + if (!historyData || effectiveSnapIdx < 0) return EMPTY_FC; + const snap = historyData[effectiveSnapIdx]; + if (!snap?.polygon) return EMPTY_FC; + return { + type: 'FeatureCollection', + features: [{ type: 'Feature', properties: { stale: isStale ? 1 : 0, interpolated: showGray ? 1 : 0 }, geometry: snap.polygon }], + }; + }, [historyData, effectiveSnapIdx, isStale, showGray]); + + // 현재 프레임의 멤버 위치 (가상 아이콘) + const animMembersGeoJson = useMemo((): GeoJSON => { + if (!historyData || effectiveSnapIdx < 0) return EMPTY_FC; + const snap = historyData[effectiveSnapIdx]; + if (!snap) return EMPTY_FC; + return { + type: 'FeatureCollection', + features: snap.members.map(m => ({ + type: 'Feature' as const, + properties: { mmsi: m.mmsi, name: m.name, cog: m.cog ?? 0, role: m.role, isGear: m.role === 'GEAR' ? 1 : 0, stale: isStale ? 1 : 0, interpolated: showGray ? 1 : 0 }, + geometry: { type: 'Point' as const, coordinates: [m.lon, m.lat] }, + })), + }; + }, [historyData, effectiveSnapIdx, isStale, showGray]); + + // ── 연관 대상 마커 (사전계산 룩업 or ships fallback) ── + const correlationVesselGeoJson = useMemo((): GeoJSON => { + if (!selectedGearGroup || correlationByModel.size === 0) return EMPTY_FC; + const positions = correlationPosMap && effectiveSnapIdx >= 0 ? correlationPosMap[effectiveSnapIdx] : null; + const features: GeoJSON.Feature[] = []; + const seen = new Set(); + + for (const [mn, items] of correlationByModel) { + if (!enabledModels.has(mn)) continue; + const color = MODEL_COLORS[mn] ?? '#94a3b8'; + for (const c of items) { + if (seen.has(c.targetMmsi)) continue; + let lon: number | undefined, lat: number | undefined, cog = 0; + const cached = positions?.get(c.targetMmsi); + if (cached) { lon = cached.lon; lat = cached.lat; cog = cached.cog; } + if (lon === undefined) { + const s = ships.find(x => x.mmsi === c.targetMmsi); + if (s) { lon = s.lng; lat = s.lat; cog = s.course ?? 0; } + } + if (lon === undefined || lat === undefined) continue; + seen.add(c.targetMmsi); + features.push({ + type: 'Feature', + properties: { mmsi: c.targetMmsi, name: c.targetName || c.targetMmsi, score: c.score, cog, color, isVessel: c.targetType === 'VESSEL' ? 1 : 0 }, + geometry: { type: 'Point', coordinates: [lon, lat] }, + }); + } + } + return { type: 'FeatureCollection', features }; + }, [selectedGearGroup, correlationByModel, enabledModels, correlationPosMap, effectiveSnapIdx, ships]); + + // 연관 대상 트레일 (사전계산 클립 인덱스 룩업) + const correlationTrailGeoJson = useMemo((): GeoJSON => { + if (correlationTracks.length === 0) return EMPTY_FC; + const clips = trailClipMap && effectiveSnapIdx >= 0 ? trailClipMap[effectiveSnapIdx] : null; + const features: GeoJSON.Feature[] = []; + const vesselColor = new Map(); + for (const [mn, items] of correlationByModel) { + if (!enabledModels.has(mn)) continue; + for (const c of items) { + if (!vesselColor.has(c.targetMmsi)) vesselColor.set(c.targetMmsi, MODEL_COLORS[mn] ?? '#60a5fa'); + } + } + for (const vt of correlationTracks) { + if (!enabledVessels.has(vt.mmsi)) continue; + const color = vesselColor.get(vt.mmsi) ?? '#60a5fa'; + const clipIdx = clips?.get(vt.mmsi) ?? vt.track.length; + const coords: [number, number][] = vt.track.slice(0, clipIdx).map(pt => [pt.lon, pt.lat]); + if (coords.length >= 2) { + features.push({ type: 'Feature', properties: { mmsi: vt.mmsi, color }, geometry: { type: 'LineString', coordinates: coords } }); + } + } + return { type: 'FeatureCollection', features }; + }, [correlationTracks, enabledVessels, correlationByModel, enabledModels, trailClipMap, effectiveSnapIdx]); + + // 모델 배지 GeoJSON (사전계산 위치 룩업) + const modelBadgesGeoJson = useMemo((): GeoJSON => { + if (!selectedGearGroup) return EMPTY_FC; + const positions = correlationPosMap && effectiveSnapIdx >= 0 ? correlationPosMap[effectiveSnapIdx] : null; + const targets = new Map }>(); + + if (enabledModels.has('identity')) { + const members = (historyData && effectiveSnapIdx >= 0) + ? historyData[effectiveSnapIdx].members + : (() => { + if (!groupPolygons) return []; + const all = [...groupPolygons.gearInZoneGroups, ...groupPolygons.gearOutZoneGroups]; + return all.find(g => g.groupKey === selectedGearGroup)?.members ?? []; + })(); + for (const m of members) { + const e = targets.get(m.mmsi) ?? { lon: m.lon, lat: m.lat, models: new Set() }; + e.lon = m.lon; e.lat = m.lat; e.models.add('identity'); + targets.set(m.mmsi, e); + } + } + for (const [mn, items] of correlationByModel) { + if (!enabledModels.has(mn)) continue; + for (const c of items) { + if (c.score < 0.3) continue; + let lon: number | undefined, lat: number | undefined; + const cached = positions?.get(c.targetMmsi); + if (cached) { lon = cached.lon; lat = cached.lat; } + if (lon === undefined) { const s = ships.find(x => x.mmsi === c.targetMmsi); if (s) { lon = s.lng; lat = s.lat; } } + if (lon !== undefined && lat !== undefined) { + const e = targets.get(c.targetMmsi) ?? { lon, lat, models: new Set() }; + e.lon = lon; e.lat = lat; e.models.add(mn); + targets.set(c.targetMmsi, e); + } + } + } + const features: GeoJSON.Feature[] = []; + for (const [mmsi, t] of targets) { + if (t.models.size === 0) continue; + const props: Record = { mmsi }; + for (let i = 0; i < MODEL_ORDER.length; i++) props[`m${i}`] = t.models.has(MODEL_ORDER[i]) ? 1 : 0; + features.push({ type: 'Feature', properties: props, geometry: { type: 'Point', coordinates: [t.lon, t.lat] } }); + } + return { type: 'FeatureCollection', features }; + }, [selectedGearGroup, enabledModels, historyData, effectiveSnapIdx, groupPolygons, + correlationByModel, correlationPosMap, ships]); + + // 호버 하이라이트 — 대상 위치 (사전계산 룩업) + const hoverHighlightGeoJson = useMemo((): GeoJSON => { + if (!hoveredMmsi || !selectedGearGroup) return EMPTY_FC; + const positions = correlationPosMap && effectiveSnapIdx >= 0 ? correlationPosMap[effectiveSnapIdx] : null; + if (historyData && effectiveSnapIdx >= 0) { + const m = historyData[effectiveSnapIdx].members.find(x => x.mmsi === hoveredMmsi); + if (m) return { type: 'FeatureCollection', features: [{ type: 'Feature', properties: {}, geometry: { type: 'Point', coordinates: [m.lon, m.lat] } }] }; + } + const cached = positions?.get(hoveredMmsi); + if (cached) return { type: 'FeatureCollection', features: [{ type: 'Feature', properties: {}, geometry: { type: 'Point', coordinates: [cached.lon, cached.lat] } }] }; + if (groupPolygons) { + const all = [...groupPolygons.gearInZoneGroups, ...groupPolygons.gearOutZoneGroups]; + const m = all.find(g => g.groupKey === selectedGearGroup)?.members.find(x => x.mmsi === hoveredMmsi); + if (m) return { type: 'FeatureCollection', features: [{ type: 'Feature', properties: {}, geometry: { type: 'Point', coordinates: [m.lon, m.lat] } }] }; + } + const s = ships.find(x => x.mmsi === hoveredMmsi); + if (s) return { type: 'FeatureCollection', features: [{ type: 'Feature', properties: {}, geometry: { type: 'Point', coordinates: [s.lng, s.lat] } }] }; + return EMPTY_FC; + }, [hoveredMmsi, selectedGearGroup, historyData, effectiveSnapIdx, correlationPosMap, groupPolygons, ships]); + + // 호버 하이라이트 — 대상 항적 (사전계산 클립 룩업) + const hoverHighlightTrailGeoJson = useMemo((): GeoJSON => { + if (!hoveredMmsi) return EMPTY_FC; + const vt = correlationTracks.find(v => v.mmsi === hoveredMmsi); + if (!vt) return EMPTY_FC; + const clipIdx = trailClipMap && effectiveSnapIdx >= 0 + ? (trailClipMap[effectiveSnapIdx]?.get(hoveredMmsi) ?? vt.track.length) + : vt.track.length; + const coords: [number, number][] = vt.track.slice(0, clipIdx).map(pt => [pt.lon, pt.lat]); + if (coords.length < 2) return EMPTY_FC; + return { type: 'FeatureCollection', features: [{ type: 'Feature', properties: {}, geometry: { type: 'LineString', coordinates: coords } }] }; + }, [hoveredMmsi, correlationTracks, trailClipMap, effectiveSnapIdx]); + + // 선단 목록 (멤버 수 내림차순) + const fleetList = useMemo((): FleetListItem[] => { + if (!groupPolygons) return []; + return groupPolygons.fleetGroups.map(g => ({ + id: Number(g.groupKey), + mmsiList: g.members.map(m => m.mmsi), + label: g.groupLabel, + memberCount: g.memberCount, + areaSqNm: g.areaSqNm, + color: g.color, + members: g.members, + })).sort((a, b) => b.memberCount - a.memberCount); + }, [groupPolygons]); + + return { + fleetPolygonGeoJSON, + lineGeoJSON, + hoveredGeoJSON, + gearClusterGeoJson, + memberMarkersGeoJson, + pickerHighlightGeoJson, + selectedGearHighlightGeoJson, + memberTrailsGeoJson, + centerTrailGeoJson, + currentCenterGeoJson, + animPolygonGeoJson, + animMembersGeoJson, + correlationVesselGeoJson, + correlationTrailGeoJson, + modelBadgesGeoJson, + hoverHighlightGeoJson, + hoverHighlightTrailGeoJson, + operationalPolygons, + fleetList, + currentFrame, + showGray, + isStale, + snapshotRanges, + correlationByModel, + availableModels, + }; +} diff --git a/frontend/src/hooks/useGearReplayLayers.ts b/frontend/src/hooks/useGearReplayLayers.ts new file mode 100644 index 0000000..c7a8f50 --- /dev/null +++ b/frontend/src/hooks/useGearReplayLayers.ts @@ -0,0 +1,452 @@ +import { useEffect, useRef, useCallback } from 'react'; +import type { Layer } from '@deck.gl/core'; +import { TripsLayer } from '@deck.gl/geo-layers'; +import { ScatterplotLayer, PathLayer, PolygonLayer, TextLayer } from '@deck.gl/layers'; +import { useGearReplayStore } from '../stores/gearReplayStore'; +import { findFrameAtTime, interpolateMemberPositions } from '../stores/gearReplayPreprocess'; +import type { MemberPosition } from '../stores/gearReplayPreprocess'; +import { MODEL_COLORS } from '../components/korea/fleetClusterConstants'; +import { buildInterpPolygon } from '../components/korea/fleetClusterUtils'; +import type { GearCorrelationItem } from '../services/vesselAnalysis'; + +// ── Constants ───────────────────────────────────────────────────────────────── + +const TRAIL_LENGTH_MS = 3_600_000; // 1 hour trail +const RENDER_INTERVAL_MS = 100; // 10fps throttle during playback + +// ── Helper ─────────────────────────────────────────────────────────────────── + +function hexToRgb(hex: string): [number, number, number] { + const h = hex.replace('#', ''); + return [ + parseInt(h.substring(0, 2), 16), + parseInt(h.substring(2, 4), 16), + parseInt(h.substring(4, 6), 16), + ]; +} + +// ── Types ───────────────────────────────────────────────────────────────────── + +interface CorrPosition { + mmsi: string; + name: string; + lon: number; + lat: number; + color: [number, number, number, number]; + isVessel: boolean; +} + +// ── Hook ────────────────────────────────────────────────────────────────────── + +/** + * Gear group replay animation layers for deck.gl. + * + * Performance: + * - currentTime changes are subscribed via zustand.subscribe (NOT React selectors). + * React never re-renders during playback. + * - Layer objects are built imperatively and written to replayLayerRef. + * - The parent calls overlay.setProps() to push layers to WebGL. + */ +export function useGearReplayLayers( + replayLayerRef: React.MutableRefObject, + requestRender: () => void, +): void { + // ── React selectors (infrequent changes only) ──────────────────────────── + const historyFrames = useGearReplayStore(s => s.historyFrames); + const memberTripsData = useGearReplayStore(s => s.memberTripsData); + const correlationTripsData = useGearReplayStore(s => s.correlationTripsData); + const centerTrailSegments = useGearReplayStore(s => s.centerTrailSegments); + const centerDotsPositions = useGearReplayStore(s => s.centerDotsPositions); + const enabledModels = useGearReplayStore(s => s.enabledModels); + const enabledVessels = useGearReplayStore(s => s.enabledVessels); + const hoveredMmsi = useGearReplayStore(s => s.hoveredMmsi); + const correlationByModel = useGearReplayStore(s => s.correlationByModel); + + // ── Refs ───────────────────────────────────────────────────────────────── + const cursorRef = useRef(0); // frame cursor for O(1) forward lookup + + // ── renderFrame ────────────────────────────────────────────────────────── + + const renderFrame = useCallback(() => { + if (historyFrames.length === 0) { + replayLayerRef.current = []; + requestRender(); + return; + } + + const state = useGearReplayStore.getState(); + const ct = state.currentTime; + const st = state.startTime; + + // Find current frame + const { index: frameIdx, cursor } = findFrameAtTime(state.frameTimes, ct, cursorRef.current); + cursorRef.current = cursor; + + const layers: Layer[] = []; + + // ── Static layers (center trail + dots) ─────────────────────────────── + + // Center trail segments (PathLayer) + for (let i = 0; i < centerTrailSegments.length; i++) { + const seg = centerTrailSegments[i]; + if (seg.path.length < 2) continue; + layers.push(new PathLayer({ + id: `replay-center-trail-${i}`, + data: [{ path: seg.path }], + getPath: (d: { path: [number, number][] }) => d.path, + getColor: seg.isInterpolated + ? [249, 115, 22, 200] + : [251, 191, 36, 180], + widthMinPixels: 2, + })); + } + + // Center dots (real data only) + if (centerDotsPositions.length > 0) { + layers.push(new ScatterplotLayer({ + id: 'replay-center-dots', + data: centerDotsPositions, + getPosition: (d: [number, number]) => d, + getFillColor: [251, 191, 36, 150], + getRadius: 80, + radiusUnits: 'meters', + radiusMinPixels: 2.5, + })); + } + + // ── Dynamic layers (depend on currentTime) ──────────────────────────── + + if (frameIdx < 0) { + // No valid frame at this time — only show static layers + replayLayerRef.current = layers; + requestRender(); + return; + } + + const frame = state.historyFrames[frameIdx]; + const isStale = !!frame._longGap || !!frame._interp; + + // Member positions (interpolated) + const members = interpolateMemberPositions(state.historyFrames, frameIdx, ct); + + // 1. TripsLayer — member trails (GPU animated) + if (memberTripsData.length > 0) { + layers.push(new TripsLayer({ + id: 'replay-member-trails', + data: memberTripsData, + getPath: d => d.path, + getTimestamps: d => d.timestamps, + getColor: d => d.color, + widthMinPixels: 1.5, + fadeTrail: true, + trailLength: TRAIL_LENGTH_MS, + currentTime: ct - st, + })); + } + + // 2. TripsLayer — correlation trails (GPU animated) + if (correlationTripsData.length > 0) { + const enabledTrips = correlationTripsData.filter(d => enabledVessels.has(d.id)); + if (enabledTrips.length > 0) { + layers.push(new TripsLayer({ + id: 'replay-corr-trails', + data: enabledTrips, + getPath: d => d.path, + getTimestamps: d => d.timestamps, + getColor: d => d.color, + widthMinPixels: 2, + fadeTrail: true, + trailLength: TRAIL_LENGTH_MS, + currentTime: ct - st, + })); + } + } + + // 3. Current animated polygon (convex hull of members) + const memberPts: [number, number][] = members.map(m => [m.lon, m.lat]); + const polygon = buildInterpPolygon(memberPts); + if (polygon) { + layers.push(new PolygonLayer({ + id: 'replay-polygon', + data: [{ polygon: polygon.coordinates }], + getPolygon: (d: { polygon: number[][][] }) => d.polygon, + getFillColor: isStale ? [148, 163, 184, 30] : [251, 191, 36, 40], + getLineColor: isStale ? [148, 163, 184, 100] : [251, 191, 36, 180], + getLineWidth: isStale ? 1 : 2, + lineWidthMinPixels: 1, + filled: true, + stroked: true, + })); + } + + // 4. Current center point + layers.push(new ScatterplotLayer({ + id: 'replay-center', + data: [{ position: [frame.centerLon, frame.centerLat] as [number, number] }], + getPosition: (d: { position: [number, number] }) => d.position, + getFillColor: isStale ? [249, 115, 22, 255] : [239, 68, 68, 255], + getRadius: 200, + radiusUnits: 'meters', + radiusMinPixels: 7, + stroked: true, + getLineColor: [255, 255, 255, 255], + lineWidthMinPixels: 2, + })); + + // 5. Member position markers + if (members.length > 0) { + layers.push(new ScatterplotLayer({ + id: 'replay-members', + data: members, + getPosition: d => [d.lon, d.lat], + getFillColor: d => { + if (d.stale) return [100, 116, 139, 150]; + if (d.isGear) return [168, 184, 200, 230]; + return [251, 191, 36, 230]; + }, + getRadius: d => d.isParent ? 150 : d.isGear ? 80 : 120, + radiusUnits: 'meters', + radiusMinPixels: 3, + stroked: true, + getLineColor: [0, 0, 0, 150], + lineWidthMinPixels: 0.5, + })); + + // Member labels + layers.push(new TextLayer({ + id: 'replay-member-labels', + data: members, + getPosition: d => [d.lon, d.lat], + getText: d => d.name || d.mmsi, + getColor: d => d.stale + ? [148, 163, 184, 200] + : d.isGear + ? [226, 232, 240, 255] + : [251, 191, 36, 255], + getSize: 10, + getPixelOffset: [0, 14], + background: true, + getBackgroundColor: [0, 0, 0, 200], + backgroundPadding: [2, 1], + fontFamily: '"Fira Code Variable", monospace', + })); + } + + // 6. Correlation vessel positions (interpolated from correlationTripsData) + const corrPositions: CorrPosition[] = []; + const corrTrackMap = new Map(correlationTripsData.map(d => [d.id, d])); + + for (const [mn, items] of correlationByModel) { + if (!enabledModels.has(mn)) continue; + const color = MODEL_COLORS[mn] ?? '#94a3b8'; + const [r, g, b] = hexToRgb(color); + + for (const c of items as GearCorrelationItem[]) { + if (corrPositions.some(p => p.mmsi === c.targetMmsi)) continue; + + const tripData = corrTrackMap.get(c.targetMmsi); + if (!tripData) continue; + + const relTime = ct - st; + const ts = tripData.timestamps; + const path = tripData.path; + if (ts.length === 0) continue; + if (relTime < ts[0] || relTime > ts[ts.length - 1]) continue; + + // Binary search in timestamps + let lo = 0; + let hi = ts.length - 1; + while (lo < hi - 1) { + const mid = (lo + hi) >> 1; + if (ts[mid] <= relTime) lo = mid; else hi = mid; + } + const ratio = ts[hi] !== ts[lo] ? (relTime - ts[lo]) / (ts[hi] - ts[lo]) : 0; + const lon = path[lo][0] + (path[hi][0] - path[lo][0]) * ratio; + const lat = path[lo][1] + (path[hi][1] - path[lo][1]) * ratio; + + corrPositions.push({ + mmsi: c.targetMmsi, + name: c.targetName || c.targetMmsi, + lon, + lat, + color: [r, g, b, 230], + isVessel: c.targetType === 'VESSEL', + }); + } + } + + if (corrPositions.length > 0) { + layers.push(new ScatterplotLayer({ + id: 'replay-corr-vessels', + data: corrPositions, + getPosition: d => [d.lon, d.lat], + getFillColor: d => d.color, + getRadius: d => d.isVessel ? 130 : 80, + radiusUnits: 'meters', + radiusMinPixels: 3, + stroked: true, + getLineColor: [0, 0, 0, 150], + lineWidthMinPixels: 1, + })); + + layers.push(new TextLayer({ + id: 'replay-corr-labels', + data: corrPositions, + getPosition: d => [d.lon, d.lat], + getText: d => d.name, + getColor: d => d.color, + getSize: 8, + getPixelOffset: [0, 15], + background: true, + getBackgroundColor: [0, 0, 0, 200], + backgroundPadding: [2, 1], + })); + } + + // 7. Hover highlight + if (hoveredMmsi) { + const hoveredMember = members.find(m => m.mmsi === hoveredMmsi); + const hoveredCorr = corrPositions.find(c => c.mmsi === hoveredMmsi); + const hoveredPos: [number, number] | null = hoveredMember + ? [hoveredMember.lon, hoveredMember.lat] + : hoveredCorr + ? [hoveredCorr.lon, hoveredCorr.lat] + : null; + + if (hoveredPos) { + layers.push(new ScatterplotLayer({ + id: 'replay-hover-glow', + data: [{ position: hoveredPos }], + getPosition: (d: { position: [number, number] }) => d.position, + getFillColor: [255, 255, 255, 60], + getRadius: 400, + radiusUnits: 'meters', + radiusMinPixels: 14, + })); + layers.push(new ScatterplotLayer({ + id: 'replay-hover-ring', + data: [{ position: hoveredPos }], + getPosition: (d: { position: [number, number] }) => d.position, + getFillColor: [0, 0, 0, 0], + getRadius: 250, + radiusUnits: 'meters', + radiusMinPixels: 8, + stroked: true, + getLineColor: [255, 255, 255, 255], + lineWidthMinPixels: 2, + })); + } + + // Hover trail (from correlation track) + const hoveredTrack = correlationTripsData.find(d => d.id === hoveredMmsi); + if (hoveredTrack) { + const relTime = ct - st; + let clipIdx = hoveredTrack.timestamps.length; + for (let i = 0; i < hoveredTrack.timestamps.length; i++) { + if (hoveredTrack.timestamps[i] > relTime) { + clipIdx = i; + break; + } + } + const clippedPath = hoveredTrack.path.slice(0, clipIdx); + if (clippedPath.length >= 2) { + layers.push(new PathLayer({ + id: 'replay-hover-trail', + data: [{ path: clippedPath }], + getPath: (d: { path: [number, number][] }) => d.path, + getColor: [255, 255, 255, 180], + widthMinPixels: 3, + })); + } + } + } + + // 8. Operational polygons (per model — union of member positions + high-score correlation vessels) + for (const [mn, items] of correlationByModel) { + if (!enabledModels.has(mn)) continue; + const color = MODEL_COLORS[mn] ?? '#94a3b8'; + const [r, g, b] = hexToRgb(color); + + const extraPts: [number, number][] = []; + for (const c of items as GearCorrelationItem[]) { + if (c.score < 0.7) continue; + const cp = corrPositions.find(p => p.mmsi === c.targetMmsi); + if (cp) extraPts.push([cp.lon, cp.lat]); + } + if (extraPts.length === 0) continue; + + const opPolygon = buildInterpPolygon([...memberPts, ...extraPts]); + if (!opPolygon) continue; + + layers.push(new PolygonLayer({ + id: `replay-op-${mn}`, + data: [{ polygon: opPolygon.coordinates }], + getPolygon: (d: { polygon: number[][][] }) => d.polygon, + getFillColor: [r, g, b, 30], + getLineColor: [r, g, b, 200], + getLineWidth: 2, + lineWidthMinPixels: 2, + filled: true, + stroked: true, + })); + } + + replayLayerRef.current = layers; + requestRender(); + }, [ + historyFrames, memberTripsData, correlationTripsData, + centerTrailSegments, centerDotsPositions, + enabledModels, enabledVessels, hoveredMmsi, correlationByModel, + replayLayerRef, requestRender, + ]); + + // ── zustand.subscribe effect (currentTime → renderFrame) ───────────────── + + useEffect(() => { + if (historyFrames.length === 0) return; + + // Initial render + renderFrame(); + + let lastRenderTime = 0; + let pendingRafId: number | null = null; + + const unsub = useGearReplayStore.subscribe( + s => s.currentTime, + () => { + const isPlaying = useGearReplayStore.getState().isPlaying; + if (!isPlaying) { + // Seek/pause — immediate render for responsiveness + renderFrame(); + return; + } + const now = performance.now(); + if (now - lastRenderTime >= RENDER_INTERVAL_MS) { + lastRenderTime = now; + renderFrame(); + } else if (!pendingRafId) { + pendingRafId = requestAnimationFrame(() => { + pendingRafId = null; + lastRenderTime = performance.now(); + renderFrame(); + }); + } + }, + ); + + return () => { + unsub(); + if (pendingRafId) cancelAnimationFrame(pendingRafId); + }; + }, [historyFrames, renderFrame]); + + // ── Cleanup on unmount ──────────────────────────────────────────────────── + + useEffect(() => { + return () => { + replayLayerRef.current = []; + requestRender(); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps -- intentional: run only on unmount + }, []); +} diff --git a/frontend/src/services/vesselAnalysis.ts b/frontend/src/services/vesselAnalysis.ts index dc66deb..5eea1b7 100644 --- a/frontend/src/services/vesselAnalysis.ts +++ b/frontend/src/services/vesselAnalysis.ts @@ -131,6 +131,41 @@ export async function fetchGroupCorrelations( return res.json(); } +/* ── Correlation Tracks (Prediction API) ──────────────────────── */ + +export interface CorrelationTrackPoint { + ts: number; // epoch ms + lat: number; + lon: number; + sog: number; + cog: number; +} + +export interface CorrelationVesselTrack { + mmsi: string; + name: string; + score: number; + modelName: string; + track: CorrelationTrackPoint[]; +} + +export interface CorrelationTracksResponse { + groupKey: string; + vessels: CorrelationVesselTrack[]; +} + +export async function fetchCorrelationTracks( + groupKey: string, + hours = 24, + minScore = 0.3, +): Promise { + const res = await fetch( + `/api/prediction/v1/correlation/${encodeURIComponent(groupKey)}/tracks?hours=${hours}&minScore=${minScore}`, + ); + if (!res.ok) return { groupKey, vessels: [] }; + return res.json(); +} + /* ── Fleet Companies ─────────────────────────────────────────── */ // 캐시 (세션 중 1회 로드) diff --git a/frontend/src/stores/gearReplayPreprocess.ts b/frontend/src/stores/gearReplayPreprocess.ts new file mode 100644 index 0000000..9e17969 --- /dev/null +++ b/frontend/src/stores/gearReplayPreprocess.ts @@ -0,0 +1,235 @@ +import type { HistoryFrame } from '../components/korea/fleetClusterTypes'; +import type { CorrelationVesselTrack } from '../services/vesselAnalysis'; + +export interface TripsLayerDatum { + id: string; + path: [number, number][]; // [lon, lat][] + timestamps: number[]; // relative ms from startTime (TripsLayer requirement) + color: [number, number, number, number]; +} + +export interface MemberPosition { + mmsi: string; + name: string; + lon: number; + lat: number; + cog: number; + role: string; + isParent: boolean; + isGear: boolean; + stale: boolean; +} + +export interface CenterTrailSegment { + path: [number, number][]; + isInterpolated: boolean; +} + +/** + * Walk all frames and collect per-MMSI tracks for TripsLayer rendering. + * timestamps are relative ms from startTime (deck.gl TripsLayer requirement). + */ +export function buildMemberTripsData(frames: HistoryFrame[], startTime: number): TripsLayerDatum[] { + const memberMap = new Map(); + + for (const frame of frames) { + const t = new Date(frame.snapshotTime).getTime() - startTime; + for (const member of frame.members) { + const entry = memberMap.get(member.mmsi) ?? { path: [], timestamps: [] }; + entry.path.push([member.lon, member.lat]); + entry.timestamps.push(t); + memberMap.set(member.mmsi, entry); + } + } + + const result: TripsLayerDatum[] = []; + for (const [mmsi, data] of memberMap) { + if (data.path.length >= 2) { + result.push({ + id: mmsi, + path: data.path, + timestamps: data.timestamps, + color: [200, 200, 200, 180], + }); + } + } + return result; +} + +/** + * Convert correlation vessel tracks to TripsLayer format. + * timestamps are relative ms from startTime (deck.gl TripsLayer requirement). + */ +export function buildCorrelationTripsData( + tracks: CorrelationVesselTrack[], + startTime: number, +): TripsLayerDatum[] { + const result: TripsLayerDatum[] = []; + for (const vt of tracks) { + if (vt.track.length >= 2) { + result.push({ + id: vt.mmsi, + path: vt.track.map(pt => [pt.lon, pt.lat]), + timestamps: vt.track.map(pt => pt.ts - startTime), + color: [96, 165, 250, 150], + }); + } + } + return result; +} + +/** + * Split center trail into real/interpolated segments and collect real-data dot positions. + * Consecutive frames with the same _longGap flag form one segment. + */ +export function buildCenterTrailData( + frames: HistoryFrame[], +): { segments: CenterTrailSegment[]; dots: [number, number][] } { + const segments: CenterTrailSegment[] = []; + const dots: [number, number][] = []; + + if (frames.length === 0) return { segments, dots }; + + let segStart = 0; + + for (let i = 1; i <= frames.length; i++) { + const curInterp = i < frames.length ? !!frames[i]._longGap : null; + const startInterp = !!frames[segStart]._longGap; + + if (i < frames.length && curInterp === startInterp) continue; + + const from = segStart > 0 ? segStart - 1 : segStart; + const seg = frames.slice(from, i); + if (seg.length >= 2) { + segments.push({ + path: seg.map(s => [s.centerLon, s.centerLat]), + isInterpolated: startInterp, + }); + } + segStart = i; + } + + for (const frame of frames) { + if (!frame._longGap && !frame._interp) { + dots.push([frame.centerLon, frame.centerLat]); + } + } + + return { segments, dots }; +} + +/** + * Map real (non-interpolated) frames to normalized [0, 1] positions + * along the timeline, for progress bar gap indicators. + */ +export function buildSnapshotRanges( + frames: HistoryFrame[], + startTime: number, + endTime: number, +): number[] { + const duration = endTime - startTime; + if (duration <= 0) return []; + return frames + .filter(h => !h._interp) + .map(h => (new Date(h.snapshotTime).getTime() - startTime) / duration); +} + +/** + * Cursor-based frame index lookup. + * Uses forward linear scan from cursorHint during normal playback (O(1–2)), + * falls back to binary search when time goes backward or hint is invalid. + * Returns { index: -1 } when the closest frame is more than 30 minutes away. + */ +export function findFrameAtTime( + frameTimes: number[], + timeMs: number, + cursorHint: number, +): { index: number; cursor: number } { + if (frameTimes.length === 0) return { index: -1, cursor: 0 }; + + // Forward linear scan from cursor + if (cursorHint >= 0 && cursorHint < frameTimes.length) { + if (frameTimes[cursorHint] <= timeMs) { + let i = cursorHint; + while (i < frameTimes.length - 1 && frameTimes[i + 1] <= timeMs) { + i++; + } + return { index: i, cursor: i }; + } + // Time went backward — fall through to binary search + } + + // Binary search fallback + let lo = 0; + let hi = frameTimes.length - 1; + while (lo < hi) { + const mid = (lo + hi + 1) >> 1; + if (frameTimes[mid] <= timeMs) { + lo = mid; + } else { + hi = mid - 1; + } + } + + if (Math.abs(frameTimes[lo] - timeMs) > 1_800_000) { + return { index: -1, cursor: lo }; + } + return { index: lo, cursor: lo }; +} + +/** + * Interpolate member positions between frameIdx and frameIdx+1 at timeMs. + * Returns stale=true for frames marked as _longGap or _interp. + */ +export function interpolateMemberPositions( + frames: HistoryFrame[], + frameIdx: number, + timeMs: number, +): MemberPosition[] { + if (frameIdx < 0 || frameIdx >= frames.length) return []; + + const frame = frames[frameIdx]; + const isStale = !!frame._longGap || !!frame._interp; + + const toPosition = ( + m: { mmsi: string; name: string; lon: number; lat: number; cog: number; role: string; isParent: boolean }, + lon: number, + lat: number, + cog: number, + ): MemberPosition => ({ + mmsi: m.mmsi, + name: m.name, + lon, + lat, + cog, + role: m.role, + isParent: m.isParent, + isGear: m.role === 'GEAR' || !m.isParent, + stale: isStale, + }); + + // No next frame — return current positions as-is + if (frameIdx >= frames.length - 1) { + return frame.members.map(m => toPosition(m, m.lon, m.lat, m.cog)); + } + + const nextFrame = frames[frameIdx + 1]; + const t0 = new Date(frame.snapshotTime).getTime(); + const t1 = new Date(nextFrame.snapshotTime).getTime(); + const ratio = t1 > t0 ? Math.max(0, Math.min(1, (timeMs - t0) / (t1 - t0))) : 0; + + const nextMap = new Map(nextFrame.members.map(m => [m.mmsi, m])); + + return frame.members.map(m => { + const nm = nextMap.get(m.mmsi); + if (!nm) { + return toPosition(m, m.lon, m.lat, m.cog); + } + return toPosition( + m, + m.lon + (nm.lon - m.lon) * ratio, + m.lat + (nm.lat - m.lat) * ratio, + nm.cog, + ); + }); +} diff --git a/frontend/src/stores/gearReplayStore.ts b/frontend/src/stores/gearReplayStore.ts new file mode 100644 index 0000000..eab5e8d --- /dev/null +++ b/frontend/src/stores/gearReplayStore.ts @@ -0,0 +1,245 @@ +import { create } from 'zustand'; +import { subscribeWithSelector } from 'zustand/middleware'; +import type { HistoryFrame } from '../components/korea/fleetClusterTypes'; +import type { GearCorrelationItem, CorrelationVesselTrack } from '../services/vesselAnalysis'; +import { + buildMemberTripsData, + buildCorrelationTripsData, + buildCenterTrailData, + buildSnapshotRanges, +} from './gearReplayPreprocess'; + +// ── Pre-processed data types for deck.gl layers ────────────────── + +export interface TripsLayerDatum { + id: string; + path: [number, number][]; + timestamps: number[]; + color: [number, number, number, number]; +} + +export interface MemberPosition { + mmsi: string; + name: string; + lon: number; + lat: number; + cog: number; + role: string; + isParent: boolean; + isGear: boolean; + stale: boolean; +} + +export interface CenterTrailSegment { + path: [number, number][]; + isInterpolated: boolean; +} + +// ── Speed factor: 1x = 30 real seconds covers 12 timeline hours ── +const SPEED_FACTOR = (12 * 60 * 60 * 1000) / (30 * 1000); // 1440 + +// ── Module-level rAF state (outside React) ─────────────────────── +let animationFrameId: number | null = null; +let lastFrameTime: number | null = null; + +// ── Store interface ─────────────────────────────────────────────── + +interface GearReplayState { + // Playback state + isPlaying: boolean; + currentTime: number; + startTime: number; + endTime: number; + playbackSpeed: number; + + // Source data + historyFrames: HistoryFrame[]; + frameTimes: number[]; + selectedGroupKey: string | null; + + // Pre-computed layer data + memberTripsData: TripsLayerDatum[]; + correlationTripsData: TripsLayerDatum[]; + centerTrailSegments: CenterTrailSegment[]; + centerDotsPositions: [number, number][]; + snapshotRanges: number[]; + + // Filter state + enabledModels: Set; + enabledVessels: Set; + hoveredMmsi: string | null; + correlationByModel: Map; + + // Actions + loadHistory: ( + frames: HistoryFrame[], + corrTracks: CorrelationVesselTrack[], + corrData: GearCorrelationItem[], + enabledModels: Set, + enabledVessels: Set, + ) => void; + play: () => void; + pause: () => void; + seek: (timeMs: number) => void; + setPlaybackSpeed: (speed: number) => void; + setEnabledModels: (models: Set) => void; + setEnabledVessels: (vessels: Set) => void; + setHoveredMmsi: (mmsi: string | null) => void; + reset: () => void; +} + +// ── Store ───────────────────────────────────────────────────────── + +export const useGearReplayStore = create()( + subscribeWithSelector((set, get) => { + const animate = (): void => { + const state = get(); + if (!state.isPlaying) return; + + const now = performance.now(); + if (lastFrameTime === null) lastFrameTime = now; + + const delta = now - lastFrameTime; + lastFrameTime = now; + + const newTime = state.currentTime + delta * SPEED_FACTOR * state.playbackSpeed; + + if (newTime >= state.endTime) { + set({ currentTime: state.startTime }); + animationFrameId = requestAnimationFrame(animate); + return; + } + + set({ currentTime: newTime }); + animationFrameId = requestAnimationFrame(animate); + }; + + return { + // Playback state + isPlaying: false, + currentTime: 0, + startTime: 0, + endTime: 0, + playbackSpeed: 1, + + // Source data + historyFrames: [], + frameTimes: [], + selectedGroupKey: null, + + // Pre-computed layer data + memberTripsData: [], + correlationTripsData: [], + centerTrailSegments: [], + centerDotsPositions: [], + snapshotRanges: [], + + // Filter state + enabledModels: new Set(), + enabledVessels: new Set(), + hoveredMmsi: null, + correlationByModel: new Map(), + + // ── Actions ──────────────────────────────────────────────── + + loadHistory: (frames, corrTracks, corrData, enabledModels, enabledVessels) => { + const startTime = Date.now() - 12 * 60 * 60 * 1000; + const endTime = Date.now(); + const frameTimes = frames.map(f => new Date(f.snapshotTime).getTime()); + + const memberTrips = buildMemberTripsData(frames, startTime); + const corrTrips = buildCorrelationTripsData(corrTracks, startTime); + const { segments, dots } = buildCenterTrailData(frames); + const ranges = buildSnapshotRanges(frames, startTime, endTime); + + const byModel = new Map(); + for (const c of corrData) { + const list = byModel.get(c.modelName) ?? []; + list.push(c); + byModel.set(c.modelName, list); + } + + set({ + historyFrames: frames, + frameTimes, + startTime, + endTime, + currentTime: startTime, + memberTripsData: memberTrips, + correlationTripsData: corrTrips, + centerTrailSegments: segments, + centerDotsPositions: dots, + snapshotRanges: ranges, + enabledModels, + enabledVessels, + correlationByModel: byModel, + selectedGroupKey: frames[0]?.groupKey ?? null, + }); + }, + + play: () => { + const state = get(); + if (state.endTime <= state.startTime) return; + + lastFrameTime = null; + + if (state.currentTime >= state.endTime) { + set({ isPlaying: true, currentTime: state.startTime }); + } else { + set({ isPlaying: true }); + } + + animationFrameId = requestAnimationFrame(animate); + }, + + pause: () => { + if (animationFrameId !== null) { + cancelAnimationFrame(animationFrameId); + animationFrameId = null; + } + lastFrameTime = null; + set({ isPlaying: false }); + }, + + seek: (timeMs) => { + const { startTime, endTime } = get(); + set({ currentTime: Math.max(startTime, Math.min(endTime, timeMs)) }); + }, + + setPlaybackSpeed: (speed) => set({ playbackSpeed: speed }), + + setEnabledModels: (models) => set({ enabledModels: models }), + + setEnabledVessels: (vessels) => set({ enabledVessels: vessels }), + + setHoveredMmsi: (mmsi) => set({ hoveredMmsi: mmsi }), + + reset: () => { + if (animationFrameId !== null) { + cancelAnimationFrame(animationFrameId); + animationFrameId = null; + } + lastFrameTime = null; + set({ + isPlaying: false, + currentTime: 0, + startTime: 0, + endTime: 0, + playbackSpeed: 1, + historyFrames: [], + frameTimes: [], + selectedGroupKey: null, + memberTripsData: [], + correlationTripsData: [], + centerTrailSegments: [], + centerDotsPositions: [], + snapshotRanges: [], + enabledModels: new Set(), + enabledVessels: new Set(), + hoveredMmsi: null, + correlationByModel: new Map(), + }); + }, + }; + }), +); diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index c0c411e..928304b 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -115,6 +115,11 @@ export default defineConfig(({ mode }): UserConfig => ({ changeOrigin: true, secure: true, }, + '/api/prediction': { + target: 'http://localhost:8001', + changeOrigin: true, + rewrite: (path) => path.replace(/^\/api\/prediction/, ''), + }, '/ollama': { target: 'http://localhost:11434', changeOrigin: true, diff --git a/prediction/algorithms/gear_correlation.py b/prediction/algorithms/gear_correlation.py index edae548..21c5b95 100644 --- a/prediction/algorithms/gear_correlation.py +++ b/prediction/algorithms/gear_correlation.py @@ -537,7 +537,7 @@ def run_gear_correlation( import time as _time import re as _re - _gear_re = _re.compile(r'^.+_\d+_\d*$') + _gear_re = _re.compile(r'^.+_(?=\S*\d)\S+(?:[_ ]\S*)*[_ ]*$|^.+%$|^\d+$') t0 = _time.time() now = datetime.now(timezone.utc) @@ -768,6 +768,8 @@ def _batch_upsert_scores(conn, batch: list[tuple]): VALUES %s ON CONFLICT (model_id, group_key, target_mmsi) DO UPDATE SET + target_type = EXCLUDED.target_type, + target_name = EXCLUDED.target_name, current_score = EXCLUDED.current_score, streak_count = EXCLUDED.streak_count, freeze_state = EXCLUDED.freeze_state, diff --git a/prediction/algorithms/polygon_builder.py b/prediction/algorithms/polygon_builder.py index 75f0e15..0e592cf 100644 --- a/prediction/algorithms/polygon_builder.py +++ b/prediction/algorithms/polygon_builder.py @@ -23,8 +23,8 @@ from algorithms.location import classify_zone logger = logging.getLogger(__name__) -# 프론트 FleetClusterLayer.tsx gearGroupMap 패턴과 동일 -GEAR_PATTERN = re.compile(r'^(.+?)_\d+_\d+_?$') +# 어구 이름 패턴 — _ 필수 (공백만으로는 어구 미판정, fleet_tracker.py와 동일) +GEAR_PATTERN = re.compile(r'^(.+?)_(?=\S*\d)\S+(?:[_ ]\S*)*[_ ]*$|^(\d+)$') MAX_DIST_DEG = 0.15 # ~10NM STALE_SEC = 21600 # 6시간 (어구 P75 갭 3.5h, P90 갭 8h 커버) FLEET_BUFFER_DEG = 0.02 @@ -130,14 +130,21 @@ def detect_gear_groups( all_positions = vessel_store.get_all_latest_positions() # 선박명 → mmsi 맵 (모선 탐색용, 어구 패턴이 아닌 선박만) + # 정규화 키(공백 제거) + 원본 이름 모두 등록 name_to_mmsi: dict[str, str] = {} for mmsi, pos in all_positions.items(): name = (pos.get('name') or '').strip() if name and not GEAR_PATTERN.match(name): name_to_mmsi[name] = mmsi + name_to_mmsi[name.replace(' ', '')] = mmsi - # 1단계: 같은 모선명 어구 수집 (60분 이내만) + # parent 이름 정규화 — 공백 제거 후 같은 모선은 하나로 통합 + def _normalize_parent(raw: str) -> str: + return raw.replace(' ', '') + + # 1단계: 같은 모선명 어구 수집 (60분 이내만, 공백 정규화) raw_groups: dict[str, list[dict]] = {} + parent_display: dict[str, str] = {} # normalized → 대표 원본 이름 for mmsi, pos in all_positions.items(): name = (pos.get('name') or '').strip() if not name: @@ -164,7 +171,11 @@ def detect_gear_groups( if not m: continue - parent_name = m.group(1).strip() + parent_raw = (m.group(1) or name).strip() + parent_key = _normalize_parent(parent_raw) + # 대표 이름: 공백 없는 버전 우선 (더 정규화된 형태) + if parent_key not in parent_display or ' ' not in parent_raw: + parent_display[parent_key] = parent_raw entry = { 'mmsi': mmsi, 'name': name, @@ -173,61 +184,121 @@ def detect_gear_groups( 'sog': pos.get('sog', 0), 'cog': pos.get('cog', 0), } - raw_groups.setdefault(parent_name, []).append(entry) + raw_groups.setdefault(parent_key, []).append(entry) - # 2단계: 거리 기반 서브 클러스터링 (anchor 기준 MAX_DIST_DEG 이내만) + # 2단계: 연결 기반 서브 클러스터링 (각 어구가 클러스터 내 최소 1개와 MAX_DIST_DEG 이내) + # 같은 parent 이름이라도 거리가 먼 어구들은 별도 서브그룹으로 분리 results: list[dict] = [] - for parent_name, gears in raw_groups.items(): - parent_mmsi = name_to_mmsi.get(parent_name) + for parent_key, gears in raw_groups.items(): + parent_mmsi = name_to_mmsi.get(parent_key) + display_name = parent_display.get(parent_key, parent_key) - # 기준점(anchor): 모선 있으면 모선 위치, 없으면 첫 어구 - anchor_lat: Optional[float] = None - anchor_lon: Optional[float] = None + if not gears: + continue + # 모선 위치 (있으면 시드 포인트로 활용) + seed_lat: Optional[float] = None + seed_lon: Optional[float] = None if parent_mmsi and parent_mmsi in all_positions: - parent_pos = all_positions[parent_mmsi] - anchor_lat = parent_pos['lat'] - anchor_lon = parent_pos['lon'] + p = all_positions[parent_mmsi] + seed_lat, seed_lon = p['lat'], p['lon'] - if anchor_lat is None and gears: - anchor_lat = gears[0]['lat'] - anchor_lon = gears[0]['lon'] + # 연결 기반 클러스터링 (Union-Find 방식) + n = len(gears) + parent_uf = list(range(n)) - if anchor_lat is None or anchor_lon is None: + def find(x: int) -> int: + while parent_uf[x] != x: + parent_uf[x] = parent_uf[parent_uf[x]] + x = parent_uf[x] + return x + + def union(a: int, b: int) -> None: + ra, rb = find(a), find(b) + if ra != rb: + parent_uf[ra] = rb + + for i in range(n): + for j in range(i + 1, n): + if (abs(gears[i]['lat'] - gears[j]['lat']) <= MAX_DIST_DEG + and abs(gears[i]['lon'] - gears[j]['lon']) <= MAX_DIST_DEG): + union(i, j) + + # 클러스터별 그룹화 + clusters: dict[int, list[int]] = {} + for i in range(n): + clusters.setdefault(find(i), []).append(i) + + # 모선이 있으면 모선과 가장 가까운 클러스터에 연결 (MAX_DIST_DEG 이내만) + seed_cluster_root: Optional[int] = None + if seed_lat is not None and seed_lon is not None: + best_dist = float('inf') + for root, idxs in clusters.items(): + for i in idxs: + d = abs(gears[i]['lat'] - seed_lat) + abs(gears[i]['lon'] - seed_lon) + if d < best_dist: + best_dist = d + seed_cluster_root = root + # 모선이 어느 클러스터와도 MAX_DIST_DEG 초과 → 연결하지 않음 + if best_dist > MAX_DIST_DEG * 2: + seed_cluster_root = None + + # 클러스터마다 서브그룹 생성 (최소 2개 이상이거나 모선 포함) + for ci, (root, idxs) in enumerate(clusters.items()): + has_seed = (root == seed_cluster_root) + if len(idxs) < 2 and not has_seed: + continue + + members = [ + {'mmsi': gears[i]['mmsi'], 'name': gears[i]['name'], + 'lat': gears[i]['lat'], 'lon': gears[i]['lon'], + 'sog': gears[i]['sog'], 'cog': gears[i]['cog']} + for i in idxs + ] + + # 서브그룹 이름: 1개면 원본, 2개 이상이면 #1, #2 + sub_name = display_name if len(clusters) == 1 else f'{display_name}#{ci + 1}' + sub_mmsi = parent_mmsi if has_seed else None + + results.append({ + 'parent_name': sub_name, + 'parent_key': parent_key, + 'parent_mmsi': sub_mmsi, + 'members': members, + }) + + # 3단계: 동일 parent_key 서브그룹 간 근접 병합 (거리 이내 시) + # prefix 기반 병합은 과도한 그룹화 유발 → 동일 키만 병합 + def _groups_nearby(a: dict, b: dict) -> bool: + for ma in a['members']: + for mb in b['members']: + if abs(ma['lat'] - mb['lat']) <= MAX_DIST_DEG and abs(ma['lon'] - mb['lon']) <= MAX_DIST_DEG: + return True + return False + + merged: list[dict] = [] + skip: set[int] = set() + results.sort(key=lambda g: len(g['members']), reverse=True) + for i, big in enumerate(results): + if i in skip: continue + for j, small in enumerate(results): + if j <= i or j in skip: + continue + # 동일 parent_key만 병합 (prefix 매칭 제거 — 과도한 병합 방지) + if big['parent_key'] == small['parent_key'] and _groups_nearby(big, small): + existing_mmsis = {m['mmsi'] for m in big['members']} + for m in small['members']: + if m['mmsi'] not in existing_mmsis: + big['members'].append(m) + existing_mmsis.add(m['mmsi']) + if not big['parent_mmsi'] and small['parent_mmsi']: + big['parent_mmsi'] = small['parent_mmsi'] + skip.add(j) + del big['parent_key'] + merged.append(big) - # MAX_DIST_DEG 이내 어구만 포함 - _anchor_lat: float = anchor_lat - _anchor_lon: float = anchor_lon - nearby = [ - g for g in gears - if abs(g['lat'] - _anchor_lat) <= MAX_DIST_DEG - and abs(g['lon'] - _anchor_lon) <= MAX_DIST_DEG - ] - - if not nearby: - continue - - # members 구성: 어구 목록 - members = [ - { - 'mmsi': g['mmsi'], - 'name': g['name'], - 'lat': g['lat'], - 'lon': g['lon'], - 'sog': g['sog'], - 'cog': g['cog'], - } - for g in nearby - ] - - results.append({ - 'parent_name': parent_name, - 'parent_mmsi': parent_mmsi, - 'members': members, - }) - - return results + return merged def build_all_group_snapshots( @@ -340,13 +411,18 @@ def build_all_group_snapshots( if not in_zone and len(gear_members) < MIN_GEAR_GROUP_SIZE: continue - # 폴리곤 points: 어구 좌표 + 모선 좌표 + # 폴리곤 points: 어구 좌표 + 모선 좌표 (근접 시에만) points = [(g['lon'], g['lat']) for g in gear_members] + parent_nearby = False if parent_mmsi and parent_mmsi in all_positions: parent_pos = all_positions[parent_mmsi] p_lon, p_lat = parent_pos['lon'], parent_pos['lat'] - if (p_lon, p_lat) not in points: - points.append((p_lon, p_lat)) + # 모선이 어구 클러스터 내 최소 1개와 MAX_DIST_DEG*2 이내일 때만 포함 + if any(abs(g['lat'] - p_lat) <= MAX_DIST_DEG * 2 + and abs(g['lon'] - p_lon) <= MAX_DIST_DEG * 2 for g in gear_members): + if (p_lon, p_lat) not in points: + points.append((p_lon, p_lat)) + parent_nearby = True polygon_wkt, center_wkt, area_sq_nm, _clat, _clon = build_group_polygon( points, GEAR_BUFFER_DEG @@ -354,8 +430,8 @@ def build_all_group_snapshots( # members JSONB 구성 members_out: list[dict] = [] - # 모선 먼저 - if parent_mmsi and parent_mmsi in all_positions: + # 모선 먼저 (근접 시에만) + if parent_nearby and parent_mmsi and parent_mmsi in all_positions: parent_pos = all_positions[parent_mmsi] members_out.append({ 'mmsi': parent_mmsi, diff --git a/prediction/cache/vessel_store.py b/prediction/cache/vessel_store.py index 8f67bee..00b82e7 100644 --- a/prediction/cache/vessel_store.py +++ b/prediction/cache/vessel_store.py @@ -349,6 +349,66 @@ class VesselStore: } return result + def get_vessel_tracks(self, mmsis: list[str], hours: int = 24) -> dict[str, list[dict]]: + """Return track points for given MMSIs within the specified hours window. + + Returns dict mapping mmsi to list of {ts, lat, lon, sog, cog} dicts, + sorted by timestamp ascending. + """ + import datetime as _dt + + now = datetime.now(timezone.utc) + cutoff_aware = now - _dt.timedelta(hours=hours) + cutoff_naive = cutoff_aware.replace(tzinfo=None) + + result: dict[str, list[dict]] = {} + for mmsi in mmsis: + df = self._tracks.get(mmsi) + if df is None or len(df) == 0: + continue + + ts_col = df['timestamp'] + if hasattr(ts_col.dtype, 'tz') and ts_col.dtype.tz is not None: + mask = ts_col >= pd.Timestamp(cutoff_aware) + else: + mask = ts_col >= pd.Timestamp(cutoff_naive) + + filtered = df[mask].sort_values('timestamp') + if filtered.empty: + continue + + # Compute SOG/COG for this vessel's track + if len(filtered) >= 2: + track_with_sog = _compute_sog_cog(filtered.copy()) + else: + track_with_sog = filtered.copy() + if 'sog' not in track_with_sog.columns: + track_with_sog['sog'] = track_with_sog.get('raw_sog', 0) + if 'cog' not in track_with_sog.columns: + track_with_sog['cog'] = 0 + + points = [] + for _, row in track_with_sog.iterrows(): + ts = row['timestamp'] + # Convert to epoch ms + if hasattr(ts, 'timestamp'): + epoch_ms = int(ts.timestamp() * 1000) + else: + epoch_ms = int(pd.Timestamp(ts).timestamp() * 1000) + + points.append({ + 'ts': epoch_ms, + 'lat': float(row['lat']), + 'lon': float(row['lon']), + 'sog': float(row.get('sog', 0) or 0), + 'cog': float(row.get('cog', 0) or 0), + }) + + if points: + result[mmsi] = points + + return result + def get_chinese_mmsis(self) -> set: """Return the set of all Chinese vessel MMSIs (412*) currently in the store.""" return {m for m in self._tracks if m.startswith(_CHINESE_MMSI_PREFIX)} diff --git a/prediction/fleet_tracker.py b/prediction/fleet_tracker.py index f1c83d7..db85628 100644 --- a/prediction/fleet_tracker.py +++ b/prediction/fleet_tracker.py @@ -9,8 +9,8 @@ import pandas as pd logger = logging.getLogger(__name__) -# 어구 이름 패턴 -GEAR_PATTERN = re.compile(r'^(.+?)_(\d+)_(\d*)$') +# 어구 이름 패턴 — 공백/영숫자 인덱스/끝_ 허용 +GEAR_PATTERN = re.compile(r'^(.+?)_(?=\S*\d)\S+(?:[_ ]\S*)*[_ ]*$|^(\d+)$') GEAR_PATTERN_PCT = re.compile(r'^(.+?)%$') _REGISTRY_CACHE_SEC = 3600 @@ -139,9 +139,16 @@ class FleetTracker: m = GEAR_PATTERN.match(name) if m: - parent_name = m.group(1).strip() - idx1 = int(m.group(2)) - idx2 = int(m.group(3)) if m.group(3) else None + # group(1): parent+index 패턴, group(2): 순수 숫자 패턴 + if m.group(1): + parent_name = m.group(1).strip() + suffix = name[m.end(1):].strip(' _') + digits = re.findall(r'\d+', suffix) + idx1 = int(digits[0]) if len(digits) >= 1 else None + idx2 = int(digits[1]) if len(digits) >= 2 else None + else: + # 순수 숫자 이름 (예: 12345) — parent 없음, 인덱스만 + idx1 = int(m.group(2)) else: m2 = GEAR_PATTERN_PCT.match(name) if m2: diff --git a/prediction/main.py b/prediction/main.py index 2e1a9dc..30ae293 100644 --- a/prediction/main.py +++ b/prediction/main.py @@ -68,3 +68,75 @@ def analysis_status(): def trigger_analysis(background_tasks: BackgroundTasks): background_tasks.add_task(run_analysis_cycle) return {'message': 'analysis cycle triggered'} + + +@app.get('/api/v1/correlation/{group_key:path}/tracks') +def get_correlation_tracks( + group_key: str, + hours: int = 24, + min_score: float = 0.3, +): + """Return correlated vessels with their track history for map rendering. + + Queries gear_correlation_scores (default model) and enriches with + 24h track data from in-memory vessel_store. + """ + from cache.vessel_store import vessel_store + + try: + conn = kcgdb.get_conn() + cur = conn.cursor() + + # Get correlated vessels from default model + cur.execute(""" + SELECT s.target_mmsi, s.target_type, s.target_name, + s.current_score, m.name AS model_name + FROM kcg.gear_correlation_scores s + JOIN kcg.correlation_param_models m ON s.model_id = m.id + WHERE s.group_key = %s + AND s.current_score >= %s + AND m.is_default = TRUE + AND m.is_active = TRUE + ORDER BY s.current_score DESC + """, (group_key, min_score)) + + rows = cur.fetchall() + cur.close() + conn.close() + + if not rows: + return {'groupKey': group_key, 'vessels': []} + + # Collect target MMSIs + vessel_info = [] + mmsis = [] + for row in rows: + vessel_info.append({ + 'mmsi': row[0], + 'type': row[1], + 'name': row[2] or '', + 'score': float(row[3]), + 'modelName': row[4], + }) + mmsis.append(row[0]) + + # Get tracks from vessel_store + tracks = vessel_store.get_vessel_tracks(mmsis, hours) + + # Build response + vessels = [] + for info in vessel_info: + track = tracks.get(info['mmsi'], []) + vessels.append({ + 'mmsi': info['mmsi'], + 'name': info['name'], + 'score': info['score'], + 'modelName': info['modelName'], + 'track': track, + }) + + return {'groupKey': group_key, 'vessels': vessels} + + except Exception as e: + logger.warning('get_correlation_tracks failed for %s: %s', group_key, e) + return {'groupKey': group_key, 'vessels': []} diff --git a/prediction/scheduler.py b/prediction/scheduler.py index 8cecb17..10eba03 100644 --- a/prediction/scheduler.py +++ b/prediction/scheduler.py @@ -75,7 +75,7 @@ def run_analysis_cycle(): return # 4. 등록 선단 기반 fleet 분석 - _gear_re = _re.compile(r'^.+_\d+_\d*$|%$') + _gear_re = _re.compile(r'^.+_(?=\S*\d)\S+(?:[_ ]\S*)*[_ ]*$|^\d+$|^.+%$') with kcgdb.get_conn() as kcg_conn: fleet_tracker.load_registry(kcg_conn)