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 50afc57..6549140 100755
--- a/frontend/src/common/components/map/MapView.tsx
+++ b/frontend/src/common/components/map/MapView.tsx
@@ -1,38 +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'
const GEOSERVER_URL = import.meta.env.VITE_GEOSERVER_URL || 'http://localhost:8080'
-// 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 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',
}
// 오일펜스 우선순위별 색상/두께
@@ -92,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,
@@ -126,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(() => {
@@ -150,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])
@@ -163,7 +185,7 @@ export function MapView({
}
}, [oilTrajectory.length])
- // WMS 레이어 목록 생성
+ // WMS 레이어 목록
const wmsLayers = useMemo(() => {
return Array.from(enabledLayers)
.map(layerId => {
@@ -173,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}개 포인트)
)}
@@ -405,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 (
-
+
- {/* 줌 인 */}
map.zoomIn()}
+ onClick={() => map?.zoomIn()}
className="w-[38px] h-[38px] bg-[rgba(18,25,41,0.9)] backdrop-blur-xl border border-border rounded-sm text-text-2 flex items-center justify-center hover:bg-bg-hover hover:text-text-1 transition-all text-base"
>
+
-
- {/* 줌 아웃 */}
map.zoomOut()}
+ onClick={() => map?.zoomOut()}
className="w-[38px] h-[38px] bg-[rgba(18,25,41,0.9)] backdrop-blur-xl border border-border rounded-sm text-text-2 flex items-center justify-center hover:bg-bg-hover hover:text-text-1 transition-all text-base"
>
−
-
- {/* 현재 위치로 */}
map.setView(DEFAULT_CENTER, DEFAULT_ZOOM)}
+ onClick={() => map?.flyTo({ center: [center[1], center[0]], zoom, duration: 1000 })}
className="w-[38px] h-[38px] bg-[rgba(18,25,41,0.9)] backdrop-blur-xl border border-border rounded-sm text-text-2 flex items-center justify-center hover:text-text-1 transition-all text-sm"
>
- 🎯
+ 🎯
@@ -469,11 +579,9 @@ interface MapLegendProps {
}
function MapLegend({ dispersionResult, incidentCoord, oilTrajectory = [], boomLines = [], selectedModels = new Set(['OpenDrift'] as PredictionModel[]) }: MapLegendProps) {
- // HNS 대기확산 범례
if (dispersionResult && incidentCoord) {
return (
-
- {/* 헤더 */}
+
📍
@@ -483,8 +591,6 @@ function MapLegend({ dispersionResult, incidentCoord, oilTrajectory = [], boomLi
-
- {/* 사고 정보 */}
물질
@@ -499,8 +605,6 @@ function MapLegend({ dispersionResult, incidentCoord, oilTrajectory = [], boomLi
{dispersionResult.zones.length}개
-
- {/* 위험 구역 범례 */}
위험 구역
@@ -518,8 +622,6 @@ function MapLegend({ dispersionResult, incidentCoord, oilTrajectory = [], boomLi
-
- {/* 풍향 표시 */}
🧭
풍향 (방사형)
@@ -528,13 +630,11 @@ function MapLegend({ dispersionResult, incidentCoord, oilTrajectory = [], boomLi
)
}
- // 유류 확산 범례 (유출유 확산예측에만 표시)
if (oilTrajectory.length > 0) {
return (
-
+
범례
- {/* 선택된 모델별 색상 */}
{Array.from(selectedModels).map(model => (
@@ -573,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'
@@ -608,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)
@@ -657,7 +732,6 @@ function TimelineControl({
return (
- {/* 재생 컨트롤 */}
⏮
◀
@@ -669,16 +743,10 @@ function TimelineControl({
{playbackSpeed}×
-
- {/* 타임라인 트랙 */}
{timeLabels.map(t => (
-
+
{t}h
))}
@@ -687,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)],
@@ -751,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
@@ -791,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 => (
- setViewMode(v.mode)} style={{
- padding: '3px 10px', borderRadius: 3, fontSize: 9, fontWeight: 600,
- fontFamily: 'var(--fK)', cursor: 'pointer',
- background: viewMode === v.mode ? 'rgba(6,182,212,0.12)' : 'var(--bg3)',
- border: viewMode === v.mode ? '1px solid rgba(6,182,212,0.3)' : '1px solid var(--bd)',
- color: viewMode === v.mode ? 'var(--cyan)' : 'var(--t3)',
- }}>{v.icon} {v.label}
+ {(
+ [
+ { mode: 'overlay' as ViewMode, icon: '🗂', label: '오버레이' },
+ { mode: 'split2' as ViewMode, icon: '◫', label: '2분할' },
+ { mode: 'split3' as ViewMode, icon: '⊞', label: '3분할' },
+ ] as const
+ ).map(v => (
+ setViewMode(v.mode)}
+ style={{
+ padding: '3px 10px',
+ borderRadius: 3,
+ fontSize: 9,
+ fontWeight: 600,
+ fontFamily: 'var(--fK)',
+ cursor: 'pointer',
+ background: viewMode === v.mode ? 'rgba(6,182,212,0.12)' : 'var(--bg3)',
+ border: viewMode === v.mode ? '1px solid rgba(6,182,212,0.3)' : '1px solid var(--bd)',
+ color: viewMode === v.mode ? 'var(--cyan)' : 'var(--t3)',
+ }}
+ >
+ {v.icon} {v.label}
+
))}
- ✕ 닫기
+
+ ✕ 닫기
+
)}
@@ -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 => (
))}
-
AIS 선박
+
+ AIS 선박
+
{VESSEL_LEGEND.map(vl => (
))}
- {/* 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(' + ')} 분석 결과 비교
- 📋 보고서 생성
- 🔗 R&D 연계
+
+ 📋 보고서 생성
+
+
+ 🔗 R&D 연계
+
)}
@@ -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 */}
-
+
@@ -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 => (
- setTab(t.key)} style={{
- padding: '8px 11px', fontSize: 11, fontWeight: tab === t.key ? 600 : 400,
- color: tab === t.key ? '#58a6ff' : '#8b949e', cursor: 'pointer',
- borderBottom: tab === t.key ? '2px solid #58a6ff' : '2px solid transparent',
- fontFamily: 'var(--fK)', background: 'none', border: 'none',
- whiteSpace: 'nowrap', transition: '0.15s',
- }}>{t.label}
+ setTab(t.key)}
+ style={{
+ padding: '8px 11px',
+ fontSize: 11,
+ fontWeight: tab === t.key ? 600 : 400,
+ color: tab === t.key ? '#58a6ff' : '#8b949e',
+ cursor: 'pointer',
+ borderBottom: tab === t.key ? '2px solid #58a6ff' : '2px solid transparent',
+ fontFamily: 'var(--fK)',
+ background: 'none',
+ border: 'none',
+ whiteSpace: 'nowrap',
+ transition: '0.15s',
+ }}
+ >
+ {t.label}
+
))}
- {/* 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) => (
-
- 평균: 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 (
-
{icon} {label}
+
+ {icon} {label}
+
)
}
diff --git a/frontend/src/tabs/scat/components/ScatMap.tsx b/frontend/src/tabs/scat/components/ScatMap.tsx
index 60799f6..382258a 100644
--- a/frontend/src/tabs/scat/components/ScatMap.tsx
+++ b/frontend/src/tabs/scat/components/ScatMap.tsx
@@ -1,210 +1,281 @@
-import { useState, useEffect, useRef } from 'react';
-import L from 'leaflet';
-import 'leaflet/dist/leaflet.css';
-import type { ScatSegment } from './scatTypes';
-import { esiColor, jejuCoastCoords } from './scatConstants';
+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'
-interface ScatMapProps {
- segments: ScatSegment[];
- selectedSeg: ScatSegment;
- onSelectSeg: (s: ScatSegment) => void;
- onOpenPopup: (sn: number) => void;
+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[]
+ selectedSeg: ScatSegment
+ onSelectSeg: (s: ScatSegment) => void
+ onOpenPopup: (idx: number) => void
+}
+
+// ── 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 (!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])
+
+ return null
+}
+
+// ── 줌 기반 스케일 계산 ─────────────────────────────────
+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,
+ }
+}
+
+// ── 세그먼트 폴리라인 좌표 생성 (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 mapContainerRef = useRef(null);
- const mapRef = useRef(null);
- const markersRef = useRef(null);
- const [zoom, setZoom] = useState(10);
+ const [zoom, setZoom] = useState(10)
+ const [tooltip, setTooltip] = useState(null)
- useEffect(() => {
- if (!mapContainerRef.current || mapRef.current) return;
+ const handleClick = useCallback(
+ (seg: ScatSegment) => {
+ onSelectSeg(seg)
+ onOpenPopup(seg.id % scatDetailData.length)
+ },
+ [onSelectSeg, onOpenPopup],
+ )
- const map = L.map(mapContainerRef.current, {
- center: [33.38, 126.55],
- zoom: 10,
- zoomControl: false,
- attributionControl: false,
- });
+ const zs = useMemo(() => getZoomScale(zoom), [zoom])
- L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
- maxZoom: 19,
- }).addTo(map);
+ // 제주도 해안선 레퍼런스 라인
+ 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,
+ }),
+ [],
+ )
- 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;
- };
- }, []);
-
- useEffect(() => {
- if (!mapRef.current || !markersRef.current) return;
- markersRef.current.clearLayers();
-
- // 줌 기반 스케일 계수 (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;
-
- // 제주도 해안선 레퍼런스 라인
- const coastline = L.polyline(jejuCoastCoords as [number, number][], {
- color: 'rgba(6, 182, 212, 0.18)',
- weight: 1.5,
- dashArray: '8, 6',
- });
- markersRef.current.addLayer(coastline);
-
- segments.forEach((seg, segIdx) => {
- const isSelected = selectedSeg.id === seg.id;
- const color = esiColor(seg.esiNum);
-
- // 해안선 방향 계산 (세그먼트 폴리라인 각도 결정)
- const coastIdx = segIdx % (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',
+ // 선택된 구간 글로우 레이어
+ 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],
+ )
- polyline.on('click', () => {
- onSelectSeg(seg);
- onOpenPopup(seg.id);
- });
- markersRef.current!.addLayer(polyline);
+ // 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],
+ )
- // 조사 상태 마커 (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);
+ // 조사 상태 마커 (줌 >= 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],
+ },
+ })
+ }, [segments, zs.showStatusMarker, zs.markerRadius, handleClick])
- const statusMarker = L.marker([seg.lat, seg.lng], {
- icon: L.divIcon({
- className: '',
- html: `${showStatusText ? stText : ''}
`,
- iconSize: [0, 0],
- }),
- });
+ // 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])
- statusMarker.on('click', () => {
- onSelectSeg(seg);
- onOpenPopup(seg.id);
- });
- 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]);
-
- 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 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 = segments.length > 0 ? Math.round((doneCount / segments.length) * 100) : 0;
- const progPct = segments.length > 0 ? Math.round((progCount / segments.length) * 100) : 0;
- const notPct = 100 - donePct - progPct;
+ .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 */}
@@ -221,9 +292,7 @@ function ScatMap({ segments, selectedSeg, onSelectSeg, onOpenPopup }: ScatMapPro
{/* ESI Legend */}
-
- ESI 민감도 분류 범례
-
+
ESI 민감도 분류 범례
{[
{ esi: 'ESI 10', label: '갯벌·습지·맹그로브', color: '#991b1b' },
{ esi: 'ESI 9', label: '쉘터 갯벌', color: '#b91c1c' },
@@ -235,10 +304,7 @@ function ScatMap({ segments, selectedSeg, onSelectSeg, onOpenPopup }: ScatMapPro
{ esi: 'ESI 1-2', label: '암반·인공 구조물', color: '#4ade80' },
].map((item, i) => (
-
+
{item.label}
{item.esi}
@@ -247,30 +313,15 @@ function ScatMap({ segments, selectedSeg, onSelectSeg, onOpenPopup }: ScatMapPro
{/* Progress */}
-
- 조사 진행률
-
+
조사 진행률
-
- 완료 {donePct}%
-
-
- 진행 {progPct}%
-
+ 완료 {donePct}%
+ 진행 {progPct}%
미조사 {notPct}%
@@ -278,21 +329,11 @@ function ScatMap({ segments, selectedSeg, onSelectSeg, onOpenPopup }: ScatMapPro
['총 해안선', `${(totalLen / 1000).toFixed(1)} km`, ''],
['조사 완료', `${(doneLen / 1000).toFixed(1)} km`, 'var(--green)'],
['고민감 구간', `${(highSens / 1000).toFixed(1)} km`, 'var(--red)'],
- [
- '방제 우선 구간',
- `${segments.filter((s) => s.sensitivity === '최상').length}개`,
- 'var(--orange)',
- ],
+ ['방제 우선 구간', `${segments.filter(s => s.sensitivity === '최상').length}개`, 'var(--orange)'],
].map(([label, val, color], i) => (
-
+
{label}
-
+
{val}
@@ -314,7 +355,7 @@ function ScatMap({ segments, selectedSeg, onSelectSeg, onOpenPopup }: ScatMapPro
- );
+ )
}
-export default ScatMap;
+export default ScatMap
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 (
+
+
+ map?.zoomIn()}
+ className="w-[38px] h-[38px] bg-[rgba(18,25,41,0.9)] backdrop-blur-xl border border-border rounded-sm text-text-2 flex items-center justify-center hover:bg-bg-hover hover:text-text-1 transition-all text-base"
+ >
+ +
+
+ map?.zoomOut()}
+ className="w-[38px] h-[38px] bg-[rgba(18,25,41,0.9)] backdrop-blur-xl border border-border rounded-sm text-text-2 flex items-center justify-center hover:bg-bg-hover hover:text-text-1 transition-all text-base"
+ >
+ −
+
+
+ map?.flyTo({ center: WEATHER_MAP_CENTER, zoom: WEATHER_MAP_ZOOM, duration: 1000 })
+ }
+ className="w-[38px] h-[38px] bg-[rgba(18,25,41,0.9)] backdrop-blur-xl border border-border rounded-sm text-text-2 flex items-center justify-center hover:text-text-1 transition-all text-sm"
+ >
+ 🎯
+
+
+
+ )
+}
+
+/**
+ * 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 */}
-
setTimeOffset('0')}
- className={`px-3 py-2 text-xs font-semibold rounded transition-all ${
- timeOffset === '0'
- ? 'bg-primary-cyan text-bg-0'
- : 'bg-bg-3 border border-border text-text-2 hover:bg-bg-hover'
- }`}
- >
- 현재
-
-
setTimeOffset('3')}
- className={`px-3 py-2 text-xs font-semibold rounded transition-all ${
- timeOffset === '3'
- ? 'bg-primary-cyan text-bg-0'
- : 'bg-bg-3 border border-border text-text-2 hover:bg-bg-hover'
- }`}
- >
- +3시간
-
-
setTimeOffset('6')}
- className={`px-3 py-2 text-xs font-semibold rounded transition-all ${
- timeOffset === '6'
- ? 'bg-primary-cyan text-bg-0'
- : 'bg-bg-3 border border-border text-text-2 hover:bg-bg-hover'
- }`}
- >
- +6시간
-
-
setTimeOffset('9')}
- className={`px-3 py-2 text-xs font-semibold rounded transition-all ${
- timeOffset === '9'
- ? 'bg-primary-cyan text-bg-0'
- : 'bg-bg-3 border border-border text-text-2 hover:bg-bg-hover'
- }`}
- >
- +9시간
-
+ {(['0', '3', '6', '9'] as TimeOffset[]).map((offset) => (
+
setTimeOffset(offset)}
+ className={`px-3 py-2 text-xs font-semibold rounded transition-all ${
+ timeOffset === offset
+ ? 'bg-primary-cyan text-bg-0'
+ : 'bg-bg-3 border border-border text-text-2 hover:bg-bg-hover'
+ }`}
+ >
+ {offset === '0' ? '현재' : `+${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 */}
-
+ {/* 레이어 컨트롤 */}
+
기상 레이어
@@ -371,7 +454,6 @@ export function WeatherView() {
🌊 해황예보도
- {/* 투명도 조절 슬라이더 */}
{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)
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
-
-
3 5 7 10 13 16 20+
+
+ 3
+ 5
+ 7
+ 10
+ 13
+ 16
+ 20+
- {/* Wave Height */}
+ {/* 파고 */}
@@ -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
}