From ed0f3056b1b0018e83ffe0e5aaffe4145b8464ea Mon Sep 17 00:00:00 2001 From: htlee Date: Fri, 20 Feb 2026 15:19:21 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20Ship-GIS=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EC=9D=B4=EA=B4=80=20=E2=80=94=20=EC=B5=9C=EA=B7=BC=EC=9C=84?= =?UTF-8?q?=EC=B9=98/=EC=84=A0=EB=B0=95=ED=95=AD=EC=A0=81/=EB=B7=B0?= =?UTF-8?q?=ED=8F=AC=ED=8A=B8=20=EB=A6=AC=ED=94=8C=EB=A0=88=EC=9D=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit dark(ship-gis) 프로젝트의 맵 기반 3대 기능을 API 탐색기에 이관. Feature 폴더 모듈화 구조로 타 프로젝트 재활용 가능하게 구성. Phase 1: vessel-map 공유 모듈 (Deck.gl 9 + Zustand 5 + STOMP) Phase 2: 최근 위치 (30초 폴링 + IconLayer + 선종 필터 + 팝업) Phase 3: 선박 항적 (MMSI 조회 + PathLayer + 타임라인 보간) Phase 4: 뷰포트 리플레이 (STOMP WebSocket 청크 + TripsLayer 애니메이션) Co-Authored-By: Claude Opus 4.6 --- frontend/package-lock.json | 1165 ++++++++++++++++- frontend/package.json | 8 +- frontend/src/api/gisApi.ts | 25 +- frontend/src/components/map/MapContainer.tsx | 20 +- .../components/PositionFilterPanel.tsx | 69 + .../components/RecentPositionsLayer.tsx | 54 + .../components/VesselPopup.tsx | 87 ++ .../hooks/useRecentPositions.ts | 60 + .../src/features/recent-positions/index.ts | 5 + .../recent-positions/stores/positionStore.ts | 72 + .../src/features/vessel-map/assets/atlas.png | Bin 0 -> 10207 bytes frontend/src/features/vessel-map/constants.ts | 117 ++ .../vessel-map/hooks/useMapInstance.ts | 70 + frontend/src/features/vessel-map/index.ts | 61 + .../vessel-map/layers/TrackPathLayer.ts | 42 + .../vessel-map/layers/VesselIconLayer.ts | 48 + frontend/src/features/vessel-map/types.ts | 75 ++ .../vessel-map/utils/interpolation.ts | 121 ++ .../vessel-map/utils/shipKindColors.ts | 55 + .../src/features/vessel-map/utils/viewport.ts | 52 + .../components/TrackInfoPanel.tsx | 58 + .../components/TrackQueryPanel.tsx | 118 ++ .../components/VesselTracksLayer.tsx | 54 + .../vessel-tracks/hooks/useVesselTracks.ts | 52 + frontend/src/features/vessel-tracks/index.ts | 5 + .../vessel-tracks/stores/trackStore.ts | 89 ++ .../components/ReplayControlPanel.tsx | 99 ++ .../components/ReplayLayer.tsx | 125 ++ .../components/ReplaySetupPanel.tsx | 141 ++ .../hooks/useReplayAnimation.ts | 210 +++ .../src/features/viewport-replay/index.ts | 7 + .../services/replayWebSocket.ts | 253 ++++ .../stores/mergedTrackStore.ts | 127 ++ .../viewport-replay/stores/replayStore.ts | 74 ++ frontend/src/i18n/en.ts | 5 +- frontend/src/i18n/ko.ts | 5 +- frontend/src/pages/ApiExplorer.tsx | 135 +- 37 files changed, 3701 insertions(+), 62 deletions(-) create mode 100644 frontend/src/features/recent-positions/components/PositionFilterPanel.tsx create mode 100644 frontend/src/features/recent-positions/components/RecentPositionsLayer.tsx create mode 100644 frontend/src/features/recent-positions/components/VesselPopup.tsx create mode 100644 frontend/src/features/recent-positions/hooks/useRecentPositions.ts create mode 100644 frontend/src/features/recent-positions/index.ts create mode 100644 frontend/src/features/recent-positions/stores/positionStore.ts create mode 100644 frontend/src/features/vessel-map/assets/atlas.png create mode 100644 frontend/src/features/vessel-map/constants.ts create mode 100644 frontend/src/features/vessel-map/hooks/useMapInstance.ts create mode 100644 frontend/src/features/vessel-map/index.ts create mode 100644 frontend/src/features/vessel-map/layers/TrackPathLayer.ts create mode 100644 frontend/src/features/vessel-map/layers/VesselIconLayer.ts create mode 100644 frontend/src/features/vessel-map/types.ts create mode 100644 frontend/src/features/vessel-map/utils/interpolation.ts create mode 100644 frontend/src/features/vessel-map/utils/shipKindColors.ts create mode 100644 frontend/src/features/vessel-map/utils/viewport.ts create mode 100644 frontend/src/features/vessel-tracks/components/TrackInfoPanel.tsx create mode 100644 frontend/src/features/vessel-tracks/components/TrackQueryPanel.tsx create mode 100644 frontend/src/features/vessel-tracks/components/VesselTracksLayer.tsx create mode 100644 frontend/src/features/vessel-tracks/hooks/useVesselTracks.ts create mode 100644 frontend/src/features/vessel-tracks/index.ts create mode 100644 frontend/src/features/vessel-tracks/stores/trackStore.ts create mode 100644 frontend/src/features/viewport-replay/components/ReplayControlPanel.tsx create mode 100644 frontend/src/features/viewport-replay/components/ReplayLayer.tsx create mode 100644 frontend/src/features/viewport-replay/components/ReplaySetupPanel.tsx create mode 100644 frontend/src/features/viewport-replay/hooks/useReplayAnimation.ts create mode 100644 frontend/src/features/viewport-replay/index.ts create mode 100644 frontend/src/features/viewport-replay/services/replayWebSocket.ts create mode 100644 frontend/src/features/viewport-replay/stores/mergedTrackStore.ts create mode 100644 frontend/src/features/viewport-replay/stores/replayStore.ts diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 9a43181..156f5ca 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,11 +8,17 @@ "name": "signal-batch-dashboard", "version": "0.0.0", "dependencies": { + "@deck.gl/core": "^9.2.8", + "@deck.gl/geo-layers": "^9.2.8", + "@deck.gl/layers": "^9.2.8", + "@deck.gl/mapbox": "^9.2.8", + "@stomp/stompjs": "^7.3.0", "maplibre-gl": "^5.18.0", "react": "^19.2.0", "react-dom": "^19.2.0", "react-router-dom": "^7.13.0", - "recharts": "^2.15.3" + "recharts": "^2.15.3", + "zustand": "^5.0.11" }, "devDependencies": { "@eslint/js": "^9.39.1", @@ -322,6 +328,146 @@ "node": ">=6.9.0" } }, + "node_modules/@deck.gl/core": { + "version": "9.2.8", + "resolved": "https://registry.npmjs.org/@deck.gl/core/-/core-9.2.8.tgz", + "integrity": "sha512-VZgf+LN53KEm/o8xYzh7fejmslIbTwze5Daoqg2Wt7VAlw734M9EHcINpyGTm7ln9R5qywG/KW7MNfHVvx6EKw==", + "license": "MIT", + "dependencies": { + "@loaders.gl/core": "^4.3.4", + "@loaders.gl/images": "^4.3.4", + "@luma.gl/constants": "^9.2.6", + "@luma.gl/core": "^9.2.6", + "@luma.gl/engine": "^9.2.6", + "@luma.gl/shadertools": "^9.2.6", + "@luma.gl/webgl": "^9.2.6", + "@math.gl/core": "^4.1.0", + "@math.gl/sun": "^4.1.0", + "@math.gl/types": "^4.1.0", + "@math.gl/web-mercator": "^4.1.0", + "@probe.gl/env": "^4.1.0", + "@probe.gl/log": "^4.1.0", + "@probe.gl/stats": "^4.1.0", + "@types/offscreencanvas": "^2019.6.4", + "gl-matrix": "^3.0.0", + "mjolnir.js": "^3.0.0" + } + }, + "node_modules/@deck.gl/extensions": { + "version": "9.2.8", + "resolved": "https://registry.npmjs.org/@deck.gl/extensions/-/extensions-9.2.8.tgz", + "integrity": "sha512-BgdGdEe5TklwYpnzB/yq0u+JgEYsUVHyKhccRZHjgVKOWr/AVHi2Q/bY9B3bxMIyJifsQo4SS9Qo0b8TOpRxuQ==", + "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.8", + "resolved": "https://registry.npmjs.org/@deck.gl/geo-layers/-/geo-layers-9.2.8.tgz", + "integrity": "sha512-WEiPbDM6bqXityfFGplsKNTpYKzfUyj/mpnQpaNJ5qJ6Cl2W7SDnP+0K/UisbZ8m8eXYCiiu+ykb4+YwvOFc8g==", + "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.8", + "resolved": "https://registry.npmjs.org/@deck.gl/layers/-/layers-9.2.8.tgz", + "integrity": "sha512-LnIZ07jKuFRTYulBjyS2z1VHMkGXTUs8C0iSxJR8GsCBBI8d/1zEIT1xbkGaArai50OjsrCDuG2KKKLbG8ME3w==", + "license": "MIT", + "dependencies": { + "@loaders.gl/images": "^4.3.4", + "@loaders.gl/schema": "^4.3.4", + "@luma.gl/shadertools": "^9.2.6", + "@mapbox/tiny-sdf": "^2.0.5", + "@math.gl/core": "^4.1.0", + "@math.gl/polygon": "^4.1.0", + "@math.gl/web-mercator": "^4.1.0", + "earcut": "^2.2.4" + }, + "peerDependencies": { + "@deck.gl/core": "~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/node_modules/earcut": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/earcut/-/earcut-2.2.4.tgz", + "integrity": "sha512-/pjZsA1b4RPHbeWZQn66SWS8nZZWLQQ23oE3Eam7aroEFGEvwKAsJfZ9ytiEMycfzXWpca4FA9QIOehf7PocBQ==", + "license": "ISC" + }, + "node_modules/@deck.gl/mapbox": { + "version": "9.2.8", + "resolved": "https://registry.npmjs.org/@deck.gl/mapbox/-/mapbox-9.2.8.tgz", + "integrity": "sha512-kWeu4C4USPwT1vf/I/6xZFSzuAxCeQM79FfJ2WOcoNWn5AI7MegCjgumYLR7RR555Trk1qM2adztIW2DXh3IqQ==", + "license": "MIT", + "dependencies": { + "@luma.gl/constants": "^9.2.6", + "@math.gl/web-mercator": "^4.1.0" + }, + "peerDependencies": { + "@deck.gl/core": "~9.2.0", + "@luma.gl/constants": "~9.2.6", + "@luma.gl/core": "~9.2.6", + "@math.gl/web-mercator": "^4.1.0" + } + }, + "node_modules/@deck.gl/mesh-layers": { + "version": "9.2.8", + "resolved": "https://registry.npmjs.org/@deck.gl/mesh-layers/-/mesh-layers-9.2.8.tgz", + "integrity": "sha512-Ax0Y4bb+288myWUu4fa+uXIPHQP3cSgRPMVi6HsPzWovv2E7wtfVzyGGWChs3DmdttGY/ooM6R7aNpwC1ZzEyQ==", + "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.3", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", @@ -1023,6 +1169,435 @@ "@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", + "integrity": "sha512-cG0C5fMZ1jyW6WCsf4LoHGvaIAJCEVA/ioqKoYRwoSfXkOf+17KupK1OUQyUCw5XoRn+oWA1FulJQOYlXnb9Gw==", + "license": "MIT", + "dependencies": { + "@loaders.gl/loader-utils": "4.3.4", + "@loaders.gl/schema": "4.3.4", + "@loaders.gl/worker-utils": "4.3.4", + "@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", + "integrity": "sha512-qgc33BaNsqN9cWa/xvcGvQ50wGDONgQQdzHCKDDKhV2w/uptZoR5iofJfuG8UUV2vUMMd82Uk9zbopRx2rS4Ag==", + "license": "MIT", + "dependencies": { + "@loaders.gl/loader-utils": "4.3.4" + }, + "peerDependencies": { + "@loaders.gl/core": "^4.3.0" + } + }, + "node_modules/@loaders.gl/loader-utils": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/@loaders.gl/loader-utils/-/loader-utils-4.3.4.tgz", + "integrity": "sha512-tjMZvlKQSaMl2qmYTAxg+ySR6zd6hQn5n3XaU8+Ehp90TD3WzxvDKOMNDqOa72fFmIV+KgPhcmIJTpq4lAdC4Q==", + "license": "MIT", + "dependencies": { + "@loaders.gl/schema": "4.3.4", + "@loaders.gl/worker-utils": "4.3.4", + "@probe.gl/log": "^4.0.2", + "@probe.gl/stats": "^4.0.2" + }, + "peerDependencies": { + "@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", + "integrity": "sha512-1YTYoatgzr/6JTxqBLwDiD3AVGwQZheYiQwAimWdRBVB0JAzych7s1yBuE0CVEzj4JDPKOzVAz8KnU1TiBvJGw==", + "license": "MIT", + "dependencies": { + "@types/geojson": "^7946.0.7" + }, + "peerDependencies": { + "@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", + "integrity": "sha512-EbsszrASgT85GH3B7jkx7YXfQyIYo/rlobwMx6V3ewETapPUwdSAInv+89flnk5n2eu2Lpdeh+2zS6PvqbL2RA==", + "license": "MIT", + "peerDependencies": { + "@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" + }, + "node_modules/@luma.gl/core": { + "version": "9.2.6", + "resolved": "https://registry.npmjs.org/@luma.gl/core/-/core-9.2.6.tgz", + "integrity": "sha512-d8KcH8ZZcjDAodSN/G2nueA9YE2X8kMz7Q0OxDGpCww6to1MZXM3Ydate/Jqsb5DDKVgUF6yD6RL8P5jOki9Yw==", + "license": "MIT", + "dependencies": { + "@math.gl/types": "^4.1.0", + "@probe.gl/env": "^4.0.8", + "@probe.gl/log": "^4.0.8", + "@probe.gl/stats": "^4.0.8", + "@types/offscreencanvas": "^2019.6.4" + } + }, + "node_modules/@luma.gl/engine": { + "version": "9.2.6", + "resolved": "https://registry.npmjs.org/@luma.gl/engine/-/engine-9.2.6.tgz", + "integrity": "sha512-1AEDs2AUqOWh7Wl4onOhXmQF+Rz1zNdPXF+Kxm4aWl92RQ42Sh2CmTvRt2BJku83VQ91KFIEm/v3qd3Urzf+Uw==", + "license": "MIT", + "dependencies": { + "@math.gl/core": "^4.1.0", + "@math.gl/types": "^4.1.0", + "@probe.gl/log": "^4.0.8", + "@probe.gl/stats": "^4.0.8" + }, + "peerDependencies": { + "@luma.gl/core": "~9.2.0", + "@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", + "integrity": "sha512-4+uUbynqPUra9d/z1nQChyHmhLgmKfSMjS7kOwLB6exSnhKnpHL3+Hu9fv55qyaX50nGH3oHawhGtJ6RRvu65w==", + "license": "MIT", + "dependencies": { + "@math.gl/core": "^4.1.0", + "@math.gl/types": "^4.1.0", + "wgsl_reflect": "^1.2.0" + }, + "peerDependencies": { + "@luma.gl/core": "~9.2.0" + } + }, + "node_modules/@luma.gl/webgl": { + "version": "9.2.6", + "resolved": "https://registry.npmjs.org/@luma.gl/webgl/-/webgl-9.2.6.tgz", + "integrity": "sha512-NGBTdxJMk7j8Ygr1zuTyAvr1Tw+EpupMIQo7RelFjEsZXg6pujFqiDMM+rgxex8voCeuhWBJc7Rs+MoSqd46UQ==", + "license": "MIT", + "dependencies": { + "@luma.gl/constants": "9.2.6", + "@math.gl/types": "^4.1.0", + "@probe.gl/env": "^4.0.8" + }, + "peerDependencies": { + "@luma.gl/core": "~9.2.0" + } + }, "node_modules/@mapbox/geojson-rewind": { "version": "0.5.2", "resolved": "https://registry.npmjs.org/@mapbox/geojson-rewind/-/geojson-rewind-0.5.2.tgz", @@ -1044,6 +1619,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", "resolved": "https://registry.npmjs.org/@mapbox/point-geometry/-/point-geometry-1.1.0.tgz", @@ -1132,6 +1713,86 @@ "supercluster": "^8.0.1" } }, + "node_modules/@math.gl/core": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@math.gl/core/-/core-4.1.0.tgz", + "integrity": "sha512-FrdHBCVG3QdrworwrUSzXIaK+/9OCRLscxI2OUy6sLOHyHgBMyfnEGs99/m3KNvs+95BsnQLWklVfpKfQzfwKA==", + "license": "MIT", + "dependencies": { + "@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", + "integrity": "sha512-YA/9PzaCRHbIP5/0E9uTYrqe+jsYTQoqoDWhf6/b0Ixz8bPZBaGDEafLg3z7ffBomZLacUty9U3TlPjqMtzPjA==", + "license": "MIT", + "dependencies": { + "@math.gl/core": "4.1.0" + } + }, + "node_modules/@math.gl/sun": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@math.gl/sun/-/sun-4.1.0.tgz", + "integrity": "sha512-i3q6OCBLSZ5wgZVhXg+X7gsjY/TUtuFW/2KBiq/U1ypLso3S4sEykoU/MGjxUv1xiiGtr+v8TeMbO1OBIh/HmA==", + "license": "MIT" + }, + "node_modules/@math.gl/types": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@math.gl/types/-/types-4.1.0.tgz", + "integrity": "sha512-clYZdHcmRvMzVK5fjeDkQlHUzXQSNdZ7s4xOqC3nJPgz4C/TZkUecTo9YS4PruZqtDda/ag4erndP0MIn40dGA==", + "license": "MIT" + }, + "node_modules/@math.gl/web-mercator": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@math.gl/web-mercator/-/web-mercator-4.1.0.tgz", + "integrity": "sha512-HZo3vO5GCMkXJThxRJ5/QYUYRr3XumfT8CzNNCwoJfinxy5NtKUd7dusNTXn7yJ40UoB8FMIwkVwNlqaiRZZAw==", + "license": "MIT", + "dependencies": { + "@math.gl/core": "4.1.0" + } + }, + "node_modules/@probe.gl/env": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@probe.gl/env/-/env-4.1.0.tgz", + "integrity": "sha512-5ac2Jm2K72VCs4eSMsM7ykVRrV47w32xOGMvcgqn8vQdEMF9PRXyBGYEV9YbqRKWNKpNKmQJVi4AHM/fkCxs9w==", + "license": "MIT" + }, + "node_modules/@probe.gl/log": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@probe.gl/log/-/log-4.1.0.tgz", + "integrity": "sha512-r4gRReNY6f+OZEMgfWEXrAE2qJEt8rX0HsDJQXUBMoc+5H47bdB7f/5HBHAmapK8UydwPKL9wCDoS22rJ0yq7Q==", + "license": "MIT", + "dependencies": { + "@probe.gl/env": "4.1.0" + } + }, + "node_modules/@probe.gl/stats": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@probe.gl/stats/-/stats-4.1.0.tgz", + "integrity": "sha512-EI413MkWKBDVNIfLdqbeNSJTs7ToBz/KVGkwi3D+dQrSIkRI2IYbWGAU3xX+D6+CI4ls8ehxMhNpUVMaZggDvQ==", + "license": "MIT" + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-rc.3", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz", @@ -1489,6 +2150,12 @@ "win32" ] }, + "node_modules/@stomp/stompjs": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/@stomp/stompjs/-/stompjs-7.3.0.tgz", + "integrity": "sha512-nKMLoFfJhrQAqkvvKd1vLq/cVBGCMwPRCD0LqW7UT1fecRx9C3GoKEIR2CYwVuErGeZu8w0kFkl2rlhPlqHVgQ==", + "license": "Apache-2.0" + }, "node_modules/@tailwindcss/node": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.0.tgz", @@ -1761,6 +2428,62 @@ "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/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/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/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/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/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/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -1806,6 +2529,21 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/brotli": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@types/brotli/-/brotli-1.3.4.tgz", + "integrity": "sha512-cKYjgaS2DMdCKF7R0F5cgx1nfBYObN2ihIuPGQ4/dlIY6RpV7OWNwe9L8V4tTVKL2eZqOkNM9FM/rgTvLf4oXw==", + "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", "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", @@ -1893,17 +2631,28 @@ "version": "24.10.13", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.13.tgz", "integrity": "sha512-oH72nZRfDv9lADUBSo104Aq7gPHpQZc4BTx38r9xf9pg5LfP6EzSyH2n7qFmmxRQXh7YlUXODcYsg6PuTDSxGg==", - "dev": true, "license": "MIT", "dependencies": { "undici-types": "~7.16.0" } }, + "node_modules/@types/offscreencanvas": { + "version": "2019.7.3", + "resolved": "https://registry.npmjs.org/@types/offscreencanvas/-/offscreencanvas-2019.7.3.tgz", + "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", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "csstype": "^3.2.2" @@ -2231,6 +2980,15 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.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.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", @@ -2301,6 +3059,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.9.19", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz", @@ -2322,6 +3101,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", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", @@ -2356,6 +3145,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/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -2404,6 +3202,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", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", @@ -2460,6 +3267,25 @@ "url": "https://opencollective.com/express" } }, + "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", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -2475,6 +3301,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", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -2633,6 +3468,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", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -2653,6 +3500,12 @@ "csstype": "^3.0.2" } }, + "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", "resolved": "https://registry.npmjs.org/earcut/-/earcut-3.0.2.tgz", @@ -2965,6 +3818,24 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-xml-parser": { + "version": "4.5.3", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-4.5.3.tgz", + "integrity": "sha512-RKihhV+SHsIUGXObeVy9AXiBbFwkVk7Syp8XgwN5U3JV416+Gwp/GO9i0JYKmikykgz/UHRrrV4ROuZEo/T0ig==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "strnum": "^1.1.1" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -2983,6 +3854,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", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", @@ -3110,6 +3987,17 @@ "dev": true, "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", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -3137,6 +4025,26 @@ "hermes-estree": "0.25.1" } }, + "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", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -3147,6 +4055,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/import-fresh": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", @@ -3174,6 +4100,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", "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", @@ -3183,6 +4115,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-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -3206,6 +4150,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", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -3295,6 +4245,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", "resolved": "https://registry.npmjs.org/kdbush/-/kdbush-4.0.2.tgz", @@ -3311,6 +4273,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/levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", @@ -3325,6 +4293,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", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.31.1.tgz", @@ -3615,6 +4592,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/loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", @@ -3637,6 +4623,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", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -3684,6 +4683,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.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", @@ -3706,6 +4716,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/mjolnir.js": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mjolnir.js/-/mjolnir.js-3.0.0.tgz", + "integrity": "sha512-siX3YCG7N2HnmN1xMH3cK4JkUZJhbkhRFJL+G5N1vH0mh1t5088rJknIoqDFWDIU6NPGvRRgLnYW3ZHjSMEBLA==", + "license": "MIT" + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -3811,6 +4827,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", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -3921,6 +4943,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/prop-types": { "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", @@ -4066,6 +5094,21 @@ "react-dom": ">=16.6.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": "2.15.4", "resolved": "https://registry.npmjs.org/recharts/-/recharts-2.15.4.tgz", @@ -4168,6 +5211,12 @@ "integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==", "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/scheduler": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", @@ -4190,6 +5239,12 @@ "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", "license": "MIT" }, + "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", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -4213,6 +5268,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/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -4223,6 +5284,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", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -4236,6 +5312,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", "resolved": "https://registry.npmjs.org/supercluster/-/supercluster-8.0.1.tgz", @@ -4279,6 +5367,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", "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", @@ -4376,7 +5486,6 @@ "version": "7.16.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", - "dev": true, "license": "MIT" }, "node_modules/update-browserslist-db": { @@ -4420,6 +5529,12 @@ "punycode": "^2.1.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": "36.9.2", "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz", @@ -4517,6 +5632,12 @@ } } }, + "node_modules/wgsl_reflect": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/wgsl_reflect/-/wgsl_reflect-1.2.3.tgz", + "integrity": "sha512-BQWBIsOn411M+ffBxmA6QRLvAOVbuz3Uk4NusxnqC1H7aeQcVLhdA3k2k/EFFFtqVjhz3z7JOOZF1a9hj2tv4Q==", + "license": "MIT" + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -4585,6 +5706,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.11", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.11.tgz", + "integrity": "sha512-fdZY+dk7zn/vbWNCYmzZULHRrss0jx5pPFiOuMZ/5HJN6Yv3u+1Wswy/4MpZEkEGhtNH+pwxZB8OKgUBPzYAGg==", + "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 bf2b6e9..f5654e1 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -10,11 +10,17 @@ "preview": "vite preview" }, "dependencies": { + "@deck.gl/core": "^9.2.8", + "@deck.gl/geo-layers": "^9.2.8", + "@deck.gl/layers": "^9.2.8", + "@deck.gl/mapbox": "^9.2.8", + "@stomp/stompjs": "^7.3.0", "maplibre-gl": "^5.18.0", "react": "^19.2.0", "react-dom": "^19.2.0", "react-router-dom": "^7.13.0", - "recharts": "^2.15.3" + "recharts": "^2.15.3", + "zustand": "^5.0.11" }, "devDependencies": { "@eslint/js": "^9.39.1", diff --git a/frontend/src/api/gisApi.ts b/frontend/src/api/gisApi.ts index 63ae787..fd7129d 100644 --- a/frontend/src/api/gisApi.ts +++ b/frontend/src/api/gisApi.ts @@ -8,24 +8,37 @@ export interface HaeguBoundary { center_lat: number } +/** CompactVesselTrack.java 기준 */ export interface VesselTrackResult { - mmsi: string + vesselId: string nationalCode: string - shipKindCode: string + shipKindCode?: string + shipName?: string + shipType?: string geometry: number[][] - timestamps: number[] + timestamps: string[] // 백엔드가 문자열 배열로 전송 speeds: number[] + totalDistance?: number + avgSpeed?: number + maxSpeed?: number + pointCount?: number } +/** RecentVesselPositionDto.java 기준 */ export interface RecentPosition { mmsi: string - lat: number + imo?: number lon: number + lat: number sog: number cog: number - lastUpdate: string - vesselName?: string + shipNm?: string + shipTy?: string shipKindCode?: string + nationalCode?: string + lastUpdate: string + shipImagePath?: string | null + shipImageCount?: number | null } export const gisApi = { diff --git a/frontend/src/components/map/MapContainer.tsx b/frontend/src/components/map/MapContainer.tsx index f2d4fe0..cd21529 100644 --- a/frontend/src/components/map/MapContainer.tsx +++ b/frontend/src/components/map/MapContainer.tsx @@ -1,6 +1,8 @@ import { useRef, useEffect, useState } from 'react' import maplibregl from 'maplibre-gl' import 'maplibre-gl/dist/maplibre-gl.css' +import type { Layer } from '@deck.gl/core' +import { MapboxOverlay } from '@deck.gl/mapbox' import { useTheme } from '../../hooks/useTheme.ts' import { MAP_CENTER, @@ -13,12 +15,14 @@ import { interface MapContainerProps { onMapReady?: (map: maplibregl.Map) => void + deckLayers?: Layer[] className?: string } -export default function MapContainer({ onMapReady, className = '' }: MapContainerProps) { +export default function MapContainer({ onMapReady, deckLayers, className = '' }: MapContainerProps) { const containerRef = useRef(null) const mapRef = useRef(null) + const overlayRef = useRef(null) const { theme } = useTheme() const [ready, setReady] = useState(false) @@ -41,6 +45,13 @@ export default function MapContainer({ onMapReady, className = '' }: MapContaine 'bottom-right', ) + const overlay = new MapboxOverlay({ + interleaved: false, + layers: [], + }) + map.addControl(overlay) + overlayRef.current = overlay + map.on('load', () => { mapRef.current = map setReady(true) @@ -48,12 +59,19 @@ export default function MapContainer({ onMapReady, className = '' }: MapContaine }) return () => { + overlayRef.current = null mapRef.current = null map.remove() } // eslint-disable-next-line react-hooks/exhaustive-deps }, []) + /* Deck.gl 레이어 업데이트 */ + useEffect(() => { + if (!overlayRef.current || !ready) return + overlayRef.current.setProps({ layers: deckLayers || [] }) + }, [deckLayers, ready]) + /* 테마 변경 시 스타일 교체 */ useEffect(() => { if (!mapRef.current || !ready) return diff --git a/frontend/src/features/recent-positions/components/PositionFilterPanel.tsx b/frontend/src/features/recent-positions/components/PositionFilterPanel.tsx new file mode 100644 index 0000000..5a36104 --- /dev/null +++ b/frontend/src/features/recent-positions/components/PositionFilterPanel.tsx @@ -0,0 +1,69 @@ +import { usePositionStore } from '../stores/positionStore' +import { getAllShipKinds } from '../../vessel-map/utils/shipKindColors' + +const ALL_KINDS = getAllShipKinds() + +export default function PositionFilterPanel() { + const kindVisibility = usePositionStore((s) => s.kindVisibility) + const toggleKindVisibility = usePositionStore((s) => s.toggleKindVisibility) + const setAllKindVisibility = usePositionStore((s) => s.setAllKindVisibility) + const positions = usePositionStore((s) => s.positions) + + // 선종별 선박 수 계산 + const kindCounts: Record = {} + for (const p of positions.values()) { + const code = p.shipKindCode || '' + kindCounts[code] = (kindCounts[code] || 0) + 1 + } + + const allVisible = ALL_KINDS.every((k) => kindVisibility[k.code] !== false) + const totalCount = positions.size + const visibleCount = ALL_KINDS.reduce((sum, k) => { + if (kindVisibility[k.code] === false) return sum + return sum + (kindCounts[k.code] || 0) + }, 0) + + return ( +
+ {/* 요약 */} +
+ + {visibleCount} / {totalCount} 선박 + + +
+ + {/* 선종별 토글 */} +
+ {ALL_KINDS.map((kind) => { + const count = kindCounts[kind.code] || 0 + const visible = kindVisibility[kind.code] !== false + + return ( + + ) + })} +
+
+ ) +} diff --git a/frontend/src/features/recent-positions/components/RecentPositionsLayer.tsx b/frontend/src/features/recent-positions/components/RecentPositionsLayer.tsx new file mode 100644 index 0000000..9200839 --- /dev/null +++ b/frontend/src/features/recent-positions/components/RecentPositionsLayer.tsx @@ -0,0 +1,54 @@ +import { useMemo } from 'react' +import type { Layer } from '@deck.gl/core' +import { usePositionStore } from '../stores/positionStore' +import { createVesselIconLayer } from '../../vessel-map' +import { getIconKey, getIconSize } from '../../vessel-map/utils/shipKindColors' +import type { VesselIconData, VesselPosition } from '../../vessel-map' + +interface RecentPositionsLayerProps { + zoom: number + onVesselClick?: (mmsi: string) => void +} + +function toIconData(p: VesselPosition, zoom: number): VesselIconData { + return { + mmsi: p.mmsi, + position: [p.lon, p.lat], + angle: p.cog, + icon: getIconKey(p.shipKindCode, p.sog), + size: getIconSize(zoom, p.shipKindCode, p.sog), + shipNm: p.shipNm, + shipKindCode: p.shipKindCode, + sog: p.sog, + } +} + +/** + * 최근 위치 Deck.gl 레이어 생성 훅 + * positionStore의 가시성 필터 적용 + 줌 기반 아이콘 크기 + */ +export function useRecentPositionsLayer({ + zoom, + onVesselClick, +}: RecentPositionsLayerProps): Layer[] { + const getVisiblePositions = usePositionStore((s) => s.getVisiblePositions) + const positions = usePositionStore((s) => s.positions) + const kindVisibility = usePositionStore((s) => s.kindVisibility) + + return useMemo(() => { + const visible = getVisiblePositions() + const data = visible.map((p) => toIconData(p, zoom)) + + return [ + createVesselIconLayer({ + id: 'recent-positions', + data, + onClick: (info) => { + if (info.object) onVesselClick?.(info.object.mmsi) + }, + }), + ] + // positions, kindVisibility를 deps에 포함하여 데이터 변경 시 재생성 + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [positions, kindVisibility, zoom, onVesselClick, getVisiblePositions]) +} diff --git a/frontend/src/features/recent-positions/components/VesselPopup.tsx b/frontend/src/features/recent-positions/components/VesselPopup.tsx new file mode 100644 index 0000000..e007e6e --- /dev/null +++ b/frontend/src/features/recent-positions/components/VesselPopup.tsx @@ -0,0 +1,87 @@ +import { usePositionStore } from '../stores/positionStore' +import { getShipKindLabel, getShipKindColor } from '../../vessel-map/utils/shipKindColors' + +interface VesselPopupProps { + onTrackQuery?: (mmsi: string) => void +} + +export default function VesselPopup({ onTrackQuery }: VesselPopupProps) { + const selectedMmsi = usePositionStore((s) => s.selectedMmsi) + const positions = usePositionStore((s) => s.positions) + const selectVessel = usePositionStore((s) => s.selectVessel) + + if (!selectedMmsi) return null + + const vessel = positions.get(selectedMmsi) + if (!vessel) return null + + const kindLabel = getShipKindLabel(vessel.shipKindCode) + const kindColor = getShipKindColor(vessel.shipKindCode) + + return ( +
+ {/* 헤더 */} +
+
+ + + {vessel.shipNm || vessel.mmsi} + +
+ +
+ + {/* 선박 정보 그리드 */} +
+ + {vessel.imo ? : null} + + + + + + +
+ + {/* 선박 사진 */} + {vessel.shipImagePath && ( +
+ {vessel.shipNm { (e.target as HTMLImageElement).style.display = 'none' }} + /> +
+ )} + + {/* 항적 조회 버튼 */} + {onTrackQuery && ( + + )} +
+ ) +} + +function InfoRow({ label, value }: { label: string; value: string }) { + return ( + <> + {label} + {value} + + ) +} diff --git a/frontend/src/features/recent-positions/hooks/useRecentPositions.ts b/frontend/src/features/recent-positions/hooks/useRecentPositions.ts new file mode 100644 index 0000000..7c148f6 --- /dev/null +++ b/frontend/src/features/recent-positions/hooks/useRecentPositions.ts @@ -0,0 +1,60 @@ +import { useEffect, useRef, useCallback } from 'react' +import { gisApi, type RecentPosition } from '../../../api/gisApi' +import { usePositionStore } from '../stores/positionStore' +import type { VesselPosition } from '../../vessel-map' + +const POLL_INTERVAL_MS = 30_000 + +/** RecentPosition API 응답 → 내부 VesselPosition 변환 */ +function toVesselPosition(r: RecentPosition): VesselPosition { + return { + mmsi: r.mmsi, + imo: r.imo, + lon: r.lon, + lat: r.lat, + sog: r.sog, + cog: r.cog, + shipNm: r.shipNm, + shipTy: r.shipTy, + shipKindCode: r.shipKindCode, + nationalCode: r.nationalCode, + lastUpdate: r.lastUpdate, + shipImagePath: r.shipImagePath, + shipImageCount: r.shipImageCount, + } +} + +/** + * 최근 위치 폴링 훅 + * @param minutes API 조회 범위 (기본 10분) + * @param enabled 활성화 여부 + */ +export function useRecentPositions(minutes = 10, enabled = true) { + const setPositions = usePositionStore((s) => s.setPositions) + const timerRef = useRef | null>(null) + + const fetchPositions = useCallback(async () => { + try { + const data = await gisApi.getRecentPositions(minutes) + setPositions(data.map(toVesselPosition)) + } catch (err) { + console.error('[useRecentPositions] fetch failed:', err) + } + }, [minutes, setPositions]) + + useEffect(() => { + if (!enabled) return + + fetchPositions() + timerRef.current = setInterval(fetchPositions, POLL_INTERVAL_MS) + + return () => { + if (timerRef.current) { + clearInterval(timerRef.current) + timerRef.current = null + } + } + }, [enabled, fetchPositions]) + + return { refetch: fetchPositions } +} diff --git a/frontend/src/features/recent-positions/index.ts b/frontend/src/features/recent-positions/index.ts new file mode 100644 index 0000000..fc9e679 --- /dev/null +++ b/frontend/src/features/recent-positions/index.ts @@ -0,0 +1,5 @@ +export { usePositionStore } from './stores/positionStore' +export { useRecentPositions } from './hooks/useRecentPositions' +export { useRecentPositionsLayer } from './components/RecentPositionsLayer' +export { default as VesselPopup } from './components/VesselPopup' +export { default as PositionFilterPanel } from './components/PositionFilterPanel' diff --git a/frontend/src/features/recent-positions/stores/positionStore.ts b/frontend/src/features/recent-positions/stores/positionStore.ts new file mode 100644 index 0000000..344ef44 --- /dev/null +++ b/frontend/src/features/recent-positions/stores/positionStore.ts @@ -0,0 +1,72 @@ +import { create } from 'zustand' +import type { VesselPosition } from '../../vessel-map' + +interface PositionState { + /** MMSI → 위치 맵 */ + positions: Map + + /** 선택된 MMSI (팝업 표시용) */ + selectedMmsi: string | null + + /** 선종별 가시성 필터 */ + kindVisibility: Record + + /** 위치 데이터 전체 교체 */ + setPositions: (list: VesselPosition[]) => void + + /** 선박 선택/해제 */ + selectVessel: (mmsi: string | null) => void + + /** 특정 선종 토글 */ + toggleKindVisibility: (kindCode: string) => void + + /** 전체 선종 표시/숨김 */ + setAllKindVisibility: (visible: boolean) => void + + /** 가시성 필터 적용된 선박 목록 */ + getVisiblePositions: () => VesselPosition[] +} + +export const usePositionStore = create((set, get) => ({ + positions: new Map(), + selectedMmsi: null, + kindVisibility: {}, + + setPositions: (list) => { + const map = new Map() + for (const p of list) { + map.set(p.mmsi, p) + } + set({ positions: map }) + }, + + selectVessel: (mmsi) => set({ selectedMmsi: mmsi }), + + toggleKindVisibility: (kindCode) => + set((state) => ({ + kindVisibility: { + ...state.kindVisibility, + [kindCode]: !(state.kindVisibility[kindCode] ?? true), + }, + })), + + setAllKindVisibility: (visible) => + set((state) => { + const next: Record = {} + for (const code of Object.keys(state.kindVisibility)) { + next[code] = visible + } + return { kindVisibility: next } + }), + + getVisiblePositions: () => { + const { positions, kindVisibility } = get() + const result: VesselPosition[] = [] + for (const p of positions.values()) { + const code = p.shipKindCode || '' + if (kindVisibility[code] === false) continue + result.push(p) + } + return result + }, +})) diff --git a/frontend/src/features/vessel-map/assets/atlas.png b/frontend/src/features/vessel-map/assets/atlas.png new file mode 100644 index 0000000000000000000000000000000000000000..0e316ef8325246aa4040b51d3111732194cc8f74 GIT binary patch literal 10207 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 = { + [SHIP_KIND_FISHING]: '어선', + [SHIP_KIND_KCGV]: '경비함정', + [SHIP_KIND_PASSENGER]: '여객선', + [SHIP_KIND_CARGO]: '화물선', + [SHIP_KIND_TANKER]: '유조선', + [SHIP_KIND_GOV]: '관공선', + [SHIP_KIND_NORMAL]: '일반', + [SHIP_KIND_BUOY]: '부이', +} + +// --- 선종별 CSS HEX 색상 (범례, UI 표시용) --- +export const SHIP_KIND_COLORS: Record = { + [SHIP_KIND_FISHING]: '#00C853', + [SHIP_KIND_KCGV]: '#FF5722', + [SHIP_KIND_PASSENGER]: '#2196F3', + [SHIP_KIND_CARGO]: '#9C27B0', + [SHIP_KIND_TANKER]: '#F44336', + [SHIP_KIND_GOV]: '#FF9800', + [SHIP_KIND_NORMAL]: '#607D8B', + [SHIP_KIND_BUOY]: '#795548', +} + +// --- 선종별 RGBA 색상 (Deck.gl 항적 레이어용) --- +export const SHIP_KIND_TRACK_RGBA: Record = { + [SHIP_KIND_FISHING]: [25, 116, 25, 150], + [SHIP_KIND_KCGV]: [0, 41, 255, 150], + [SHIP_KIND_PASSENGER]: [176, 42, 42, 150], + [SHIP_KIND_CARGO]: [255, 139, 54, 150], + [SHIP_KIND_TANKER]: [255, 0, 0, 150], + [SHIP_KIND_GOV]: [92, 30, 224, 150], + [SHIP_KIND_NORMAL]: [255, 135, 207, 150], + [SHIP_KIND_BUOY]: [232, 95, 27, 150], +} +export const DEFAULT_TRACK_RGBA: [number, number, number, number] = [128, 128, 128, 150] + +// --- 속도 임계값 --- +export const SPEED_THRESHOLD = 1 // knots (정박/운항 경계) + +// --- 줌 레벨별 아이콘 크기 --- +export const ZOOM_ICON_SIZES: { maxZoom: number; size: number }[] = [ + { maxZoom: 8, size: 15 }, + { maxZoom: 11, size: 25 }, + { maxZoom: 14, size: 35 }, + { maxZoom: Infinity, size: 40 }, +] +export const BUOY_ICON_SIZE = 16 +export const STOPPED_ICON_SIZE = 8 + +// --- 아이콘 Atlas 스프라이트 매핑 --- +export interface IconAtlasEntry { + x: number + y: number + width: number + height: number + anchorY?: number +} + +export const ICON_ATLAS_MAPPING: Record = { + // 이동 중 (화살표 형태) + fishingImg: { x: 1, y: 518, width: 16, height: 27 }, + kcgvImg: { x: 45, y: 115, width: 17, height: 27 }, + passImg: { x: 24, y: 486, width: 17, height: 27 }, + cargoImg: { x: 44, y: 144, width: 17, height: 27 }, + hazardImg: { x: 44, y: 173, width: 17, height: 27 }, + govImg: { x: 43, y: 486, width: 17, height: 27 }, + etcImg: { x: 24, y: 515, width: 17, height: 27 }, + bouyImg: { x: 1, y: 485, width: 21, height: 31 }, + + // 정지 (원형) + fishingStopImg: { x: 51, y: 51, width: 8, height: 8 }, + kcgvStopImg: { x: 51, y: 41, width: 8, height: 8 }, + passStopImg: { x: 51, y: 21, width: 8, height: 8 }, + cargoStopImg: { x: 51, y: 1, width: 8, height: 8 }, + hazardStopImg: { x: 51, y: 11, width: 8, height: 8 }, + govStopImg: { x: 51, y: 31, width: 8, height: 8 }, + etcStopImg: { x: 51, y: 71, width: 8, height: 8 }, +} + +// --- 선종 → 아이콘 키 매핑 --- +export const ICON_KEY_MOVING: Record = { + [SHIP_KIND_FISHING]: 'fishingImg', + [SHIP_KIND_KCGV]: 'kcgvImg', + [SHIP_KIND_PASSENGER]: 'passImg', + [SHIP_KIND_CARGO]: 'cargoImg', + [SHIP_KIND_TANKER]: 'hazardImg', + [SHIP_KIND_GOV]: 'govImg', + [SHIP_KIND_NORMAL]: 'etcImg', + [SHIP_KIND_BUOY]: 'bouyImg', +} + +export const ICON_KEY_STOPPED: Record = { + [SHIP_KIND_FISHING]: 'fishingStopImg', + [SHIP_KIND_KCGV]: 'kcgvStopImg', + [SHIP_KIND_PASSENGER]: 'passStopImg', + [SHIP_KIND_CARGO]: 'cargoStopImg', + [SHIP_KIND_TANKER]: 'hazardStopImg', + [SHIP_KIND_GOV]: 'govStopImg', + [SHIP_KIND_NORMAL]: 'etcStopImg', + [SHIP_KIND_BUOY]: 'bouyImg', +} diff --git a/frontend/src/features/vessel-map/hooks/useMapInstance.ts b/frontend/src/features/vessel-map/hooks/useMapInstance.ts new file mode 100644 index 0000000..a7f45ce --- /dev/null +++ b/frontend/src/features/vessel-map/hooks/useMapInstance.ts @@ -0,0 +1,70 @@ +import { useRef, useEffect, useCallback } from 'react' +import { MapboxOverlay } from '@deck.gl/mapbox' +import type { Layer } from '@deck.gl/core' +import type maplibregl from 'maplibre-gl' + +/** + * MapLibre + Deck.gl MapboxOverlay 통합 관리 훅 + * + * - MapboxOverlay 생성/제거 라이프사이클 + * - setLayers()로 Deck.gl 레이어 동적 업데이트 + * - pickObject()로 클릭/호버 인터랙션 + */ +export function useMapInstance(map: maplibregl.Map | null) { + const overlayRef = useRef(null) + + // Overlay 초기화 + 정리 + useEffect(() => { + if (!map) return + + const initOverlay = () => { + if (overlayRef.current) return + + const overlay = new MapboxOverlay({ + interleaved: false, + layers: [], + }) + map.addControl(overlay) + overlayRef.current = overlay + } + + if (map.loaded()) { + initOverlay() + } else { + map.on('load', initOverlay) + } + + return () => { + map.off('load', initOverlay) + if (overlayRef.current) { + try { + map.removeControl(overlayRef.current) + } catch { + // 맵이 이미 제거된 경우 + } + overlayRef.current.finalize() + overlayRef.current = null + } + } + }, [map]) + + /** Deck.gl 레이어 배열 업데이트 */ + const setLayers = useCallback((layers: Layer[]) => { + overlayRef.current?.setProps({ layers }) + }, []) + + /** 특정 좌표의 객체 피킹 */ + const pickObject = useCallback( + (x: number, y: number, layerIds?: string[]) => { + if (!overlayRef.current) return null + try { + return overlayRef.current.pickObject({ x, y, layerIds }) ?? null + } catch { + return null + } + }, + [], + ) + + return { overlayRef, setLayers, pickObject } +} diff --git a/frontend/src/features/vessel-map/index.ts b/frontend/src/features/vessel-map/index.ts new file mode 100644 index 0000000..d000923 --- /dev/null +++ b/frontend/src/features/vessel-map/index.ts @@ -0,0 +1,61 @@ +// Types +export type { + VesselPosition, + TrackSegment, + InterpolatedPosition, + ViewportBounds, + VesselIconData, + TrackPathData, +} from './types' + +// Constants +export { + SHIP_KIND_FISHING, + SHIP_KIND_KCGV, + SHIP_KIND_PASSENGER, + SHIP_KIND_CARGO, + SHIP_KIND_TANKER, + SHIP_KIND_GOV, + SHIP_KIND_NORMAL, + SHIP_KIND_BUOY, + SHIP_KIND_LABELS, + SHIP_KIND_COLORS, + SHIP_KIND_TRACK_RGBA, + DEFAULT_TRACK_RGBA, + SPEED_THRESHOLD, + ICON_ATLAS_MAPPING, + ICON_KEY_MOVING, + ICON_KEY_STOPPED, +} from './constants' + +// Utils +export { + getViewportBounds, + filterByViewport, + expandBounds, + calcPositionBounds, +} from './utils/viewport' + +export { + getPositionsAtTime, + getTimeRange, +} from './utils/interpolation' + +export { + getShipKindColor, + getShipKindLabel, + getTrackColor, + getIconKey, + getIconSize, + getAllShipKinds, +} from './utils/shipKindColors' + +// Layers +export { createVesselIconLayer } from './layers/VesselIconLayer' +export type { VesselIconLayerProps } from './layers/VesselIconLayer' + +export { createTrackPathLayer } from './layers/TrackPathLayer' +export type { TrackPathLayerProps } from './layers/TrackPathLayer' + +// Hooks +export { useMapInstance } from './hooks/useMapInstance' diff --git a/frontend/src/features/vessel-map/layers/TrackPathLayer.ts b/frontend/src/features/vessel-map/layers/TrackPathLayer.ts new file mode 100644 index 0000000..05124a2 --- /dev/null +++ b/frontend/src/features/vessel-map/layers/TrackPathLayer.ts @@ -0,0 +1,42 @@ +import { PathLayer } from '@deck.gl/layers' +import type { TrackPathData } from '../types' + +export interface TrackPathLayerProps { + id?: string + data: TrackPathData[] + visible?: boolean + widthMinPixels?: number + pickable?: boolean +} + +/** + * 항적 경로 레이어 팩토리 + * 선종별 RGBA 색상 적용, 줌에 따라 폭 자동 조절 + */ +export function createTrackPathLayer({ + id = 'track-path', + data, + visible = true, + widthMinPixels = 2, + pickable = false, +}: TrackPathLayerProps): PathLayer { + return new PathLayer({ + id, + data, + visible, + pickable, + getPath: (d) => d.path, + getColor: (d) => d.color, + getWidth: 3, + widthMinPixels, + widthMaxPixels: 6, + widthUnits: 'pixels' as const, + jointRounded: true, + capRounded: true, + billboard: false, + updateTriggers: { + getPath: [data], + getColor: [data], + }, + }) +} diff --git a/frontend/src/features/vessel-map/layers/VesselIconLayer.ts b/frontend/src/features/vessel-map/layers/VesselIconLayer.ts new file mode 100644 index 0000000..89f0452 --- /dev/null +++ b/frontend/src/features/vessel-map/layers/VesselIconLayer.ts @@ -0,0 +1,48 @@ +import { IconLayer } from '@deck.gl/layers' +import type { VesselIconData } from '../types' +import { ICON_ATLAS_MAPPING } from '../constants' +import atlasUrl from '../assets/atlas.png' + +export interface VesselIconLayerProps { + id?: string + data: VesselIconData[] + visible?: boolean + pickable?: boolean + onClick?: (info: { object?: VesselIconData }) => void +} + +/** + * 선박 아이콘 레이어 팩토리 + * Atlas 스프라이트 기반 — SOG에 따라 화살표/원형 아이콘, COG 회전 + */ +export function createVesselIconLayer({ + id = 'vessel-icon', + data, + visible = true, + pickable = true, + onClick, +}: VesselIconLayerProps): IconLayer { + return new IconLayer({ + id, + data, + visible, + pickable, + iconAtlas: atlasUrl, + iconMapping: ICON_ATLAS_MAPPING, + getIcon: (d) => d.icon, + getPosition: (d) => d.position, + getSize: (d) => d.size, + getAngle: (d) => -d.angle, // Deck.gl: 반시계 양수 → COG 시계방향 보정 + sizeScale: 1, + sizeUnits: 'pixels' as const, + sizeMinPixels: 4, + billboard: false, + alphaCutoff: 0.05, + onClick: onClick as never, + updateTriggers: { + getIcon: [data], + getSize: [data], + getAngle: [data], + }, + }) +} diff --git a/frontend/src/features/vessel-map/types.ts b/frontend/src/features/vessel-map/types.ts new file mode 100644 index 0000000..f56bee9 --- /dev/null +++ b/frontend/src/features/vessel-map/types.ts @@ -0,0 +1,75 @@ +/** + * vessel-map 공유 타입 정의 + * 최근위치 / 선박항적 / 뷰포트리플레이 공통으로 사용 + */ + +/** 선박 위치 (최근위치 API 응답 → 내부 표현) */ +export interface VesselPosition { + mmsi: string + imo?: number + lon: number + lat: number + sog: number + cog: number + shipNm?: string + shipTy?: string + shipKindCode?: string + nationalCode?: string + lastUpdate: string + shipImagePath?: string | null + shipImageCount?: number | null +} + +/** 항적 세그먼트 (API 응답 → 내부 처리용) */ +export interface TrackSegment { + vesselId: string + nationalCode?: string + shipKindCode?: string + shipName?: string + geometry: [number, number][] // [lon, lat][] + timestampsMs: number[] // Unix epoch ms + speeds: number[] + totalDistance?: number + avgSpeed?: number + maxSpeed?: number + pointCount?: number +} + +/** 보간된 현재 위치 (타임라인 스크럽 / 애니메이션용) */ +export interface InterpolatedPosition { + vesselId: string + lon: number + lat: number + heading: number + speed: number + shipName: string + shipKindCode: string +} + +/** 맵 뷰포트 바운드 */ +export interface ViewportBounds { + west: number + south: number + east: number + north: number +} + +/** Deck.gl IconLayer 데이터 포인트 */ +export interface VesselIconData { + mmsi: string + position: [number, number] // [lon, lat] + angle: number // COG (rotation) + icon: string // atlas mapping key + size: number + shipNm?: string + shipKindCode?: string + sog?: number +} + +/** Deck.gl PathLayer 데이터 포인트 */ +export interface TrackPathData { + vesselId: string + path: [number, number][] // [lon, lat][] + color: [number, number, number, number] + shipKindCode?: string +} diff --git a/frontend/src/features/vessel-map/utils/interpolation.ts b/frontend/src/features/vessel-map/utils/interpolation.ts new file mode 100644 index 0000000..639e66e --- /dev/null +++ b/frontend/src/features/vessel-map/utils/interpolation.ts @@ -0,0 +1,121 @@ +import type { TrackSegment, InterpolatedPosition } from '../types' + +/** + * 이진 탐색으로 currentTime이 속하는 구간 [lo, hi] 인덱스 반환 + * O(log n) — 프레임당 선박 수 × O(log n) + */ +function findTimeIndex(timestampsMs: number[], currentTime: number): [number, number] { + let lo = 0 + let hi = timestampsMs.length - 1 + + while (lo < hi - 1) { + const mid = (lo + hi) >> 1 + if (timestampsMs[mid] <= currentTime) lo = mid + else hi = mid + } + + return [lo, hi] +} + +/** 두 점 사이 방위각 계산 (degrees, 0-360) */ +function calculateHeading( + lon1: number, lat1: number, + lon2: number, lat2: number, +): number { + const dLon = ((lon2 - lon1) * Math.PI) / 180 + const lat1Rad = (lat1 * Math.PI) / 180 + const lat2Rad = (lat2 * Math.PI) / 180 + const y = Math.sin(dLon) * Math.cos(lat2Rad) + const x = + Math.cos(lat1Rad) * Math.sin(lat2Rad) - + Math.sin(lat1Rad) * Math.cos(lat2Rad) * Math.cos(dLon) + let heading = (Math.atan2(y, x) * 180) / Math.PI + if (heading < 0) heading += 360 + return heading +} + +/** + * 트랙 배열에서 currentTime 기준 보간된 위치 목록 생성 + * disabledIds에 포함된 vesselId는 제외 + */ +export function getPositionsAtTime( + tracks: TrackSegment[], + currentTime: number, + disabledIds?: Set, +): InterpolatedPosition[] { + const positions: InterpolatedPosition[] = [] + + for (const track of tracks) { + if (disabledIds?.has(track.vesselId)) continue + + const { timestampsMs, geometry, speeds } = track + if (timestampsMs.length === 0) continue + + // 경계값 처리 + if (currentTime <= timestampsMs[0]) { + positions.push({ + vesselId: track.vesselId, + lon: geometry[0][0], + lat: geometry[0][1], + heading: 0, + speed: speeds[0] || 0, + shipName: track.shipName || '', + shipKindCode: track.shipKindCode || '', + }) + continue + } + + if (currentTime >= timestampsMs[timestampsMs.length - 1]) { + const last = geometry.length - 1 + positions.push({ + vesselId: track.vesselId, + lon: geometry[last][0], + lat: geometry[last][1], + heading: 0, + speed: speeds[last] || 0, + shipName: track.shipName || '', + shipKindCode: track.shipKindCode || '', + }) + continue + } + + // 이진 탐색 + 선형 보간 + const [lo, hi] = findTimeIndex(timestampsMs, currentTime) + const t1 = timestampsMs[lo] + const t2 = timestampsMs[hi] + const ratio = t2 === t1 ? 0 : (currentTime - t1) / (t2 - t1) + + const p1 = geometry[lo] + const p2 = geometry[hi] + const lon = p1[0] + (p2[0] - p1[0]) * ratio + const lat = p1[1] + (p2[1] - p1[1]) * ratio + const heading = calculateHeading(p1[0], p1[1], p2[0], p2[1]) + const speed = speeds[lo] + (speeds[hi] - speeds[lo]) * ratio + + positions.push({ + vesselId: track.vesselId, + lon, + lat, + heading, + speed, + shipName: track.shipName || '', + shipKindCode: track.shipKindCode || '', + }) + } + + return positions +} + +/** 트랙 배열의 전체 시간 범위 [min, max] (ms) */ +export function getTimeRange(tracks: TrackSegment[]): [number, number] { + let min = Infinity + let max = -Infinity + for (const t of tracks) { + if (t.timestampsMs.length === 0) continue + const first = t.timestampsMs[0] + const last = t.timestampsMs[t.timestampsMs.length - 1] + if (first < min) min = first + if (last > max) max = last + } + return [min, max] +} diff --git a/frontend/src/features/vessel-map/utils/shipKindColors.ts b/frontend/src/features/vessel-map/utils/shipKindColors.ts new file mode 100644 index 0000000..31de9e0 --- /dev/null +++ b/frontend/src/features/vessel-map/utils/shipKindColors.ts @@ -0,0 +1,55 @@ +import { + SHIP_KIND_COLORS, + SHIP_KIND_LABELS, + SHIP_KIND_TRACK_RGBA, + DEFAULT_TRACK_RGBA, + ICON_KEY_MOVING, + ICON_KEY_STOPPED, + SPEED_THRESHOLD, + ZOOM_ICON_SIZES, + BUOY_ICON_SIZE, + STOPPED_ICON_SIZE, + SHIP_KIND_BUOY, +} from '../constants' + +/** 선종 → HEX 색상 (UI/범례용) */ +export function getShipKindColor(shipKindCode?: string): string { + return (shipKindCode && SHIP_KIND_COLORS[shipKindCode]) || '#607D8B' +} + +/** 선종 → 라벨 */ +export function getShipKindLabel(shipKindCode?: string): string { + return (shipKindCode && SHIP_KIND_LABELS[shipKindCode]) || '기타' +} + +/** 선종 → RGBA 색상 (Deck.gl 항적용) */ +export function getTrackColor(shipKindCode?: string): [number, number, number, number] { + return (shipKindCode && SHIP_KIND_TRACK_RGBA[shipKindCode]) || DEFAULT_TRACK_RGBA +} + +/** SOG + 선종 → 아이콘 atlas key */ +export function getIconKey(shipKindCode: string | undefined, sog: number): string { + const isMoving = sog > SPEED_THRESHOLD + const mapping = isMoving ? ICON_KEY_MOVING : ICON_KEY_STOPPED + return (shipKindCode && mapping[shipKindCode]) || (isMoving ? 'etcImg' : 'etcStopImg') +} + +/** 줌 + SOG + 선종 → 아이콘 크기 (px) */ +export function getIconSize(zoom: number, shipKindCode: string | undefined, sog: number): number { + if (shipKindCode === SHIP_KIND_BUOY) return BUOY_ICON_SIZE + if (sog <= SPEED_THRESHOLD) return STOPPED_ICON_SIZE + + for (const entry of ZOOM_ICON_SIZES) { + if (zoom < entry.maxZoom) return entry.size + } + return ZOOM_ICON_SIZES[ZOOM_ICON_SIZES.length - 1].size +} + +/** 모든 선종 코드와 라벨/색상 목록 (범례 렌더링용) */ +export function getAllShipKinds(): { code: string; label: string; color: string }[] { + return Object.entries(SHIP_KIND_LABELS).map(([code, label]) => ({ + code, + label, + color: SHIP_KIND_COLORS[code] || '#607D8B', + })) +} diff --git a/frontend/src/features/vessel-map/utils/viewport.ts b/frontend/src/features/vessel-map/utils/viewport.ts new file mode 100644 index 0000000..aec6abb --- /dev/null +++ b/frontend/src/features/vessel-map/utils/viewport.ts @@ -0,0 +1,52 @@ +import type maplibregl from 'maplibre-gl' +import type { ViewportBounds, VesselPosition } from '../types' + +/** 맵 인스턴스에서 현재 뷰포트 바운드 추출 */ +export function getViewportBounds(map: maplibregl.Map): ViewportBounds { + const bounds = map.getBounds() + return { + west: bounds.getWest(), + south: bounds.getSouth(), + east: bounds.getEast(), + north: bounds.getNorth(), + } +} + +/** 바운드 내부에 있는 선박만 필터링 */ +export function filterByViewport( + positions: T[], + bounds: ViewportBounds, +): T[] { + return positions.filter( + (p) => + p.lon >= bounds.west && + p.lon <= bounds.east && + p.lat >= bounds.south && + p.lat <= bounds.north, + ) +} + +/** 바운드를 마진(비율)만큼 확장 */ +export function expandBounds(bounds: ViewportBounds, margin = 0.1): ViewportBounds { + const lonSpan = bounds.east - bounds.west + const latSpan = bounds.north - bounds.south + return { + west: bounds.west - lonSpan * margin, + south: bounds.south - latSpan * margin, + east: bounds.east + lonSpan * margin, + north: bounds.north + latSpan * margin, + } +} + +/** VesselPosition 배열의 전체 바운드 계산 (fitBounds용) */ +export function calcPositionBounds(positions: VesselPosition[]): ViewportBounds | null { + if (positions.length === 0) return null + let west = Infinity, south = Infinity, east = -Infinity, north = -Infinity + for (const p of positions) { + if (p.lon < west) west = p.lon + if (p.lon > east) east = p.lon + if (p.lat < south) south = p.lat + if (p.lat > north) north = p.lat + } + return { west, south, east, north } +} diff --git a/frontend/src/features/vessel-tracks/components/TrackInfoPanel.tsx b/frontend/src/features/vessel-tracks/components/TrackInfoPanel.tsx new file mode 100644 index 0000000..e9959b2 --- /dev/null +++ b/frontend/src/features/vessel-tracks/components/TrackInfoPanel.tsx @@ -0,0 +1,58 @@ +import { useTrackStore } from '../stores/trackStore' + +/** + * 맵 하단 오버레이: 타임라인 슬라이더 + 항적 통계 + */ +export default function TrackInfoPanel() { + const tracks = useTrackStore((s) => s.tracks) + const currentTime = useTrackStore((s) => s.currentTime) + const timeRange = useTrackStore((s) => s.timeRange) + const setProgressByRatio = useTrackStore((s) => s.setProgressByRatio) + + if (tracks.length === 0) return null + + const [min, max] = timeRange + const duration = max - min + const progress = duration > 0 ? (currentTime - min) / duration : 0 + + const formatTime = (ms: number) => { + const d = new Date(ms) + return d.toLocaleString('ko-KR', { + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + }) + } + + const totalDistance = tracks.reduce((sum, t) => sum + (t.totalDistance || 0), 0) + const maxSpeed = Math.max(...tracks.map((t) => t.maxSpeed || 0)) + + return ( +
+ {/* 타임라인 슬라이더 */} +
+ setProgressByRatio(Number(e.target.value) / 1000)} + className="h-1.5 w-full cursor-pointer accent-primary" + /> +
+ {formatTime(min)} + {formatTime(currentTime)} + {formatTime(max)} +
+
+ + {/* 통계 요약 */} +
+ {tracks.length}척 + 총 거리: {totalDistance.toFixed(1)} nm + 최고속도: {maxSpeed.toFixed(1)} kn +
+
+ ) +} diff --git a/frontend/src/features/vessel-tracks/components/TrackQueryPanel.tsx b/frontend/src/features/vessel-tracks/components/TrackQueryPanel.tsx new file mode 100644 index 0000000..6d4eeba --- /dev/null +++ b/frontend/src/features/vessel-tracks/components/TrackQueryPanel.tsx @@ -0,0 +1,118 @@ +import { useState } from 'react' +import { useVesselTracks } from '../hooks/useVesselTracks' +import { useTrackStore } from '../stores/trackStore' + +interface TrackQueryPanelProps { + /** 최근위치에서 클릭하여 넘어온 MMSI */ + initialMmsi?: string +} + +/** 기본 시간 범위: 지금부터 24시간 전 */ +function getDefaultTimeRange(): { start: string; end: string } { + const now = new Date() + const yesterday = new Date(now.getTime() - 24 * 60 * 60 * 1000) + return { + start: toLocalISOString(yesterday), + end: toLocalISOString(now), + } +} + +function toLocalISOString(d: Date): string { + const pad = (n: number) => String(n).padStart(2, '0') + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}` +} + +export default function TrackQueryPanel({ initialMmsi }: TrackQueryPanelProps) { + const { start, end } = getDefaultTimeRange() + const [mmsiInput, setMmsiInput] = useState(initialMmsi || '') + const [startTime, setStartTime] = useState(start) + const [endTime, setEndTime] = useState(end) + + const { fetchTracks, clearTracks } = useVesselTracks() + const loading = useTrackStore((s) => s.loading) + const tracks = useTrackStore((s) => s.tracks) + + const handleQuery = () => { + const mmsiList = mmsiInput + .split(/[,\s]+/) + .map((s) => s.trim()) + .filter(Boolean) + if (mmsiList.length === 0) return + + const startISO = startTime.replace('T', ' ') + ':00' + const endISO = endTime.replace('T', ' ') + ':00' + fetchTracks(mmsiList, startISO, endISO) + } + + return ( +
+ {/* MMSI 입력 */} +
+ + setMmsiInput(e.target.value)} + placeholder="440113620, 441027000" + className="w-full rounded-md border border-border bg-surface px-3 py-1.5 text-sm text-foreground placeholder:text-muted focus:border-primary focus:outline-none" + /> +
+ + {/* 시간 범위 */} +
+
+ + setStartTime(e.target.value)} + className="w-full rounded-md border border-border bg-surface px-2 py-1.5 text-xs text-foreground focus:border-primary focus:outline-none" + /> +
+
+ + setEndTime(e.target.value)} + className="w-full rounded-md border border-border bg-surface px-2 py-1.5 text-xs text-foreground focus:border-primary focus:outline-none" + /> +
+
+ + {/* 버튼 */} +
+ + {tracks.length > 0 && ( + + )} +
+ + {/* 결과 요약 */} + {tracks.length > 0 && ( +
+
{tracks.length}척 항적 로드
+ {tracks.map((t) => ( +
+ {t.shipName || t.vesselId} + {t.geometry.length} pts +
+ ))} +
+ )} +
+ ) +} diff --git a/frontend/src/features/vessel-tracks/components/VesselTracksLayer.tsx b/frontend/src/features/vessel-tracks/components/VesselTracksLayer.tsx new file mode 100644 index 0000000..542f690 --- /dev/null +++ b/frontend/src/features/vessel-tracks/components/VesselTracksLayer.tsx @@ -0,0 +1,54 @@ +import { useMemo } from 'react' +import type { Layer } from '@deck.gl/core' +import { useTrackStore } from '../stores/trackStore' +import { createTrackPathLayer } from '../../vessel-map/layers/TrackPathLayer' +import { createVesselIconLayer } from '../../vessel-map/layers/VesselIconLayer' +import { getTrackColor, getIconKey, getIconSize } from '../../vessel-map/utils/shipKindColors' +import type { TrackPathData, VesselIconData } from '../../vessel-map' + +interface VesselTracksLayerProps { + zoom: number +} + +/** + * 선박 항적 Deck.gl 레이어 생성 훅 + * PathLayer(경로) + IconLayer(현재 보간 위치) + */ +export function useVesselTracksLayer({ zoom }: VesselTracksLayerProps): Layer[] { + const tracks = useTrackStore((s) => s.tracks) + const disabledVesselIds = useTrackStore((s) => s.disabledVesselIds) + const getCurrentPositions = useTrackStore((s) => s.getCurrentPositions) + const currentTime = useTrackStore((s) => s.currentTime) + + return useMemo(() => { + if (tracks.length === 0) return [] + + // 1. 항적 경로 레이어 + const pathData: TrackPathData[] = tracks + .filter((t) => !disabledVesselIds.has(t.vesselId)) + .map((t) => ({ + vesselId: t.vesselId, + path: t.geometry, + color: getTrackColor(t.shipKindCode), + shipKindCode: t.shipKindCode, + })) + + // 2. 현재 위치 아이콘 레이어 + const positions = getCurrentPositions() + const iconData: VesselIconData[] = positions.map((p) => ({ + mmsi: p.vesselId, + position: [p.lon, p.lat], + angle: p.heading, + icon: getIconKey(p.shipKindCode, p.speed), + size: getIconSize(zoom, p.shipKindCode, p.speed), + shipNm: p.shipName, + shipKindCode: p.shipKindCode, + sog: p.speed, + })) + + return [ + createTrackPathLayer({ id: 'vessel-track-path', data: pathData }), + createVesselIconLayer({ id: 'vessel-track-icon', data: iconData, pickable: false }), + ] + }, [tracks, disabledVesselIds, currentTime, zoom, getCurrentPositions]) +} diff --git a/frontend/src/features/vessel-tracks/hooks/useVesselTracks.ts b/frontend/src/features/vessel-tracks/hooks/useVesselTracks.ts new file mode 100644 index 0000000..f296b93 --- /dev/null +++ b/frontend/src/features/vessel-tracks/hooks/useVesselTracks.ts @@ -0,0 +1,52 @@ +import { useCallback } from 'react' +import { gisApi, type VesselTrackResult } from '../../../api/gisApi' +import { useTrackStore } from '../stores/trackStore' +import type { TrackSegment } from '../../vessel-map' + +/** API 응답 → 내부 TrackSegment 변환 */ +function toTrackSegment(r: VesselTrackResult): TrackSegment { + return { + vesselId: r.vesselId, + nationalCode: r.nationalCode, + shipKindCode: r.shipKindCode, + shipName: r.shipName, + geometry: r.geometry as [number, number][], + // 백엔드가 문자열 배열로 전송 → 숫자 ms 변환 + timestampsMs: r.timestamps.map((t) => { + const n = Number(t) + // 초 단위(10자리)면 ×1000, ms 단위(13자리)면 그대로 + return n < 1e12 ? n * 1000 : n + }), + speeds: r.speeds, + totalDistance: r.totalDistance, + avgSpeed: r.avgSpeed, + maxSpeed: r.maxSpeed, + pointCount: r.pointCount, + } +} + +/** 선박 항적 조회 훅 */ +export function useVesselTracks() { + const setTracks = useTrackStore((s) => s.setTracks) + const setLoading = useTrackStore((s) => s.setLoading) + const clearTracks = useTrackStore((s) => s.clearTracks) + + const fetchTracks = useCallback( + async (mmsiList: string[], startTime: string, endTime: string) => { + setLoading(true) + try { + const data = await gisApi.getVesselTracks(mmsiList, startTime, endTime) + const segments = data.map(toTrackSegment) + setTracks(segments) + } catch (err) { + console.error('[useVesselTracks] fetch failed:', err) + clearTracks() + } finally { + setLoading(false) + } + }, + [setTracks, setLoading, clearTracks], + ) + + return { fetchTracks, clearTracks } +} diff --git a/frontend/src/features/vessel-tracks/index.ts b/frontend/src/features/vessel-tracks/index.ts new file mode 100644 index 0000000..937b6f4 --- /dev/null +++ b/frontend/src/features/vessel-tracks/index.ts @@ -0,0 +1,5 @@ +export { useTrackStore } from './stores/trackStore' +export { useVesselTracks } from './hooks/useVesselTracks' +export { useVesselTracksLayer } from './components/VesselTracksLayer' +export { default as TrackQueryPanel } from './components/TrackQueryPanel' +export { default as TrackInfoPanel } from './components/TrackInfoPanel' diff --git a/frontend/src/features/vessel-tracks/stores/trackStore.ts b/frontend/src/features/vessel-tracks/stores/trackStore.ts new file mode 100644 index 0000000..c70b243 --- /dev/null +++ b/frontend/src/features/vessel-tracks/stores/trackStore.ts @@ -0,0 +1,89 @@ +import { create } from 'zustand' +import type { TrackSegment, InterpolatedPosition } from '../../vessel-map' +import { getPositionsAtTime, getTimeRange } from '../../vessel-map/utils/interpolation' + +interface TrackState { + /** 현재 로드된 트랙 데이터 */ + tracks: TrackSegment[] + + /** 타임라인 현재 시간 (ms) */ + currentTime: number + + /** 시간 범위 [min, max] (ms) */ + timeRange: [number, number] + + /** 비활성화된 선박 ID (항적 숨김) */ + disabledVesselIds: Set + + /** 로딩 상태 */ + loading: boolean + + /** 트랙 데이터 설정 */ + setTracks: (tracks: TrackSegment[]) => void + + /** 트랙 초기화 */ + clearTracks: () => void + + /** 현재 시간 설정 */ + setCurrentTime: (time: number) => void + + /** 비율(0~1)로 현재 시간 설정 (타임라인 드래그) */ + setProgressByRatio: (ratio: number) => void + + /** 선박 ID 토글 (항적 표시/숨김) */ + toggleVesselVisibility: (vesselId: string) => void + + /** 현재 시간 기준 보간된 위치 목록 */ + getCurrentPositions: () => InterpolatedPosition[] + + /** 로딩 상태 설정 */ + setLoading: (loading: boolean) => void +} + +export const useTrackStore = create((set, get) => ({ + tracks: [], + currentTime: 0, + timeRange: [0, 0], + disabledVesselIds: new Set(), + loading: false, + + setTracks: (tracks) => { + const range = getTimeRange(tracks) + set({ + tracks, + timeRange: range, + currentTime: range[0], + disabledVesselIds: new Set(), + }) + }, + + clearTracks: () => + set({ + tracks: [], + currentTime: 0, + timeRange: [0, 0], + disabledVesselIds: new Set(), + }), + + setCurrentTime: (time) => set({ currentTime: time }), + + setProgressByRatio: (ratio) => { + const [min, max] = get().timeRange + set({ currentTime: min + (max - min) * Math.max(0, Math.min(1, ratio)) }) + }, + + toggleVesselVisibility: (vesselId) => + set((state) => { + const next = new Set(state.disabledVesselIds) + if (next.has(vesselId)) next.delete(vesselId) + else next.add(vesselId) + return { disabledVesselIds: next } + }), + + getCurrentPositions: () => { + const { tracks, currentTime, disabledVesselIds } = get() + return getPositionsAtTime(tracks, currentTime, disabledVesselIds) + }, + + setLoading: (loading) => set({ loading }), +})) diff --git a/frontend/src/features/viewport-replay/components/ReplayControlPanel.tsx b/frontend/src/features/viewport-replay/components/ReplayControlPanel.tsx new file mode 100644 index 0000000..6d343de --- /dev/null +++ b/frontend/src/features/viewport-replay/components/ReplayControlPanel.tsx @@ -0,0 +1,99 @@ +import { useAnimationStore, PLAYBACK_SPEEDS } from '../hooks/useReplayAnimation' + +export default function ReplayControlPanel() { + const isPlaying = useAnimationStore((s) => s.isPlaying) + const currentTime = useAnimationStore((s) => s.currentTime) + const startTime = useAnimationStore((s) => s.startTime) + const endTime = useAnimationStore((s) => s.endTime) + const playbackSpeed = useAnimationStore((s) => s.playbackSpeed) + const loop = useAnimationStore((s) => s.loop) + const play = useAnimationStore((s) => s.play) + const pause = useAnimationStore((s) => s.pause) + const stop = useAnimationStore((s) => s.stop) + const setPlaybackSpeed = useAnimationStore((s) => s.setPlaybackSpeed) + const setProgressByRatio = useAnimationStore((s) => s.setProgressByRatio) + const toggleLoop = useAnimationStore((s) => s.toggleLoop) + + const duration = endTime - startTime + const progress = duration > 0 ? (currentTime - startTime) / duration : 0 + + const formatTime = (ms: number) => { + const d = new Date(ms) + return d.toLocaleString('ko-KR', { + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + }) + } + + if (startTime === 0 && endTime === 0) return null + + return ( +
+ {/* 타임라인 */} +
+ setProgressByRatio(Number(e.target.value) / 1000)} + className="h-1.5 w-full cursor-pointer accent-primary" + /> +
+ {formatTime(startTime)} + {formatTime(currentTime)} + {formatTime(endTime)} +
+
+ + {/* 컨트롤 */} +
+ {/* 재생 버튼 */} +
+ + + +
+ + {/* 배속 선택 */} +
+ {PLAYBACK_SPEEDS.map((speed) => ( + + ))} +
+
+
+ ) +} diff --git a/frontend/src/features/viewport-replay/components/ReplayLayer.tsx b/frontend/src/features/viewport-replay/components/ReplayLayer.tsx new file mode 100644 index 0000000..bed2cbb --- /dev/null +++ b/frontend/src/features/viewport-replay/components/ReplayLayer.tsx @@ -0,0 +1,125 @@ +import { useMemo, useRef, useEffect } from 'react' +import type { Layer } from '@deck.gl/core' +import { TripsLayer } from '@deck.gl/geo-layers' +import { useAnimationStore } from '../hooks/useReplayAnimation' +import { useMergedTrackStore } from '../stores/mergedTrackStore' +import { useReplayStore } from '../stores/replayStore' +import { createVesselIconLayer } from '../../vessel-map/layers/VesselIconLayer' +import { getIconKey, getIconSize } from '../../vessel-map/utils/shipKindColors' +import type { VesselIconData } from '../../vessel-map' + +const TRAIL_LENGTH_MS = 3_600_000 // 1시간 시각적 트레일 +const RENDER_INTERVAL_MS = 100 // ~10fps 스로틀 + +interface TripsData { + vesselId: string + shipKindCode: string + path: [number, number][] + timestamps: number[] +} + +interface ReplayLayerProps { + zoom: number + onLayersUpdate: (layers: Layer[]) => void +} + +/** + * 리플레이 레이어 관리 훅 + * TripsLayer(항적 트레일) + IconLayer(현재 위치) 조합 + * React 렌더 바이패스: zustand subscribe로 직접 레이어 업데이트 + */ +export function useReplayLayer({ zoom, onLayersUpdate }: ReplayLayerProps) { + const queryCompleted = useReplayStore((s) => s.queryCompleted) + const vesselChunks = useMergedTrackStore((s) => s.vesselChunks) + const tripsDataRef = useRef([]) + const lastRenderTimeRef = useRef(0) + + // TripsData 구축 (쿼리 완료 또는 청크 수신 시) + useMemo(() => { + const paths = useMergedTrackStore.getState().getAllMergedPaths() + if (paths.length === 0) { + tripsDataRef.current = [] + return + } + + const startTime = Math.min(...paths.map((p) => p.timestampsMs[0])) + + tripsDataRef.current = paths.map((p) => ({ + vesselId: p.vesselId, + shipKindCode: p.shipKindCode || '000027', + path: p.geometry, + timestamps: p.timestampsMs.map((t) => t - startTime), + })) + + // 애니메이션 시간 범위 초기화 + useAnimationStore.getState().initTimeRange() + }, [vesselChunks]) + + // 렌더링 루프 (zustand subscribe → React 바이패스) + useEffect(() => { + if (!queryCompleted || tripsDataRef.current.length === 0) return + + const renderFrame = () => { + const { currentTime, startTime } = useAnimationStore.getState() + const relativeTime = currentTime - startTime + + const positions = useAnimationStore.getState().getCurrentVesselPositions() + const iconData: VesselIconData[] = positions.map((p) => ({ + mmsi: p.vesselId, + position: [p.lon, p.lat], + angle: p.heading, + icon: getIconKey(p.shipKindCode, p.speed), + size: getIconSize(zoom, p.shipKindCode, p.speed), + shipNm: p.shipName, + shipKindCode: p.shipKindCode, + sog: p.speed, + })) + + const layers: Layer[] = [ + new TripsLayer({ + id: 'replay-trips-trail', + data: tripsDataRef.current, + getPath: (d) => d.path, + getTimestamps: (d) => d.timestamps, + getColor: [120, 120, 120, 180], + widthMinPixels: 2, + widthMaxPixels: 3, + jointRounded: true, + capRounded: true, + fadeTrail: true, + trailLength: TRAIL_LENGTH_MS, + currentTime: relativeTime, + }), + createVesselIconLayer({ + id: 'replay-vessel-icon', + data: iconData, + pickable: false, + }), + ] + + onLayersUpdate(layers) + } + + // 초기 렌더 + renderFrame() + + // currentTime 구독 (Zustand v5: selector 없이 전체 상태 구독) + let prevTime = useAnimationStore.getState().currentTime + const unsub = useAnimationStore.subscribe((state) => { + if (state.currentTime === prevTime) return + prevTime = state.currentTime + + if (!state.isPlaying) { + renderFrame() + return + } + const now = performance.now() + if (now - lastRenderTimeRef.current >= RENDER_INTERVAL_MS) { + lastRenderTimeRef.current = now + renderFrame() + } + }) + + return unsub + }, [queryCompleted, zoom, onLayersUpdate]) +} diff --git a/frontend/src/features/viewport-replay/components/ReplaySetupPanel.tsx b/frontend/src/features/viewport-replay/components/ReplaySetupPanel.tsx new file mode 100644 index 0000000..d4c4199 --- /dev/null +++ b/frontend/src/features/viewport-replay/components/ReplaySetupPanel.tsx @@ -0,0 +1,141 @@ +import { useState } from 'react' +import { useReplayStore } from '../stores/replayStore' +import { replayWebSocket } from '../services/replayWebSocket' +import type maplibregl from 'maplibre-gl' +import { getViewportBounds } from '../../vessel-map/utils/viewport' + +interface ReplaySetupPanelProps { + map: maplibregl.Map | null +} + +function getDefaultTimeRange(): { start: string; end: string } { + const now = new Date() + const hoursAgo = new Date(now.getTime() - 3 * 60 * 60 * 1000) + const pad = (n: number) => String(n).padStart(2, '0') + const fmt = (d: Date) => + `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}` + return { start: fmt(hoursAgo), end: fmt(now) } +} + +export default function ReplaySetupPanel({ map }: ReplaySetupPanelProps) { + const { start, end } = getDefaultTimeRange() + const [startTime, setStartTime] = useState(start) + const [endTime, setEndTime] = useState(end) + + const connectionState = useReplayStore((s) => s.connectionState) + const querying = useReplayStore((s) => s.querying) + const queryCompleted = useReplayStore((s) => s.queryCompleted) + const receivedChunks = useReplayStore((s) => s.receivedChunks) + const totalChunks = useReplayStore((s) => s.totalChunks) + + const handleConnect = async () => { + const wsUrl = `${window.location.protocol === 'https:' ? 'wss:' : 'ws:'}//${window.location.host}/signal-batch/ws-tracks` + try { + await replayWebSocket.connect(wsUrl) + } catch (err) { + console.error('[Replay] Connection failed:', err) + } + } + + const handleQuery = () => { + if (!map) return + const bounds = getViewportBounds(map) + const zoom = map.getZoom() + const startISO = startTime.replace('T', ' ') + ':00' + const endISO = endTime.replace('T', ' ') + ':00' + replayWebSocket.executeQuery(startISO, endISO, bounds, zoom) + } + + const handleCancel = () => { + replayWebSocket.cancelQuery() + } + + const isConnected = connectionState === 'connected' + + return ( +
+ {/* 연결 상태 */} +
+
+ + + {connectionState === 'connected' ? '연결됨' : + connectionState === 'connecting' ? '연결 중...' : + connectionState === 'error' ? '연결 실패' : '미연결'} + +
+ {!isConnected && ( + + )} +
+ + {/* 시간 범위 */} +
+
+ + setStartTime(e.target.value)} + className="w-full rounded-md border border-border bg-surface px-2 py-1.5 text-xs text-foreground focus:border-primary focus:outline-none" + /> +
+
+ + setEndTime(e.target.value)} + className="w-full rounded-md border border-border bg-surface px-2 py-1.5 text-xs text-foreground focus:border-primary focus:outline-none" + /> +
+
+ + {/* 쿼리 버튼 */} + + + {/* 진행 상태 */} + {(querying || queryCompleted) && ( +
+ {querying && ( +
+
+
0 ? `${(receivedChunks / totalChunks) * 100}%` : '50%', + animation: totalChunks === 0 ? 'pulse 1.5s ease-in-out infinite' : undefined, + }} + /> +
+ {receivedChunks} 청크 +
+ )} + {queryCompleted && !querying && ( + {receivedChunks} 청크 수신 완료 + )} +
+ )} +
+ ) +} diff --git a/frontend/src/features/viewport-replay/hooks/useReplayAnimation.ts b/frontend/src/features/viewport-replay/hooks/useReplayAnimation.ts new file mode 100644 index 0000000..9122f5e --- /dev/null +++ b/frontend/src/features/viewport-replay/hooks/useReplayAnimation.ts @@ -0,0 +1,210 @@ +import { create } from 'zustand' +import { useMergedTrackStore } from '../stores/mergedTrackStore' +import type { InterpolatedPosition } from '../../vessel-map' + +export const PLAYBACK_SPEEDS = [1, 10, 50, 100, 500, 1000] as const + +interface AnimationState { + isPlaying: boolean + currentTime: number + startTime: number + endTime: number + playbackSpeed: number + loop: boolean + animationFrameId: number | null + lastFrameTime: number + + /** 커서 기반 위치 캐시 (O(1) per frame) */ + positionCursors: Map + + play: () => void + pause: () => void + stop: () => void + setPlaybackSpeed: (speed: number) => void + setCurrentTime: (time: number) => void + setProgressByRatio: (ratio: number) => void + toggleLoop: () => void + initTimeRange: () => void + getCurrentVesselPositions: () => InterpolatedPosition[] +} + +export const useAnimationStore = create((set, get) => ({ + isPlaying: false, + currentTime: 0, + startTime: 0, + endTime: 0, + playbackSpeed: 500, + loop: true, + animationFrameId: null, + lastFrameTime: 0, + positionCursors: new Map(), + + play: () => { + const state = get() + if (state.isPlaying) return + + const paths = useMergedTrackStore.getState().getAllMergedPaths() + if (paths.length === 0) return + + // 시간 범위 초기화 + if (state.startTime === 0 || state.endTime === 0) { + get().initTimeRange() + } + + const { startTime, endTime, currentTime } = get() + if (currentTime >= endTime) { + set({ currentTime: startTime }) + } + + set({ + isPlaying: true, + lastFrameTime: performance.now(), + positionCursors: new Map(), + }) + + const animate = (timestamp: number) => { + const s = get() + if (!s.isPlaying) return + + const deltaMs = timestamp - s.lastFrameTime + const increment = (deltaMs / 1000) * s.playbackSpeed * 1000 + let newTime = s.currentTime + increment + + if (newTime > s.endTime) { + if (s.loop) { + newTime = s.startTime + set({ positionCursors: new Map() }) + } else { + set({ isPlaying: false, currentTime: s.endTime, animationFrameId: null }) + return + } + } + + set({ currentTime: newTime, lastFrameTime: timestamp }) + + const frameId = requestAnimationFrame(animate) + set({ animationFrameId: frameId }) + } + + const frameId = requestAnimationFrame(animate) + set({ animationFrameId: frameId }) + }, + + pause: () => { + const { animationFrameId } = get() + if (animationFrameId !== null) { + cancelAnimationFrame(animationFrameId) + } + set({ isPlaying: false, animationFrameId: null }) + }, + + stop: () => { + const { animationFrameId, startTime } = get() + if (animationFrameId !== null) { + cancelAnimationFrame(animationFrameId) + } + set({ + isPlaying: false, + animationFrameId: null, + currentTime: startTime, + positionCursors: new Map(), + }) + }, + + setPlaybackSpeed: (speed) => set({ playbackSpeed: speed }), + + setCurrentTime: (time) => set({ currentTime: time, positionCursors: new Map() }), + + setProgressByRatio: (ratio) => { + const { startTime, endTime } = get() + const time = startTime + (endTime - startTime) * Math.max(0, Math.min(1, ratio)) + set({ currentTime: time, positionCursors: new Map() }) + }, + + toggleLoop: () => set((s) => ({ loop: !s.loop })), + + initTimeRange: () => { + const paths = useMergedTrackStore.getState().getAllMergedPaths() + let min = Infinity, max = -Infinity + for (const p of paths) { + if (p.timestampsMs.length === 0) continue + if (p.timestampsMs[0] < min) min = p.timestampsMs[0] + if (p.timestampsMs[p.timestampsMs.length - 1] > max) max = p.timestampsMs[p.timestampsMs.length - 1] + } + if (min < max) { + set({ startTime: min, endTime: max, currentTime: min }) + } + }, + + getCurrentVesselPositions: () => { + const { currentTime, positionCursors } = get() + const mergedStore = useMergedTrackStore.getState() + const positions: InterpolatedPosition[] = [] + + for (const [vesselId] of mergedStore.vesselChunks) { + const path = mergedStore.getMergedPath(vesselId) + if (!path || path.timestampsMs.length === 0) continue + + const ts = path.timestampsMs + if (currentTime < ts[0] || currentTime > ts[ts.length - 1]) continue + + // 커서 기반 선형 전진 (O(1) per frame) + let cursor = positionCursors.get(vesselId) + if (cursor === undefined || cursor >= ts.length || (cursor > 0 && ts[cursor - 1] > currentTime)) { + // fallback: 이진 탐색 + let lo = 0, hi = ts.length - 1 + while (lo < hi) { + const mid = (lo + hi) >> 1 + if (ts[mid] < currentTime) lo = mid + 1 + else hi = mid + } + cursor = lo + } else { + while (cursor < ts.length - 1 && ts[cursor] < currentTime) { + cursor++ + } + } + positionCursors.set(vesselId, cursor) + + const idx1 = Math.max(0, cursor - 1) + const idx2 = Math.min(ts.length - 1, cursor) + + let lon: number, lat: number, heading: number, speed: number + + if (idx1 === idx2 || ts[idx1] === ts[idx2]) { + lon = path.geometry[idx1][0] + lat = path.geometry[idx1][1] + heading = 0 + speed = path.speeds[idx1] || 0 + } else { + const ratio = (currentTime - ts[idx1]) / (ts[idx2] - ts[idx1]) + const p1 = path.geometry[idx1] + const p2 = path.geometry[idx2] + lon = p1[0] + (p2[0] - p1[0]) * ratio + lat = p1[1] + (p2[1] - p1[1]) * ratio + speed = (path.speeds[idx1] || 0) + ((path.speeds[idx2] || 0) - (path.speeds[idx1] || 0)) * ratio + + const dLon = ((p2[0] - p1[0]) * Math.PI) / 180 + const lat1R = (p1[1] * Math.PI) / 180 + const lat2R = (p2[1] * Math.PI) / 180 + const y = Math.sin(dLon) * Math.cos(lat2R) + const x = Math.cos(lat1R) * Math.sin(lat2R) - Math.sin(lat1R) * Math.cos(lat2R) * Math.cos(dLon) + heading = (Math.atan2(y, x) * 180) / Math.PI + if (heading < 0) heading += 360 + } + + const vesselData = mergedStore.vesselChunks.get(vesselId) + positions.push({ + vesselId, + lon, + lat, + heading, + speed, + shipName: vesselData?.shipName || '', + shipKindCode: vesselData?.shipKindCode || '', + }) + } + + return positions + }, +})) diff --git a/frontend/src/features/viewport-replay/index.ts b/frontend/src/features/viewport-replay/index.ts new file mode 100644 index 0000000..778ab87 --- /dev/null +++ b/frontend/src/features/viewport-replay/index.ts @@ -0,0 +1,7 @@ +export { replayWebSocket } from './services/replayWebSocket' +export { useMergedTrackStore } from './stores/mergedTrackStore' +export { useReplayStore } from './stores/replayStore' +export { useAnimationStore, PLAYBACK_SPEEDS } from './hooks/useReplayAnimation' +export { useReplayLayer } from './components/ReplayLayer' +export { default as ReplaySetupPanel } from './components/ReplaySetupPanel' +export { default as ReplayControlPanel } from './components/ReplayControlPanel' diff --git a/frontend/src/features/viewport-replay/services/replayWebSocket.ts b/frontend/src/features/viewport-replay/services/replayWebSocket.ts new file mode 100644 index 0000000..cc47c16 --- /dev/null +++ b/frontend/src/features/viewport-replay/services/replayWebSocket.ts @@ -0,0 +1,253 @@ +import { Client, type IMessage } from '@stomp/stompjs' +import { useMergedTrackStore } from '../stores/mergedTrackStore' +import { useReplayStore } from '../stores/replayStore' +import type { ViewportBounds } from '../../vessel-map' + +const CONNECTION_TIMEOUT = 10_000 +const QUERY_TIMEOUT = 300_000 + +export interface TrackQueryRequest { + startTime: string + endTime: string + viewport?: { + minLon: number + maxLon: number + minLat: number + maxLat: number + } + chunkedMode: boolean + chunkSize: number + simplificationMode: string + zoomLevel: number +} + +export interface TrackChunkResponse { + queryId: string + chunkIndex: number + totalChunks?: number | null + tracks?: TrackChunkData[] + mergedTracks?: TrackChunkData[] + compactTracks?: TrackChunkData[] + isLastChunk?: boolean +} + +export interface TrackChunkData { + vesselId: string + shipName?: string + shipKindCode?: string + nationalCode?: string + geometry?: [number, number][] + timestamps?: (string | number)[] + speeds?: number[] + totalDistance?: number + maxSpeed?: number + avgSpeed?: number +} + +/** 타임스탬프를 ms 단위로 정규화 */ +function parseTimestamp(ts: string | number): number { + if (typeof ts === 'number') { + return ts < 1e12 ? ts * 1000 : ts + } + if (/^\d{10,}$/.test(ts)) { + return parseInt(ts, 10) * 1000 + } + if (ts.includes(' ') && !ts.includes('T')) { + const [datePart, timePart] = ts.split(' ') + return new Date(`${datePart}T${timePart}`).getTime() + } + const parsed = new Date(ts).getTime() + return isNaN(parsed) ? 0 : parsed +} + +/** + * STOMP WebSocket 리플레이 서비스 + * 싱글턴 — connect/disconnect/executeQuery/cancel + */ +class ReplayWebSocketService { + private client: Client | null = null + private currentQueryId: string | null = null + private queryTimeoutId: ReturnType | null = null + + /** WebSocket 연결 */ + connect(wsUrl: string): Promise { + return new Promise((resolve, reject) => { + if (this.client?.connected) { + resolve() + return + } + + const replayStore = useReplayStore.getState() + replayStore.setConnectionState('connecting') + + this.client = new Client({ + brokerURL: wsUrl, + reconnectDelay: 0, + connectionTimeout: CONNECTION_TIMEOUT, + heartbeatIncoming: 10_000, + heartbeatOutgoing: 10_000, + + onConnect: () => { + replayStore.setConnectionState('connected') + this.setupSubscriptions() + resolve() + }, + + onStompError: (frame) => { + console.error('[ReplayWS] STOMP error:', frame.headers.message) + replayStore.setConnectionState('error') + reject(new Error(frame.headers.message)) + }, + + onWebSocketError: () => { + replayStore.setConnectionState('error') + reject(new Error('WebSocket connection failed')) + }, + + onDisconnect: () => { + replayStore.setConnectionState('disconnected') + }, + }) + + this.client.activate() + }) + } + + /** WebSocket 연결 해제 */ + disconnect(): void { + this.clearQueryTimeout() + if (this.client) { + this.client.deactivate() + this.client = null + } + useReplayStore.getState().setConnectionState('disconnected') + } + + /** 항적 쿼리 실행 */ + executeQuery( + startTime: string, + endTime: string, + viewport: ViewportBounds, + zoomLevel: number, + ): void { + if (!this.client?.connected) { + console.error('[ReplayWS] Not connected') + return + } + + // 이전 쿼리 정리 + if (this.currentQueryId) { + this.cancelQuery() + } + + useMergedTrackStore.getState().clear() + useReplayStore.getState().startQuery() + + const request: TrackQueryRequest = { + startTime, + endTime, + viewport: { + minLon: viewport.west, + maxLon: viewport.east, + minLat: viewport.south, + maxLat: viewport.north, + }, + chunkedMode: true, + chunkSize: 20_000, + simplificationMode: 'AUTO', + zoomLevel, + } + + this.client.publish({ + destination: '/app/tracks/query', + body: JSON.stringify(request), + }) + + this.queryTimeoutId = setTimeout(() => { + console.warn('[ReplayWS] Query timeout') + useReplayStore.getState().completeQuery() + }, QUERY_TIMEOUT) + } + + /** 진행 중인 쿼리 취소 */ + cancelQuery(): void { + if (this.currentQueryId && this.client?.connected) { + this.client.publish({ + destination: `/app/tracks/cancel/${this.currentQueryId}`, + body: '', + }) + } + this.clearQueryTimeout() + this.currentQueryId = null + useReplayStore.getState().completeQuery() + } + + private setupSubscriptions(): void { + if (!this.client) return + + this.client.subscribe('/user/queue/tracks/chunk', (msg: IMessage) => { + this.handleChunkMessage(msg) + }) + + this.client.subscribe('/user/queue/tracks/status', (msg: IMessage) => { + this.handleStatusMessage(msg) + }) + + this.client.subscribe('/user/queue/tracks/response', (msg: IMessage) => { + try { + const data = JSON.parse(msg.body) + if (data.queryId) { + this.currentQueryId = data.queryId + } + } catch { /* ignore */ } + }) + } + + private handleChunkMessage(msg: IMessage): void { + try { + const chunk = JSON.parse(msg.body) as TrackChunkResponse + const tracks = chunk.tracks || chunk.mergedTracks || chunk.compactTracks || [] + if (tracks.length === 0) return + + // 타임스탬프 정규화 + const normalizedTracks = tracks.map((t) => ({ + ...t, + timestampsMs: (t.timestamps || []).map(parseTimestamp), + })) + + useMergedTrackStore.getState().addChunk(normalizedTracks) + + const replayStore = useReplayStore.getState() + replayStore.updateProgress( + replayStore.receivedChunks + 1, + chunk.totalChunks ?? replayStore.totalChunks, + ) + + if (chunk.isLastChunk) { + this.clearQueryTimeout() + replayStore.completeQuery() + } + } catch (err) { + console.error('[ReplayWS] Chunk parse error:', err) + } + } + + private handleStatusMessage(msg: IMessage): void { + try { + const data = JSON.parse(msg.body) + if (data.status === 'COMPLETED' || data.status === 'ERROR') { + this.clearQueryTimeout() + useReplayStore.getState().completeQuery() + } + } catch { /* ignore */ } + } + + private clearQueryTimeout(): void { + if (this.queryTimeoutId) { + clearTimeout(this.queryTimeoutId) + this.queryTimeoutId = null + } + } +} + +export const replayWebSocket = new ReplayWebSocketService() diff --git a/frontend/src/features/viewport-replay/stores/mergedTrackStore.ts b/frontend/src/features/viewport-replay/stores/mergedTrackStore.ts new file mode 100644 index 0000000..6af0588 --- /dev/null +++ b/frontend/src/features/viewport-replay/stores/mergedTrackStore.ts @@ -0,0 +1,127 @@ +import { create } from 'zustand' +import type { TrackSegment } from '../../vessel-map' + +interface NormalizedChunkTrack { + vesselId: string + shipName?: string + shipKindCode?: string + nationalCode?: string + geometry?: [number, number][] + timestampsMs: number[] + speeds?: number[] + totalDistance?: number + maxSpeed?: number + avgSpeed?: number +} + +interface VesselChunkData { + shipName: string + shipKindCode: string + nationalCode: string + chunks: NormalizedChunkTrack[] + cachedPath: TrackSegment | null +} + +interface MergedTrackState { + /** MMSI → 청크 데이터 */ + vesselChunks: Map + + /** 청크 추가 (WS 메시지 수신 시) */ + addChunk: (tracks: NormalizedChunkTrack[]) => void + + /** 특정 선박의 병합된 경로 (캐시) */ + getMergedPath: (vesselId: string) => TrackSegment | null + + /** 전체 선박의 병합된 경로 목록 */ + getAllMergedPaths: () => TrackSegment[] + + /** 전체 초기화 */ + clear: () => void +} + +/** 청크들을 시간순 단일 TrackSegment로 병합 */ +function buildMergedPath(data: VesselChunkData): TrackSegment { + const allPoints: { lon: number; lat: number; ts: number; speed: number }[] = [] + + for (const chunk of data.chunks) { + const geom = chunk.geometry || [] + const ts = chunk.timestampsMs + const speeds = chunk.speeds || [] + + for (let i = 0; i < geom.length; i++) { + allPoints.push({ + lon: geom[i][0], + lat: geom[i][1], + ts: ts[i] || 0, + speed: speeds[i] || 0, + }) + } + } + + // 시간순 정렬 + allPoints.sort((a, b) => a.ts - b.ts) + + return { + vesselId: data.chunks[0]?.vesselId || '', + shipName: data.shipName, + shipKindCode: data.shipKindCode, + nationalCode: data.nationalCode, + geometry: allPoints.map((p) => [p.lon, p.lat] as [number, number]), + timestampsMs: allPoints.map((p) => p.ts), + speeds: allPoints.map((p) => p.speed), + } +} + +export const useMergedTrackStore = create((set, get) => ({ + vesselChunks: new Map(), + + addChunk: (tracks) => { + set((state) => { + const next = new Map(state.vesselChunks) + + for (const track of tracks) { + const existing = next.get(track.vesselId) + if (existing) { + existing.chunks.push(track) + existing.cachedPath = null // 캐시 무효화 + } else { + next.set(track.vesselId, { + shipName: track.shipName || '', + shipKindCode: track.shipKindCode || '000027', + nationalCode: track.nationalCode || '', + chunks: [track], + cachedPath: null, + }) + } + } + + return { vesselChunks: next } + }) + }, + + getMergedPath: (vesselId) => { + const data = get().vesselChunks.get(vesselId) + if (!data) return null + + if (!data.cachedPath) { + data.cachedPath = buildMergedPath(data) + } + return data.cachedPath + }, + + getAllMergedPaths: () => { + const { vesselChunks } = get() + const getMergedPath = get().getMergedPath + const paths: TrackSegment[] = [] + + for (const vesselId of vesselChunks.keys()) { + const path = getMergedPath(vesselId) + if (path && path.geometry.length >= 2) { + paths.push(path) + } + } + return paths + }, + + clear: () => set({ vesselChunks: new Map() }), +})) diff --git a/frontend/src/features/viewport-replay/stores/replayStore.ts b/frontend/src/features/viewport-replay/stores/replayStore.ts new file mode 100644 index 0000000..e0cb4a7 --- /dev/null +++ b/frontend/src/features/viewport-replay/stores/replayStore.ts @@ -0,0 +1,74 @@ +import { create } from 'zustand' + +type ConnectionState = 'disconnected' | 'connecting' | 'connected' | 'error' + +interface ReplayState { + /** WebSocket 연결 상태 */ + connectionState: ConnectionState + + /** 쿼리 진행 중 여부 */ + querying: boolean + + /** 쿼리 완료 여부 */ + queryCompleted: boolean + + /** 수신 청크 수 */ + receivedChunks: number + + /** 예상 전체 청크 수 */ + totalChunks: number + + /** 연결 상태 업데이트 */ + setConnectionState: (state: ConnectionState) => void + + /** 쿼리 시작 */ + startQuery: () => void + + /** 쿼리 진행 업데이트 */ + updateProgress: (received: number, total: number) => void + + /** 쿼리 완료 */ + completeQuery: () => void + + /** 전체 초기화 */ + reset: () => void +} + +export const useReplayStore = create((set) => ({ + connectionState: 'disconnected', + querying: false, + queryCompleted: false, + receivedChunks: 0, + totalChunks: 0, + + setConnectionState: (connectionState) => set({ connectionState }), + + startQuery: () => + set({ + querying: true, + queryCompleted: false, + receivedChunks: 0, + totalChunks: 0, + }), + + updateProgress: (received, total) => + set({ + receivedChunks: received, + totalChunks: total, + }), + + completeQuery: () => + set({ + querying: false, + queryCompleted: true, + }), + + reset: () => + set({ + connectionState: 'disconnected', + querying: false, + queryCompleted: false, + receivedChunks: 0, + totalChunks: 0, + }), +})) diff --git a/frontend/src/i18n/en.ts b/frontend/src/i18n/en.ts index 719cb65..6f2783a 100644 --- a/frontend/src/i18n/en.ts +++ b/frontend/src/i18n/en.ts @@ -125,14 +125,11 @@ const en = { 'explorer.title': 'API Explorer', 'explorer.apiType': 'API Type', 'explorer.recentPositions': 'Recent Positions', - 'explorer.haeguTracks': 'Area Tracks', 'explorer.vesselTracks': 'Vessel Tracks', + 'explorer.viewportReplay': 'Viewport Replay', 'explorer.parameters': 'Parameters', 'explorer.positionsDesc': 'Fetches vessels with position updates within 10 minutes.', - 'explorer.haeguDesc': 'Fetches vessel tracks within a specific area as GeoJSON.', 'explorer.vesselDesc': 'Fetches tracks for specific vessels by MMSI list.', - 'explorer.comingSoon': 'Detailed API Demo (Coming Soon)', - 'explorer.comingSoonDesc': 'Request/Response panels, track layers, replay', // Abnormal Tracks 'abnormal.title': 'Abnormal Tracks', diff --git a/frontend/src/i18n/ko.ts b/frontend/src/i18n/ko.ts index cec7d0f..aca3a7d 100644 --- a/frontend/src/i18n/ko.ts +++ b/frontend/src/i18n/ko.ts @@ -125,14 +125,11 @@ const ko = { 'explorer.title': 'API 탐색기', 'explorer.apiType': 'API 유형', 'explorer.recentPositions': '최근 위치', - 'explorer.haeguTracks': '해구별 항적', 'explorer.vesselTracks': '선박별 항적', + 'explorer.viewportReplay': '뷰포트 리플레이', 'explorer.parameters': '파라미터', 'explorer.positionsDesc': '최근 10분 이내 위치 업데이트된 선박 목록을 조회합니다.', - 'explorer.haeguDesc': '특정 해구 내 선박 항적을 GeoJSON 형태로 조회합니다.', 'explorer.vesselDesc': 'MMSI 목록으로 특정 선박의 항적을 조회합니다.', - 'explorer.comingSoon': '상세 API 시연 (향후 구현)', - 'explorer.comingSoonDesc': 'Request/Response 패널, 항적 레이어, 리플레이', // Abnormal Tracks 'abnormal.title': '비정상 항적', diff --git a/frontend/src/pages/ApiExplorer.tsx b/frontend/src/pages/ApiExplorer.tsx index 47d7999..b7fd216 100644 --- a/frontend/src/pages/ApiExplorer.tsx +++ b/frontend/src/pages/ApiExplorer.tsx @@ -1,30 +1,90 @@ -import { useState, useCallback } from 'react' +import { useState, useCallback, useRef } from 'react' import { useI18n } from '../hooks/useI18n.ts' import MapContainer from '../components/map/MapContainer.tsx' import Sidebar from '../components/layout/Sidebar.tsx' import type maplibregl from 'maplibre-gl' +import type { Layer } from '@deck.gl/core' +import { + useRecentPositions, + useRecentPositionsLayer, + usePositionStore, + VesselPopup, + PositionFilterPanel, +} from '../features/recent-positions' +import { + useVesselTracksLayer, + TrackQueryPanel, + TrackInfoPanel, +} from '../features/vessel-tracks' +import { + useReplayLayer, + ReplaySetupPanel, + ReplayControlPanel, +} from '../features/viewport-replay' -type ApiMode = 'haegu' | 'vessel' | 'positions' +type ApiMode = 'positions' | 'vessel' | 'replay' export default function ApiExplorer() { const { t } = useI18n() const [mode, setMode] = useState('positions') - const [, setMap] = useState(null) + const mapRef = useRef(null) + const [zoom, setZoom] = useState(7) + const [trackMmsi, setTrackMmsi] = useState() + const [replayLayers, setReplayLayers] = useState([]) + + const selectVessel = usePositionStore((s) => s.selectVessel) + + // 최근 위치 30초 폴링 (positions 모드일 때만) + useRecentPositions(10, mode === 'positions') + + // Deck.gl 레이어 — positions + const positionLayers = useRecentPositionsLayer({ + zoom, + onVesselClick: (mmsi) => selectVessel(mmsi), + }) + + // Deck.gl 레이어 — vessel tracks + const trackLayers = useVesselTracksLayer({ zoom }) + + // Deck.gl 레이어 — replay (콜백으로 업데이트) + const handleReplayLayersUpdate = useCallback((layers: Layer[]) => { + setReplayLayers(layers) + }, []) + + useReplayLayer({ + zoom, + onLayersUpdate: handleReplayLayersUpdate, + }) const handleMapReady = useCallback((m: maplibregl.Map) => { - setMap(m) + mapRef.current = m + setZoom(m.getZoom()) + m.on('zoom', () => setZoom(m.getZoom())) }, []) + // 최근위치 → 항적조회 전환 + const handleTrackQuery = useCallback((mmsi: string) => { + setTrackMmsi(mmsi) + setMode('vessel') + selectVessel(null) + }, [selectVessel]) + + // 모드별 Deck.gl 레이어 + const deckLayers: Layer[] = + mode === 'positions' ? positionLayers : + mode === 'vessel' ? trackLayers : + mode === 'replay' ? replayLayers : + [] + return (
{/* Sidebar */}
- {/* 제목 */}

{t('explorer.title')}

- {/* API 유형 선택 */} + {/* 모드 선택 */}
) -- 2.45.2