From 85749c2f684c76e1fdd23bbc1111f6d49cf20b6c Mon Sep 17 00:00:00 2001 From: htlee Date: Sun, 1 Mar 2026 02:48:54 +0900 Subject: [PATCH] =?UTF-8?q?feat(map):=20Leaflet=20=E2=86=92=20MapLibre=20G?= =?UTF-8?q?L=20JS=20+=20deck.gl=20=EC=A0=84=ED=99=98=20(Phase=206)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 지도 엔진을 Leaflet 1.9에서 MapLibre GL JS 5.x + deck.gl 9.x로 전환. 15개 파일 수정, Leaflet 완전 제거. WebGL 단일 canvas로 z-index 충돌 해결, 유류 입자 ScatterplotLayer GPU 렌더링으로 10~100배 성능 향상. - MapView.tsx: MapLibre Map + DeckGLOverlay(MapboxOverlay interleaved) - 유류 입자/오일펜스/HNS: deck.gl ScatterplotLayer/PathLayer - 역추적 리플레이: createBacktrackLayers() 함수 패턴 - 기상 오버레이: WeatherMapOverlay/OceanCurrent/WindParticle deck.gl 전환 - 수온 히트맵: WaterTemperatureLayer deck.gl ScatterplotLayer - 해황예보도: MapLibre image source + raster layer - SCAT/Assets/Incidents: MapLibre Map + deck.gl 레이어 - WMS 밝기: raster-brightness-min/max 네이티브 속성 Co-Authored-By: Claude Opus 4.6 --- frontend/package-lock.json | 1686 +++++++++++++++- frontend/package.json | 10 +- .../components/map/BacktrackReplayOverlay.tsx | 250 ++- .../src/common/components/map/MapView.tsx | 769 ++++---- .../src/common/components/map/mapUtils.ts | 7 + frontend/src/index.css | 8 +- .../src/tabs/assets/components/AssetMap.tsx | 185 +- .../incidents/components/IncidentsView.tsx | 1744 +++++++++++++---- frontend/src/tabs/scat/components/ScatMap.tsx | 441 +++-- .../src/tabs/scat/components/ScatPopup.tsx | 324 ++- .../weather/components/OceanCurrentLayer.tsx | 153 +- .../components/OceanForecastOverlay.tsx | 73 +- .../components/WaterTemperatureLayer.tsx | 192 +- .../weather/components/WeatherMapOverlay.tsx | 529 +++-- .../tabs/weather/components/WeatherView.tsx | 455 +++-- .../weather/components/WindParticleLayer.tsx | 146 +- 16 files changed, 4988 insertions(+), 1984 deletions(-) create mode 100644 frontend/src/common/components/map/mapUtils.ts diff --git a/frontend/package-lock.json b/frontend/package-lock.json index bc22c88..7182874 100755 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,6 +8,11 @@ "name": "frontend", "version": "0.0.0", "dependencies": { + "@deck.gl/aggregation-layers": "^9.2.10", + "@deck.gl/core": "^9.2.10", + "@deck.gl/geo-layers": "^9.2.10", + "@deck.gl/layers": "^9.2.10", + "@deck.gl/mapbox": "^9.2.10", "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", @@ -15,19 +20,18 @@ "@emoji-mart/react": "^1.1.1", "@react-oauth/google": "^0.13.4", "@tanstack/react-query": "^5.90.21", + "@vis.gl/react-maplibre": "^8.1.0", "axios": "^1.13.5", "emoji-mart": "^5.6.0", - "leaflet": "^1.9.4", "lucide-react": "^0.564.0", + "maplibre-gl": "^5.19.0", "react": "^19.2.0", "react-dom": "^19.2.0", - "react-leaflet": "^5.0.0", "socket.io-client": "^4.8.3", "zustand": "^5.0.11" }, "devDependencies": { "@eslint/js": "^9.39.1", - "@types/leaflet": "^1.9.21", "@types/node": "^24.10.1", "@types/react": "^19.2.7", "@types/react-dom": "^19.2.3", @@ -339,6 +343,122 @@ "node": ">=6.9.0" } }, + "node_modules/@deck.gl/aggregation-layers": { + "version": "9.2.10", + "resolved": "https://registry.npmjs.org/@deck.gl/aggregation-layers/-/aggregation-layers-9.2.10.tgz", + "integrity": "sha512-of0FLOLk3xLFyISSw9xnz4r7vLOU+joNuROD+sY7VjCl0n387tVhNCl8E9W5D0leFhAMdeY5CmY/y/75mh/mng==", + "license": "MIT", + "dependencies": { + "@luma.gl/constants": "^9.2.6", + "@luma.gl/shadertools": "^9.2.6", + "@math.gl/core": "^4.1.0", + "@math.gl/web-mercator": "^4.1.0", + "d3-hexbin": "^0.2.1" + }, + "peerDependencies": { + "@deck.gl/core": "~9.2.0", + "@deck.gl/layers": "~9.2.0", + "@luma.gl/core": "~9.2.6", + "@luma.gl/engine": "~9.2.6" + } + }, + "node_modules/@deck.gl/core": { + "version": "9.2.10", + "resolved": "https://registry.npmjs.org/@deck.gl/core/-/core-9.2.10.tgz", + "integrity": "sha512-c3IYKNAeTKSkH0LPUBoAfYef0/aw32Uo7UorcH+FCG9n7iPZT8tg+yaGb7ylWYt5Rdx9Ah/ztnZxOVJ+jUT0Sg==", + "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.1", + "@probe.gl/log": "^4.1.1", + "@probe.gl/stats": "^4.1.1", + "@types/offscreencanvas": "^2019.6.4", + "gl-matrix": "^3.0.0", + "mjolnir.js": "^3.0.0" + } + }, + "node_modules/@deck.gl/geo-layers": { + "version": "9.2.10", + "resolved": "https://registry.npmjs.org/@deck.gl/geo-layers/-/geo-layers-9.2.10.tgz", + "integrity": "sha512-Rt271ANfwZbGQIH83yxvbxaguBK6QQ3OrdxT05dN0B2NxsBwzaFcnB1UTB0Fna5+SVGBwZAMUr0FzunG/V5ljA==", + "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.10", + "resolved": "https://registry.npmjs.org/@deck.gl/layers/-/layers-9.2.10.tgz", + "integrity": "sha512-1emc+0Z+w6Vrmyp39Q+3R/HNbPcABH6kYXe6zTOOZYJX5M3pUssukFLW+Ds96/X9emF53vUC1Rnq9Aj/YY2lWg==", + "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/mapbox": { + "version": "9.2.10", + "resolved": "https://registry.npmjs.org/@deck.gl/mapbox/-/mapbox-9.2.10.tgz", + "integrity": "sha512-6xyFv/SBjGyecxCYPmyDQc8XA+Ldj+/BpsT3Szk1QFNekgbvHA/4W62PzomMmoMAf4iwG80mWIqaq8wSzSlR0Q==", + "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/@dnd-kit/accessibility": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz", @@ -1109,6 +1229,600 @@ "@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/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/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", + "integrity": "sha512-tJaT+RbYGJYStt7wI3cq4Nl4SXxG8W7JDG5DMJu97V25RnbNg3QtQtf+KD+VLjNpWKYsRvXDNmNrBgEETr1ifA==", + "license": "ISC", + "dependencies": { + "get-stream": "^6.0.1", + "minimist": "^1.2.6" + }, + "bin": { + "geojson-rewind": "geojson-rewind" + } + }, + "node_modules/@mapbox/jsonlint-lines-primitives": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@mapbox/jsonlint-lines-primitives/-/jsonlint-lines-primitives-2.0.2.tgz", + "integrity": "sha512-rY0o9A5ECsTQRVhv7tL/OyDpGAoUB4tTvLiW1DSzQGq4bvTPhNw1VpSNjDJc5GFZ2XuyOtSWSVN05qOtcD71qQ==", + "engines": { + "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": "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/@mapbox/tiny-sdf": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@mapbox/tiny-sdf/-/tiny-sdf-2.0.7.tgz", + "integrity": "sha512-25gQLQMcpivjOSA40g3gO6qgiFPDpWRoMfd+G/GoppPIeP6JDaMMkMrEJnMZhKyyS6iKwVt5YKu02vCUyJM3Ug==", + "license": "BSD-2-Clause" + }, + "node_modules/@mapbox/unitbezier": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/@mapbox/unitbezier/-/unitbezier-0.0.1.tgz", + "integrity": "sha512-nMkuDXFv60aBr9soUG5q+GvZYL+2KZHVvsqFCzqnkGEf46U2fvmytHaEVc1/YZbiLn8X+eR3QzX1+dwDO1lxlw==", + "license": "BSD-2-Clause" + }, + "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/@mapbox/whoots-js": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@mapbox/whoots-js/-/whoots-js-3.1.0.tgz", + "integrity": "sha512-Es6WcD0nO5l+2BOQS4uLfNPYQaNDfbot3X1XUoloz+x0mPDS3eeORZJl06HXjwBG1fOGwCRnzK88LMdxKRrd6Q==", + "license": "ISC", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@maplibre/geojson-vt": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@maplibre/geojson-vt/-/geojson-vt-5.0.4.tgz", + "integrity": "sha512-KGg9sma45S+stfH9vPCJk1J0lSDLWZgCT9Y8u8qWZJyjFlP8MNP1WGTxIMYJZjDvVT3PDn05kN1C95Sut1HpgQ==", + "license": "ISC" + }, + "node_modules/@maplibre/maplibre-gl-style-spec": { + "version": "19.3.3", + "resolved": "https://registry.npmjs.org/@maplibre/maplibre-gl-style-spec/-/maplibre-gl-style-spec-19.3.3.tgz", + "integrity": "sha512-cOZZOVhDSulgK0meTsTkmNXb1ahVvmTmWmfx9gRBwc6hq98wS9JP35ESIoNq3xqEan+UN+gn8187Z6E4NKhLsw==", + "license": "ISC", + "dependencies": { + "@mapbox/jsonlint-lines-primitives": "~2.0.2", + "@mapbox/unitbezier": "^0.0.1", + "json-stringify-pretty-compact": "^3.0.0", + "minimist": "^1.2.8", + "rw": "^1.3.3", + "sort-object": "^3.0.3" + }, + "bin": { + "gl-style-format": "dist/gl-style-format.mjs", + "gl-style-migrate": "dist/gl-style-migrate.mjs", + "gl-style-validate": "dist/gl-style-validate.mjs" + } + }, + "node_modules/@maplibre/mlt": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@maplibre/mlt/-/mlt-1.1.6.tgz", + "integrity": "sha512-rgtY3x65lrrfXycLf6/T22ZnjTg5WgIOsptOIoCaMZy4O4UAKTyZlYY0h6v8le721pTptF94U65yMDQkug+URw==", + "license": "(MIT OR Apache-2.0)", + "dependencies": { + "@mapbox/point-geometry": "^1.1.0" + } + }, + "node_modules/@maplibre/mlt/node_modules/@mapbox/point-geometry": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@mapbox/point-geometry/-/point-geometry-1.1.0.tgz", + "integrity": "sha512-YGcBz1cg4ATXDCM/71L9xveh4dynfGmcLDqufR+nQQy3fKwsAZsWd/x4621/6uJaeB9mwOHE6hPeDgXz9uViUQ==", + "license": "ISC" + }, + "node_modules/@maplibre/vt-pbf": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@maplibre/vt-pbf/-/vt-pbf-4.3.0.tgz", + "integrity": "sha512-jIvp8F5hQCcreqOOpEt42TJMUlsrEcpf/kI1T2v85YrQRV6PPXUcEXUg5karKtH6oh47XJZ4kHu56pUkOuqA7w==", + "license": "MIT", + "dependencies": { + "@mapbox/point-geometry": "^1.1.0", + "@mapbox/vector-tile": "^2.0.4", + "@maplibre/geojson-vt": "^5.0.4", + "@types/geojson": "^7946.0.16", + "@types/supercluster": "^7.1.3", + "pbf": "^4.0.1", + "supercluster": "^8.0.1" + } + }, + "node_modules/@maplibre/vt-pbf/node_modules/@mapbox/point-geometry": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@mapbox/point-geometry/-/point-geometry-1.1.0.tgz", + "integrity": "sha512-YGcBz1cg4ATXDCM/71L9xveh4dynfGmcLDqufR+nQQy3fKwsAZsWd/x4621/6uJaeB9mwOHE6hPeDgXz9uViUQ==", + "license": "ISC" + }, + "node_modules/@maplibre/vt-pbf/node_modules/@mapbox/vector-tile": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@mapbox/vector-tile/-/vector-tile-2.0.4.tgz", + "integrity": "sha512-AkOLcbgGTdXScosBWwmmD7cDlvOjkg/DetGva26pIRiZPdeJYjYKarIlb4uxVzi6bwHO6EWH82eZ5Nuv4T5DUg==", + "license": "BSD-3-Clause", + "dependencies": { + "@mapbox/point-geometry": "~1.1.0", + "@types/geojson": "^7946.0.16", + "pbf": "^4.0.1" + } + }, + "node_modules/@maplibre/vt-pbf/node_modules/pbf": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/pbf/-/pbf-4.0.1.tgz", + "integrity": "sha512-SuLdBvS42z33m8ejRbInMapQe8n0D3vN/Xd5fmWM3tufNgRQFBpaW2YVJxQZV4iPNqb0vEFvssMEo5w9c6BTIA==", + "license": "BSD-3-Clause", + "dependencies": { + "resolve-protobuf-schema": "^2.1.0" + }, + "bin": { + "pbf": "bin/pbf" + } + }, + "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/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -1147,17 +1861,27 @@ "node": ">= 8" } }, - "node_modules/@react-leaflet/core": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@react-leaflet/core/-/core-3.0.0.tgz", - "integrity": "sha512-3EWmekh4Nz+pGcr+xjf0KNyYfC3U2JjnkWsh0zcqaexYqmmB5ZhH37kz41JXGmKzpaMZCnPofBBm64i+YrEvGQ==", - "license": "Hippocratic-2.1", - "peerDependencies": { - "leaflet": "^1.9.0", - "react": "^19.0.0", - "react-dom": "^19.0.0" + "node_modules/@probe.gl/env": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@probe.gl/env/-/env-4.1.1.tgz", + "integrity": "sha512-+68seNDMVsEegRB47pFA/Ws1Fjy8agcFYXxzorKToyPcD6zd+gZ5uhwoLd7TzsSw6Ydns//2KEszWn+EnNHTbA==", + "license": "MIT" + }, + "node_modules/@probe.gl/log": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@probe.gl/log/-/log-4.1.1.tgz", + "integrity": "sha512-kcZs9BT44pL7hS1OkRGKYRXI/SN9KejUlPD+BY40DguRLzdC5tLG/28WGMyfKdn/51GT4a0p+0P8xvDn1Ez+Kg==", + "license": "MIT", + "dependencies": { + "@probe.gl/env": "4.1.1" } }, + "node_modules/@probe.gl/stats": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@probe.gl/stats/-/stats-4.1.1.tgz", + "integrity": "sha512-4VpAyMHOqydSvPlEyHwXaE+AkIdR03nX+Qhlxsk2D/IW4OVmDZgIsvJB1cDzyEEtcfKcnaEbfXeiPgejBceT6g==", + "license": "MIT" + }, "node_modules/@react-oauth/google": { "version": "0.13.4", "resolved": "https://registry.npmjs.org/@react-oauth/google/-/google-0.13.4.tgz", @@ -1557,6 +2281,62 @@ "react": "^18 || ^19" } }, + "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", @@ -1602,6 +2382,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/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -1613,7 +2408,6 @@ "version": "7946.0.16", "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", - "dev": true, "license": "MIT" }, "node_modules/@types/json-schema": { @@ -1623,26 +2417,27 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/leaflet": { - "version": "1.9.21", - "resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.21.tgz", - "integrity": "sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/geojson": "*" - } - }, "node_modules/@types/node": { "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", @@ -1663,6 +2458,15 @@ "@types/react": "^19.2.0" } }, + "node_modules/@types/supercluster": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/@types/supercluster/-/supercluster-7.1.3.tgz", + "integrity": "sha512-Z0pOY34GDFl3Q6hUFYf3HkTwKEE02e7QgtJppBt+beEAxnyOpJua+voGFvxINBHa06GwLFFym7gRPY2SiKIfIA==", + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.55.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.55.0.tgz", @@ -1932,6 +2736,25 @@ "url": "https://opencollective.com/typescript-eslint" } }, + "node_modules/@vis.gl/react-maplibre": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/@vis.gl/react-maplibre/-/react-maplibre-8.1.0.tgz", + "integrity": "sha512-PkAK/gp3mUfhCLhUuc+4gc3PN9zCtVGxTF2hB6R5R5yYUw+hdg84OZ770U5MU4tPMTCG6fbduExuIW6RRKN6qQ==", + "license": "MIT", + "dependencies": { + "@maplibre/maplibre-gl-style-spec": "^19.2.1" + }, + "peerDependencies": { + "maplibre-gl": ">=4.0.0", + "react": ">=16.3.0", + "react-dom": ">=16.3.0" + }, + "peerDependenciesMeta": { + "maplibre-gl": { + "optional": true + } + } + }, "node_modules/@vitejs/plugin-react": { "version": "5.1.4", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.1.4.tgz", @@ -1953,6 +2776,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", @@ -2057,6 +2889,24 @@ "dev": true, "license": "Python-2.0" }, + "node_modules/arr-union": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/arr-union/-/arr-union-3.1.0.tgz", + "integrity": "sha512-sKpyeERZ02v1FeCZT8lrfJq5u6goHCtpTAzPwJYe7c8SPFOboNjNg1vz2L4VTn9T4PQxEx13TbXLmYUcS6Ug7Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/assign-symbols": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/assign-symbols/-/assign-symbols-1.0.0.tgz", + "integrity": "sha512-Q+JC7Whu8HhmTdBph/Tq59IoRtoy6KAm5zzPv00WdujX82lbAL8K7WVjne7vdCsAmbF4AYaDOPyO3k0kl8qIrw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", @@ -2118,6 +2968,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", @@ -2165,6 +3036,16 @@ "node": ">=8" } }, + "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", @@ -2199,6 +3080,34 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/buf-compare": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buf-compare/-/buf-compare-1.0.1.tgz", + "integrity": "sha512-Bvx4xH00qweepGc43xFvMs5BKASXTbHaHm6+kDYIK9p/4iFwjATQkmPKHQSgJZzKbAymhztRbXUf1Nqhzl73/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/bytewise": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/bytewise/-/bytewise-1.1.0.tgz", + "integrity": "sha512-rHuuseJ9iQ0na6UDhnrRVDh8YnWVlU6xM3VH6q/+yHDeUH2zIhUzP+2/h3LIrhLDBtTqzWpE3p3tP/boefskKQ==", + "license": "MIT", + "dependencies": { + "bytewise-core": "^1.2.2", + "typewise": "^1.0.3" + } + }, + "node_modules/bytewise-core": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/bytewise-core/-/bytewise-core-1.2.3.tgz", + "integrity": "sha512-nZD//kc78OOxeYtRlVk8/zXqTB4gf/nlguL1ggWA8FuchMyOxcyHR4QPQZMUmA7czC+YnaBrPUCubqAWe50DaA==", + "license": "MIT", + "dependencies": { + "typewise-core": "^1.2" + } + }, "node_modules/call-bind-apply-helpers": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", @@ -2270,6 +3179,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/chokidar": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", @@ -2364,6 +3282,25 @@ "dev": true, "license": "MIT" }, + "node_modules/core-assert": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/core-assert/-/core-assert-0.2.1.tgz", + "integrity": "sha512-IG97qShIP+nrJCXMCgkNZgH7jZQ4n8RpPyPeXX++T6avR/KhLhgLiHKoEn5Rc1KjfycSfA9DMa6m+4C4eguHhw==", + "license": "MIT", + "dependencies": { + "buf-compare": "^1.0.0", + "is-error": "^2.2.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "license": "MIT" + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -2379,6 +3316,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/cssesc": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", @@ -2399,6 +3345,12 @@ "dev": true, "license": "MIT" }, + "node_modules/d3-hexbin": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/d3-hexbin/-/d3-hexbin-0.2.2.tgz", + "integrity": "sha512-KS3fUT2ReD4RlGCjvCEm1RgMtp2NFZumdMu4DBzQK8AZv3fXRM6Xm8I4fSU07UXvH4xxg03NwWKWdvxfS/yc4w==", + "license": "BSD-3-Clause" + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -2423,6 +3375,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/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -2446,6 +3410,12 @@ "dev": true, "license": "MIT" }, + "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/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -2460,6 +3430,12 @@ "node": ">= 0.4" } }, + "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/electron-to-chromium": { "version": "1.5.286", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.286.tgz", @@ -2789,6 +3765,18 @@ "node": ">=0.10.0" } }, + "node_modules/extend-shallow": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", + "integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==", + "license": "MIT", + "dependencies": { + "is-extendable": "^0.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -2840,6 +3828,24 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-xml-parser": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-4.5.4.tgz", + "integrity": "sha512-jE8ugADnYOBsu1uaoayVl1tVKAMNOXyjwvv2U6udEA2ORBhDooJDWoGxTkhd4Qn4yh59JVVt/pKXtjPwx9OguQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "strnum": "^1.0.5" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } + }, "node_modules/fastq": { "version": "1.20.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", @@ -2868,6 +3874,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", @@ -3053,6 +4065,33 @@ "node": ">= 0.4" } }, + "node_modules/get-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-value": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/get-value/-/get-value-2.0.6.tgz", + "integrity": "sha512-Ln0UQDlxH1BapMu3GPtf7CuYNwRZf2gwCuPqbyG6pB8WfmFpzqcy4xtAaAMUhnNqjMKTiCPZG2oMT3YSx8U2NA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/gl-matrix": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/gl-matrix/-/gl-matrix-3.4.4.tgz", + "integrity": "sha512-latSnyDNt/8zYUB6VIJ6PCh2jBjJX6gnDsoCZ7LyW7GkqrD51EWwa9qCoGixj8YqBtETQK/xY7OmpTF8xz1DdQ==", + "license": "MIT" + }, "node_modules/glob-parent": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", @@ -3091,6 +4130,17 @@ "url": "https://github.com/sponsors/ljharb" } }, + "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", @@ -3157,6 +4207,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", @@ -3167,6 +4237,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", @@ -3194,6 +4282,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/is-binary-path": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", @@ -3207,6 +4301,12 @@ "node": ">=8" } }, + "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-core-module": { "version": "2.16.1", "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", @@ -3223,6 +4323,21 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-error": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/is-error/-/is-error-2.2.2.tgz", + "integrity": "sha512-IOQqts/aHWbiisY5DuPJQ0gcbvaLFCa7fBa9xoLfxBZvQ+ZI/Zh9xoI7Gk+G64N0FdK4AbibytHht2tWgpJWLg==", + "license": "MIT" + }, + "node_modules/is-extendable": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz", + "integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -3256,6 +4371,24 @@ "node": ">=0.12.0" } }, + "node_modules/is-plain-object": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz", + "integrity": "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==", + "license": "MIT", + "dependencies": { + "isobject": "^3.0.1" + }, + "engines": { + "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", @@ -3263,6 +4396,15 @@ "dev": true, "license": "ISC" }, + "node_modules/isobject": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", + "integrity": "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/jiti": { "version": "1.21.7", "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", @@ -3327,6 +4469,12 @@ "dev": true, "license": "MIT" }, + "node_modules/json-stringify-pretty-compact": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/json-stringify-pretty-compact/-/json-stringify-pretty-compact-3.0.0.tgz", + "integrity": "sha512-Rc2suX5meI0S3bfdZuA7JMFBGkJ875ApfVyq2WHELjBiiG22My/l7/8zPpH/CfFVQHuVLd8NLR0nv6vi0BYYKA==", + "license": "MIT" + }, "node_modules/json5": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", @@ -3340,6 +4488,24 @@ "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", + "integrity": "sha512-WbCVYJ27Sz8zi9Q7Q0xHC+05iwkm3Znipc2XTlrnJbsHMYktW4hPhXUE8Ys1engBrvffoSCqbil1JQAa7clRpA==", + "license": "ISC" + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -3350,11 +4516,11 @@ "json-buffer": "3.0.1" } }, - "node_modules/leaflet": { - "version": "1.9.4", - "resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz", - "integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==", - "license": "BSD-2-Clause" + "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", @@ -3370,6 +4536,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/lilconfig": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", @@ -3413,6 +4588,15 @@ "dev": true, "license": "MIT" }, + "node_modules/long": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/long/-/long-3.2.0.tgz", + "integrity": "sha512-ZYvPPOMqUwPoDsbJaR10iQJYnMuZhRTvHYl62ErLIEX7RgFlziSBUUvrt3OVfc47QlHHpzPZYP17g3Fv7oeJkg==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.6" + } + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -3432,6 +4616,117 @@ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "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/maplibre-gl": { + "version": "5.19.0", + "resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-5.19.0.tgz", + "integrity": "sha512-REhYUN8gNP3HlcIZS6QU2uy8iovl31cXsrNDkCcqWSQbCkcpdYLczqDz5PVIwNH42UQNyvukjes/RoHPDrOUmQ==", + "license": "BSD-3-Clause", + "dependencies": { + "@mapbox/geojson-rewind": "^0.5.2", + "@mapbox/jsonlint-lines-primitives": "^2.0.2", + "@mapbox/point-geometry": "^1.1.0", + "@mapbox/tiny-sdf": "^2.0.7", + "@mapbox/unitbezier": "^0.0.1", + "@mapbox/vector-tile": "^2.0.4", + "@mapbox/whoots-js": "^3.1.0", + "@maplibre/geojson-vt": "^5.0.4", + "@maplibre/maplibre-gl-style-spec": "^24.4.1", + "@maplibre/mlt": "^1.1.6", + "@maplibre/vt-pbf": "^4.2.1", + "@types/geojson": "^7946.0.16", + "@types/supercluster": "^7.1.3", + "earcut": "^3.0.2", + "gl-matrix": "^3.4.4", + "kdbush": "^4.0.2", + "murmurhash-js": "^1.0.0", + "pbf": "^4.0.1", + "potpack": "^2.1.0", + "quickselect": "^3.0.0", + "supercluster": "^8.0.1", + "tinyqueue": "^3.0.0" + }, + "engines": { + "node": ">=16.14.0", + "npm": ">=8.1.0" + }, + "funding": { + "url": "https://github.com/maplibre/maplibre-gl-js?sponsor=1" + } + }, + "node_modules/maplibre-gl/node_modules/@mapbox/point-geometry": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@mapbox/point-geometry/-/point-geometry-1.1.0.tgz", + "integrity": "sha512-YGcBz1cg4ATXDCM/71L9xveh4dynfGmcLDqufR+nQQy3fKwsAZsWd/x4621/6uJaeB9mwOHE6hPeDgXz9uViUQ==", + "license": "ISC" + }, + "node_modules/maplibre-gl/node_modules/@mapbox/vector-tile": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@mapbox/vector-tile/-/vector-tile-2.0.4.tgz", + "integrity": "sha512-AkOLcbgGTdXScosBWwmmD7cDlvOjkg/DetGva26pIRiZPdeJYjYKarIlb4uxVzi6bwHO6EWH82eZ5Nuv4T5DUg==", + "license": "BSD-3-Clause", + "dependencies": { + "@mapbox/point-geometry": "~1.1.0", + "@types/geojson": "^7946.0.16", + "pbf": "^4.0.1" + } + }, + "node_modules/maplibre-gl/node_modules/@maplibre/maplibre-gl-style-spec": { + "version": "24.6.0", + "resolved": "https://registry.npmjs.org/@maplibre/maplibre-gl-style-spec/-/maplibre-gl-style-spec-24.6.0.tgz", + "integrity": "sha512-+lxMYE+DvInshwVrqSQ3CkW9YRwVlRXeDzfthVOa1c9pwK5d7YgCwhgFwlSmjJLvTXn4gL8EvPUGT620sk2Pzg==", + "license": "ISC", + "dependencies": { + "@mapbox/jsonlint-lines-primitives": "~2.0.2", + "@mapbox/unitbezier": "^0.0.1", + "json-stringify-pretty-compact": "^4.0.0", + "minimist": "^1.2.8", + "quickselect": "^3.0.0", + "rw": "^1.3.3", + "tinyqueue": "^3.0.0" + }, + "bin": { + "gl-style-format": "dist/gl-style-format.mjs", + "gl-style-migrate": "dist/gl-style-migrate.mjs", + "gl-style-validate": "dist/gl-style-validate.mjs" + } + }, + "node_modules/maplibre-gl/node_modules/earcut": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/earcut/-/earcut-3.0.2.tgz", + "integrity": "sha512-X7hshQbLyMJ/3RPhyObLARM2sNxxmRALLKx1+NVFFnQ9gKzmCrxm9+uLIAdBcvc8FNLpctqlQ2V6AE92Ol9UDQ==", + "license": "ISC" + }, + "node_modules/maplibre-gl/node_modules/json-stringify-pretty-compact": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/json-stringify-pretty-compact/-/json-stringify-pretty-compact-4.0.0.tgz", + "integrity": "sha512-3CNZ2DnrpByG9Nqj6Xo8vqbjT4F6N+tb4Gb28ESAZjYZ5yqvmc56J+/kuIwkaAMOyblTQhUW7PxMkUb8Q36N3Q==", + "license": "MIT" + }, + "node_modules/maplibre-gl/node_modules/pbf": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/pbf/-/pbf-4.0.1.tgz", + "integrity": "sha512-SuLdBvS42z33m8ejRbInMapQe8n0D3vN/Xd5fmWM3tufNgRQFBpaW2YVJxQZV4iPNqb0vEFvssMEo5w9c6BTIA==", + "license": "BSD-3-Clause", + "dependencies": { + "resolve-protobuf-schema": "^2.1.0" + }, + "bin": { + "pbf": "bin/pbf" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -3441,6 +4736,17 @@ "node": ">= 0.4" } }, + "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/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -3512,12 +4818,33 @@ "node": "*" } }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "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", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/murmurhash-js": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/murmurhash-js/-/murmurhash-js-1.0.0.tgz", + "integrity": "sha512-TvmkNhkv8yct0SVBSy+o8wYzXjE4Zz3PCesbfs8HiCXXdcTuocApFv11UWlNFWKYsP2okqrhb7JNlSm9InBhIw==", + "license": "MIT" + }, "node_modules/mz": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", @@ -3643,6 +4970,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", @@ -3683,6 +5016,19 @@ "dev": true, "license": "MIT" }, + "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/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -3886,6 +5232,12 @@ "dev": true, "license": "MIT" }, + "node_modules/potpack": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/potpack/-/potpack-2.1.0.tgz", + "integrity": "sha512-pcaShQc1Shq0y+E7GqJqvZj8DTthWV1KeHGdi0Z6IAin2Oi3JnLCOfwnCo84qc+HAp52wT9nK9H7FAJp5a44GQ==", + "license": "ISC" + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -3896,6 +5248,18 @@ "node": ">= 0.8.0" } }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "license": "MIT" + }, + "node_modules/protocol-buffers-schema": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.6.0.tgz", + "integrity": "sha512-TdDRD+/QNdrCGCE7v8340QyuXd4kIWIgapsE2+n/SaGiSSbomYl4TjHlvIoCWRpE7wFt02EpB35VVA2ImcBVqw==", + "license": "MIT" + }, "node_modules/proxy-from-env": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", @@ -3933,6 +5297,12 @@ ], "license": "MIT" }, + "node_modules/quickselect": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/quickselect/-/quickselect-3.0.0.tgz", + "integrity": "sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g==", + "license": "ISC" + }, "node_modules/react": { "version": "19.2.4", "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", @@ -3954,20 +5324,6 @@ "react": "^19.2.4" } }, - "node_modules/react-leaflet": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/react-leaflet/-/react-leaflet-5.0.0.tgz", - "integrity": "sha512-CWbTpr5vcHw5bt9i4zSlPEVQdTVcML390TjeDG0cK59z1ylexpqC6M1PJFjV8jD7CF+ACBFsLIDs6DRMoLEofw==", - "license": "Hippocratic-2.1", - "dependencies": { - "@react-leaflet/core": "^3.0.0" - }, - "peerDependencies": { - "leaflet": "^1.9.0", - "react": "^19.0.0", - "react-dom": "^19.0.0" - } - }, "node_modules/react-refresh": { "version": "0.18.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", @@ -3988,6 +5344,21 @@ "pify": "^2.3.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/readdirp": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", @@ -4045,6 +5416,15 @@ "node": ">=4" } }, + "node_modules/resolve-protobuf-schema": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/resolve-protobuf-schema/-/resolve-protobuf-schema-2.1.0.tgz", + "integrity": "sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ==", + "license": "MIT", + "dependencies": { + "protocol-buffers-schema": "^3.3.1" + } + }, "node_modules/reusify": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", @@ -4125,6 +5505,18 @@ "queue-microtask": "^1.2.2" } }, + "node_modules/rw": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz", + "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", @@ -4141,6 +5533,27 @@ "semver": "bin/semver.js" } }, + "node_modules/set-value": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/set-value/-/set-value-2.0.1.tgz", + "integrity": "sha512-JxHc1weCN68wRY0fhCoXpyK55m/XPHafOmK4UWD7m2CI14GMcFypt4w/0+NV5f/ZMby2F6S2wwA7fgynh9gWSw==", + "license": "MIT", + "dependencies": { + "extend-shallow": "^2.0.1", + "is-extendable": "^0.1.1", + "is-plain-object": "^2.0.3", + "split-string": "^3.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", + "license": "MIT" + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -4164,6 +5577,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/socket.io-client": { "version": "4.8.3", "resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.3.tgz", @@ -4192,6 +5611,41 @@ "node": ">=10.0.0" } }, + "node_modules/sort-asc": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/sort-asc/-/sort-asc-0.2.0.tgz", + "integrity": "sha512-umMGhjPeHAI6YjABoSTrFp2zaBtXBej1a0yKkuMUyjjqu6FJsTF+JYwCswWDg+zJfk/5npWUUbd33HH/WLzpaA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/sort-desc": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/sort-desc/-/sort-desc-0.2.0.tgz", + "integrity": "sha512-NqZqyvL4VPW+RAxxXnB8gvE1kyikh8+pR+T+CXLksVRN9eiQqkQlPwqWYU0mF9Jm7UnctShlxLyAt1CaBOTL1w==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/sort-object": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/sort-object/-/sort-object-3.0.3.tgz", + "integrity": "sha512-nK7WOY8jik6zaG9CRwZTaD5O7ETWDLZYMM12pqY8htll+7dYeqGfEUPcUBHOpSJg2vJOrvFIY2Dl5cX2ih1hAQ==", + "license": "MIT", + "dependencies": { + "bytewise": "^1.1.0", + "get-value": "^2.0.2", + "is-extendable": "^0.1.1", + "sort-asc": "^0.2.0", + "sort-desc": "^0.2.0", + "union-value": "^1.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -4202,6 +5656,58 @@ "node": ">=0.10.0" } }, + "node_modules/split-string": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/split-string/-/split-string-3.1.0.tgz", + "integrity": "sha512-NzNVhJDYpwceVVii8/Hu6DKfD2G+NrQHlS/V/qgv763EYudVwEcMQNxd2lh+0VrUByXN/oJkl5grOhYWvQUYiw==", + "license": "MIT", + "dependencies": { + "extend-shallow": "^3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/split-string/node_modules/extend-shallow": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-3.0.2.tgz", + "integrity": "sha512-BwY5b5Ql4+qZoefgMj2NUmx+tehVTH/Kf4k1ZEtOHNFcm2wSxMRo992l6X3TIgni2eZVTZ85xMOjF31fwZAj6Q==", + "license": "MIT", + "dependencies": { + "assign-symbols": "^1.0.0", + "is-extendable": "^1.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/split-string/node_modules/is-extendable": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-1.0.1.tgz", + "integrity": "sha512-arnXMxT1hhoKo9k1LZdmlNyJdDDfy2v0fXjFlmok4+i8ul/6WlbVge9bhM74OpNPQPMGUToDtz+KXa1PneJxOA==", + "license": "MIT", + "dependencies": { + "is-plain-object": "^2.0.4" + }, + "engines": { + "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", @@ -4215,6 +5721,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/sucrase": { "version": "3.35.1", "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", @@ -4238,6 +5756,15 @@ "node": ">=16 || 14 >=14.17" } }, + "node_modules/supercluster": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/supercluster/-/supercluster-8.0.1.tgz", + "integrity": "sha512-IiOea5kJ9iqzD2t7QJq/cREyLHTtSmUT6gQsweojg9WH2sYJqZK9SswTu6jrscO6D1G5v5vYZ9ru/eq85lXeZQ==", + "license": "ISC", + "dependencies": { + "kdbush": "^4.0.2" + } + }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -4302,6 +5829,28 @@ "node": ">=14.0.0" } }, + "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/thenify": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", @@ -4342,6 +5891,12 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinyqueue": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/tinyqueue/-/tinyqueue-3.0.0.tgz", + "integrity": "sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g==", + "license": "ISC" + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -4432,13 +5987,42 @@ "typescript": ">=4.8.4 <6.0.0" } }, + "node_modules/typewise": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typewise/-/typewise-1.0.3.tgz", + "integrity": "sha512-aXofE06xGhaQSPzt8hlTY+/YWQhm9P0jYUp1f2XtmW/3Bk0qzXcyFWAtPoo2uTGQj1ZwbDuSyuxicq+aDo8lCQ==", + "license": "MIT", + "dependencies": { + "typewise-core": "^1.2.0" + } + }, + "node_modules/typewise-core": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/typewise-core/-/typewise-core-1.2.0.tgz", + "integrity": "sha512-2SCC/WLzj2SbUwzFOzqMCkz5amXLlxtJqDKTICqg30x+2DZxcfZN2MvQZmGfXWKNWaKK9pBPsvkcwv8bF/gxKg==", + "license": "MIT" + }, "node_modules/undici-types": { "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/union-value": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/union-value/-/union-value-1.0.1.tgz", + "integrity": "sha512-tJfXmxMeWYnczCVs7XAEvIV7ieppALdyepWMkHkwciRpZraG/xwT+s2JN8+pr1+8jCRf80FFzvr+MpQeeoF4Xg==", + "license": "MIT", + "dependencies": { + "arr-union": "^3.1.0", + "get-value": "^2.0.6", + "is-extendable": "^0.1.1", + "set-value": "^2.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/update-browserslist-db": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", @@ -4484,7 +6068,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", - "dev": true, "license": "MIT" }, "node_modules/vite": { @@ -4562,6 +6145,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", @@ -4660,6 +6249,13 @@ "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", diff --git a/frontend/package.json b/frontend/package.json index aa8625f..d37aeb5 100755 --- a/frontend/package.json +++ b/frontend/package.json @@ -10,6 +10,11 @@ "preview": "vite preview" }, "dependencies": { + "@deck.gl/aggregation-layers": "^9.2.10", + "@deck.gl/core": "^9.2.10", + "@deck.gl/geo-layers": "^9.2.10", + "@deck.gl/layers": "^9.2.10", + "@deck.gl/mapbox": "^9.2.10", "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", @@ -17,19 +22,18 @@ "@emoji-mart/react": "^1.1.1", "@react-oauth/google": "^0.13.4", "@tanstack/react-query": "^5.90.21", + "@vis.gl/react-maplibre": "^8.1.0", "axios": "^1.13.5", "emoji-mart": "^5.6.0", - "leaflet": "^1.9.4", "lucide-react": "^0.564.0", + "maplibre-gl": "^5.19.0", "react": "^19.2.0", "react-dom": "^19.2.0", - "react-leaflet": "^5.0.0", "socket.io-client": "^4.8.3", "zustand": "^5.0.11" }, "devDependencies": { "@eslint/js": "^9.39.1", - "@types/leaflet": "^1.9.21", "@types/node": "^24.10.1", "@types/react": "^19.2.7", "@types/react-dom": "^19.2.3", diff --git a/frontend/src/common/components/map/BacktrackReplayOverlay.tsx b/frontend/src/common/components/map/BacktrackReplayOverlay.tsx index c2a6c51..42bc289 100755 --- a/frontend/src/common/components/map/BacktrackReplayOverlay.tsx +++ b/frontend/src/common/components/map/BacktrackReplayOverlay.tsx @@ -1,15 +1,6 @@ -import { useMemo } from 'react' -import { Polyline, CircleMarker, Circle, Marker, Popup } from 'react-leaflet' -import L from 'leaflet' +import { ScatterplotLayer, PathLayer } from '@deck.gl/layers' import type { ReplayShip, CollisionEvent, ReplayPathPoint } from '@common/types/backtrack' - -interface BacktrackReplayOverlayProps { - replayShips: ReplayShip[] - collisionEvent: CollisionEvent | null - replayFrame: number - totalFrames: number - incidentCoord: { lat: number; lon: number } -} +import { hexToRgba } from './mapUtils' function getInterpolatedPosition( path: ReplayPathPoint[], @@ -27,129 +18,132 @@ function getInterpolatedPosition( } } -export function BacktrackReplayOverlay({ - replayShips, - collisionEvent, - replayFrame, - totalFrames, - incidentCoord, -}: BacktrackReplayOverlayProps) { +interface BacktrackReplayParams { + replayShips: ReplayShip[] + collisionEvent: CollisionEvent | null + replayFrame: number + totalFrames: number + incidentCoord: { lat: number; lon: number } +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function createBacktrackLayers(params: BacktrackReplayParams): any[] { + const { replayShips, collisionEvent, replayFrame, totalFrames, incidentCoord } = params + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const layers: any[] = [] const progress = replayFrame / totalFrames - // Ship icons using DivIcon - const shipIcons = useMemo(() => { - return replayShips.map((ship) => - L.divIcon({ - className: 'bt-ship-marker', - html: ` -
🚢
- `, - iconSize: [26, 26], - iconAnchor: [13, 13], - }) - ) - }, [replayShips]) + // Per-ship track lines + waypoints + ship position + const allTrackData: Array<{ path: [number, number][]; color: [number, number, number, number] }> = [] + const allWaypoints: Array<{ position: [number, number]; color: [number, number, number, number] }> = [] + const allShipPositions: Array<{ position: [number, number]; color: [number, number, number, number]; name: string }> = [] + replayShips.forEach((ship) => { + const pos = getInterpolatedPosition(ship.path, replayFrame, totalFrames) + const trackPath: [number, number][] = ship.path + .slice(0, pos.segmentIndex + 2) + .map((p, i, arr) => { + if (i === arr.length - 1) return [pos.lon, pos.lat] + return [p.lon, p.lat] + }) + + const rgba = hexToRgba(ship.color, 180) + allTrackData.push({ path: trackPath, color: rgba }) + + ship.path.slice(0, pos.segmentIndex + 1).forEach((p) => { + allWaypoints.push({ position: [p.lon, p.lat], color: hexToRgba(ship.color, 130) }) + }) + + allShipPositions.push({ + position: [pos.lon, pos.lat], + color: hexToRgba(ship.color), + name: ship.vesselName, + }) + }) + + // Track lines + layers.push( + new PathLayer({ + id: 'bt-tracks', + data: allTrackData, + getPath: (d: (typeof allTrackData)[0]) => d.path, + getColor: (d: (typeof allTrackData)[0]) => d.color, + getWidth: 2, + getDashArray: [6, 4], + dashJustified: true, + widthMinPixels: 2, + extensions: [], + }) + ) + + // Waypoint dots + layers.push( + new ScatterplotLayer({ + id: 'bt-waypoints', + data: allWaypoints, + getPosition: (d: (typeof allWaypoints)[0]) => d.position, + getRadius: 3, + getFillColor: (d: (typeof allWaypoints)[0]) => d.color, + radiusMinPixels: 3, + radiusMaxPixels: 5, + }) + ) + + // Ship position markers + layers.push( + new ScatterplotLayer({ + id: 'bt-ships', + data: allShipPositions, + getPosition: (d: (typeof allShipPositions)[0]) => d.position, + getRadius: 8, + getFillColor: (d: (typeof allShipPositions)[0]) => d.color, + getLineColor: [255, 255, 255, 200], + getLineWidth: 2, + stroked: true, + radiusMinPixels: 8, + radiusMaxPixels: 14, + pickable: true, + }) + ) + + // Collision point const collisionProgress = collisionEvent ? collisionEvent.progressPercent / 100 : 0.75 const showCollision = progress >= collisionProgress - const spillSize = showCollision ? Math.min(500, (progress - collisionProgress) / (1 - collisionProgress) * 500) : 0 - return ( - <> - {replayShips.map((ship, shipIdx) => { - const pos = getInterpolatedPosition(ship.path, replayFrame, totalFrames) - const trackUpToCurrent = ship.path.slice(0, pos.segmentIndex + 2).map( - (p, i, arr) => { - if (i === arr.length - 1) return [pos.lat, pos.lon] as [number, number] - return [p.lat, p.lon] as [number, number] - } - ) + if (showCollision && collisionEvent) { + layers.push( + new ScatterplotLayer({ + id: 'bt-collision', + data: [{ position: [collisionEvent.position.lon, collisionEvent.position.lat] }], + getPosition: (d: { position: [number, number] }) => d.position, + getRadius: 12, + getFillColor: [239, 68, 68, 80], + getLineColor: [239, 68, 68, 200], + getLineWidth: 2, + stroked: true, + radiusMinPixels: 12, + pickable: true, + }) + ) - return ( -
- {/* Track line (dashed) */} - + // Oil spill expansion + const spillSize = Math.min(500, ((progress - collisionProgress) / (1 - collisionProgress)) * 500) + if (spillSize > 0) { + layers.push( + new ScatterplotLayer({ + id: 'bt-spill', + data: [{ position: [incidentCoord.lon, incidentCoord.lat], radius: spillSize }], + getPosition: (d: { position: [number, number] }) => d.position, + getRadius: (d: { radius: number }) => d.radius, + getFillColor: [249, 115, 22, 50], + getLineColor: [249, 115, 22, 100], + getLineWidth: 1, + stroked: true, + radiusUnits: 'meters' as const, + }) + ) + } + } - {/* Waypoint dots */} - {ship.path.slice(0, pos.segmentIndex + 1).map((p, i) => ( - - ))} - - {/* Ship marker at current position */} - - -
- {ship.vesselName} -
- - {ship.speedLabels[Math.min(pos.segmentIndex, ship.speedLabels.length - 1)]} - -
-
-
-
- ) - })} - - {/* Collision point */} - {showCollision && collisionEvent && ( - <> - - -
- 💥 {collisionEvent.timeLabel} -
-
-
- - {/* Oil spill expansion */} - {spillSize > 0 && ( - - )} - - )} - - ) + return layers } diff --git a/frontend/src/common/components/map/MapView.tsx b/frontend/src/common/components/map/MapView.tsx index fce41a6..6549140 100755 --- a/frontend/src/common/components/map/MapView.tsx +++ b/frontend/src/common/components/map/MapView.tsx @@ -1,36 +1,56 @@ -import { useState, useMemo, useEffect } from 'react' -import { MapContainer, TileLayer, Marker, Popup, useMap, useMapEvents, CircleMarker, Circle, Polyline } from 'react-leaflet' -import 'leaflet/dist/leaflet.css' -import L from 'leaflet' +import { useState, useMemo, useEffect, useCallback } from 'react' +import { Map, Marker, Popup, Source, Layer, useControl, useMap } from '@vis.gl/react-maplibre' +import { MapboxOverlay } from '@deck.gl/mapbox' +import { ScatterplotLayer, PathLayer } from '@deck.gl/layers' +import type { PickingInfo } from '@deck.gl/core' +import type { StyleSpecification } from 'maplibre-gl' +import type { MapLayerMouseEvent } from 'maplibre-gl' +import 'maplibre-gl/dist/maplibre-gl.css' import { layerDatabase } from '@common/services/layerService' import { decimalToDMS } from '@common/utils/coordinates' import type { PredictionModel } from '@tabs/prediction/components/OilSpillView' import type { BoomLine, BoomLineCoord } from '@common/types/boomLine' import type { ReplayShip, CollisionEvent } from '@common/types/backtrack' -import { BacktrackReplayOverlay } from './BacktrackReplayOverlay' +import { createBacktrackLayers } from './BacktrackReplayOverlay' +import { hexToRgba } from './mapUtils' -// Fix Leaflet default icon issue -import icon from 'leaflet/dist/images/marker-icon.png' -import iconShadow from 'leaflet/dist/images/marker-shadow.png' - -const DefaultIcon = L.icon({ - iconUrl: icon, - shadowUrl: iconShadow, - iconSize: [25, 41], - iconAnchor: [12, 41], -}) - -L.Marker.prototype.options.icon = DefaultIcon +const GEOSERVER_URL = import.meta.env.VITE_GEOSERVER_URL || 'http://localhost:8080' // 남해안 중심 좌표 (여수 앞바다) const DEFAULT_CENTER: [number, number] = [34.5, 127.8] const DEFAULT_ZOOM = 10 +// CartoDB Dark Matter 스타일 +const BASE_STYLE: StyleSpecification = { + version: 8, + sources: { + 'carto-dark': { + type: 'raster', + tiles: [ + 'https://a.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}@2x.png', + 'https://b.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}@2x.png', + 'https://c.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}@2x.png', + ], + tileSize: 256, + attribution: '© OpenStreetMap © CARTO', + }, + }, + layers: [ + { + id: 'carto-dark-layer', + type: 'raster', + source: 'carto-dark', + minzoom: 0, + maxzoom: 22, + }, + ], +} + // 모델별 색상 매핑 const MODEL_COLORS: Record = { - 'KOSPS': '#06b6d4', // cyan - 'POSEIDON': '#ef4444', // red - 'OpenDrift': '#3b82f6', // blue + 'KOSPS': '#06b6d4', + 'POSEIDON': '#ef4444', + 'OpenDrift': '#3b82f6', } // 오일펜스 우선순위별 색상/두께 @@ -90,19 +110,21 @@ interface MapViewProps { } } -// WMS 레이어에 CSS brightness 필터 적용 -function WmsBrightnessApplier({ brightness }: { brightness: number }) { - const map = useMap() - useEffect(() => { - const container = map.getContainer() - // WMS 타일 이미지에만 필터 적용 (베이스맵 제외) - const imgs = container.querySelectorAll('.leaflet-tile-pane .leaflet-layer:not(:first-child) img') - const filterVal = `brightness(${brightness / 50})` - imgs.forEach(img => { img.style.filter = filterVal }) - }, [map, brightness]) +// deck.gl 오버레이 컴포넌트 (MapLibre 컨트롤로 등록) +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function DeckGLOverlay({ layers }: { layers: any[] }) { + const overlay = useControl(() => new MapboxOverlay({ interleaved: true })) + overlay.setProps({ layers }) return null } +// 팝업 정보 +interface PopupInfo { + longitude: number + latitude: number + content: React.ReactNode +} + export function MapView({ center = DEFAULT_CENTER, zoom = DEFAULT_ZOOM, @@ -124,14 +146,16 @@ export function MapView({ const [currentTime, setCurrentTime] = useState(0) const [isPlaying, setIsPlaying] = useState(false) const [playbackSpeed, setPlaybackSpeed] = useState(1) + const [popupInfo, setPopupInfo] = useState(null) - const handleMapClick = (position: [number, number]) => { - const [lat, lng] = position - setCurrentPosition(position) + const handleMapClick = useCallback((e: MapLayerMouseEvent) => { + const { lng, lat } = e.lngLat + setCurrentPosition([lat, lng]) if (onMapClick) { - onMapClick(lng, lat) // onMapClick expects (lon, lat) + onMapClick(lng, lat) } - } + setPopupInfo(null) + }, [onMapClick]) // 애니메이션 재생 로직 useEffect(() => { @@ -148,7 +172,7 @@ export function MapView({ const next = prev + (1 * playbackSpeed) return next > maxTime ? maxTime : next }) - }, 200) // 200ms마다 업데이트 + }, 200) return () => clearInterval(interval) }, [isPlaying, currentTime, playbackSpeed, oilTrajectory]) @@ -161,7 +185,7 @@ export function MapView({ } }, [oilTrajectory.length]) - // WMS 레이어 목록 생성 + // WMS 레이어 목록 const wmsLayers = useMemo(() => { return Array.from(enabledLayers) .map(layerId => { @@ -171,212 +195,311 @@ export function MapView({ .filter((l): l is { id: string; wmsLayer: string } => l !== null) }, [enabledLayers]) + // WMS 밝기 값 (MapLibre raster paint) + const wmsBrightnessMax = Math.min(layerBrightness / 50, 2) + const wmsBrightnessMin = wmsBrightnessMax > 1 ? wmsBrightnessMax - 1 : 0 + const wmsOpacity = layerOpacity / 100 + + // deck.gl 레이어 구축 + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const deckLayers = useMemo((): any[] => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const result: any[] = [] + + // --- 유류 확산 입자 (ScatterplotLayer) --- + const visibleParticles = oilTrajectory.filter(p => p.time <= currentTime) + if (visibleParticles.length > 0) { + result.push( + new ScatterplotLayer({ + id: 'oil-particles', + data: visibleParticles, + getPosition: (d: (typeof visibleParticles)[0]) => [d.lon, d.lat], + getRadius: 3, + getFillColor: (d: (typeof visibleParticles)[0]) => { + const modelKey = d.model || Array.from(selectedModels)[0] || 'OpenDrift' + return hexToRgba(MODEL_COLORS[modelKey] || '#3b82f6', 180) + }, + radiusMinPixels: 2.5, + radiusMaxPixels: 5, + pickable: true, + onClick: (info: PickingInfo) => { + if (info.object) { + const d = info.object as (typeof visibleParticles)[0] + const modelKey = d.model || Array.from(selectedModels)[0] || 'OpenDrift' + setPopupInfo({ + longitude: d.lon, + latitude: d.lat, + content: ( +
+ {modelKey} 입자 #{(d.particle ?? 0) + 1} +
+ 시간: +{d.time}h +
+ 위치: {d.lat.toFixed(4)}°, {d.lon.toFixed(4)}° +
+ ), + }) + } + }, + updateTriggers: { + getFillColor: [selectedModels], + }, + }) + ) + } + + // --- 오일펜스 라인 (PathLayer) --- + if (boomLines.length > 0) { + result.push( + new PathLayer({ + id: 'boom-lines', + data: boomLines, + getPath: (d: BoomLine) => d.coords.map(c => [c.lon, c.lat] as [number, number]), + getColor: (d: BoomLine) => hexToRgba(PRIORITY_COLORS[d.priority] || '#f59e0b', 230), + getWidth: (d: BoomLine) => PRIORITY_WEIGHTS[d.priority] || 2, + getDashArray: (d: BoomLine) => d.status === 'PLANNED' ? [10, 5] : null, + dashJustified: true, + widthMinPixels: 2, + widthMaxPixels: 6, + pickable: true, + onClick: (info: PickingInfo) => { + if (info.object) { + const d = info.object as BoomLine + setPopupInfo({ + longitude: info.coordinate?.[0] ?? 0, + latitude: info.coordinate?.[1] ?? 0, + content: ( +
+ {d.name} +
+ 우선순위: {PRIORITY_LABELS[d.priority] || d.priority} +
+ 길이: {d.length.toFixed(0)}m +
+ 각도: {d.angle.toFixed(0)}° +
+ 차단 효율: {d.efficiency}% +
+ ), + }) + } + }, + }) + ) + + // 오일펜스 끝점 마커 + const endpoints: Array<{ position: [number, number]; color: [number, number, number, number] }> = [] + boomLines.forEach(line => { + if (line.coords.length >= 2) { + const c = hexToRgba(PRIORITY_COLORS[line.priority] || '#f59e0b', 230) + endpoints.push({ position: [line.coords[0].lon, line.coords[0].lat], color: c }) + endpoints.push({ position: [line.coords[line.coords.length - 1].lon, line.coords[line.coords.length - 1].lat], color: c }) + } + }) + if (endpoints.length > 0) { + result.push( + new ScatterplotLayer({ + id: 'boom-endpoints', + data: endpoints, + getPosition: (d: (typeof endpoints)[0]) => d.position, + getRadius: 5, + getFillColor: (d: (typeof endpoints)[0]) => d.color, + getLineColor: [255, 255, 255, 255], + getLineWidth: 2, + stroked: true, + radiusMinPixels: 5, + radiusMaxPixels: 8, + }) + ) + } + } + + // --- 드로잉 미리보기 --- + if (isDrawingBoom && drawingPoints.length > 0) { + result.push( + new PathLayer({ + id: 'drawing-preview', + data: [{ path: drawingPoints.map(c => [c.lon, c.lat] as [number, number]) }], + getPath: (d: { path: [number, number][] }) => d.path, + getColor: [245, 158, 11, 200], + getWidth: 3, + getDashArray: [10, 6], + dashJustified: true, + widthMinPixels: 3, + }) + ) + result.push( + new ScatterplotLayer({ + id: 'drawing-points', + data: drawingPoints.map(c => ({ position: [c.lon, c.lat] as [number, number] })), + getPosition: (d: { position: [number, number] }) => d.position, + getRadius: 4, + getFillColor: [245, 158, 11, 255], + getLineColor: [255, 255, 255, 255], + getLineWidth: 2, + stroked: true, + radiusMinPixels: 4, + radiusMaxPixels: 6, + }) + ) + } + + // --- HNS 대기확산 구역 (ScatterplotLayer, meters 단위) --- + if (dispersionResult && incidentCoord) { + const zones = dispersionResult.zones.map((zone, idx) => ({ + position: [incidentCoord.lon, incidentCoord.lat] as [number, number], + radius: zone.radius, + fillColor: hexToRgba(zone.color, 100), + lineColor: hexToRgba(zone.color, 180), + level: zone.level, + idx, + })) + + result.push( + new ScatterplotLayer({ + id: 'hns-zones', + data: zones, + getPosition: (d: (typeof zones)[0]) => d.position, + getRadius: (d: (typeof zones)[0]) => d.radius, + getFillColor: (d: (typeof zones)[0]) => d.fillColor, + getLineColor: (d: (typeof zones)[0]) => d.lineColor, + getLineWidth: 2, + stroked: true, + radiusUnits: 'meters' as const, + pickable: true, + onClick: (info: PickingInfo) => { + if (info.object) { + const d = info.object as (typeof zones)[0] + setPopupInfo({ + longitude: incidentCoord.lon, + latitude: incidentCoord.lat, + content: ( +
+ {d.level} +
+ 물질: {dispersionResult.substance} +
+ 농도: {dispersionResult.concentration[d.level]} +
+ 반경: {d.radius}m +
+ ), + }) + } + }, + }) + ) + } + + // --- 역추적 리플레이 --- + if (backtrackReplay?.isActive) { + result.push(...createBacktrackLayers({ + replayShips: backtrackReplay.ships, + collisionEvent: backtrackReplay.collisionEvent, + replayFrame: backtrackReplay.replayFrame, + totalFrames: backtrackReplay.totalFrames, + incidentCoord: backtrackReplay.incidentCoord, + })) + } + + return result + }, [ + oilTrajectory, currentTime, selectedModels, + boomLines, isDrawingBoom, drawingPoints, + dispersionResult, incidentCoord, backtrackReplay, + ]) + return (
- - {/* CartoDB Dark Matter Tile Layer */} - - - {/* WMS 레이어 (투명도 + 밝기 적용) */} + {/* WMS 레이어 */} {wmsLayers.map(layer => ( - + id={`wms-${layer.id}`} + type="raster" + tiles={[ + `${GEOSERVER_URL}/geoserver/gwc/service/wms?service=WMS&version=1.1.0&request=GetMap&layers=${layer.wmsLayer}&styles=&bbox={bbox-epsg-3857}&width=256&height=256&srs=EPSG:3857&format=image/png&transparent=true` + ]} + tileSize={256} + > + + ))} - - {/* 사고 위치 마커 */} + {/* deck.gl 오버레이 */} + + + {/* 사고 위치 마커 (MapLibre Marker) */} {incidentCoord && !isNaN(incidentCoord.lat) && !isNaN(incidentCoord.lon) && ( - - -
- 사고 지점 -
- - {decimalToDMS(incidentCoord.lat, true)} -
- {decimalToDMS(incidentCoord.lon, false)} -
-
- - ({incidentCoord.lat.toFixed(4)}°, {incidentCoord.lon.toFixed(4)}°) - -
-
+ +
)} - {/* 오일 확산 입자 시각화 (모델별 색상) */} - {oilTrajectory - .filter(point => point.time <= currentTime) - .map((point, idx) => { - const modelKey = point.model || Array.from(selectedModels)[0] || 'OpenDrift' - const particleColor = MODEL_COLORS[modelKey] || '#3b82f6' - return ( - - -
- {modelKey} 입자 #{(point.particle ?? 0) + 1} -
- 시간: +{point.time}h -
- 위치: {point.lat.toFixed(4)}°, {point.lon.toFixed(4)}° -
-
-
- ) - })} + {/* 사고 위치 팝업 (클릭 시) */} + {incidentCoord && !isNaN(incidentCoord.lat) && !isNaN(incidentCoord.lon) && !popupInfo && ( + +
+ 사고 지점 +
+ + {decimalToDMS(incidentCoord.lat, true)} +
+ {decimalToDMS(incidentCoord.lon, false)} +
+
+ + ({incidentCoord.lat.toFixed(4)}°, {incidentCoord.lon.toFixed(4)}°) + +
+
+ )} - {/* 오일펜스 라인 렌더링 */} - {boomLines.map(line => ( - [c.lat, c.lon] as [number, number])} - pathOptions={{ - color: PRIORITY_COLORS[line.priority] || '#f59e0b', - weight: PRIORITY_WEIGHTS[line.priority] || 2, - opacity: 0.9, - dashArray: line.status === 'PLANNED' ? '10, 5' : undefined, - }} + {/* deck.gl 객체 클릭 팝업 */} + {popupInfo && ( + setPopupInfo(null)} > - -
- {line.name} -
- 우선순위: {PRIORITY_LABELS[line.priority] || line.priority} -
- 길이: {line.length.toFixed(0)}m -
- 각도: {line.angle.toFixed(0)}° -
- 차단 효율: {line.efficiency}% -
-
-
- ))} - - {/* 오일펜스 끝점 마커 */} - {boomLines.map(line => - line.coords.length >= 2 - ? [line.coords[0], line.coords[line.coords.length - 1]].map((pt, i) => ( - - )) - : null +
{popupInfo.content}
+ )} - {/* 드로잉 미리보기 */} - {isDrawingBoom && drawingPoints.length > 0 && ( - <> - [c.lat, c.lon] as [number, number])} - pathOptions={{ - color: '#f59e0b', - weight: 3, - dashArray: '10, 6', - opacity: 0.8, - }} - /> - {drawingPoints.map((pt, i) => ( - - ))} - - )} - - {/* HNS 대기확산 결과 시각화 */} - {dispersionResult && incidentCoord && dispersionResult.zones.map((zone, idx) => ( - - -
- {zone.level} -
- 물질: {dispersionResult.substance} -
- 농도: {dispersionResult.concentration[zone.level]} -
- 반경: {zone.radius}m -
- 풍향: {zone.angle}° -
-
-
- ))} - - {/* 역추적 리플레이 오버레이 */} - {backtrackReplay?.isActive && ( - - )} - - {/* 지도 클릭 이벤트 핸들러 */} - - - {/* 커스텀 컨트롤 */} - - + {/* 커스텀 줌 컨트롤 */} + + {/* 드로잉 모드 안내 */} {isDrawingBoom && (
- 🛡 오일펜스 배치 모드 — 지도를 클릭하여 포인트를 추가하세요 ({drawingPoints.length}개 포인트) + 오일펜스 배치 모드 — 지도를 클릭하여 포인트를 추가하세요 ({drawingPoints.length}개 포인트)
)} @@ -403,54 +526,43 @@ export function MapView({ onSpeedChange={setPlaybackSpeed} /> )} + + {/* 역추적 리플레이 바 */} + {backtrackReplay?.isActive && ( + + )}
) } -// 지도 클릭 이벤트 핸들러 -interface MapClickHandlerProps { - onPositionChange: (position: [number, number]) => void -} - -function MapClickHandler({ onPositionChange }: MapClickHandlerProps) { - useMapEvents({ - click: (e) => { - const { lat, lng } = e.latlng - onPositionChange([lat, lng]) - }, - }) - return null -} - -// 지도 컨트롤 (줌, 레이어 등) -function MapControls() { - const map = useMap() +// 지도 컨트롤 (줌, 위치 초기화) +function MapControls({ center, zoom }: { center: [number, number]; zoom: number }) { + const { current: map } = useMap() return ( -
+
- {/* 줌 인 */} - - {/* 줌 아웃 */} - - {/* 현재 위치로 */}
@@ -467,11 +579,9 @@ interface MapLegendProps { } function MapLegend({ dispersionResult, incidentCoord, oilTrajectory = [], boomLines = [], selectedModels = new Set(['OpenDrift'] as PredictionModel[]) }: MapLegendProps) { - // HNS 대기확산 범례 if (dispersionResult && incidentCoord) { return ( -
- {/* 헤더 */} +
📍
@@ -481,8 +591,6 @@ function MapLegend({ dispersionResult, incidentCoord, oilTrajectory = [], boomLi
- - {/* 사고 정보 */}
물질 @@ -497,8 +605,6 @@ function MapLegend({ dispersionResult, incidentCoord, oilTrajectory = [], boomLi {dispersionResult.zones.length}개
- - {/* 위험 구역 범례 */}
위험 구역
@@ -516,8 +622,6 @@ function MapLegend({ dispersionResult, incidentCoord, oilTrajectory = [], boomLi
- - {/* 풍향 표시 */}
🧭
풍향 (방사형) @@ -526,13 +630,11 @@ function MapLegend({ dispersionResult, incidentCoord, oilTrajectory = [], boomLi ) } - // 유류 확산 범례 (유출유 확산예측에만 표시) if (oilTrajectory.length > 0) { return ( -
+

범례

- {/* 선택된 모델별 색상 */} {Array.from(selectedModels).map(model => (
@@ -571,16 +673,11 @@ function MapLegend({ dispersionResult, incidentCoord, oilTrajectory = [], boomLi ) } - // 범례 없음 return null } // 좌표 표시 -interface CoordinateDisplayProps { - position: [number, number] -} - -function CoordinateDisplay({ position }: CoordinateDisplayProps) { +function CoordinateDisplay({ position }: { position: [number, number] }) { const [lat, lng] = position const latDirection = lat >= 0 ? 'N' : 'S' const lngDirection = lng >= 0 ? 'E' : 'W' @@ -606,48 +703,28 @@ interface TimelineControlProps { } function TimelineControl({ - currentTime, - maxTime, - isPlaying, - playbackSpeed, - onTimeChange, - onPlayPause, - onSpeedChange + currentTime, maxTime, isPlaying, playbackSpeed, + onTimeChange, onPlayPause, onSpeedChange }: TimelineControlProps) { const progressPercent = (currentTime / maxTime) * 100 - const handleRewind = () => { - onTimeChange(Math.max(0, currentTime - 6)) - } - - const handleForward = () => { - onTimeChange(Math.min(maxTime, currentTime + 6)) - } - - const handleStart = () => { - onTimeChange(0) - } - - const handleEnd = () => { - onTimeChange(maxTime) - } + const handleRewind = () => onTimeChange(Math.max(0, currentTime - 6)) + const handleForward = () => onTimeChange(Math.min(maxTime, currentTime + 6)) + const handleStart = () => onTimeChange(0) + const handleEnd = () => onTimeChange(maxTime) const toggleSpeed = () => { const speeds = [1, 2, 4] const currentIndex = speeds.indexOf(playbackSpeed) - const nextIndex = (currentIndex + 1) % speeds.length - onSpeedChange(speeds[nextIndex]) + onSpeedChange(speeds[(currentIndex + 1) % speeds.length]) } const handleTimelineClick = (e: React.MouseEvent) => { const rect = e.currentTarget.getBoundingClientRect() - const clickX = e.clientX - rect.left - const percent = clickX / rect.width - const newTime = Math.round(percent * maxTime) - onTimeChange(Math.max(0, Math.min(maxTime, newTime))) + const percent = (e.clientX - rect.left) / rect.width + onTimeChange(Math.max(0, Math.min(maxTime, Math.round(percent * maxTime)))) } - // 시간 레이블 생성 (0h, 6h, 12h, ...) const timeLabels = [] for (let t = 0; t <= maxTime; t += 6) { timeLabels.push(t) @@ -655,7 +732,6 @@ function TimelineControl({ return (
- {/* 재생 컨트롤 */}
@@ -667,16 +743,10 @@ function TimelineControl({
{playbackSpeed}×
- - {/* 타임라인 트랙 */}
{timeLabels.map(t => ( - + {t}h ))} @@ -685,60 +755,31 @@ function TimelineControl({
{timeLabels.map(t => ( -
+
))}
- - {/* 정보 표시 */}
{/* eslint-disable-next-line react-hooks/purity */}
+{currentTime.toFixed(0)}h — {new Date(Date.now() + currentTime * 3600000).toLocaleDateString('ko-KR', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })} KST
-
- 진행률 - {progressPercent.toFixed(0)}% -
-
- 속도 - {playbackSpeed}× -
-
- 시간 - {currentTime.toFixed(0)}/{maxTime}h -
+
진행률{progressPercent.toFixed(0)}%
+
속도{playbackSpeed}×
+
시간{currentTime.toFixed(0)}/{maxTime}h
) } -// 기상 데이터 타입 -interface WeatherData { - windSpeed: number - windDirection: string - waveHeight: number - waterTemp: number - currentSpeed: number - currentDirection: string -} - -// 좌표 기반 기상 데이터 조회 (Mock 함수) -function getWeatherData(position: [number, number]): WeatherData { +// 기상 데이터 Mock +function getWeatherData(position: [number, number]) { const [lat, lng] = position - - // 좌표 기반으로 변화를 주기 위한 시드값 const latSeed = Math.abs(lat * 100) % 10 const lngSeed = Math.abs(lng * 100) % 10 - const directions = ['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW'] - return { windSpeed: Number((5 + latSeed).toFixed(1)), windDirection: directions[Math.floor(lngSeed * 0.8)], @@ -749,38 +790,25 @@ function getWeatherData(position: [number, number]): WeatherData { } } -// 기상청 연계 정보 -interface WeatherInfoPanelProps { - position: [number, number] -} - -function WeatherInfoPanel({ position }: WeatherInfoPanelProps) { +function WeatherInfoPanel({ position }: { position: [number, number] }) { const weather = getWeatherData(position) - return (
- {/* 풍속 */}
💨
{weather.windSpeed} m/s
풍속 ({weather.windDirection})
- - {/* 파고 */}
🌊
{weather.waveHeight} m
파고
- - {/* 수온 */}
🌡
{weather.waterTemp}°C
수온
- - {/* 해류 */}
🔄
{weather.currentSpeed} m/s
@@ -789,3 +817,32 @@ function WeatherInfoPanel({ position }: WeatherInfoPanelProps) {
) } + +// 역추적 리플레이 컨트롤 바 (HTML 오버레이) +function BacktrackReplayBar({ replayFrame, totalFrames, ships }: { replayFrame: number; totalFrames: number; ships: ReplayShip[] }) { + const progress = (replayFrame / totalFrames) * 100 + return ( +
+
+ {progress.toFixed(0)}% +
+
+
+
+
+ {ships.map(s => ( +
+ ))} +
+
+ ) +} diff --git a/frontend/src/common/components/map/mapUtils.ts b/frontend/src/common/components/map/mapUtils.ts new file mode 100644 index 0000000..02c734b --- /dev/null +++ b/frontend/src/common/components/map/mapUtils.ts @@ -0,0 +1,7 @@ +/** hex 색상(#rrggbb)을 deck.gl용 RGBA 배열로 변환 */ +export function hexToRgba(hex: string, alpha = 255): [number, number, number, number] { + const r = parseInt(hex.slice(1, 3), 16) + const g = parseInt(hex.slice(3, 5), 16) + const b = parseInt(hex.slice(5, 7), 16) + return [r, g, b, alpha] +} diff --git a/frontend/src/index.css b/frontend/src/index.css index 470fc2f..cfb669a 100755 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -331,7 +331,7 @@ font-family: 'JetBrains Mono', monospace; font-size: 11px; color: var(--t2); - z-index: 1001; + z-index: 20; display: flex; gap: 16px; } @@ -351,7 +351,7 @@ border: 1px solid var(--bd); border-radius: 8px; padding: 12px 14px; - z-index: 1001; + z-index: 20; display: flex; gap: 20px; } @@ -395,7 +395,7 @@ align-items: center; padding: 0 20px; gap: 16px; - z-index: 1001; + z-index: 30; } .tlc { @@ -753,7 +753,7 @@ font-weight: 600; color: var(--boom); font-family: var(--fK); - z-index: 1002; + z-index: 40; white-space: nowrap; pointer-events: none; animation: fadeSlideDown 0.3s ease; diff --git a/frontend/src/tabs/assets/components/AssetMap.tsx b/frontend/src/tabs/assets/components/AssetMap.tsx index be29fa4..80aeae1 100644 --- a/frontend/src/tabs/assets/components/AssetMap.tsx +++ b/frontend/src/tabs/assets/components/AssetMap.tsx @@ -1,8 +1,53 @@ -import { useEffect, useRef } from 'react' -import L from 'leaflet' -import 'leaflet/dist/leaflet.css' +import { useMemo, useCallback, useEffect, useRef } from 'react' +import { Map, useControl, useMap } from '@vis.gl/react-maplibre' +import { MapboxOverlay } from '@deck.gl/mapbox' +import { ScatterplotLayer } from '@deck.gl/layers' +import type { StyleSpecification } from 'maplibre-gl' +import 'maplibre-gl/dist/maplibre-gl.css' import type { AssetOrgCompat } from '../services/assetsApi' import { typeColor } from './assetTypes' +import { hexToRgba } from '@common/components/map/mapUtils' + +const BASE_STYLE: StyleSpecification = { + version: 8, + sources: { + 'carto-dark': { + type: 'raster', + tiles: [ + 'https://a.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}@2x.png', + 'https://b.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}@2x.png', + 'https://c.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}@2x.png', + ], + tileSize: 256, + attribution: '© OSM © CARTO', + }, + }, + layers: [{ id: 'carto-dark-layer', type: 'raster', source: 'carto-dark' }], +} + +// ── DeckGLOverlay ────────────────────────────────────── +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function DeckGLOverlay({ layers }: { layers: any[] }) { + const overlay = useControl(() => new MapboxOverlay({ interleaved: true })) + overlay.setProps({ layers }) + return null +} + +// ── FlyTo Controller ──────────────────────────────────── +function FlyToController({ selectedOrg }: { selectedOrg: AssetOrgCompat }) { + const { current: map } = useMap() + const prevIdRef = useRef(undefined) + + useEffect(() => { + if (!map) return + if (prevIdRef.current !== undefined && prevIdRef.current !== selectedOrg.id) { + map.flyTo({ center: [selectedOrg.lng, selectedOrg.lat], zoom: 10, duration: 800 }) + } + prevIdRef.current = selectedOrg.id + }, [map, selectedOrg]) + + return null +} interface AssetMapProps { organizations: AssetOrgCompat[] @@ -19,94 +64,62 @@ function AssetMap({ regionFilter, onRegionFilterChange, }: AssetMapProps) { - const mapContainerRef = useRef(null) - const mapRef = useRef(null) - const markersRef = useRef(null) + const handleClick = useCallback( + (org: AssetOrgCompat) => { + onSelectOrg(org) + }, + [onSelectOrg], + ) - // Initialize map once - useEffect(() => { - if (!mapContainerRef.current || mapRef.current) return - - const map = L.map(mapContainerRef.current, { - center: [35.9, 127.8], - zoom: 7, - zoomControl: false, - attributionControl: false, + const markerLayer = useMemo(() => { + return new ScatterplotLayer({ + id: 'asset-orgs', + data: orgs, + getPosition: (d: AssetOrgCompat) => [d.lng, d.lat], + getRadius: (d: AssetOrgCompat) => { + const baseRadius = d.pinSize === 'hq' ? 14 : d.pinSize === 'lg' ? 10 : 7 + const isSelected = selectedOrg.id === d.id + return isSelected ? baseRadius + 4 : baseRadius + }, + getFillColor: (d: AssetOrgCompat) => { + const tc = typeColor(d.type) + const isSelected = selectedOrg.id === d.id + return hexToRgba(isSelected ? tc.selected : tc.border, isSelected ? 229 : 178) + }, + getLineColor: (d: AssetOrgCompat) => { + const tc = typeColor(d.type) + const isSelected = selectedOrg.id === d.id + return hexToRgba(isSelected ? tc.selected : tc.border, isSelected ? 255 : 200) + }, + getLineWidth: (d: AssetOrgCompat) => (selectedOrg.id === d.id ? 3 : 2), + stroked: true, + radiusMinPixels: 4, + radiusMaxPixels: 20, + radiusUnits: 'pixels', + pickable: true, + onClick: (info: { object?: AssetOrgCompat }) => { + if (info.object) handleClick(info.object) + }, + updateTriggers: { + getRadius: [selectedOrg.id], + getFillColor: [selectedOrg.id], + getLineColor: [selectedOrg.id], + getLineWidth: [selectedOrg.id], + }, }) - - // Dark-themed OpenStreetMap tiles - L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', { - maxZoom: 19, - }).addTo(map) - - L.control.zoom({ position: 'topright' }).addTo(map) - L.control.attribution({ position: 'bottomright' }).addAttribution( - '© OSM © CARTO' - ).addTo(map) - - mapRef.current = map - markersRef.current = L.layerGroup().addTo(map) - - return () => { - map.remove() - mapRef.current = null - markersRef.current = null - } - }, []) - - // Update markers when orgs or selectedOrg changes - useEffect(() => { - if (!mapRef.current || !markersRef.current) return - - markersRef.current.clearLayers() - - orgs.forEach(org => { - const isSelected = selectedOrg.id === org.id - const tc = typeColor(org.type) - const radius = org.pinSize === 'hq' ? 14 : org.pinSize === 'lg' ? 10 : 7 - - const cm = L.circleMarker([org.lat, org.lng], { - radius: isSelected ? radius + 4 : radius, - fillColor: isSelected ? tc.selected : tc.bg, - color: isSelected ? tc.selected : tc.border, - weight: isSelected ? 3 : 2, - fillOpacity: isSelected ? 0.9 : 0.7, - }) - cm.bindTooltip( - `
-
${org.name}
-
${org.type} · 자산 ${org.totalAssets}건
-
`, - { permanent: org.pinSize === 'hq' || isSelected, direction: 'top', offset: [0, -radius - 2], className: 'asset-map-tooltip' } - ) - cm.on('click', () => onSelectOrg(org)) - - markersRef.current!.addLayer(cm) - }) - }, [orgs, selectedOrg, onSelectOrg]) - - // Pan to selected org - useEffect(() => { - if (!mapRef.current) return - mapRef.current.flyTo([selectedOrg.lat, selectedOrg.lng], 10, { duration: 0.8 }) - }, [selectedOrg]) + }, [orgs, selectedOrg, handleClick]) return (
- -
+ + + + {/* Region filter overlay */}
diff --git a/frontend/src/tabs/incidents/components/IncidentsView.tsx b/frontend/src/tabs/incidents/components/IncidentsView.tsx index 7cba78d..f5c4892 100755 --- a/frontend/src/tabs/incidents/components/IncidentsView.tsx +++ b/frontend/src/tabs/incidents/components/IncidentsView.tsx @@ -1,25 +1,74 @@ import { useState, useEffect, useMemo } from 'react' -import { MapContainer, TileLayer, CircleMarker, Popup, Marker } from 'react-leaflet' -import L from 'leaflet' -import type { LatLngExpression } from 'leaflet' -import 'leaflet/dist/leaflet.css' +import { Map, Popup, useControl } from '@vis.gl/react-maplibre' +import { MapboxOverlay } from '@deck.gl/mapbox' +import { ScatterplotLayer, IconLayer } from '@deck.gl/layers' +import type { StyleSpecification } from 'maplibre-gl' +import 'maplibre-gl/dist/maplibre-gl.css' import { IncidentsLeftPanel, type Incident } from './IncidentsLeftPanel' import { IncidentsRightPanel, type ViewMode, type AnalysisSection } from './IncidentsRightPanel' import { mockVessels, VESSEL_LEGEND, type Vessel } from '@common/mock/vesselMockData' import { fetchIncidents } from '../services/incidentsApi' import type { IncidentCompat } from '../services/incidentsApi' +import { hexToRgba } from '@common/components/map/mapUtils' -/* ── Vessel DivIcon ──────────────────────────────── */ -function makeVesselIcon(v: Vessel) { - const isAccident = v.status.includes('사고') - return L.divIcon({ - className: '', - html: `
-
-
`, - iconSize: [10, 12], - iconAnchor: [5, 6], - }) +// ── CartoDB Dark Matter 베이스맵 ──────────────────────── +const BASE_STYLE: StyleSpecification = { + version: 8, + sources: { + 'carto-dark': { + type: 'raster', + tiles: [ + 'https://a.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}@2x.png', + 'https://b.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}@2x.png', + 'https://c.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}@2x.png', + ], + tileSize: 256, + attribution: '© OpenStreetMap', + }, + }, + layers: [{ id: 'carto-dark-layer', type: 'raster', source: 'carto-dark' }], +} + +// ── DeckGLOverlay ────────────────────────────────────── +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function DeckGLOverlay({ layers }: { layers: any[] }) { + const overlay = useControl(() => new MapboxOverlay({ interleaved: true })) + overlay.setProps({ layers }) + return null +} + +// ── 사고 상태 색상 ────────────────────────────────────── +function getMarkerColor(s: string): [number, number, number, number] { + if (s === 'active') return [239, 68, 68, 204] + if (s === 'investigating') return [245, 158, 11, 204] + return [107, 114, 128, 204] +} + +function getMarkerStroke(s: string): [number, number, number, number] { + if (s === 'active') return [220, 38, 38, 255] + if (s === 'investigating') return [217, 119, 6, 255] + return [75, 85, 99, 255] +} + +const getStatusLabel = (s: string) => + s === 'active' ? '대응중' : s === 'investigating' ? '조사중' : s === 'closed' ? '종료' : '' + +// ── 선박 아이콘 SVG (삼각형) ──────────────────────────── +// deck.gl IconLayer용 atlas 없이 SVGOverlay 방식 대신 +// ScatterplotLayer로 단순화하여 선박을 원으로 표현 (heading 정보 별도 레이어) +// → 원 마커 + 방향 지시선을 deck.gl ScatterplotLayer로 표현 + +// 팝업 정보 +interface VesselPopupInfo { + longitude: number + latitude: number + vessel: Vessel +} + +interface IncidentPopupInfo { + longitude: number + latitude: number + incident: IncidentCompat } /* ════════════════════════════════════════════════════ @@ -30,6 +79,8 @@ export function IncidentsView() { const [selectedIncidentId, setSelectedIncidentId] = useState(null) const [selectedVessel, setSelectedVessel] = useState(null) const [detailVessel, setDetailVessel] = useState(null) + const [vesselPopup, setVesselPopup] = useState(null) + const [incidentPopup, setIncidentPopup] = useState(null) // Analysis view mode const [viewMode, setViewMode] = useState('overlay') @@ -37,18 +88,15 @@ export function IncidentsView() { const [analysisTags, setAnalysisTags] = useState<{ icon: string; label: string; color: string }[]>([]) useEffect(() => { - fetchIncidents().then((data) => { - setIncidents(data); + fetchIncidents().then(data => { + setIncidents(data) if (data.length > 0) { - setSelectedIncidentId(data[0].id); + setSelectedIncidentId(data[0].id) } - }); - }, []); + }) + }, []) - const mapCenter: LatLngExpression = [35.0, 127.8] - const selectedIncident = incidents.find((i) => i.id === selectedIncidentId) ?? null - - const vesselIcons = useMemo(() => mockVessels.map((v) => makeVesselIcon(v)), []) + const selectedIncident = incidents.find(i => i.id === selectedIncidentId) ?? null const handleRunAnalysis = (sections: AnalysisSection[], sensitiveCount: number) => { if (sections.length === 0) return @@ -68,13 +116,111 @@ export function IncidentsView() { setAnalysisTags([]) } - const getMarkerColor = (s: string) => { - if (s === 'active') return { fill: '#ef4444', stroke: '#dc2626' } - if (s === 'investigating') return { fill: '#f59e0b', stroke: '#d97706' } - return { fill: '#6b7280', stroke: '#4b5563' } - } - const getStatusLabel = (s: string) => - s === 'active' ? '대응중' : s === 'investigating' ? '조사중' : s === 'closed' ? '종료' : '' + // ── 사고 마커 (ScatterplotLayer) ────────────────────── + const incidentLayer = useMemo( + () => + new ScatterplotLayer({ + id: 'incidents', + data: incidents, + getPosition: (d: IncidentCompat) => [d.location.lon, d.location.lat], + getRadius: (d: IncidentCompat) => (selectedIncidentId === d.id ? 16 : 12), + getFillColor: (d: IncidentCompat) => getMarkerColor(d.status), + getLineColor: (d: IncidentCompat) => + selectedIncidentId === d.id ? [6, 182, 212, 255] : getMarkerStroke(d.status), + getLineWidth: (d: IncidentCompat) => (selectedIncidentId === d.id ? 3 : 2), + stroked: true, + radiusMinPixels: 6, + radiusMaxPixels: 20, + radiusUnits: 'pixels', + pickable: true, + onClick: (info: { object?: IncidentCompat; coordinate?: number[] }) => { + if (info.object && info.coordinate) { + setSelectedIncidentId(info.object.id) + setIncidentPopup({ + longitude: info.coordinate[0], + latitude: info.coordinate[1], + incident: info.object, + }) + setVesselPopup(null) + } + }, + updateTriggers: { + getRadius: [selectedIncidentId], + getLineColor: [selectedIncidentId], + getLineWidth: [selectedIncidentId], + }, + }), + [incidents, selectedIncidentId], + ) + + // ── 선박 마커: ScatterplotLayer (원) ───────────────── + const vesselLayer = useMemo( + () => + new ScatterplotLayer({ + id: 'vessels', + data: mockVessels, + getPosition: (d: Vessel) => [d.lng, d.lat], + getRadius: 5, + getFillColor: (d: Vessel) => hexToRgba(d.color, d.status.includes('사고') ? 255 : 200), + getLineColor: (d: Vessel) => hexToRgba(d.color, 255), + getLineWidth: 1, + stroked: true, + radiusMinPixels: 4, + radiusMaxPixels: 8, + radiusUnits: 'pixels', + pickable: true, + onClick: (info: { object?: Vessel; coordinate?: number[] }) => { + if (info.object && info.coordinate) { + setSelectedVessel(info.object) + setVesselPopup({ + longitude: info.coordinate[0], + latitude: info.coordinate[1], + vessel: info.object, + }) + setIncidentPopup(null) + setDetailVessel(null) + } + }, + }), + [], + ) + + // ── 선박 방향 지시 아이콘 (삼각형 SVG → IconLayer) ────── + // IconLayer는 atlas 이미지가 필요하여, 대신 HTML overlay로 선박 방향 표현 + // 실제 지도 위 선박 방향은 ScatterplotLayer + 별도 SVG 오버레이로 처리 가능하나 + // deck.gl에서 가장 간단한 방법은 커스텀 SVG를 data URL로 활용 + const vesselIconLayer = useMemo(() => { + const makeTriangleSvg = (color: string, isAccident: boolean) => { + const svgStr = ` + + ` + return `data:image/svg+xml;base64,${btoa(svgStr)}` + } + + return new IconLayer({ + id: 'vessel-icons', + data: mockVessels, + getPosition: (d: Vessel) => [d.lng, d.lat], + getIcon: (d: Vessel) => ({ + url: makeTriangleSvg(d.color, d.status.includes('사고')), + width: 10, + height: 12, + anchorX: 5, + anchorY: 6, + }), + getSize: 12, + getAngle: (d: Vessel) => -d.heading, + sizeUnits: 'pixels', + sizeScale: 1, + pickable: false, + }) + }, []) + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const deckLayers: any[] = useMemo( + () => [incidentLayer, vesselIconLayer, vesselLayer], + [incidentLayer, vesselIconLayer, vesselLayer], + ) return (
@@ -87,14 +233,20 @@ export function IncidentsView() { {/* Center - Map + Analysis Views */}
- {/* Analysis Bar (shown when analysis is active) */} + {/* Analysis Bar */} {analysisActive && ( -
+
🔬 통합 분석 비교 @@ -104,33 +256,66 @@ export function IncidentsView() {
{analysisTags.map((t, i) => ( - {t.icon} {t.label} + + {t.icon} {t.label} + ))}
- {([ - { mode: 'overlay' as ViewMode, icon: '🗂', label: '오버레이' }, - { mode: 'split2' as ViewMode, icon: '◫', label: '2분할' }, - { mode: 'split3' as ViewMode, icon: '⊞', label: '3분할' }, - ]).map(v => ( - + {( + [ + { mode: 'overlay' as ViewMode, icon: '🗂', label: '오버레이' }, + { mode: 'split2' as ViewMode, icon: '◫', label: '2분할' }, + { mode: 'split3' as ViewMode, icon: '⊞', label: '3분할' }, + ] as const + ).map(v => ( + ))} - +
)} @@ -140,126 +325,221 @@ export function IncidentsView() { {/* Default Map (visible when not in analysis or in overlay mode) */} {(!analysisActive || viewMode === 'overlay') && (
- - - {incidents.map((inc) => { - const c = getMarkerColor(inc.status) - const sel = selectedIncidentId === inc.id - return ( - setSelectedIncidentId(inc.id) }} - > - -
-
{inc.name}
-
-
상태: {getStatusLabel(inc.status)}
-
일시: {inc.date} {inc.time}
-
관할: {inc.office}
- {inc.causeType &&
원인: {inc.causeType}
} - {inc.prediction &&
{inc.prediction}
} -
-
-
-
- ) - })} - {mockVessels.map((v, idx) => ( - { setSelectedVessel(v); setDetailVessel(null) } }} - /> - ))} -
+ - {/* Overlay layers (shown on top of map when analysis active) */} + {/* 사고 팝업 */} + {incidentPopup && ( + setIncidentPopup(null)} + closeButton={true} + closeOnClick={false} + > +
+
+ {incidentPopup.incident.name} +
+
+
상태: {getStatusLabel(incidentPopup.incident.status)}
+
+ 일시: {incidentPopup.incident.date} {incidentPopup.incident.time} +
+
관할: {incidentPopup.incident.office}
+ {incidentPopup.incident.causeType && ( +
원인: {incidentPopup.incident.causeType}
+ )} + {incidentPopup.incident.prediction && ( +
{incidentPopup.incident.prediction}
+ )} +
+
+
+ )} + + + {/* 분석 오버레이 (지도 위 시각효과) */} {analysisActive && viewMode === 'overlay' && ( -
+
{analysisTags.some(t => t.label === '유출유') && ( -
+
)} {analysisTags.some(t => t.label === 'HNS') && ( -
+
)} {analysisTags.some(t => t.label === '구난') && ( -
+
)}
)} {/* AIS Live Badge */} -
+
-
- AIS Live +
+ + AIS Live + MarineTraffic
-
선박 20
-
사고 6
-
방제선 2
+
+ 선박 20 +
+
+ 사고 6 +
+
+ 방제선 2 +
{/* Legend */} -
-
사고 상태
+
+
+ 사고 상태 +
- {[{ c: '#ef4444', l: '대응중' }, { c: '#f59e0b', l: '조사중' }, { c: '#6b7280', l: '종료' }].map(s => ( + {[ + { c: '#ef4444', l: '대응중' }, + { c: '#f59e0b', l: '조사중' }, + { c: '#6b7280', l: '종료' }, + ].map(s => (
{s.l}
))}
-
AIS 선박
+
+ AIS 선박 +
{VESSEL_LEGEND.map(vl => (
-
+
{vl.type}
))}
- {/* Vessel Popup */} - {selectedVessel && !detailVessel && ( + {/* 선박 팝업 패널 */} + {vesselPopup && selectedVessel && !detailVessel && ( setSelectedVessel(null)} - onDetail={() => { setDetailVessel(selectedVessel); setSelectedVessel(null) }} + onClose={() => { + setVesselPopup(null) + setSelectedVessel(null) + }} + onDetail={() => { + setDetailVessel(selectedVessel) + setVesselPopup(null) + setSelectedVessel(null) + }} /> )} {detailVessel && ( @@ -271,29 +551,75 @@ export function IncidentsView() { {/* ── 2분할 View ─────────────────────────────── */} {analysisActive && viewMode === 'split2' && (
-
-
+
+
- {analysisTags[0] ? `${analysisTags[0].icon} ${analysisTags[0].label}` : '— 분석 결과를 선택하세요 —'} + {analysisTags[0] + ? `${analysisTags[0].icon} ${analysisTags[0].label}` + : '— 분석 결과를 선택하세요 —'}
-
+
-
+
- {analysisTags[1] ? `${analysisTags[1].icon} ${analysisTags[1].label}` : '— 분석 결과를 선택하세요 —'} + {analysisTags[1] + ? `${analysisTags[1].icon} ${analysisTags[1].label}` + : '— 분석 결과를 선택하세요 —'}
-
+
@@ -303,73 +629,172 @@ export function IncidentsView() { {/* ── 3분할 View ─────────────────────────────── */} {analysisActive && viewMode === 'split3' && (
- {/* Oil spill */} -
-
- 🛢 유출유 확산예측 +
+
+ + 🛢 유출유 확산예측 +
-
- +
+
- {/* HNS */} -
-
- 🧪 HNS 대기확산 +
+
+ + 🧪 HNS 대기확산 +
-
- +
+
- {/* Emergency rescue */}
-
- 🚨 긴급구난 +
+ + 🚨 긴급구난 +
-
- +
+
)}
- {/* Decision Bar (shown at bottom when analysis is active) */} + {/* Decision Bar */} {analysisActive && ( -
+
📊 {selectedIncident?.name} · {analysisTags.map(t => t.label).join(' + ')} 분석 결과 비교
- - + +
)} @@ -389,25 +814,43 @@ export function IncidentsView() { } /* ════════════════════════════════════════════════════ - SplitPanelContent – 분할뷰 패널 내용 + SplitPanelContent ════════════════════════════════════════════════════ */ -function SplitPanelContent({ tag, incident }: { +function SplitPanelContent({ + tag, + incident, +}: { tag?: { icon: string; label: string; color: string } incident: Incident | null }) { if (!tag) { return ( -
+
R&D 분석 결과를 선택하세요
) } - const mockData: Record = { - '유출유': { + const mockData: Record< + string, + { + title: string + model: string + items: { label: string; value: string; color?: string }[] + summary: string + } + > = { + 유출유: { title: '유출유 확산예측 결과', model: 'KOSPS + OpenDrift · BUNKER-C 150kL', items: [ @@ -420,7 +863,7 @@ function SplitPanelContent({ tag, incident }: { ], summary: '여수항 남동쪽 방향 확산, 18시간 후 돌산도 해안 도달 예상. 조류 영향으로 남서쪽 이동.', }, - 'HNS': { + HNS: { title: 'HNS 대기확산 결과', model: 'ALOHA + PHAST · 톨루엔 5톤', items: [ @@ -433,7 +876,7 @@ function SplitPanelContent({ tag, incident }: { ], summary: '남서풍에 의해 북동쪽 내륙 방향 확산. IDLH 1.2km 이내 즉시 대피 필요.', }, - '구난': { + 구난: { title: '긴급구난 SAR 결과', model: 'SAROPS · Monte Carlo 10,000회 시뮬레이션', items: [ @@ -452,12 +895,17 @@ function SplitPanelContent({ tag, incident }: { return ( <> - {/* Title card */} -
-
+
+
{tag.icon} {data.title}
{data.model}
@@ -468,37 +916,62 @@ function SplitPanelContent({ tag, incident }: { )}
- {/* Data items */} -
+
{data.items.map((item, i) => ( -
+
{item.label} - {item.value} + + {item.value} +
))}
- {/* Summary */} -
+
💡 {data.summary}
- {/* Mock visualization */} -
+
{tag.icon}
시각화 영역
@@ -507,72 +980,153 @@ function SplitPanelContent({ tag, incident }: { } /* ════════════════════════════════════════════════════ - VesselPopupPanel – HTML vsl-popup 스타일 재현 + VesselPopupPanel ════════════════════════════════════════════════════ */ -function VesselPopupPanel({ vessel: v, onClose, onDetail }: { - vessel: Vessel; onClose: () => void; onDetail: () => void +function VesselPopupPanel({ + vessel: v, + onClose, + onDetail, +}: { + vessel: Vessel + onClose: () => void + onDetail: () => void }) { const statusColor = v.status.includes('사고') ? '#ef4444' : '#22c55e' const statusBg = v.status.includes('사고') ? 'rgba(239,68,68,0.15)' : 'rgba(34,197,94,0.1)' return ( -
+
{/* Header */} -
-
+
+
{v.flag}
-
+
{v.name}
MMSI: {v.mmsi}
- + + ✕ +
{/* Ship Image */} -
+
🚢
{/* Tags */}
- {v.typS} - {v.status} + + {v.typS} + + + {v.status} +
{/* Data rows */}
-
+
출항지 - {v.depart} + + {v.depart} +
입항지 - {v.arrive} + + {v.arrive} +
@@ -580,37 +1134,101 @@ function VesselPopupPanel({ vessel: v, onClose, onDetail }: { {/* Buttons */}
- - - + + +
) } -function PopupRow({ label, value, accent, muted }: { label: string; value: string; accent?: boolean; muted?: boolean }) { +function PopupRow({ + label, + value, + accent, + muted, +}: { + label: string + value: string + accent?: boolean + muted?: boolean +}) { return ( -
+
{label} - {value} + + {value} +
) } /* ════════════════════════════════════════════════════ - VesselDetailModal – 5탭 (상세/항해/제원/보험/위험물) + VesselDetailModal ════════════════════════════════════════════════════ */ type DetTab = 'info' | 'nav' | 'spec' | 'ins' | 'dg' const TAB_LABELS: { key: DetTab; label: string }[] = [ @@ -625,24 +1243,46 @@ function VesselDetailModal({ vessel: v, onClose }: { vessel: Vessel; onClose: () const [tab, setTab] = useState('info') return ( -
{ if (e.target === e.currentTarget) onClose() }} style={{ - position: 'fixed', inset: 0, zIndex: 10000, - display: 'flex', alignItems: 'center', justifyContent: 'center', - background: 'rgba(0,0,0,0.65)', backdropFilter: 'blur(6px)', - }}> -
+
{ + if (e.target === e.currentTarget) onClose() + }} + style={{ + position: 'fixed', + inset: 0, + zIndex: 10000, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + background: 'rgba(0,0,0,0.65)', + backdropFilter: 'blur(6px)', + }} + > +
{/* Header */} -
+
{v.flag}
@@ -652,32 +1292,59 @@ function VesselDetailModal({ vessel: v, onClose }: { vessel: Vessel; onClose: ()
- + + ✕ +
{/* Tabs */} -
+
{TAB_LABELS.map(t => ( - + ))}
- {/* Body – scrollable */} -
+ {/* Body */} +
{tab === 'info' && } {tab === 'nav' && } {tab === 'spec' && } @@ -690,17 +1357,36 @@ function VesselDetailModal({ vessel: v, onClose }: { vessel: Vessel; onClose: () } /* ── shared section helpers ──────────────────────── */ -function Sec({ title, borderColor, bgColor, badge, children }: { - title: string; borderColor?: string; bgColor?: string; badge?: React.ReactNode; children: React.ReactNode +function Sec({ + title, + borderColor, + bgColor, + badge, + children, +}: { + title: string + borderColor?: string + bgColor?: string + badge?: React.ReactNode + children: React.ReactNode }) { return (
-
- {title}{badge} +
+ {title} + {badge}
{children}
@@ -711,25 +1397,49 @@ function Grid({ children }: { children: React.ReactNode }) { return
{children}
} -function Cell({ label, value, span, color }: { label: string; value: string; span?: boolean; color?: string }) { +function Cell({ + label, + value, + span, + color, +}: { + label: string + value: string + span?: boolean + color?: string +}) { return ( -
+
{label}
-
{value}
+
+ {value} +
) } function StatusBadge({ label, color }: { label: string; color: string }) { return ( - {label} + + {label} + ) } @@ -737,11 +1447,22 @@ function StatusBadge({ label, color }: { label: string; color: string }) { function TabInfo({ v }: { v: Vessel }) { return ( <> - {/* Ship image */} -
🚢
+
+ 🚢 +
@@ -771,14 +1492,39 @@ function TabInfo({ v }: { v: Vessel }) { function TabNav(_props: { v: Vessel }) { const hours = ['08', '09', '10', '11', '12', '13', '14'] const heights = [45, 60, 78, 82, 70, 85, 75] - const colors = ['rgba(34,197,94,.3)', 'rgba(34,197,94,.4)', 'rgba(59,130,246,.4)', 'rgba(59,130,246,.5)', 'rgba(59,130,246,.5)', 'rgba(59,130,246,.6)', 'rgba(6,182,212,.5)'] + const colors = [ + 'rgba(34,197,94,.3)', + 'rgba(34,197,94,.4)', + 'rgba(59,130,246,.4)', + 'rgba(59,130,246,.5)', + 'rgba(59,130,246,.5)', + 'rgba(59,130,246,.6)', + 'rgba(6,182,212,.5)', + ] return ( <> -
+
- + @@ -795,14 +1541,25 @@ function TabNav(_props: { v: Vessel }) {
{hours.map((h, i) => ( -
-
+
+
{h}
))}
- 평균: 8.4 kn · 최대: 11.2 kn + 평균: 8.4 kn · 최대:{' '} + 11.2 kn
@@ -843,17 +1600,37 @@ function TabSpec({ v }: { v: Vessel }) {
-
+
🛢
-
{v.cargo.split('·')[0].trim()}
+
+ {v.cargo.split('·')[0].trim()} +
{v.cargo}
{v.cargo.includes('IMO') && ( - 위험 + + 위험 + )}
@@ -875,8 +1652,12 @@ function TabInsurance(_props: { v: Vessel }) { - }> + } + > @@ -885,8 +1666,12 @@ function TabInsurance(_props: { v: Vessel }) { - }> + } + > @@ -895,8 +1680,12 @@ function TabInsurance(_props: { v: Vessel }) { - }> + } + > @@ -907,11 +1696,18 @@ function TabInsurance(_props: { v: Vessel }) { -
+
💡 보험정보는 한국해운조합(KSA) Open API 및 해양수산부 선박정보시스템 연동 데이터입니다. 실시간 갱신 주기: 24시간
@@ -922,8 +1718,24 @@ function TabInsurance(_props: { v: Vessel }) { function TabDangerous({ v }: { v: Vessel }) { return ( <> - PORT-MIS}> + + PORT-MIS + + } + > @@ -939,22 +1751,63 @@ function TabDangerous({ v }: { v: Vessel }) { -
-
+
+
화물창 2개이상 여부 - + + ✓ +
- +
@@ -970,27 +1823,83 @@ function TabDangerous({ v }: { v: Vessel }) { -
- - - +
+ + +
-
- 💡 위험물정보는 PORT-MIS(항만운영정보시스템) 위험물 반입신고 데이터 연동입니다. IMDG Code 최신 개정판(Amendment 42-24) 기준. +
+ 💡 위험물정보는 PORT-MIS(항만운영정보시스템) 위험물 반입신고 데이터 연동입니다. IMDG Code 최신 개정판(Amendment + 42-24) 기준.
) } -function EmsRow({ icon, label, value, bg, bd }: { icon: string; label: string; value: string; bg: string; bd: string }) { +function EmsRow({ + icon, + label, + value, + bg, + bd, +}: { + icon: string + label: string + value: string + bg: string + bd: string +}) { return ( -
+
{icon}
{label}
@@ -1000,11 +1909,36 @@ function EmsRow({ icon, label, value, bg, bd }: { icon: string; label: string; v ) } -function ActionBtn({ icon, label, bg, bd, fg }: { icon: string; label: string; bg: string; bd: string; fg: string }) { +function ActionBtn({ + icon, + label, + bg, + bd, + fg, +}: { + icon: string + label: string + bg: string + bd: string + fg: string +}) { return ( - + ) } diff --git a/frontend/src/tabs/scat/components/ScatMap.tsx b/frontend/src/tabs/scat/components/ScatMap.tsx index 60d8908..382258a 100644 --- a/frontend/src/tabs/scat/components/ScatMap.tsx +++ b/frontend/src/tabs/scat/components/ScatMap.tsx @@ -1,8 +1,29 @@ -import { useState, useEffect, useRef } from 'react' -import L from 'leaflet' -import 'leaflet/dist/leaflet.css' +import { useState, useMemo, useCallback, useEffect, useRef } from 'react' +import { Map, useControl, useMap } from '@vis.gl/react-maplibre' +import { MapboxOverlay } from '@deck.gl/mapbox' +import { PathLayer, ScatterplotLayer } from '@deck.gl/layers' +import type { StyleSpecification } from 'maplibre-gl' +import 'maplibre-gl/dist/maplibre-gl.css' import type { ScatSegment } from './scatTypes' import { esiColor, jejuCoastCoords, scatDetailData } from './scatConstants' +import { hexToRgba } from '@common/components/map/mapUtils' + +const BASE_STYLE: StyleSpecification = { + version: 8, + sources: { + 'carto-dark': { + type: 'raster', + tiles: [ + 'https://a.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}@2x.png', + 'https://b.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}@2x.png', + 'https://c.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}@2x.png', + ], + tileSize: 256, + attribution: '© OSM © CARTO', + }, + }, + layers: [{ id: 'carto-dark-layer', type: 'raster', source: 'carto-dark' }], +} interface ScatMapProps { segments: ScatSegment[] @@ -11,194 +32,250 @@ interface ScatMapProps { onOpenPopup: (idx: number) => void } -function ScatMap({ - segments, - selectedSeg, - onSelectSeg, - onOpenPopup, -}: ScatMapProps) { - const mapContainerRef = useRef(null) - const mapRef = useRef(null) - const markersRef = useRef(null) - const [zoom, setZoom] = useState(10) +// ── DeckGLOverlay ────────────────────────────────────── +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function DeckGLOverlay({ layers }: { layers: any[] }) { + const overlay = useControl(() => new MapboxOverlay({ interleaved: true })) + overlay.setProps({ layers }) + return null +} + +// ── FlyTo Controller: 선택 구간 변경 시 맵 이동 ───────── +function FlyToController({ selectedSeg }: { selectedSeg: ScatSegment }) { + const { current: map } = useMap() + const prevIdRef = useRef(undefined) useEffect(() => { - if (!mapContainerRef.current || mapRef.current) return - - const map = L.map(mapContainerRef.current, { - center: [33.38, 126.55], - zoom: 10, - zoomControl: false, - attributionControl: false, - }) - - L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', { - maxZoom: 19, - }).addTo(map) - - L.control.zoom({ position: 'bottomright' }).addTo(map) - L.control.attribution({ position: 'bottomleft' }).addAttribution( - '© OSM © CARTO' - ).addTo(map) - - map.on('zoomend', () => setZoom(map.getZoom())) - - mapRef.current = map - markersRef.current = L.layerGroup().addTo(map) - - setTimeout(() => map.invalidateSize(), 100) - - return () => { - map.remove() - mapRef.current = null - markersRef.current = null + if (!map) return + if (prevIdRef.current !== undefined && prevIdRef.current !== selectedSeg.id) { + map.flyTo({ center: [selectedSeg.lng, selectedSeg.lat], zoom: 12, duration: 600 }) } - }, []) + prevIdRef.current = selectedSeg.id + }, [map, selectedSeg]) - useEffect(() => { - if (!mapRef.current || !markersRef.current) return - markersRef.current.clearLayers() + return null +} - // 줌 기반 스케일 계수 (zoom 10=아일랜드뷰 → 14+=클로즈업) - const zScale = Math.max(0, (zoom - 9)) / 5 // 0 at z9, 1 at z14 - const polyWeight = 1 + zScale * 4 // 1 ~ 5 - const selPolyWeight = 2 + zScale * 5 // 2 ~ 7 - const glowWeight = 4 + zScale * 14 // 4 ~ 18 - const halfLenScale = 0.15 + zScale * 0.85 // 0.15 ~ 1.0 - const markerSize = Math.round(6 + zScale * 16) // 6px ~ 22px - const markerBorder = zoom >= 13 ? 2 : 1 - const markerFontSize = Math.round(4 + zScale * 6) // 4px ~ 10px - const showStatusMarker = zoom >= 11 - const showStatusText = zoom >= 13 +// ── 줌 기반 스케일 계산 ───────────────────────────────── +function getZoomScale(zoom: number) { + const zScale = Math.max(0, zoom - 9) / 5 + return { + polyWidth: 1 + zScale * 4, + selPolyWidth: 2 + zScale * 5, + glowWidth: 4 + zScale * 14, + halfLenScale: 0.15 + zScale * 0.85, + markerRadius: Math.round(6 + zScale * 16), + showStatusMarker: zoom >= 11, + } +} - // 제주도 해안선 레퍼런스 라인 - const coastline = L.polyline(jejuCoastCoords as [number, number][], { - color: 'rgba(6, 182, 212, 0.18)', - weight: 1.5, - dashArray: '8, 6', +// ── 세그먼트 폴리라인 좌표 생성 (lng, lat 순서) ────────── +function buildSegCoords(seg: ScatSegment, halfLenScale: number): [number, number][] { + const coastIdx = seg.id % (jejuCoastCoords.length - 1) + const [clat1, clng1] = jejuCoastCoords[coastIdx] + const [clat2, clng2] = jejuCoastCoords[(coastIdx + 1) % jejuCoastCoords.length] + const dlat = clat2 - clat1 + const dlng = clng2 - clng1 + const dist = Math.sqrt(dlat * dlat + dlng * dlng) + const nDlat = dist > 0 ? dlat / dist : 0 + const nDlng = dist > 0 ? dlng / dist : 1 + const halfLen = Math.min(seg.lengthM / 200000, 0.012) * halfLenScale + return [ + [seg.lng - nDlng * halfLen, seg.lat - nDlat * halfLen], + [seg.lng, seg.lat], + [seg.lng + nDlng * halfLen, seg.lat + nDlat * halfLen], + ] +} + +// ── 툴팁 상태 ─────────────────────────────────────────── +interface TooltipState { + x: number + y: number + seg: ScatSegment +} + +// ── ScatMap ───────────────────────────────────────────── +function ScatMap({ segments, selectedSeg, onSelectSeg, onOpenPopup }: ScatMapProps) { + const [zoom, setZoom] = useState(10) + const [tooltip, setTooltip] = useState(null) + + const handleClick = useCallback( + (seg: ScatSegment) => { + onSelectSeg(seg) + onOpenPopup(seg.id % scatDetailData.length) + }, + [onSelectSeg, onOpenPopup], + ) + + const zs = useMemo(() => getZoomScale(zoom), [zoom]) + + // 제주도 해안선 레퍼런스 라인 + const coastlineLayer = useMemo( + () => + new PathLayer({ + id: 'jeju-coastline', + data: [{ path: jejuCoastCoords.map(([lat, lng]) => [lng, lat] as [number, number]) }], + getPath: (d: { path: [number, number][] }) => d.path, + getColor: [6, 182, 212, 46], + getWidth: 1.5, + getDashArray: [8, 6], + dashJustified: true, + widthMinPixels: 1, + }), + [], + ) + + // 선택된 구간 글로우 레이어 + const glowLayer = useMemo( + () => + new PathLayer({ + id: 'scat-glow', + data: [selectedSeg], + getPath: (d: ScatSegment) => buildSegCoords(d, zs.halfLenScale), + getColor: [34, 197, 94, 38], + getWidth: zs.glowWidth, + capRounded: true, + jointRounded: true, + widthMinPixels: 4, + updateTriggers: { + getPath: [zs.halfLenScale], + getWidth: [zs.glowWidth], + }, + }), + [selectedSeg, zs.glowWidth, zs.halfLenScale], + ) + + // ESI 색상 세그먼트 폴리라인 + const segPathLayer = useMemo( + () => + new PathLayer({ + id: 'scat-segments', + data: segments, + getPath: (d: ScatSegment) => buildSegCoords(d, zs.halfLenScale), + getColor: (d: ScatSegment) => { + const isSelected = selectedSeg.id === d.id + const hexCol = isSelected ? '#22c55e' : esiColor(d.esiNum) + return hexToRgba(hexCol, isSelected ? 242 : 178) + }, + getWidth: (d: ScatSegment) => (selectedSeg.id === d.id ? zs.selPolyWidth : zs.polyWidth), + capRounded: true, + jointRounded: true, + widthMinPixels: 1, + pickable: true, + onHover: (info: { object?: ScatSegment; x: number; y: number }) => { + if (info.object) { + setTooltip({ x: info.x, y: info.y, seg: info.object }) + } else { + setTooltip(null) + } + }, + onClick: (info: { object?: ScatSegment }) => { + if (info.object) handleClick(info.object) + }, + updateTriggers: { + getColor: [selectedSeg.id], + getWidth: [selectedSeg.id, zs.selPolyWidth, zs.polyWidth], + getPath: [zs.halfLenScale], + }, + }), + [segments, selectedSeg, zs, handleClick], + ) + + // 조사 상태 마커 (줌 >= 11 시 표시) + const markerLayer = useMemo(() => { + if (!zs.showStatusMarker) return null + return new ScatterplotLayer({ + id: 'scat-status-markers', + data: segments, + getPosition: (d: ScatSegment) => [d.lng, d.lat], + getRadius: zs.markerRadius, + getFillColor: (d: ScatSegment) => { + if (d.status === '완료') return [34, 197, 94, 51] + if (d.status === '진행중') return [234, 179, 8, 51] + return [100, 116, 139, 51] + }, + getLineColor: (d: ScatSegment) => { + if (d.status === '완료') return [34, 197, 94, 200] + if (d.status === '진행중') return [234, 179, 8, 200] + return [100, 116, 139, 200] + }, + getLineWidth: 1, + stroked: true, + radiusMinPixels: 4, + radiusMaxPixels: 22, + radiusUnits: 'pixels', + pickable: true, + onClick: (info: { object?: ScatSegment }) => { + if (info.object) handleClick(info.object) + }, + updateTriggers: { + getRadius: [zs.markerRadius], + }, }) - markersRef.current.addLayer(coastline) + }, [segments, zs.showStatusMarker, zs.markerRadius, handleClick]) - segments.forEach(seg => { - const isSelected = selectedSeg.id === seg.id - const color = esiColor(seg.esiNum) - - // 해안선 방향 계산 (세그먼트 폴리라인 각도 결정) - const coastIdx = seg.id % (jejuCoastCoords.length - 1) - const [clat1, clng1] = jejuCoastCoords[coastIdx] - const [clat2, clng2] = jejuCoastCoords[(coastIdx + 1) % jejuCoastCoords.length] - - const dlat = clat2 - clat1 - const dlng = clng2 - clng1 - const dist = Math.sqrt(dlat * dlat + dlng * dlng) - const nDlat = dist > 0 ? dlat / dist : 0 - const nDlng = dist > 0 ? dlng / dist : 1 - - // 구간 길이를 위경도 단위로 변환 (줌 레벨에 따라 스케일링) - const halfLen = Math.min(seg.lengthM / 200000, 0.012) * halfLenScale - - // 해안선 방향을 따라 폴리라인 좌표 생성 - const segCoords: [number, number][] = [ - [seg.lat - nDlat * halfLen, seg.lng - nDlng * halfLen], - [seg.lat, seg.lng], - [seg.lat + nDlat * halfLen, seg.lng + nDlng * halfLen], - ] - - // 선택된 구간 글로우 효과 - if (isSelected) { - const glow = L.polyline(segCoords, { - color: '#22c55e', - weight: glowWeight, - opacity: 0.15, - lineCap: 'round', - }) - markersRef.current!.addLayer(glow) - } - - // ESI 색상 구간 폴리라인 - const polyline = L.polyline(segCoords, { - color: isSelected ? '#22c55e' : color, - weight: isSelected ? selPolyWeight : polyWeight, - opacity: isSelected ? 0.95 : 0.7, - lineCap: 'round', - lineJoin: 'round', - }) - - const statusIcon = seg.status === '완료' ? '✓' : seg.status === '진행중' ? '⏳' : '—' - - polyline.bindTooltip( - `
-
${seg.code} ${seg.area}
-
ESI ${seg.esi} · ${seg.length} · ${statusIcon} ${seg.status}
-
`, - { - permanent: isSelected, - direction: 'top', - offset: [0, -10], - className: 'scat-map-tooltip', - } - ) - - polyline.on('click', () => { - onSelectSeg(seg) - onOpenPopup(seg.id % scatDetailData.length) - }) - markersRef.current!.addLayer(polyline) - - // 조사 상태 마커 (DivIcon) — 줌 레벨에 따라 표시/크기 조절 - if (showStatusMarker) { - const stColor = seg.status === '완료' ? '#22c55e' : seg.status === '진행중' ? '#eab308' : '#64748b' - const stBg = seg.status === '완료' ? 'rgba(34,197,94,0.2)' : seg.status === '진행중' ? 'rgba(234,179,8,0.2)' : 'rgba(100,116,139,0.2)' - const stText = seg.status === '완료' ? '✓' : seg.status === '진행중' ? '⏳' : '—' - const half = Math.round(markerSize / 2) - - const statusMarker = L.marker([seg.lat, seg.lng], { - icon: L.divIcon({ - className: '', - html: `
${showStatusText ? stText : ''}
`, - iconSize: [0, 0], - }), - }) - - statusMarker.on('click', () => { - onSelectSeg(seg) - onOpenPopup(seg.id % scatDetailData.length) - }) - markersRef.current!.addLayer(statusMarker) - } - }) - }, [segments, selectedSeg, onSelectSeg, onOpenPopup, zoom]) - - useEffect(() => { - if (!mapRef.current) return - mapRef.current.flyTo([selectedSeg.lat, selectedSeg.lng], 12, { duration: 0.6 }) - }, [selectedSeg]) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const deckLayers: any[] = useMemo(() => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const layers: any[] = [coastlineLayer, glowLayer, segPathLayer] + if (markerLayer) layers.push(markerLayer) + return layers + }, [coastlineLayer, glowLayer, segPathLayer, markerLayer]) const doneCount = segments.filter(s => s.status === '완료').length const progCount = segments.filter(s => s.status === '진행중').length const totalLen = segments.reduce((a, s) => a + s.lengthM, 0) const doneLen = segments.filter(s => s.status === '완료').reduce((a, s) => a + s.lengthM, 0) - const highSens = segments.filter(s => s.sensitivity === '최상' || s.sensitivity === '상').reduce((a, s) => a + s.lengthM, 0) - const donePct = Math.round(doneCount / segments.length * 100) - const progPct = Math.round(progCount / segments.length * 100) + const highSens = segments + .filter(s => s.sensitivity === '최상' || s.sensitivity === '상') + .reduce((a, s) => a + s.lengthM, 0) + const donePct = Math.round((doneCount / segments.length) * 100) + const progPct = Math.round((progCount / segments.length) * 100) const notPct = 100 - donePct - progPct return (
- -
+ setZoom(e.viewState.zoom)} + > + + + + + {/* 호버 툴팁 */} + {tooltip && ( +
+
+ {tooltip.seg.code} {tooltip.seg.area} +
+
+ ESI {tooltip.seg.esi} · {tooltip.seg.length} ·{' '} + {tooltip.seg.status === '완료' ? '✓' : tooltip.seg.status === '진행중' ? '⏳' : '—'}{' '} + {tooltip.seg.status} +
+
+ )} {/* Status chips */}
@@ -256,7 +333,9 @@ function ScatMap({ ].map(([label, val, color], i) => (
{label} - {val} + + {val} +
))}
@@ -265,9 +344,15 @@ function ScatMap({ {/* Coordinates */}
- 위도 {selectedSeg.lat.toFixed(4)}°N - 경도 {selectedSeg.lng.toFixed(4)}°E - 축척 1:25,000 + + 위도 {selectedSeg.lat.toFixed(4)}°N + + + 경도 {selectedSeg.lng.toFixed(4)}°E + + + 축척 1:25,000 +
) diff --git a/frontend/src/tabs/scat/components/ScatPopup.tsx b/frontend/src/tabs/scat/components/ScatPopup.tsx index 55b36c6..a895809 100644 --- a/frontend/src/tabs/scat/components/ScatPopup.tsx +++ b/frontend/src/tabs/scat/components/ScatPopup.tsx @@ -1,88 +1,171 @@ -import { useState, useEffect, useRef } from 'react' -import L from 'leaflet' -import 'leaflet/dist/leaflet.css' +import { useState, useEffect } from 'react' +import { Map, useControl } from '@vis.gl/react-maplibre' +import { MapboxOverlay } from '@deck.gl/mapbox' +import { PathLayer, ScatterplotLayer } from '@deck.gl/layers' +import type { StyleSpecification } from 'maplibre-gl' +import 'maplibre-gl/dist/maplibre-gl.css' import type { ScatDetail } from './scatTypes' +import { hexToRgba } from '@common/components/map/mapUtils' -// ═══ Popup Map (Leaflet) ═══ - -function PopupMap({ lat, lng, esi, esiCol, code, name }: { lat: number; lng: number; esi: string; esiCol: string; code: string; name: string }) { - const containerRef = useRef(null) - const mapRef = useRef(null) - - useEffect(() => { - if (!containerRef.current) return - // 이전 맵 제거 - if (mapRef.current) { mapRef.current.remove(); mapRef.current = null } - - const map = L.map(containerRef.current, { - center: [lat, lng], - zoom: 15, - zoomControl: false, - attributionControl: false, - }) - - L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', { maxZoom: 19 }).addTo(map) - L.control.zoom({ position: 'topright' }).addTo(map) - - // 해안 구간 라인 (시뮬레이션) - const segLine: [number, number][] = [ - [lat - 0.002, lng - 0.004], - [lat - 0.001, lng - 0.002], - [lat, lng], - [lat + 0.001, lng + 0.002], - [lat + 0.002, lng + 0.004], - ] - L.polyline(segLine, { color: esiCol, weight: 5, opacity: 0.8 }).addTo(map) - - // 조사 경로 라인 - const surveyRoute: [number, number][] = [ - [lat - 0.0015, lng - 0.003], - [lat - 0.0005, lng - 0.001], - [lat + 0.0005, lng + 0.001], - [lat + 0.0015, lng + 0.003], - ] - L.polyline(surveyRoute, { color: '#3b82f6', weight: 2, opacity: 0.6, dashArray: '6, 4' }).addTo(map) - - // 메인 마커 - L.circleMarker([lat, lng], { - radius: 10, fillColor: esiCol, color: '#fff', weight: 2, fillOpacity: 0.9, - }).bindTooltip( - `
-
${code} ${name}
-
ESI ${esi}
-
`, - { permanent: true, direction: 'top', offset: [0, -12], className: 'scat-map-tooltip' } - ).addTo(map) - - // 접근 포인트 - L.circleMarker([lat - 0.0015, lng - 0.003], { - radius: 6, fillColor: '#eab308', color: '#eab308', weight: 1, fillOpacity: 0.7, - }).bindTooltip('접근 포인트', { direction: 'bottom', className: 'scat-map-tooltip' }).addTo(map) - - mapRef.current = map - return () => { map.remove(); mapRef.current = null } - }, [lat, lng, esi, esiCol, code, name]) - - return
+// ── 베이스맵 스타일 ────────────────────────────────────── +const BASE_STYLE: StyleSpecification = { + version: 8, + sources: { + 'carto-dark': { + type: 'raster', + tiles: [ + 'https://a.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}@2x.png', + 'https://b.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}@2x.png', + 'https://c.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}@2x.png', + ], + tileSize: 256, + }, + }, + layers: [{ id: 'carto-dark-layer', type: 'raster', source: 'carto-dark' }], } -// ═══ SCAT Popup Modal ═══ +// ── DeckGLOverlay ────────────────────────────────────── +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function DeckGLOverlay({ layers }: { layers: any[] }) { + const overlay = useControl(() => new MapboxOverlay({ interleaved: true })) + overlay.setProps({ layers }) + return null +} +// ── PopupMap (미니맵) ──────────────────────────────────── +function PopupMap({ + lat, + lng, + esiCol, + code, + name, + esi, +}: { + lat: number + lng: number + esiCol: string + code: string + name: string + esi: string +}) { + // 해안 구간 라인 (시뮬레이션) — [lng, lat] 순서 + const segLine: [number, number][] = [ + [lng - 0.004, lat - 0.002], + [lng - 0.002, lat - 0.001], + [lng, lat], + [lng + 0.002, lat + 0.001], + [lng + 0.004, lat + 0.002], + ] + + // 조사 경로 라인 + const surveyRoute: [number, number][] = [ + [lng - 0.003, lat - 0.0015], + [lng - 0.001, lat - 0.0005], + [lng + 0.001, lat + 0.0005], + [lng + 0.003, lat + 0.0015], + ] + + const deckLayers = [ + // 조사 경로 (파란 점선) + new PathLayer({ + id: 'survey-route', + data: [{ path: surveyRoute }], + getPath: (d: { path: [number, number][] }) => d.path, + getColor: [59, 130, 246, 153], + getWidth: 2, + getDashArray: [6, 4], + dashJustified: true, + widthMinPixels: 1, + }), + // 해안 구간 라인 (ESI 색상) + new PathLayer({ + id: 'seg-line', + data: [{ path: segLine }], + getPath: (d: { path: [number, number][] }) => d.path, + getColor: hexToRgba(esiCol, 204), + getWidth: 5, + capRounded: true, + widthMinPixels: 3, + }), + // 접근 포인트 (노란 점) + new ScatterplotLayer({ + id: 'access-point', + data: [{ position: [lng - 0.003, lat - 0.0015] as [number, number] }], + getPosition: (d: { position: [number, number] }) => d.position, + getRadius: 6, + getFillColor: hexToRgba('#eab308', 178), + getLineColor: hexToRgba('#eab308', 200), + getLineWidth: 1, + stroked: true, + radiusMinPixels: 4, + radiusMaxPixels: 8, + }), + // 메인 마커 (ESI 색상 원) + new ScatterplotLayer({ + id: 'main-marker', + data: [{ position: [lng, lat] as [number, number] }], + getPosition: (d: { position: [number, number] }) => d.position, + getRadius: 10, + getFillColor: hexToRgba(esiCol, 229), + getLineColor: [255, 255, 255, 255], + getLineWidth: 2, + stroked: true, + radiusMinPixels: 8, + radiusMaxPixels: 14, + }), + ] + + return ( +
+ + + + {/* 마커 레이블 오버레이 */} +
+
{code} {name}
+
ESI {esi}
+
+
+ ) +} + +// ── ScatPopup Modal ────────────────────────────────────── interface ScatPopupProps { data: ScatDetail | null segCode: string onClose: () => void } -function ScatPopup({ - data, - segCode, - onClose, -}: ScatPopupProps) { +function ScatPopup({ data, segCode, onClose }: ScatPopupProps) { const [popTab, setPopTab] = useState(0) useEffect(() => { - const handler = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose() } + const handler = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose() + } document.addEventListener('keydown', handler) return () => document.removeEventListener('keydown', handler) }, [onClose]) @@ -90,24 +173,42 @@ function ScatPopup({ if (!data) return null return ( -
+
e.stopPropagation()} > {/* Header */}
- {data.code} + + {data.code} + {data.name} - ESI {data.esi} + + ESI {data.esi} +
- +
{/* Tabs */} @@ -117,7 +218,9 @@ function ScatPopup({ key={i} onClick={() => setPopTab(i)} className={`px-5 py-3 text-xs font-semibold font-korean border-b-2 transition-colors cursor-pointer ${ - popTab === i ? 'text-status-green border-status-green' : 'text-text-3 border-transparent hover:text-text-2' + popTab === i + ? 'text-status-green border-status-green' + : 'text-text-3 border-transparent hover:text-text-2' }`} > {label} @@ -137,7 +240,7 @@ function ScatPopup({ src={`/scat-photos/${segCode}-1.png`} alt={`${segCode} 해안 조사 사진`} className="w-full h-auto object-contain" - onError={(e) => { + onError={e => { const target = e.currentTarget target.style.display = 'none' const fallback = target.nextElementSibling as HTMLElement @@ -160,10 +263,30 @@ function ScatPopup({
{[ ['유형', data.type, ''], - ['기질', data.substrate, data.esiColor === '#dc2626' || data.esiColor === '#991b1b' ? 'text-status-red' : data.esiColor === '#f97316' ? 'text-status-orange' : ''], + [ + '기질', + data.substrate, + data.esiColor === '#dc2626' || data.esiColor === '#991b1b' + ? 'text-status-red' + : data.esiColor === '#f97316' + ? 'text-status-orange' + : '', + ], ['구간 길이', data.length, ''], - ['민감도', data.sensitivity, data.sensitivity === '상' || data.sensitivity === '최상' ? 'text-status-red' : data.sensitivity === '중' ? 'text-status-orange' : 'text-status-green'], - ['조사 상태', data.status, data.status === '완료' ? 'text-status-green' : data.status === '진행중' ? 'text-status-orange' : ''], + [ + '민감도', + data.sensitivity, + data.sensitivity === '상' || data.sensitivity === '최상' + ? 'text-status-red' + : data.sensitivity === '중' + ? 'text-status-orange' + : 'text-status-green', + ], + [ + '조사 상태', + data.status, + data.status === '완료' ? 'text-status-green' : data.status === '진행중' ? 'text-status-orange' : '', + ], ['접근성', data.access, ''], ['접근 포인트', data.accessPt, ''], ].map(([k, v, cls], i) => ( @@ -194,7 +317,9 @@ function ScatPopup({
{data.cleanup.map((c, i) => ( - {c} + + {c} + ))}
@@ -230,11 +355,18 @@ function ScatPopup({
- {/* Right column - Satellite map */} + {/* Right column - Mini map */}
- {/* Leaflet Map */} + {/* MapLibre 미니맵 */}
- +
{/* Legend */} @@ -296,17 +428,29 @@ function ScatPopup({ {popTab === 1 && (
-
{data.code} {data.name} — 조사 이력
+
+ {data.code} {data.name} — 조사 이력 +
{[ - { date: '2026-01-15', team: '제주해경 방제과', type: 'Pre-SCAT', status: '완료', note: '초기 사전조사 실시. ESI 확인.' }, + { + date: '2026-01-15', + team: '제주해경 방제과', + type: 'Pre-SCAT', + status: '완료', + note: '초기 사전조사 실시. ESI 확인.', + }, ].map((h, i) => (
{h.date} - + {h.type}
diff --git a/frontend/src/tabs/weather/components/OceanCurrentLayer.tsx b/frontend/src/tabs/weather/components/OceanCurrentLayer.tsx index ab94d8a..09c6c79 100755 --- a/frontend/src/tabs/weather/components/OceanCurrentLayer.tsx +++ b/frontend/src/tabs/weather/components/OceanCurrentLayer.tsx @@ -1,12 +1,13 @@ -import { useEffect } from 'react' -import { useMap } from 'react-leaflet' -import L from 'leaflet' +import { useMemo } from 'react' +import { ScatterplotLayer } from '@deck.gl/layers' +import type { Layer } from '@deck.gl/core' +import { hexToRgba } from '@common/components/map/mapUtils' interface OceanCurrentData { lat: number lon: number direction: number // 0-360도 - speed: number // m/s + speed: number // m/s } interface OceanCurrentLayerProps { @@ -16,7 +17,6 @@ interface OceanCurrentLayerProps { // 한반도 육지 영역 판별 (간략화된 폴리곤) const isOnLand = (lat: number, lon: number): boolean => { - // 한반도 본토 영역 (간략화) const peninsula: [number, number][] = [ [38.5, 124.5], [38.5, 128.3], [37.8, 128.8], [37.0, 129.2], @@ -43,31 +43,27 @@ const isOnLand = (lat: number, lon: number): boolean => { return inside } -// 한국 해역의 대략적인 해류 데이터 (실제로는 API에서 가져와야 함) +// 한국 해역의 대략적인 해류 데이터 생성 const generateOceanCurrentData = (): OceanCurrentData[] => { const data: OceanCurrentData[] = [] - // 격자 형태로 해류 데이터 생성 (실제로는 API에서 받아올 데이터) for (let lat = 33.5; lat <= 38.0; lat += 0.8) { for (let lon = 125.0; lon <= 130.5; lon += 0.8) { - // 육지 위의 포인트는 제외 (바다에만 표시) if (isOnLand(lat, lon)) continue - // 간단한 해류 패턴 시뮬레이션 - // 동해: 북동진, 서해: 북진, 남해: 동진 let direction = 0 let speed = 0.3 if (lon > 128.5) { - // 동해 - 북동진하는 동한난류 + // 동해 — 북동진하는 동한난류 direction = 30 + Math.random() * 20 speed = 0.4 + Math.random() * 0.3 } else if (lon < 126.5) { - // 서해 - 북진 + // 서해 — 북진 direction = 350 + Math.random() * 20 speed = 0.2 + Math.random() * 0.2 } else { - // 남해 - 동진 + // 남해 — 동진 direction = 80 + Math.random() * 20 speed = 0.3 + Math.random() * 0.3 } @@ -79,81 +75,74 @@ const generateOceanCurrentData = (): OceanCurrentData[] => { return data } -// SVG 화살표 생성 -const createArrowSvg = (speed: number, color: string): string => { - const length = Math.min(20 + speed * 30, 50) - const width = 2 - - return ` - - - - - - - - - ` +// 속도에 따른 hex 색상 +function getCurrentHexColor(speed: number): string { + if (speed > 0.5) return '#ef4444' + if (speed > 0.3) return '#f59e0b' + return '#3b82f6' } -export function OceanCurrentLayer({ visible, opacity = 0.7 }: OceanCurrentLayerProps) { - const map = useMap() +// 해류 데이터는 컴포넌트 외부에서 한 번만 생성 (랜덤이므로 안정화) +const OCEAN_CURRENT_DATA = generateOceanCurrentData() - useEffect(() => { - if (!visible) return +// eslint-disable-next-line react-refresh/only-export-components +/** + * OceanCurrentLayer — deck.gl ScatterplotLayer 배열 반환 훅 + * + * 기존: Leaflet Marker + SVG DivIcon + CSS rotate + * 전환: ScatterplotLayer (크기=속도, 색상=방향) + * + * 방향 표현 한계: deck.gl ScatterplotLayer는 원형이므로 방향을 색상으로 구분 + * - 북동(0~90°): 파랑 + * - 남동(90~180°): 초록 + * - 남서(180~270°): 주황 + * - 북서(270~360°): 빨강 + * 속도는 반경 크기(5~20km)로 표현 + */ +// eslint-disable-next-line react-refresh/only-export-components +export function useOceanCurrentLayers(props: OceanCurrentLayerProps): Layer[] { + const { visible, opacity = 0.7 } = props - const currentData = generateOceanCurrentData() - const markers: L.Marker[] = [] + return useMemo(() => { + if (!visible) return [] - currentData.forEach((current) => { - // 속도에 따른 색상 결정 - const color = - current.speed > 0.5 ? '#ef4444' : current.speed > 0.3 ? '#f59e0b' : '#3b82f6' + const data = OCEAN_CURRENT_DATA.map((c) => ({ + position: [c.lon, c.lat] as [number, number], + // 반경: 속도 비례 (5~20km) + radius: 5000 + c.speed * 25000, + fillColor: hexToRgba(getCurrentHexColor(c.speed), Math.round(opacity * 180)), + lineColor: hexToRgba(getCurrentHexColor(c.speed), Math.round(opacity * 230)), + })) - const arrowSvg = createArrowSvg(current.speed, color) - const svgUrl = `data:image/svg+xml;base64,${btoa(arrowSvg)}` - - const icon = L.icon({ - iconUrl: svgUrl, - iconSize: [40, 40], - iconAnchor: [20, 20] - }) - - const marker = L.marker([current.lat, current.lon], { - icon, - interactive: false, - // 회전 각도 적용 - rotationAngle: current.direction - // eslint-disable-next-line @typescript-eslint/no-explicit-any - } as any) - - // CSS로 회전 적용 - marker.on('add', () => { - const element = marker.getElement() - if (element) { - element.style.transform += ` rotate(${current.direction}deg)` - element.style.opacity = opacity.toString() - } - }) - - marker.addTo(map) - markers.push(marker) - }) - - // Cleanup - return () => { - markers.forEach((marker) => marker.remove()) - } - }, [map, visible, opacity]) + return [ + new ScatterplotLayer({ + id: 'ocean-current-layer', + data, + getPosition: (d) => d.position, + getRadius: (d) => d.radius, + getFillColor: (d) => d.fillColor, + getLineColor: (d) => d.lineColor, + getLineWidth: 1, + stroked: true, + radiusUnits: 'meters', + pickable: false, + updateTriggers: { + getFillColor: [opacity], + getLineColor: [opacity], + }, + }) as unknown as Layer, + ] + }, [visible, opacity]) +} +/** + * OceanCurrentLayer — React 컴포넌트 (null 반환, layers는 useOceanCurrentLayers로 분리) + * + * WeatherView에서 deck.gl layers 배열에 useOceanCurrentLayers() 결과를 주입하여 사용한다. + * 이 컴포넌트는 이전 Leaflet 방식과의 호환성을 위해 유지하되 실제 렌더링은 하지 않는다. + */ +export function OceanCurrentLayer(props: OceanCurrentLayerProps) { + // visible 상태 변화에 따른 side-effect 없음 — 렌더링은 useOceanCurrentLayers 훅이 담당 + void props return null } diff --git a/frontend/src/tabs/weather/components/OceanForecastOverlay.tsx b/frontend/src/tabs/weather/components/OceanForecastOverlay.tsx index c4a7585..c521d5f 100755 --- a/frontend/src/tabs/weather/components/OceanForecastOverlay.tsx +++ b/frontend/src/tabs/weather/components/OceanForecastOverlay.tsx @@ -1,6 +1,5 @@ -import { ImageOverlay, useMap } from 'react-leaflet' -import { LatLngBounds } from 'leaflet' import { useEffect, useState } from 'react' +import { Source, Layer } from '@vis.gl/react-maplibre' import type { OceanForecastData } from '../services/khoaApi' interface OceanForecastOverlayProps { @@ -9,48 +8,62 @@ interface OceanForecastOverlayProps { visible?: boolean } -// 한국 해역 범위 (대략적인 경계) -const KOREA_BOUNDS = new LatLngBounds( - [33.0, 124.5], // 남서쪽 (제주 남쪽) - [38.5, 132.0] // 북동쪽 (동해 북쪽) -) +// 한국 해역 범위 (MapLibre image source용 좌표 배열) +// [left, bottom, right, top] → MapLibre coordinates 순서: [sw, nw, ne, se] +// [lon, lat] 순서 +const KOREA_IMAGE_COORDINATES: [[number, number], [number, number], [number, number], [number, number]] = [ + [124.5, 33.0], // 남서 (제주 남쪽) + [124.5, 38.5], // 북서 + [132.0, 38.5], // 북동 (동해 북쪽) + [132.0, 33.0], // 남동 +] +/** + * OceanForecastOverlay + * + * 기존: react-leaflet ImageOverlay + LatLngBounds + * 전환: @vis.gl/react-maplibre Source(type=image) + Layer(type=raster) + * + * MapLibre image source는 Map 컴포넌트 자식으로 직접 렌더링 가능 + */ export function OceanForecastOverlay({ forecast, opacity = 0.6, - visible = true + visible = true, }: OceanForecastOverlayProps) { - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const map = useMap() - const [imageLoaded, setImageLoaded] = useState(false) + const [loadedUrl, setLoadedUrl] = useState(null) useEffect(() => { - if (forecast?.filePath) { - // 이미지 미리 로드 - const img = new Image() - img.onload = () => setImageLoaded(true) - img.onerror = () => setImageLoaded(false) - img.src = forecast.filePath - } else { - // eslint-disable-next-line react-hooks/set-state-in-effect - setImageLoaded(false) - } + if (!forecast?.filePath) return + let cancelled = false + const img = new Image() + img.onload = () => { if (!cancelled) setLoadedUrl(forecast.filePath) } + img.onerror = () => { if (!cancelled) setLoadedUrl(null) } + img.src = forecast.filePath + return () => { cancelled = true } }, [forecast?.filePath]) + const imageLoaded = !!loadedUrl && loadedUrl === forecast?.filePath + if (!visible || !forecast || !imageLoaded) { return null } return ( - console.log('해황예보도 이미지 로드 완료'), - error: () => console.error('해황예보도 이미지 로드 실패') - }} - /> + coordinates={KOREA_IMAGE_COORDINATES} + > + + ) } diff --git a/frontend/src/tabs/weather/components/WaterTemperatureLayer.tsx b/frontend/src/tabs/weather/components/WaterTemperatureLayer.tsx index 087536e..81c738f 100755 --- a/frontend/src/tabs/weather/components/WaterTemperatureLayer.tsx +++ b/frontend/src/tabs/weather/components/WaterTemperatureLayer.tsx @@ -1,27 +1,24 @@ -import { useEffect, useRef } from 'react' -import { useMap } from 'react-leaflet' -import L from 'leaflet' +import { useMemo } from 'react' +import { HeatmapLayer } from '@deck.gl/aggregation-layers' +import type { Layer } from '@deck.gl/core' interface WaterTemperatureLayerProps { visible: boolean opacity?: number } -// 수온 데이터 생성 (실제로는 API에서 받아야 함) interface TemperaturePoint { lat: number lon: number temp: number } +// 수온 데이터 생성 (실제로는 API에서 받아야 함) const generateTemperatureData = (): TemperaturePoint[] => { const data: TemperaturePoint[] = [] - // 격자 형태로 수온 데이터 생성 for (let lat = 33.0; lat <= 38.5; lat += 0.3) { for (let lon = 124.5; lon <= 131.0; lon += 0.3) { - // 간단한 수온 패턴 시뮬레이션 - // 남쪽이 따뜻하고, 동해가 서해보다 차가움 let temp = 8.0 // 위도에 따른 온도 (남쪽이 따뜻함) @@ -34,8 +31,8 @@ const generateTemperatureData = (): TemperaturePoint[] => { temp += 1.0 } - // 약간의 랜덤성 - temp += (Math.random() - 0.5) * 1.5 + // 약간의 분포 변화 + temp += Math.sin(lat * 3.14) * 0.5 + Math.cos(lon * 2.0) * 0.5 data.push({ lat, lon, temp }) } @@ -44,127 +41,74 @@ const generateTemperatureData = (): TemperaturePoint[] => { return data } -// 온도에 따른 색상 반환 -const getColorForTemperature = (temp: number): string => { - // 4°C (진한 파랑) ~ 16°C (빨강) 범위 - if (temp <= 5) return 'rgba(0, 0, 139, 0.4)' // 진한 파랑 - if (temp <= 7) return 'rgba(0, 68, 204, 0.4)' // 파랑 - if (temp <= 9) return 'rgba(51, 153, 255, 0.4)' // 하늘색 - if (temp <= 11) return 'rgba(102, 204, 102, 0.4)' // 연두색 - if (temp <= 13) return 'rgba(255, 255, 102, 0.4)' // 노랑 - if (temp <= 15) return 'rgba(255, 153, 51, 0.4)' // 주황 - return 'rgba(255, 68, 68, 0.4)' // 빨강 +// 온도 데이터는 컴포넌트 외부에서 한 번만 생성 (안정화) +const TEMPERATURE_DATA = generateTemperatureData() + +// temp → HeatmapLayer weight (0~1 정규화, 범위 4~16°C) +function tempToWeight(temp: number): number { + const MIN_TEMP = 4 + const MAX_TEMP = 16 + return Math.max(0, Math.min(1, (temp - MIN_TEMP) / (MAX_TEMP - MIN_TEMP))) } -// Custom canvas layer -class TemperatureCanvasLayer extends L.Layer { - private canvas: HTMLCanvasElement | null = null - private data: TemperaturePoint[] - private layerOpacity: number +/** + * useWaterTemperatureLayers — deck.gl HeatmapLayer 배열 반환 훅 + * + * 기존: L.Layer 확장 커스텀 클래스 + Canvas RadialGradient + * 전환: @deck.gl/aggregation-layers HeatmapLayer + * + * 색상 스케일 (차가움 → 따뜻함): + * 파랑(진한) → 파랑 → 하늘 → 연두 → 노랑 → 주황 → 빨강 + */ +// eslint-disable-next-line react-refresh/only-export-components +export function useWaterTemperatureLayers(props: WaterTemperatureLayerProps): Layer[] { + const { visible, opacity = 0.5 } = props - constructor(data: TemperaturePoint[], opacity: number) { - super() - this.data = data - this.layerOpacity = opacity - } + return useMemo(() => { + if (!visible) return [] - onAdd(map: L.Map): this { - this.canvas = L.DomUtil.create('canvas', 'temperature-canvas-layer') - const size = map.getSize() - this.canvas.width = size.x - this.canvas.height = size.y - this.canvas.style.position = 'absolute' - this.canvas.style.pointerEvents = 'none' - this.canvas.style.zIndex = '400' + const data = TEMPERATURE_DATA.map((p) => ({ + position: [p.lon, p.lat] as [number, number], + weight: tempToWeight(p.temp), + })) - map.getPanes().overlayPane?.appendChild(this.canvas) - - map.on('moveend zoom', this.redraw, this) - this.redraw() - - return this - } - - onRemove(map: L.Map): this { - if (this.canvas && this.canvas.parentNode) { - this.canvas.parentNode.removeChild(this.canvas) - } - map.off('moveend zoom', this.redraw, this) - this.canvas = null - return this - } - - redraw = () => { - if (!this.canvas) return - - const map = this._map - if (!map) return - - const size = map.getSize() - this.canvas.width = size.x - this.canvas.height = size.y - - const ctx = this.canvas.getContext('2d') - if (!ctx) return - - ctx.clearRect(0, 0, size.x, size.y) - - // 각 온도 포인트를 그림 - this.data.forEach((point) => { - const latLng = L.latLng(point.lat, point.lon) - const pixelPoint = map.latLngToContainerPoint(latLng) - - const radius = 25 // 각 포인트의 영향 반경 - const gradient = ctx.createRadialGradient( - pixelPoint.x, - pixelPoint.y, - 0, - pixelPoint.x, - pixelPoint.y, - radius - ) - - const color = getColorForTemperature(point.temp) - gradient.addColorStop(0, color) - gradient.addColorStop(1, 'rgba(0, 0, 0, 0)') - - ctx.fillStyle = gradient - ctx.globalAlpha = this.layerOpacity - ctx.fillRect( - pixelPoint.x - radius, - pixelPoint.y - radius, - radius * 2, - radius * 2 - ) - }) - } + return [ + new HeatmapLayer({ + id: 'water-temperature-heatmap', + data, + getPosition: (d) => d.position, + getWeight: (d) => d.weight, + radiusPixels: 40, + intensity: 1, + threshold: 0.03, + opacity, + colorRange: [ + // 진한 파랑 (차가움) + [0, 0, 139, 255], + // 파랑 + [0, 68, 204, 255], + // 하늘색 + [51, 153, 255, 255], + // 연두색 + [102, 204, 102, 255], + // 노랑 + [255, 255, 102, 255], + // 주황 + [255, 153, 51, 255], + // 빨강 (따뜻함) + [255, 68, 68, 255], + ] as [number, number, number, number][], + }) as unknown as Layer, + ] + }, [visible, opacity]) } -export function WaterTemperatureLayer({ visible, opacity = 0.5 }: WaterTemperatureLayerProps) { - const map = useMap() - const layerRef = useRef(null) - - useEffect(() => { - if (!visible) { - if (layerRef.current) { - map.removeLayer(layerRef.current) - layerRef.current = null - } - return - } - - const temperatureData = generateTemperatureData() - const layer = new TemperatureCanvasLayer(temperatureData, opacity) - layer.addTo(map) - layerRef.current = layer - - return () => { - if (layerRef.current) { - map.removeLayer(layerRef.current) - layerRef.current = null - } - } - }, [map, visible, opacity]) - +/** + * WaterTemperatureLayer — React 컴포넌트 (null 반환) + * + * WeatherView에서 useWaterTemperatureLayers()의 결과를 DeckGLOverlay layers에 주입한다. + * 이 컴포넌트는 이전 Leaflet 방식과의 호환성을 위해 시그니처를 유지한다. + */ +export function WaterTemperatureLayer() { return null } diff --git a/frontend/src/tabs/weather/components/WeatherMapOverlay.tsx b/frontend/src/tabs/weather/components/WeatherMapOverlay.tsx index fccb56e..dc94c38 100755 --- a/frontend/src/tabs/weather/components/WeatherMapOverlay.tsx +++ b/frontend/src/tabs/weather/components/WeatherMapOverlay.tsx @@ -1,5 +1,8 @@ -import { Circle, Marker } from 'react-leaflet' -import { DivIcon } from 'leaflet' +import { useMemo } from 'react' +import { Marker } from '@vis.gl/react-maplibre' +import { ScatterplotLayer } from '@deck.gl/layers' +import type { Layer } from '@deck.gl/core' +import { hexToRgba } from '@common/components/map/mapUtils' interface WeatherStation { id: string @@ -19,6 +22,8 @@ interface WeatherStation { current: number feelsLike: number } + pressure: number + visibility: number } interface WeatherMapOverlayProps { @@ -28,244 +33,350 @@ interface WeatherMapOverlayProps { selectedStationId: string | null } -// Create wind arrow icon -const createWindArrowIcon = (speed: number, direction: number, isSelected: boolean) => { - const size = Math.min(40 + speed * 2, 80) - const color = isSelected ? '#06b6d4' : speed > 10 ? '#ef4444' : speed > 7 ? '#f59e0b' : '#3b82f6' - - return new DivIcon({ - html: ` -
- - - - -
- `, - className: 'wind-arrow-icon', - iconSize: [size, size], - iconAnchor: [size / 2, size / 2] - }) +// 풍속에 따른 hex 색상 반환 +function getWindHexColor(speed: number, isSelected: boolean): string { + if (isSelected) return '#06b6d4' + if (speed > 10) return '#ef4444' + if (speed > 7) return '#f59e0b' + return '#3b82f6' } -// Create weather data label icon (enhanced style similar to KHOA) -const createWeatherLabelIcon = (station: WeatherStation, isSelected: boolean) => { - const boxBg = isSelected ? 'rgba(6, 182, 212, 0.85)' : 'rgba(255, 255, 255, 0.1)' - const boxBorder = isSelected ? '#06b6d4' : 'rgba(255, 255, 255, 0.3)' - const boxShadow = '0 2px 8px rgba(0,0,0,0.3)' - - return new DivIcon({ - html: ` -
- -
${station.name}
- - -
-
🌡️
-
- ${station.temperature.current.toFixed(1)} - °C -
-
- - -
-
🌊
-
- ${station.wave.height.toFixed(1)} - m -
-
- - -
-
💨
-
- ${station.wind.speed.toFixed(1)} - m/s -
-
-
- `, - className: 'weather-label-icon', - iconSize: [100, 130], - iconAnchor: [50, 65] - }) +// 파고에 따른 hex 색상 반환 +function getWaveHexColor(height: number): string { + if (height > 2.5) return '#ef4444' + if (height > 1.5) return '#f59e0b' + return '#3b82f6' } +// 수온에 따른 hex 색상 반환 +function getTempHexColor(temp: number): string { + if (temp > 8) return '#ef4444' + if (temp > 6) return '#f59e0b' + return '#3b82f6' +} + +/** + * WeatherMapOverlay + * + * - deck.gl 레이어(ScatterplotLayer)를 layers prop으로 외부에 반환 + * - 라벨(HTML)은 MapLibre Marker로 직접 렌더링 + * - 풍향 화살표는 CSS rotate가 가능한 MapLibre Marker로 렌더링 + */ export function WeatherMapOverlay({ stations, enabledLayers, onStationClick, - selectedStationId + selectedStationId, }: WeatherMapOverlayProps) { + // deck.gl 레이어는 useWeatherDeckLayers 훅을 통해 외부로 전달되므로 + // 이 컴포넌트는 HTML 오버레이(Marker) 부분만 담당 return ( <> - {/* Wind Vectors */} + {/* 풍향 화살표 — MapLibre Marker + CSS rotate */} {enabledLayers.has('wind') && stations.map((station) => { const isSelected = selectedStationId === station.id + const color = getWindHexColor(station.wind.speed, isSelected) + const size = Math.min(40 + station.wind.speed * 2, 80) + return ( onStationClick(station) - }} - /> + longitude={station.location.lon} + latitude={station.location.lat} + anchor="center" + onClick={() => onStationClick(station)} + > +
+ + + + +
+
) })} - {/* Weather Data Labels */} + {/* 기상 데이터 라벨 — MapLibre Marker */} {enabledLayers.has('labels') && stations.map((station) => { const isSelected = selectedStationId === station.id + const boxBg = isSelected ? 'rgba(6, 182, 212, 0.85)' : 'rgba(255, 255, 255, 0.1)' + const boxBorder = isSelected ? '#06b6d4' : 'rgba(255, 255, 255, 0.3)' + const textColor = isSelected ? '#000' : '#fff' + return ( onStationClick(station) - }} - /> - ) - })} + longitude={station.location.lon} + latitude={station.location.lat} + anchor="center" + onClick={() => onStationClick(station)} + > +
+ {/* 관측소명 */} +
+ {station.name} +
- {/* Wave Height Circles */} - {enabledLayers.has('waves') && - stations.map((station) => { - // Color based on wave height - const waveColor = - station.wave.height > 2.5 - ? '#ef4444' - : station.wave.height > 1.5 - ? '#f59e0b' - : '#3b82f6' + {/* 수온 */} +
+
+ 🌡️ +
+
+ + {station.temperature.current.toFixed(1)} + + + °C + +
+
- const radius = station.wave.height * 15000 // Scale for visualization + {/* 파고 */} +
+
+ 🌊 +
+
+ + {station.wave.height.toFixed(1)} + + + m + +
+
- return ( - onStationClick(station) - }} - /> - ) - })} - - {/* Temperature Circles */} - {enabledLayers.has('temperature') && - stations.map((station) => { - // Color based on temperature - const tempColor = - station.temperature.current > 8 - ? '#ef4444' - : station.temperature.current > 6 - ? '#f59e0b' - : '#3b82f6' - - const radius = 10000 // Fixed size for temp - - return ( - onStationClick(station) - }} - /> + {/* 풍속 */} +
+
+ 💨 +
+
+ + {station.wind.speed.toFixed(1)} + + + m/s + +
+
+
+
) })} ) } + +/** + * WeatherMapOverlay에 대응하는 deck.gl 레이어 생성 훅 + * WeatherView의 DeckGLOverlay layers 배열에 spread하여 사용 + */ +// eslint-disable-next-line react-refresh/only-export-components +export function useWeatherDeckLayers( + stations: WeatherStation[], + enabledLayers: Set, + selectedStationId: string | null, + onStationClick: (station: WeatherStation) => void +): Layer[] { + return useMemo(() => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const result: Layer[] = [] + + // 파고 분포 ScatterplotLayer (Circle 대체, 반경 = 파고 * 15km) + if (enabledLayers.has('waves')) { + const waveData = stations.map((s) => ({ + position: [s.location.lon, s.location.lat] as [number, number], + radius: s.wave.height * 15000, + fillColor: hexToRgba(getWaveHexColor(s.wave.height), 38), // fillOpacity 0.15 + lineColor: hexToRgba(getWaveHexColor(s.wave.height), 153), // opacity 0.6 + station: s, + })) + + result.push( + new ScatterplotLayer({ + id: 'weather-wave-circles', + data: waveData, + getPosition: (d) => d.position, + getRadius: (d) => d.radius, + getFillColor: (d) => d.fillColor, + getLineColor: (d) => d.lineColor, + getLineWidth: 2, + stroked: true, + radiusUnits: 'meters', + pickable: true, + onClick: (info) => { + if (info.object) onStationClick(info.object.station) + }, + }) as unknown as Layer + ) + } + + // 수온 분포 ScatterplotLayer (Circle 대체, 고정 반경 10km) + if (enabledLayers.has('temperature')) { + const tempData = stations.map((s) => ({ + position: [s.location.lon, s.location.lat] as [number, number], + fillColor: hexToRgba(getTempHexColor(s.temperature.current), 51), // fillOpacity 0.2 + lineColor: hexToRgba(getTempHexColor(s.temperature.current), 128), // opacity 0.5 + station: s, + })) + + result.push( + new ScatterplotLayer({ + id: 'weather-temp-circles', + data: tempData, + getPosition: (d) => d.position, + getRadius: 10000, + getFillColor: (d) => d.fillColor, + getLineColor: (d) => d.lineColor, + getLineWidth: 1, + stroked: true, + radiusUnits: 'meters', + pickable: true, + onClick: (info) => { + if (info.object) onStationClick(info.object.station) + }, + updateTriggers: { + getFillColor: [selectedStationId], + getLineColor: [selectedStationId], + }, + }) as unknown as Layer + ) + } + + return result + }, [stations, enabledLayers, selectedStationId, onStationClick]) +} diff --git a/frontend/src/tabs/weather/components/WeatherView.tsx b/frontend/src/tabs/weather/components/WeatherView.tsx index c9b8339..f39ced6 100755 --- a/frontend/src/tabs/weather/components/WeatherView.tsx +++ b/frontend/src/tabs/weather/components/WeatherView.tsx @@ -1,12 +1,14 @@ -import { useState, useEffect } from 'react' -import { MapContainer, TileLayer, useMapEvents } from 'react-leaflet' -import type { LatLngExpression } from 'leaflet' -import 'leaflet/dist/leaflet.css' +import { useState, useMemo, useCallback } from 'react' +import { Map, useControl, useMap } from '@vis.gl/react-maplibre' +import { MapboxOverlay } from '@deck.gl/mapbox' +import type { Layer } from '@deck.gl/core' +import type { StyleSpecification, MapLayerMouseEvent } from 'maplibre-gl' +import 'maplibre-gl/dist/maplibre-gl.css' import { WeatherRightPanel } from './WeatherRightPanel' -import { WeatherMapOverlay } from './WeatherMapOverlay' +import { WeatherMapOverlay, useWeatherDeckLayers } from './WeatherMapOverlay' import { OceanForecastOverlay } from './OceanForecastOverlay' -import { OceanCurrentLayer } from './OceanCurrentLayer' -import { WaterTemperatureLayer } from './WaterTemperatureLayer' +import { useOceanCurrentLayers } from './OceanCurrentLayer' +import { useWaterTemperatureLayers } from './WaterTemperatureLayer' import { WindParticleLayer } from './WindParticleLayer' import { useWeatherData } from '../hooks/useWeatherData' import { useOceanForecast } from '../hooks/useOceanForecast' @@ -43,8 +45,8 @@ interface WeatherForecast { windSpeed: number } -// Base weather station locations (weather data will be fetched from API) -const baseStations = [ +// Base weather station locations +const BASE_STATIONS = [ { id: 'incheon', name: '인천', location: { lat: 37.45, lon: 126.43 } }, { id: 'ulsan', name: '울산', location: { lat: 35.52, lon: 129.38 } }, { id: 'yeosu', name: '여수', location: { lat: 34.74, lon: 127.75 } }, @@ -54,102 +56,235 @@ const baseStations = [ { id: 'gunsan', name: '군산', location: { lat: 35.97, lon: 126.7 } }, { id: 'sokcho', name: '속초', location: { lat: 38.21, lon: 128.59 } }, { id: 'tongyeong', name: '통영', location: { lat: 34.83, lon: 128.43 } }, - { id: 'donghae', name: '동해', location: { lat: 37.52, lon: 129.14 } } + { id: 'donghae', name: '동해', location: { lat: 37.52, lon: 129.14 } }, ] // Generate forecast data based on time offset const generateForecast = (timeOffset: TimeOffset): WeatherForecast[] => { const baseHour = parseInt(timeOffset) const forecasts: WeatherForecast[] = [] + const icons = ['☀️', '⛅', '☁️', '🌦️', '🌧️'] for (let i = 0; i < 5; i++) { const hour = baseHour + i * 3 - const icons = ['☀️', '⛅', '☁️', '🌦️', '🌧️'] forecasts.push({ time: `+${hour}시`, hour: `${hour}시`, icon: icons[i % icons.length], temperature: Math.floor(Math.random() * 5) + 5, - windSpeed: Math.floor(Math.random() * 5) + 6 + windSpeed: Math.floor(Math.random() * 5) + 6, }) } - return forecasts } -// Map click handler component -function MapClickHandler({ onMapClick }: { onMapClick: (lat: number, lon: number) => void }) { - useMapEvents({ - click(e) { - onMapClick(e.latlng.lat, e.latlng.lng) - } - }) +// CartoDB Dark Matter 스타일 (기존 WeatherView와 동일) +const WEATHER_MAP_STYLE: StyleSpecification = { + version: 8, + sources: { + 'carto-dark': { + type: 'raster', + tiles: [ + 'https://a.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png', + 'https://b.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png', + 'https://c.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png', + ], + tileSize: 256, + attribution: + '© OpenStreetMap © CARTO', + }, + }, + layers: [ + { + id: 'carto-dark-layer', + type: 'raster', + source: 'carto-dark', + minzoom: 0, + maxzoom: 22, + }, + ], +} + +// 한국 해역 중심 좌표 (한반도 중앙) +const WEATHER_MAP_CENTER: [number, number] = [127.8, 36.5] // [lng, lat] +const WEATHER_MAP_ZOOM = 7 + +// deck.gl 오버레이 컴포넌트 (MapLibre 컨트롤로 등록) +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function DeckGLOverlay({ layers }: { layers: Layer[] }) { + const overlay = useControl(() => new MapboxOverlay({ interleaved: true })) + overlay.setProps({ layers }) return null } -export function WeatherView() { - // Fetch real-time weather data from API - const { weatherStations, loading, error, lastUpdate } = useWeatherData(baseStations) +// 줌 컨트롤 +function WeatherMapControls() { + const { current: map } = useMap() + + return ( +
+
+ + + +
+
+ ) +} + +/** + * WeatherMapInner — Map 컴포넌트 내부 (useMap / useControl 사용 가능 영역) + */ +interface WeatherMapInnerProps { + weatherStations: WeatherStation[] + enabledLayers: Set + selectedStationId: string | null + oceanForecastOpacity: number + selectedForecast: ReturnType['selectedForecast'] + onStationClick: (station: WeatherStation) => void +} + +function WeatherMapInner({ + weatherStations, + enabledLayers, + selectedStationId, + oceanForecastOpacity, + selectedForecast, + onStationClick, +}: WeatherMapInnerProps) { + // deck.gl layers 조합 + const weatherDeckLayers = useWeatherDeckLayers( + weatherStations, + enabledLayers, + selectedStationId, + onStationClick + ) + const oceanCurrentLayers = useOceanCurrentLayers({ + visible: enabledLayers.has('oceanCurrent'), + opacity: 0.7, + }) + const waterTempLayers = useWaterTemperatureLayers({ + visible: enabledLayers.has('waterTemperature'), + opacity: 0.5, + }) + + const deckLayers = useMemo( + () => [...oceanCurrentLayers, ...waterTempLayers, ...weatherDeckLayers], + [oceanCurrentLayers, waterTempLayers, weatherDeckLayers] + ) + + return ( + <> + {/* deck.gl 오버레이 */} + + + {/* 해황예보도 — MapLibre image source + raster layer */} + + + {/* 기상 관측소 HTML 오버레이 (풍향 화살표 + 라벨) */} + + + {/* 바람 파티클 애니메이션 (Canvas 직접 조작) */} + + + {/* 줌 컨트롤 */} + + + ) +} + +export function WeatherView() { + const { weatherStations, loading, error, lastUpdate } = useWeatherData(BASE_STATIONS) - // Fetch ocean forecast data from KHOA API const { selectedForecast, availableTimes, loading: oceanLoading, error: oceanError, - selectForecast + selectForecast, } = useOceanForecast('KOREA') const [timeOffset, setTimeOffset] = useState('0') - const [selectedStation, setSelectedStation] = useState(null) + const [selectedStationRaw, setSelectedStation] = useState(null) const [selectedLocation, setSelectedLocation] = useState<{ lat: number; lon: number } | null>( null ) - const [enabledLayers, setEnabledLayers] = useState>( - new Set(['wind', 'labels']) - ) + const [enabledLayers, setEnabledLayers] = useState>(new Set(['wind', 'labels'])) const [oceanForecastOpacity, setOceanForecastOpacity] = useState(0.6) - // Set initial selected station when data loads - useEffect(() => { - if (weatherStations.length > 0 && !selectedStation) { - // eslint-disable-next-line react-hooks/set-state-in-effect - setSelectedStation(weatherStations[0]) - } - }, [weatherStations, selectedStation]) + // 첫 관측소 자동 선택 (파생 값) + const selectedStation = selectedStationRaw ?? weatherStations[0] ?? null - const mapCenter: LatLngExpression = [36.5, 127.8] + const handleStationClick = useCallback( + (station: WeatherStation) => { + setSelectedStation(station) + setSelectedLocation(null) + }, + [] + ) - const handleStationClick = (station: WeatherStation) => { - setSelectedStation(station) - setSelectedLocation(null) - } + const handleMapClick = useCallback( + (e: MapLayerMouseEvent) => { + const { lat, lng } = e.lngLat + if (weatherStations.length === 0) return - const handleMapClick = (lat: number, lon: number) => { - // Find nearest station - const nearestStation = weatherStations.reduce((nearest, station) => { - const distance = Math.sqrt( - Math.pow(station.location.lat - lat, 2) + Math.pow(station.location.lon - lon, 2) - ) - const nearestDistance = Math.sqrt( - Math.pow(nearest.location.lat - lat, 2) + Math.pow(nearest.location.lon - lon, 2) - ) - return distance < nearestDistance ? station : nearest - }, weatherStations[0]) + // 가장 가까운 관측소 선택 + const nearestStation = weatherStations.reduce((nearest, station) => { + const distance = Math.sqrt( + Math.pow(station.location.lat - lat, 2) + Math.pow(station.location.lon - lng, 2) + ) + const nearestDistance = Math.sqrt( + Math.pow(nearest.location.lat - lat, 2) + Math.pow(nearest.location.lon - lng, 2) + ) + return distance < nearestDistance ? station : nearest + }, weatherStations[0]) - setSelectedStation(nearestStation) - setSelectedLocation({ lat, lon }) - } + setSelectedStation(nearestStation) + setSelectedLocation({ lat, lon: lng }) + }, + [weatherStations] + ) - const toggleLayer = (layer: string) => { - const newLayers = new Set(enabledLayers) - if (newLayers.has(layer)) { - newLayers.delete(layer) - } else { - newLayers.add(layer) - } - setEnabledLayers(newLayers) - } + const toggleLayer = useCallback((layer: string) => { + setEnabledLayers((prev) => { + const next = new Set(prev) + if (next.has(layer)) { + next.delete(layer) + } else { + next.add(layer) + } + return next + }) + }, []) const weatherData = selectedStation ? { @@ -159,14 +294,14 @@ export function WeatherView() { location: selectedLocation || selectedStation.location, currentTime: new Date().toLocaleTimeString('ko-KR', { hour: '2-digit', - minute: '2-digit' + minute: '2-digit', }), wind: selectedStation.wind, wave: selectedStation.wave, temperature: selectedStation.temperature, pressure: selectedStation.pressure, visibility: selectedStation.visibility, - forecast: generateForecast(timeOffset) + forecast: generateForecast(timeOffset), } : null @@ -177,62 +312,33 @@ export function WeatherView() { {/* Tab Navigation */}
- - - - + {(['0', '3', '6', '9'] as TimeOffset[]).map((offset) => ( + + ))}
{lastUpdate ? `마지막 업데이트: ${lastUpdate.toLocaleTimeString('ko-KR', { hour: '2-digit', - minute: '2-digit' + minute: '2-digit', })}` : '데이터 로딩 중...'} {loading && (
)} - {error && ( - ⚠️ {error} - )} + {error && ⚠️ {error}}
@@ -247,52 +353,29 @@ export function WeatherView() { {/* Map */}
- - - - - - {/* Weather Overlay */} - + - {/* Ocean Forecast Overlay */} - - - {/* Ocean Current Arrows */} - - - {/* Water Temperature Heatmap */} - - - {/* Windy-style Wind Particle Animation */} - - - - {/* Layer Controls */} -
+ {/* 레이어 컨트롤 */} +
기상 레이어
- {/* 투명도 조절 슬라이더 */} {enabledLayers.has('oceanForecast') && (
@@ -381,13 +463,16 @@ export function WeatherView() { min="0" max="100" value={oceanForecastOpacity * 100} - onChange={(e) => setOceanForecastOpacity(Number(e.target.value) / 100)} + onChange={(e) => + setOceanForecastOpacity(Number(e.target.value) / 100) + } className="flex-1 h-1 bg-bg-3 rounded-lg appearance-none cursor-pointer" /> - {Math.round(oceanForecastOpacity * 100)}% + + {Math.round(oceanForecastOpacity * 100)}% +
- {/* 예보 시간 선택 */} {availableTimes.length > 0 && (
예보 시간:
@@ -397,7 +482,8 @@ export function WeatherView() { key={`${time.day}-${time.hour}`} onClick={() => selectForecast(time.day, time.hour)} className={`w-full px-2 py-1 text-xs rounded transition-colors ${ - selectedForecast?.day === time.day && selectedForecast?.hour === time.hour + selectedForecast?.day === time.day && + selectedForecast?.hour === time.hour ? 'bg-primary-cyan text-bg-0 font-semibold' : 'bg-bg-2 text-text-3 hover:bg-bg-3' }`} @@ -409,15 +495,13 @@ export function WeatherView() {
)} - {oceanLoading && ( -
로딩 중...
- )} - {oceanError && ( -
오류 발생
- )} + {oceanLoading &&
로딩 중...
} + {oceanError &&
오류 발생
} {selectedForecast && (
- 현재: {selectedForecast.name} • {selectedForecast.day.slice(4, 6)}/{selectedForecast.day.slice(6, 8)} {selectedForecast.hour}:00 + 현재: {selectedForecast.name} •{' '} + {selectedForecast.day.slice(4, 6)}/{selectedForecast.day.slice(6, 8)}{' '} + {selectedForecast.hour}:00
)}
@@ -426,41 +510,50 @@ export function WeatherView() {
- {/* Legend */} -
+ {/* 범례 */} +
기상 범례
- {/* Wind Speed - Windy style */} + {/* 바람 (Windy 스타일) */}
바람 (m/s)
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
-
- 35710131620+ +
+ 3 + 5 + 7 + 10 + 13 + 16 + 20+
- {/* Wave Height */} + {/* 파고 */}
파고 (m)
-
+
< 1.5: 낮음
-
+
1.5-2.5: 보통
-
+
> 2.5: 높음
@@ -472,7 +565,7 @@ export function WeatherView() {
- {/* Right Panel - Weather Details */} + {/* Right Panel */}
) diff --git a/frontend/src/tabs/weather/components/WindParticleLayer.tsx b/frontend/src/tabs/weather/components/WindParticleLayer.tsx index 226fae8..94a5ce3 100755 --- a/frontend/src/tabs/weather/components/WindParticleLayer.tsx +++ b/frontend/src/tabs/weather/components/WindParticleLayer.tsx @@ -1,6 +1,6 @@ -import { useEffect, useRef } from 'react' -import { useMap } from 'react-leaflet' -import L from 'leaflet' +import { useEffect, useRef, useCallback } from 'react' +import { useMap } from '@vis.gl/react-maplibre' +import type { Map as MapLibreMap } from 'maplibre-gl' interface WindPoint { lat: number @@ -26,14 +26,14 @@ interface WindParticleLayerProps { // 풍속 기반 색상 (Windy.com 스타일) function getWindColor(speed: number): string { - if (speed < 3) return 'rgba(98, 113, 183, 0.8)' // 연한 파랑 (미풍) - if (speed < 5) return 'rgba(57, 160, 246, 0.8)' // 파랑 - if (speed < 7) return 'rgba(80, 213, 145, 0.8)' // 초록 - if (speed < 10) return 'rgba(165, 226, 63, 0.8)' // 연두 - if (speed < 13) return 'rgba(250, 226, 30, 0.8)' // 노랑 - if (speed < 16) return 'rgba(250, 170, 25, 0.8)' // 주황 - if (speed < 20) return 'rgba(240, 84, 33, 0.8)' // 빨강 - return 'rgba(180, 30, 70, 0.8)' // 진빨강 (강풍) + if (speed < 3) return 'rgba(98, 113, 183, 0.8)' + if (speed < 5) return 'rgba(57, 160, 246, 0.8)' + if (speed < 7) return 'rgba(80, 213, 145, 0.8)' + if (speed < 10) return 'rgba(165, 226, 63, 0.8)' + if (speed < 13) return 'rgba(250, 226, 30, 0.8)' + if (speed < 16) return 'rgba(250, 170, 25, 0.8)' + if (speed < 20) return 'rgba(240, 84, 33, 0.8)' + return 'rgba(180, 30, 70, 0.8)' } // 풍속 기반 배경 색상 (반투명 오버레이) @@ -48,7 +48,7 @@ function getWindBgColor(speed: number): string { return 'rgba(180, 30, 70, 0.12)' } -// 격자 보간으로 특정 위치의 풍속/풍향 추정 +// 격자 보간으로 특정 위치의 풍속/풍향 추정 (IDW) function interpolateWind( lat: number, lon: number, @@ -65,7 +65,6 @@ function interpolateWind( const dist = Math.sqrt( Math.pow(point.lat - lat, 2) + Math.pow(point.lon - lon, 2) ) - // IDW (Inverse Distance Weighting) const weight = 1 / Math.pow(Math.max(dist, 0.01), 2) totalWeight += weight weightedSpeed += point.speed * weight @@ -76,19 +75,63 @@ function interpolateWind( } const speed = weightedSpeed / totalWeight - const direction = (Math.atan2(weightedDx / totalWeight, -weightedDy / totalWeight) * 180) / Math.PI + const direction = + (Math.atan2(weightedDx / totalWeight, -weightedDy / totalWeight) * 180) / Math.PI return { speed, direction: (direction + 360) % 360 } } +// MapLibre map.unproject()를 통해 픽셀 → 경위도 변환 +function containerPointToLatLng( + map: MapLibreMap, + x: number, + y: number +): { lat: number; lng: number } { + const lngLat = map.unproject([x, y]) + return { lat: lngLat.lat, lng: lngLat.lng } +} + +const PARTICLE_COUNT = 800 +const FADE_ALPHA = 0.93 + +/** + * WindParticleLayer + * + * 기존: Canvas 2D + requestAnimationFrame + map.containerPointToLatLng() (Leaflet) + * 전환: Canvas 2D + requestAnimationFrame + map.unproject() (MapLibre) + * + * @vis.gl/react-maplibre의 useMap()으로 MapLibre Map 인스턴스를 참조 + * Canvas는 MapLibre 컨테이너 위에 absolute 레이어로 삽입 + */ export function WindParticleLayer({ visible, stations }: WindParticleLayerProps) { - const map = useMap() + const { current: mapRef } = useMap() const canvasRef = useRef(null) const particlesRef = useRef([]) const animFrameRef = useRef(0) + const windPoints: WindPoint[] = stations.map((s) => ({ + lat: s.location.lat, + lon: s.location.lon, + speed: s.wind.speed, + direction: s.wind.direction, + })) + + const initParticles = useCallback((width: number, height: number) => { + particlesRef.current = [] + for (let i = 0; i < PARTICLE_COUNT; i++) { + particlesRef.current.push({ + x: Math.random() * width, + y: Math.random() * height, + age: Math.floor(Math.random() * 80), + maxAge: 60 + Math.floor(Math.random() * 40), + }) + } + }, []) + useEffect(() => { + const map = mapRef?.getMap() + if (!map) return + if (!visible) { - // 비활성화 시 캔버스 제거 if (canvasRef.current) { canvasRef.current.remove() canvasRef.current = null @@ -97,16 +140,9 @@ export function WindParticleLayer({ visible, stations }: WindParticleLayerProps) return } - // 관측소 데이터를 WindPoint로 변환 - const windPoints: WindPoint[] = stations.map(s => ({ - lat: s.location.lat, - lon: s.location.lon, - speed: s.wind.speed, - direction: s.wind.direction - })) - - // Canvas 생성 const container = map.getContainer() + + // Canvas 생성 또는 재사용 let canvas = canvasRef.current if (!canvas) { canvas = document.createElement('canvas') @@ -121,57 +157,39 @@ export function WindParticleLayer({ visible, stations }: WindParticleLayerProps) const resize = () => { if (!canvas) return - const size = map.getSize() - canvas.width = size.x - canvas.height = size.y + const { clientWidth: w, clientHeight: h } = container + canvas.width = w + canvas.height = h } resize() const ctx = canvas.getContext('2d') if (!ctx) return - // 파티클 수 - const PARTICLE_COUNT = 800 - const FADE_ALPHA = 0.93 + initParticles(canvas.width, canvas.height) - // 파티클 초기화 - function initParticles() { - particlesRef.current = [] - if (!canvas) return - for (let i = 0; i < PARTICLE_COUNT; i++) { - particlesRef.current.push({ - x: Math.random() * canvas.width, - y: Math.random() * canvas.height, - age: Math.floor(Math.random() * 80), - maxAge: 60 + Math.floor(Math.random() * 40) - }) - } - } - initParticles() + // 오프스크린 캔버스 (트레일 효과) + let offCanvas: HTMLCanvasElement | null = null + let offCtx: CanvasRenderingContext2D | null = null - // 배경 그라디언트 렌더링 function drawWindOverlay() { if (!ctx || !canvas) return const gridSize = 30 for (let x = 0; x < canvas.width; x += gridSize) { for (let y = 0; y < canvas.height; y += gridSize) { - const latlng = map.containerPointToLatLng(L.point(x + gridSize / 2, y + gridSize / 2)) - const wind = interpolateWind(latlng.lat, latlng.lng, windPoints) + const { lat, lng } = containerPointToLatLng(map!, x + gridSize / 2, y + gridSize / 2) + const wind = interpolateWind(lat, lng, windPoints) ctx.fillStyle = getWindBgColor(wind.speed) ctx.fillRect(x, y, gridSize, gridSize) } } } - // 애니메이션 루프 - let offCanvas: HTMLCanvasElement | null = null - let offCtx: CanvasRenderingContext2D | null = null - function animate() { if (!ctx || !canvas) return - // 오프스크린 캔버스 (트레일 효과) + // 오프스크린 캔버스 크기 동기화 if (!offCanvas || offCanvas.width !== canvas.width || offCanvas.height !== canvas.height) { offCanvas = document.createElement('canvas') offCanvas.width = canvas.width @@ -187,9 +205,9 @@ export function WindParticleLayer({ visible, stations }: WindParticleLayerProps) offCtx.fillRect(0, 0, offCanvas.width, offCanvas.height) offCtx.globalCompositeOperation = 'source-over' - const bounds = map.getBounds() + // 현재 지도 bounds 확인 + const bounds = map!.getBounds() - // 파티클 업데이트 및 렌더링 for (const particle of particlesRef.current) { particle.age++ @@ -202,17 +220,17 @@ export function WindParticleLayer({ visible, stations }: WindParticleLayerProps) continue } - const latlng = map.containerPointToLatLng(L.point(particle.x, particle.y)) + const { lat, lng } = containerPointToLatLng(map!, particle.x, particle.y) // 화면 밖이면 리셋 - if (!bounds.contains(latlng)) { + if (!bounds.contains([lng, lat])) { particle.x = Math.random() * canvas.width particle.y = Math.random() * canvas.height particle.age = 0 continue } - const wind = interpolateWind(latlng.lat, latlng.lng, windPoints) + const wind = interpolateWind(lat, lng, windPoints) const rad = (wind.direction * Math.PI) / 180 const pixelSpeed = wind.speed * 0.4 @@ -244,10 +262,10 @@ export function WindParticleLayer({ visible, stations }: WindParticleLayerProps) // 지도 이동/줌 시 리셋 const onMoveEnd = () => { resize() - initParticles() - if (offCanvas) { - offCanvas.width = canvas!.width - offCanvas.height = canvas!.height + if (canvas) initParticles(canvas.width, canvas.height) + if (offCanvas && canvas) { + offCanvas.width = canvas.width + offCanvas.height = canvas.height } } map.on('moveend', onMoveEnd) @@ -262,7 +280,9 @@ export function WindParticleLayer({ visible, stations }: WindParticleLayerProps) canvasRef.current = null } } - }, [map, visible, stations]) + // windPoints 배열은 렌더마다 재생성되므로 stations만 의존 + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [mapRef, visible, stations, initParticles]) return null }