diff --git a/webapp/frontend/package-lock.json b/webapp/frontend/package-lock.json index 6564826..c0a18d4 100644 --- a/webapp/frontend/package-lock.json +++ b/webapp/frontend/package-lock.json @@ -8,8 +8,10 @@ "name": "frontend", "version": "0.0.0", "dependencies": { + "@mapbox-controls/zoom": "^3.0.0", "@tailwindcss/vite": "^4.1.4", "@vueuse/core": "^13.1.0", + "@watergis/mapbox-gl-legend": "^1.2.6", "axios": "^1.8.4", "chart.js": "^4.4.8", "chartjs-adapter-moment": "^1.0.1", @@ -19,6 +21,11 @@ "leaflet-geometryutil": "^0.10.3", "leaflet-groupedlayercontrol": "^0.6.1", "leaflet.markercluster": "^1.5.3", + "mapbox-gl-controls": "^2.3.5", + "mapbox-gl-infobox": "^1.0.9", + "maplibre-gl": "^5.3.1", + "maplibre-gl-basemaps": "^0.1.3", + "maplibre-gl-opacity": "^1.8.0", "moment": "^2.30.1", "tailwindcss": "^4.1.4", "vue": "^3.5.13", @@ -958,6 +965,163 @@ "integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==", "license": "MIT" }, + "node_modules/@mapbox-controls/helpers": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@mapbox-controls/helpers/-/helpers-3.0.0.tgz", + "integrity": "sha512-XxCCKHNaxR8WeJM3syiXxBH/7Yfz7WytnDvhe/aQMgxYhJ68Z9aKAu6E4PtV0AI5dxyQb6UkdhQs8mg7Py0tjA==", + "license": "MIT", + "peerDependencies": { + "mapbox-gl": ">=1.0.0 <4.0.0" + } + }, + "node_modules/@mapbox-controls/zoom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@mapbox-controls/zoom/-/zoom-3.0.0.tgz", + "integrity": "sha512-3uGd53kedOnN9sUbz3Befup8MJAaMtSeSarVa8qTrWlilwacKcNSModowWjBNlPdp1K8iEmkh7gjwzrF+IEgcQ==", + "license": "MIT", + "dependencies": { + "@mapbox-controls/helpers": "3.0.0" + }, + "peerDependencies": { + "mapbox-gl": ">=1.0.0 <4.0.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/geojson-rewind/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/@mapbox/geojson-types": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@mapbox/geojson-types/-/geojson-types-1.0.2.tgz", + "integrity": "sha512-e9EBqHHv3EORHrSfbR9DqecPNn+AmuAoQxV6aL8Xu30bJMJR1o8PZLZzpk1Wq7/NfCbuhmakHTPYRhoqLsXRnw==", + "license": "ISC" + }, + "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/mapbox-gl-style-spec": { + "version": "13.28.0", + "resolved": "https://registry.npmjs.org/@mapbox/mapbox-gl-style-spec/-/mapbox-gl-style-spec-13.28.0.tgz", + "integrity": "sha512-B8xM7Fp1nh5kejfIl4SWeY0gtIeewbuRencqO3cJDrCHZpaPg7uY+V8abuR+esMeuOjRl5cLhVTP40v+1ywxbg==", + "license": "ISC", + "dependencies": { + "@mapbox/jsonlint-lines-primitives": "~2.0.2", + "@mapbox/point-geometry": "^0.1.0", + "@mapbox/unitbezier": "^0.0.0", + "csscolorparser": "~1.0.2", + "json-stringify-pretty-compact": "^2.0.0", + "minimist": "^1.2.6", + "rw": "^1.3.3", + "sort-object": "^0.3.2" + }, + "bin": { + "gl-style-composite": "bin/gl-style-composite.js", + "gl-style-format": "bin/gl-style-format.js", + "gl-style-migrate": "bin/gl-style-migrate.js", + "gl-style-validate": "bin/gl-style-validate.js" + } + }, + "node_modules/@mapbox/mapbox-gl-style-spec/node_modules/@mapbox/unitbezier": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/@mapbox/unitbezier/-/unitbezier-0.0.0.tgz", + "integrity": "sha512-HPnRdYO0WjFjRTSwO3frz1wKaU649OBFPX3Zo/2WZvuRi6zMiRGui8SnPQiQABgqCf8YikDe5t3HViTVw1WUzA==", + "license": "BSD-2-Clause" + }, + "node_modules/@mapbox/mapbox-gl-style-spec/node_modules/json-stringify-pretty-compact": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/json-stringify-pretty-compact/-/json-stringify-pretty-compact-2.0.0.tgz", + "integrity": "sha512-WRitRfs6BGq4q8gTgOy4ek7iPFXjbra0H3PmDLKm2xnZ+Gh1HUhiKGgCZkSPNULlP7mvfu6FV/mOLhCarspADQ==", + "license": "MIT" + }, + "node_modules/@mapbox/mapbox-gl-supported": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@mapbox/mapbox-gl-supported/-/mapbox-gl-supported-2.0.1.tgz", + "integrity": "sha512-HP6XvfNIzfoMVfyGjBckjiAOQK9WfX0ywdLubuPMPv+Vqf5fj0uCbgBQYpiqcWZT6cbyyRnTSXDheT1ugvF6UQ==", + "license": "BSD-3-Clause", + "peer": true + }, + "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.6", + "resolved": "https://registry.npmjs.org/@mapbox/tiny-sdf/-/tiny-sdf-2.0.6.tgz", + "integrity": "sha512-qMqa27TLw+ZQz5Jk+RcwZGH7BQf5G/TrutJhspsca/3SHwmgKQ1iq+d3Jxz5oysPVYTGP6aXxCo5Lk9Er6YBAA==", + "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/maplibre-gl-style-spec": { + "version": "23.1.0", + "resolved": "https://registry.npmjs.org/@maplibre/maplibre-gl-style-spec/-/maplibre-gl-style-spec-23.1.0.tgz", + "integrity": "sha512-R6/ihEuC5KRexmKIYkWqUv84Gm+/QwsOUgHyt1yy2XqCdGdLvlBWVWIIeTZWN4NGdwmY6xDzdSGU2R9oBLNg2w==", + "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/@polka/url": { "version": "1.0.0-next.29", "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", @@ -1522,12 +1686,90 @@ "vite": "^5.2.0 || ^6" } }, + "node_modules/@turf/distance": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/@turf/distance/-/distance-6.3.0.tgz", + "integrity": "sha512-basi24ssNFnH3iXPFjp/aNUrukjObiFWoIyDRqKyBJxVwVOwAWvfk4d38QQyBj5nDo5IahYRq/Q+T47/5hSs9w==", + "license": "MIT", + "dependencies": { + "@turf/helpers": "^6.3.0", + "@turf/invariant": "^6.3.0" + } + }, + "node_modules/@turf/helpers": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/helpers/-/helpers-6.5.0.tgz", + "integrity": "sha512-VbI1dV5bLFzohYYdgqwikdMVpe7pJ9X3E+dlr425wa2/sMJqYDhTO++ec38/pcPvPE6oD9WEEeU3Xu3gza+VPw==", + "license": "MIT", + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/invariant": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/invariant/-/invariant-6.5.0.tgz", + "integrity": "sha512-Wv8PRNCtPD31UVbdJE/KVAWKe7l6US+lJItRR/HOEW3eh+U/JwRCSUl/KZ7bmjM/C+zLNoreM2TU6OoLACs4eg==", + "license": "MIT", + "dependencies": { + "@turf/helpers": "^6.5.0" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, "node_modules/@types/estree": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.7.tgz", "integrity": "sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==", "license": "MIT" }, + "node_modules/@types/geojson": { + "version": "7946.0.16", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", + "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", + "license": "MIT" + }, + "node_modules/@types/geojson-vt": { + "version": "3.2.5", + "resolved": "https://registry.npmjs.org/@types/geojson-vt/-/geojson-vt-3.2.5.tgz", + "integrity": "sha512-qDO7wqtprzlpe8FfQ//ClPV9xiuoh2nkIgiouIptON9w5jvD/fA4szvP9GBlDVdJ5dldAl0kX/sy3URbWwLx0g==", + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, + "node_modules/@types/mapbox__point-geometry": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/@types/mapbox__point-geometry/-/mapbox__point-geometry-0.1.4.tgz", + "integrity": "sha512-mUWlSxAmYLfwnRBmgYV86tgYmMIICX4kza8YnE/eIlywGe2XoOxlpVnXWwir92xRLjwyarqwpu2EJKD2pk0IUA==", + "license": "MIT" + }, + "node_modules/@types/mapbox__vector-tile": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@types/mapbox__vector-tile/-/mapbox__vector-tile-1.3.4.tgz", + "integrity": "sha512-bpd8dRn9pr6xKvuEBQup8pwQfD4VUyqO/2deGjfpe6AwC8YRlyEipvefyRJUSiCJTZuCb8Pl1ciVV5ekqJ96Bg==", + "license": "MIT", + "dependencies": { + "@types/geojson": "*", + "@types/mapbox__point-geometry": "*", + "@types/pbf": "*" + } + }, + "node_modules/@types/pbf": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@types/pbf/-/pbf-3.0.5.tgz", + "integrity": "sha512-j3pOPiEcWZ34R6a6mN07mUkM4o4Lwf6hPNt8eilOeZhTFbxFXmKhvXl9Y28jotFPaI1bpPDJsbCprUoNke6OrA==", + "license": "MIT" + }, + "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/@types/web-bluetooth": { "version": "0.0.21", "resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz", @@ -1827,6 +2069,135 @@ "vue": "^3.5.0" } }, + "node_modules/@watergis/legend-symbol": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@watergis/legend-symbol/-/legend-symbol-0.2.3.tgz", + "integrity": "sha512-bxW2nGUvL80/iLTAstvP0QRcpprKKyTOLFueDW+dD0g9PDIFYx4De6MrKp4QJPx9SmrlidPxslOB0mXZrENmUg==", + "dependencies": { + "@mapbox/mapbox-gl-style-spec": "^13.16.0" + } + }, + "node_modules/@watergis/mapbox-gl-legend": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@watergis/mapbox-gl-legend/-/mapbox-gl-legend-1.2.6.tgz", + "integrity": "sha512-0/AjT10uobNPxHntBYM7HjeoGj6fpOWsu5aBrnD3312Gn/63ko/XpjVY6P1YJ3Un4NhulTAtD4GOmpJyWDrEag==", + "license": "MIT", + "dependencies": { + "@mapbox/mapbox-gl-style-spec": "^13.25.0", + "@watergis/legend-symbol": "^0.2.3", + "axios": "^0.27.2", + "mapbox-gl": "^1.13.2" + } + }, + "node_modules/@watergis/mapbox-gl-legend/node_modules/@mapbox/mapbox-gl-supported": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@mapbox/mapbox-gl-supported/-/mapbox-gl-supported-1.5.0.tgz", + "integrity": "sha512-/PT1P6DNf7vjEEiPkVIRJkvibbqWtqnyGaBz3nfRdcxclNSnSdaLU5tfAgcD7I8Yt5i+L19s406YLl1koLnLbg==", + "license": "BSD-3-Clause", + "peerDependencies": { + "mapbox-gl": ">=0.32.1 <2.0.0" + } + }, + "node_modules/@watergis/mapbox-gl-legend/node_modules/@mapbox/tiny-sdf": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@mapbox/tiny-sdf/-/tiny-sdf-1.2.5.tgz", + "integrity": "sha512-cD8A/zJlm6fdJOk6DqPUV8mcpyJkRz2x2R+/fYcWDYG3oWbG7/L7Yl/WqQ1VZCjnL9OTIMAn6c+BC5Eru4sQEw==", + "license": "BSD-2-Clause" + }, + "node_modules/@watergis/mapbox-gl-legend/node_modules/@mapbox/unitbezier": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/@mapbox/unitbezier/-/unitbezier-0.0.0.tgz", + "integrity": "sha512-HPnRdYO0WjFjRTSwO3frz1wKaU649OBFPX3Zo/2WZvuRi6zMiRGui8SnPQiQABgqCf8YikDe5t3HViTVw1WUzA==", + "license": "BSD-2-Clause" + }, + "node_modules/@watergis/mapbox-gl-legend/node_modules/axios": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-0.27.2.tgz", + "integrity": "sha512-t+yRIyySRTp/wua5xEr+z1q60QmLq8ABsS5O9Me1AsE5dfKqgnCFzwiCZZ/cGNd1lq4/7akDWMxdhVlucjmnOQ==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.14.9", + "form-data": "^4.0.0" + } + }, + "node_modules/@watergis/mapbox-gl-legend/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/@watergis/mapbox-gl-legend/node_modules/geojson-vt": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/geojson-vt/-/geojson-vt-3.2.1.tgz", + "integrity": "sha512-EvGQQi/zPrDA6zr6BnJD/YhwAkBP8nnJ9emh3EnHQKVMfg/MRVtPbMYdgVy/IaEmn4UfagD2a6fafPDL5hbtwg==", + "license": "ISC" + }, + "node_modules/@watergis/mapbox-gl-legend/node_modules/kdbush": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/kdbush/-/kdbush-3.0.0.tgz", + "integrity": "sha512-hRkd6/XW4HTsA9vjVpY9tuXJYLSlelnkTmVFu4M9/7MIYQtFcHpbugAU7UbOfjOiVSVYl2fqgBuJ32JUmRo5Ew==", + "license": "ISC" + }, + "node_modules/@watergis/mapbox-gl-legend/node_modules/mapbox-gl": { + "version": "1.13.3", + "resolved": "https://registry.npmjs.org/mapbox-gl/-/mapbox-gl-1.13.3.tgz", + "integrity": "sha512-p8lJFEiqmEQlyv+DQxFAOG/XPWN0Wp7j/Psq93Zywz7qt9CcUKFYDBOoOEKzqe6gudHVJY8/Bhqw6VDpX2lSBg==", + "license": "SEE LICENSE IN LICENSE.txt", + "dependencies": { + "@mapbox/geojson-rewind": "^0.5.2", + "@mapbox/geojson-types": "^1.0.2", + "@mapbox/jsonlint-lines-primitives": "^2.0.2", + "@mapbox/mapbox-gl-supported": "^1.5.0", + "@mapbox/point-geometry": "^0.1.0", + "@mapbox/tiny-sdf": "^1.1.1", + "@mapbox/unitbezier": "^0.0.0", + "@mapbox/vector-tile": "^1.3.1", + "@mapbox/whoots-js": "^3.1.0", + "csscolorparser": "~1.0.3", + "earcut": "^2.2.2", + "geojson-vt": "^3.2.1", + "gl-matrix": "^3.2.1", + "grid-index": "^1.1.0", + "murmurhash-js": "^1.0.0", + "pbf": "^3.2.1", + "potpack": "^1.0.1", + "quickselect": "^2.0.0", + "rw": "^1.3.3", + "supercluster": "^7.1.0", + "tinyqueue": "^2.0.3", + "vt-pbf": "^3.1.1" + }, + "engines": { + "node": ">=6.4.0" + } + }, + "node_modules/@watergis/mapbox-gl-legend/node_modules/potpack": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/potpack/-/potpack-1.0.2.tgz", + "integrity": "sha512-choctRBIV9EMT9WGAZHn3V7t0Z2pMQyl0EZE6pFc/6ml3ssw7Dlf/oAOvFwjm1HVsqfQN8GfeFyJ+d8tRzqueQ==", + "license": "ISC" + }, + "node_modules/@watergis/mapbox-gl-legend/node_modules/quickselect": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/quickselect/-/quickselect-2.0.0.tgz", + "integrity": "sha512-RKJ22hX8mHe3Y6wH/N3wCM6BWtjaxIyyUIkpHOvfFnxdI4yD4tBXEBKSbriGujF6jnSVkJrffuo6vxACiSSxIw==", + "license": "ISC" + }, + "node_modules/@watergis/mapbox-gl-legend/node_modules/supercluster": { + "version": "7.1.5", + "resolved": "https://registry.npmjs.org/supercluster/-/supercluster-7.1.5.tgz", + "integrity": "sha512-EulshI3pGUM66o6ZdH3ReiFcvHpM3vAigyK+vcxdjpJyEbIIrtbmBdY23mGgnI24uXiGFvrGq9Gkum/8U7vJWg==", + "license": "ISC", + "dependencies": { + "kdbush": "^3.0.0" + } + }, + "node_modules/@watergis/mapbox-gl-legend/node_modules/tinyqueue": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/tinyqueue/-/tinyqueue-2.0.3.tgz", + "integrity": "sha512-ppJZNDuKGgxzkHihX8v9v9G5f+18gzaTfrukGrq6ueg0lmH4nqVnA2IPG0AEH3jKEk2GRJCUhDoqpoiw3PHLBA==", + "license": "ISC" + }, "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", @@ -2009,6 +2380,12 @@ "node": ">= 8" } }, + "node_modules/csscolorparser": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/csscolorparser/-/csscolorparser-1.0.3.tgz", + "integrity": "sha512-umPSgYwZkdFoUrH5hIq5kf0wPSXiro51nPw0j2K/c83KflkPSTBGMz6NJvMB+07VlL0y7VPo6QJcDjcgKTTm3w==", + "license": "MIT" + }, "node_modules/csstype": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", @@ -2108,6 +2485,12 @@ "node": ">= 0.4" } }, + "node_modules/earcut": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/earcut/-/earcut-3.0.1.tgz", + "integrity": "sha512-0l1/0gOjESMeQyYaK5IDiPNvFeu93Z/cO0TjZh9eZ1vyCtZnA7KMZ8rQggpsJHIbGSdrqYq9OhuveadOVHCshw==", + "license": "ISC" + }, "node_modules/electron-to-chromium": { "version": "1.5.137", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.137.tgz", @@ -2377,6 +2760,12 @@ "node": ">=6.9.0" } }, + "node_modules/geojson-vt": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/geojson-vt/-/geojson-vt-4.0.2.tgz", + "integrity": "sha512-AV9ROqlNqoZEIJGfm1ncNjEXfkz2hdFlZf0qkVfmkwdKa8vj7H16YUOT81rJw1rdFhyEDlN2Tds91p/glzbl5A==", + "license": "ISC" + }, "node_modules/get-intrinsic": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", @@ -2431,6 +2820,50 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/gl-matrix": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/gl-matrix/-/gl-matrix-3.4.3.tgz", + "integrity": "sha512-wcCp8vu8FT22BnvKVPjXa/ICBWRq/zjFfdofZy1WSpQZpphblv12/bOQLBC1rMM7SGOFS9ltVmKOHil5+Ml7gA==", + "license": "MIT" + }, + "node_modules/global-prefix": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/global-prefix/-/global-prefix-4.0.0.tgz", + "integrity": "sha512-w0Uf9Y9/nyHinEk5vMJKRie+wa4kR5hmDbEhGGds/kG1PwGLLHKRoNMeJOyCQjjBkANlnScqgzcFwGHgmgLkVA==", + "license": "MIT", + "dependencies": { + "ini": "^4.1.3", + "kind-of": "^6.0.3", + "which": "^4.0.0" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/global-prefix/node_modules/isexe": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.1.tgz", + "integrity": "sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ==", + "license": "ISC", + "engines": { + "node": ">=16" + } + }, + "node_modules/global-prefix/node_modules/which": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/which/-/which-4.0.0.tgz", + "integrity": "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg==", + "license": "ISC", + "dependencies": { + "isexe": "^3.1.1" + }, + "bin": { + "node-which": "bin/which.js" + }, + "engines": { + "node": "^16.13.0 || >=18.0.0" + } + }, "node_modules/globals": { "version": "11.12.0", "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", @@ -2459,6 +2892,12 @@ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "license": "ISC" }, + "node_modules/grid-index": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/grid-index/-/grid-index-1.1.0.tgz", + "integrity": "sha512-HZRwumpOGUrHyxO5bqKZL0B0GlUpwtCAzZ42sgxUPniu33R1LSFH5yrIcBCHjkctCAh3mtWKcKd9J4vDDdeVHA==", + "license": "ISC" + }, "node_modules/has-symbols": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", @@ -2515,6 +2954,35 @@ "node": ">=18.18.0" } }, + "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/ini": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/ini/-/ini-4.1.3.tgz", + "integrity": "sha512-X7rqawQBvfdjS10YU1y1YVreA3SsLrW9dX2CewP2EbBJM4ypVNLDkO5y04gejPwKIY9lR+7r9gn3rFPt/kmWFg==", + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, "node_modules/install": { "version": "0.13.0", "resolved": "https://registry.npmjs.org/install/-/install-0.13.0.tgz", @@ -2663,6 +3131,12 @@ "node": ">=6" } }, + "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/json5": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", @@ -2689,6 +3163,21 @@ "graceful-fs": "^4.1.6" } }, + "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/kind-of": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", + "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/kolorist": { "version": "1.8.0", "resolved": "https://registry.npmjs.org/kolorist/-/kolorist-1.8.0.tgz", @@ -2986,6 +3475,242 @@ "@jridgewell/sourcemap-codec": "^1.5.0" } }, + "node_modules/mapbox-gl": { + "version": "2.15.0", + "resolved": "https://registry.npmjs.org/mapbox-gl/-/mapbox-gl-2.15.0.tgz", + "integrity": "sha512-fjv+aYrd5TIHiL7wRa+W7KjtUqKWziJMZUkK5hm8TvJ3OLeNPx4NmW/DgfYhd/jHej8wWL+QJBDbdMMAKvNC0A==", + "license": "SEE LICENSE IN LICENSE.txt", + "peer": true, + "dependencies": { + "@mapbox/geojson-rewind": "^0.5.2", + "@mapbox/jsonlint-lines-primitives": "^2.0.2", + "@mapbox/mapbox-gl-supported": "^2.0.1", + "@mapbox/point-geometry": "^0.1.0", + "@mapbox/tiny-sdf": "^2.0.6", + "@mapbox/unitbezier": "^0.0.1", + "@mapbox/vector-tile": "^1.3.1", + "@mapbox/whoots-js": "^3.1.0", + "csscolorparser": "~1.0.3", + "earcut": "^2.2.4", + "geojson-vt": "^3.2.1", + "gl-matrix": "^3.4.3", + "grid-index": "^1.1.0", + "kdbush": "^4.0.1", + "murmurhash-js": "^1.0.0", + "pbf": "^3.2.1", + "potpack": "^2.0.0", + "quickselect": "^2.0.0", + "rw": "^1.3.3", + "supercluster": "^8.0.0", + "tinyqueue": "^2.0.3", + "vt-pbf": "^3.1.3" + } + }, + "node_modules/mapbox-gl-controls": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/mapbox-gl-controls/-/mapbox-gl-controls-2.3.5.tgz", + "integrity": "sha512-FP4c7WhNoE+5JicMkq8TSqJwaxEpkhJlsq4Ye1lIGBy7ubS/l6EZ15ZRumrICpDZGOMMlfKKCpaCo+Kt5omkTg==", + "deprecated": "migrated to monorepo: https://github.com/korywka/mapbox-controls", + "license": "MIT", + "dependencies": { + "@turf/distance": "6.3.0" + }, + "peerDependencies": { + "mapbox-gl": ">=1.0.0 <3.0.0" + } + }, + "node_modules/mapbox-gl-infobox": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/mapbox-gl-infobox/-/mapbox-gl-infobox-1.0.9.tgz", + "integrity": "sha512-vl4u308UMexH3qC883rsqMJ0AEP+wdoTOSqP+8QhLCTwGKZTpCMnQh/75saFXIJrG97CwXr74JR/lq2RAze87w==", + "license": "GPL-3.0", + "dependencies": { + "mapbox-gl": "^1.13.0" + } + }, + "node_modules/mapbox-gl-infobox/node_modules/@mapbox/mapbox-gl-supported": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@mapbox/mapbox-gl-supported/-/mapbox-gl-supported-1.5.0.tgz", + "integrity": "sha512-/PT1P6DNf7vjEEiPkVIRJkvibbqWtqnyGaBz3nfRdcxclNSnSdaLU5tfAgcD7I8Yt5i+L19s406YLl1koLnLbg==", + "license": "BSD-3-Clause", + "peerDependencies": { + "mapbox-gl": ">=0.32.1 <2.0.0" + } + }, + "node_modules/mapbox-gl-infobox/node_modules/@mapbox/tiny-sdf": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@mapbox/tiny-sdf/-/tiny-sdf-1.2.5.tgz", + "integrity": "sha512-cD8A/zJlm6fdJOk6DqPUV8mcpyJkRz2x2R+/fYcWDYG3oWbG7/L7Yl/WqQ1VZCjnL9OTIMAn6c+BC5Eru4sQEw==", + "license": "BSD-2-Clause" + }, + "node_modules/mapbox-gl-infobox/node_modules/@mapbox/unitbezier": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/@mapbox/unitbezier/-/unitbezier-0.0.0.tgz", + "integrity": "sha512-HPnRdYO0WjFjRTSwO3frz1wKaU649OBFPX3Zo/2WZvuRi6zMiRGui8SnPQiQABgqCf8YikDe5t3HViTVw1WUzA==", + "license": "BSD-2-Clause" + }, + "node_modules/mapbox-gl-infobox/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/mapbox-gl-infobox/node_modules/geojson-vt": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/geojson-vt/-/geojson-vt-3.2.1.tgz", + "integrity": "sha512-EvGQQi/zPrDA6zr6BnJD/YhwAkBP8nnJ9emh3EnHQKVMfg/MRVtPbMYdgVy/IaEmn4UfagD2a6fafPDL5hbtwg==", + "license": "ISC" + }, + "node_modules/mapbox-gl-infobox/node_modules/kdbush": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/kdbush/-/kdbush-3.0.0.tgz", + "integrity": "sha512-hRkd6/XW4HTsA9vjVpY9tuXJYLSlelnkTmVFu4M9/7MIYQtFcHpbugAU7UbOfjOiVSVYl2fqgBuJ32JUmRo5Ew==", + "license": "ISC" + }, + "node_modules/mapbox-gl-infobox/node_modules/mapbox-gl": { + "version": "1.13.3", + "resolved": "https://registry.npmjs.org/mapbox-gl/-/mapbox-gl-1.13.3.tgz", + "integrity": "sha512-p8lJFEiqmEQlyv+DQxFAOG/XPWN0Wp7j/Psq93Zywz7qt9CcUKFYDBOoOEKzqe6gudHVJY8/Bhqw6VDpX2lSBg==", + "license": "SEE LICENSE IN LICENSE.txt", + "dependencies": { + "@mapbox/geojson-rewind": "^0.5.2", + "@mapbox/geojson-types": "^1.0.2", + "@mapbox/jsonlint-lines-primitives": "^2.0.2", + "@mapbox/mapbox-gl-supported": "^1.5.0", + "@mapbox/point-geometry": "^0.1.0", + "@mapbox/tiny-sdf": "^1.1.1", + "@mapbox/unitbezier": "^0.0.0", + "@mapbox/vector-tile": "^1.3.1", + "@mapbox/whoots-js": "^3.1.0", + "csscolorparser": "~1.0.3", + "earcut": "^2.2.2", + "geojson-vt": "^3.2.1", + "gl-matrix": "^3.2.1", + "grid-index": "^1.1.0", + "murmurhash-js": "^1.0.0", + "pbf": "^3.2.1", + "potpack": "^1.0.1", + "quickselect": "^2.0.0", + "rw": "^1.3.3", + "supercluster": "^7.1.0", + "tinyqueue": "^2.0.3", + "vt-pbf": "^3.1.1" + }, + "engines": { + "node": ">=6.4.0" + } + }, + "node_modules/mapbox-gl-infobox/node_modules/potpack": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/potpack/-/potpack-1.0.2.tgz", + "integrity": "sha512-choctRBIV9EMT9WGAZHn3V7t0Z2pMQyl0EZE6pFc/6ml3ssw7Dlf/oAOvFwjm1HVsqfQN8GfeFyJ+d8tRzqueQ==", + "license": "ISC" + }, + "node_modules/mapbox-gl-infobox/node_modules/quickselect": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/quickselect/-/quickselect-2.0.0.tgz", + "integrity": "sha512-RKJ22hX8mHe3Y6wH/N3wCM6BWtjaxIyyUIkpHOvfFnxdI4yD4tBXEBKSbriGujF6jnSVkJrffuo6vxACiSSxIw==", + "license": "ISC" + }, + "node_modules/mapbox-gl-infobox/node_modules/supercluster": { + "version": "7.1.5", + "resolved": "https://registry.npmjs.org/supercluster/-/supercluster-7.1.5.tgz", + "integrity": "sha512-EulshI3pGUM66o6ZdH3ReiFcvHpM3vAigyK+vcxdjpJyEbIIrtbmBdY23mGgnI24uXiGFvrGq9Gkum/8U7vJWg==", + "license": "ISC", + "dependencies": { + "kdbush": "^3.0.0" + } + }, + "node_modules/mapbox-gl-infobox/node_modules/tinyqueue": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/tinyqueue/-/tinyqueue-2.0.3.tgz", + "integrity": "sha512-ppJZNDuKGgxzkHihX8v9v9G5f+18gzaTfrukGrq6ueg0lmH4nqVnA2IPG0AEH3jKEk2GRJCUhDoqpoiw3PHLBA==", + "license": "ISC" + }, + "node_modules/mapbox-gl/node_modules/earcut": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/earcut/-/earcut-2.2.4.tgz", + "integrity": "sha512-/pjZsA1b4RPHbeWZQn66SWS8nZZWLQQ23oE3Eam7aroEFGEvwKAsJfZ9ytiEMycfzXWpca4FA9QIOehf7PocBQ==", + "license": "ISC", + "peer": true + }, + "node_modules/mapbox-gl/node_modules/geojson-vt": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/geojson-vt/-/geojson-vt-3.2.1.tgz", + "integrity": "sha512-EvGQQi/zPrDA6zr6BnJD/YhwAkBP8nnJ9emh3EnHQKVMfg/MRVtPbMYdgVy/IaEmn4UfagD2a6fafPDL5hbtwg==", + "license": "ISC", + "peer": true + }, + "node_modules/mapbox-gl/node_modules/quickselect": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/quickselect/-/quickselect-2.0.0.tgz", + "integrity": "sha512-RKJ22hX8mHe3Y6wH/N3wCM6BWtjaxIyyUIkpHOvfFnxdI4yD4tBXEBKSbriGujF6jnSVkJrffuo6vxACiSSxIw==", + "license": "ISC", + "peer": true + }, + "node_modules/mapbox-gl/node_modules/tinyqueue": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/tinyqueue/-/tinyqueue-2.0.3.tgz", + "integrity": "sha512-ppJZNDuKGgxzkHihX8v9v9G5f+18gzaTfrukGrq6ueg0lmH4nqVnA2IPG0AEH3jKEk2GRJCUhDoqpoiw3PHLBA==", + "license": "ISC", + "peer": true + }, + "node_modules/maplibre-gl": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-5.3.1.tgz", + "integrity": "sha512-Ihx+oUUSsZkjMou1Cw5J6silE+5OtFFQSPslWF9+7v4yFC/XDHrpsORYO9lWE4KZI0djCEUpZQJpkpnMArAbeA==", + "license": "BSD-3-Clause", + "dependencies": { + "@mapbox/geojson-rewind": "^0.5.2", + "@mapbox/jsonlint-lines-primitives": "^2.0.2", + "@mapbox/point-geometry": "^0.1.0", + "@mapbox/tiny-sdf": "^2.0.6", + "@mapbox/unitbezier": "^0.0.1", + "@mapbox/vector-tile": "^1.3.1", + "@mapbox/whoots-js": "^3.1.0", + "@maplibre/maplibre-gl-style-spec": "^23.1.0", + "@types/geojson": "^7946.0.16", + "@types/geojson-vt": "3.2.5", + "@types/mapbox__point-geometry": "^0.1.4", + "@types/mapbox__vector-tile": "^1.3.4", + "@types/pbf": "^3.0.5", + "@types/supercluster": "^7.1.3", + "earcut": "^3.0.1", + "geojson-vt": "^4.0.2", + "gl-matrix": "^3.4.3", + "global-prefix": "^4.0.0", + "kdbush": "^4.0.2", + "murmurhash-js": "^1.0.0", + "pbf": "^3.3.0", + "potpack": "^2.0.0", + "quickselect": "^3.0.0", + "supercluster": "^8.0.1", + "tinyqueue": "^3.0.0", + "vt-pbf": "^3.1.3" + }, + "engines": { + "node": ">=16.14.0", + "npm": ">=8.1.0" + }, + "funding": { + "url": "https://github.com/maplibre/maplibre-gl-js?sponsor=1" + } + }, + "node_modules/maplibre-gl-basemaps": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/maplibre-gl-basemaps/-/maplibre-gl-basemaps-0.1.3.tgz", + "integrity": "sha512-7+PF7yBoPYo7hU+a81JWuikrRA757HA6YqsLgEL47syaSiwjSLicfZQk8kGlaRS3NFI7Q7fBSO3T14C8tqbQTQ==", + "license": "ISC", + "peerDependencies": { + "maplibre-gl": ">=1" + } + }, + "node_modules/maplibre-gl-opacity": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/maplibre-gl-opacity/-/maplibre-gl-opacity-1.8.0.tgz", + "integrity": "sha512-qPQ0LL3kTx+77bkrO81QLdkazxoxxxOahpPhx7ALhgGvgugaHe5G+V7yACCdf9RWdOKCJXGxhZf80G0TKEbbPA==", + "license": "MIT" + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -3016,6 +3741,15 @@ "node": ">= 0.6" } }, + "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/mitt": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", @@ -3049,6 +3783,12 @@ "dev": true, "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/nanoid": { "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", @@ -3153,6 +3893,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/perfect-debounce": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz", @@ -3207,6 +3960,12 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/potpack": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/potpack/-/potpack-2.0.0.tgz", + "integrity": "sha512-Q+/tYsFU9r7xoOJ+y/ZTtdVQwTWfzjbiXBDMM/JKUux3+QPP02iUuIoeBQ+Ot6oEDlC+/PGjB/5A3K7KKb7hcw==", + "license": "ISC" + }, "node_modules/pretty-ms": { "version": "9.2.0", "resolved": "https://registry.npmjs.org/pretty-ms/-/pretty-ms-9.2.0.tgz", @@ -3223,12 +3982,33 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "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", "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", "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/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/rfdc": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", @@ -3288,6 +4068,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "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/semver": { "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", @@ -3349,6 +4135,34 @@ "node": ">=18" } }, + "node_modules/sort-asc": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/sort-asc/-/sort-asc-0.1.0.tgz", + "integrity": "sha512-jBgdDd+rQ+HkZF2/OHCmace5dvpos/aWQpcxuyRs9QUbPRnkEJmYVo81PIGpjIdpOcsnJ4rGjStfDHsbn+UVyw==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/sort-desc": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/sort-desc/-/sort-desc-0.1.1.tgz", + "integrity": "sha512-jfZacW5SKOP97BF5rX5kQfJmRVZP5/adDUTY8fCSPvNcXDVpUEe2pr/iKGlcyZzchRJZrswnp68fgk3qBXgkJw==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/sort-object": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/sort-object/-/sort-object-0.3.2.tgz", + "integrity": "sha512-aAQiEdqFTTdsvUFxXm3umdo04J7MRljoVGbBlkH7BgNsMvVNAJyGj7C/wV1A8wHWAJj/YikeZbfuCKqhggNWGA==", + "dependencies": { + "sort-asc": "^0.1.0", + "sort-desc": "^0.1.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", @@ -3381,6 +4195,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "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/superjson": { "version": "2.2.2", "resolved": "https://registry.npmjs.org/superjson/-/superjson-2.2.2.tgz", @@ -3409,6 +4232,12 @@ "node": ">=6" } }, + "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/totalist": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", @@ -3632,6 +4461,17 @@ "vite": "^3.0.0-0 || ^4.0.0-0 || ^5.0.0-0 || ^6.0.0-0" } }, + "node_modules/vt-pbf": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/vt-pbf/-/vt-pbf-3.1.3.tgz", + "integrity": "sha512-2LzDFzt0mZKZ9IpVF2r69G9bXaP2Q2sArJCmcCgvfTdCCZzSyz4aCLoQyUilu37Ll56tCblIZrXFIjNUpGIlmA==", + "license": "MIT", + "dependencies": { + "@mapbox/point-geometry": "0.1.0", + "@mapbox/vector-tile": "^1.3.1", + "pbf": "^3.2.1" + } + }, "node_modules/vue": { "version": "3.5.13", "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.13.tgz", diff --git a/webapp/frontend/package.json b/webapp/frontend/package.json index d25dec4..5499476 100644 --- a/webapp/frontend/package.json +++ b/webapp/frontend/package.json @@ -9,8 +9,10 @@ "preview": "vite preview" }, "dependencies": { + "@mapbox-controls/zoom": "^3.0.0", "@tailwindcss/vite": "^4.1.4", "@vueuse/core": "^13.1.0", + "@watergis/mapbox-gl-legend": "^1.2.6", "axios": "^1.8.4", "chart.js": "^4.4.8", "chartjs-adapter-moment": "^1.0.1", @@ -20,6 +22,11 @@ "leaflet-geometryutil": "^0.10.3", "leaflet-groupedlayercontrol": "^0.6.1", "leaflet.markercluster": "^1.5.3", + "mapbox-gl-controls": "^2.3.5", + "mapbox-gl-infobox": "^1.0.9", + "maplibre-gl": "^5.3.1", + "maplibre-gl-basemaps": "^0.1.3", + "maplibre-gl-opacity": "^1.8.0", "moment": "^2.30.1", "tailwindcss": "^4.1.4", "vue": "^3.5.13", diff --git a/webapp/frontend/src/LayerControl.js b/webapp/frontend/src/LayerControl.js new file mode 100644 index 0000000..c985b6d --- /dev/null +++ b/webapp/frontend/src/LayerControl.js @@ -0,0 +1,304 @@ +export default class LayersControl { + constructor(options) { + // This div will hold all the checkboxes and their labels + this._container = document.createElement('div'); + this._container.style.padding = '12px'; + this._container.classList.add( + // Built-in classes for consistency + 'maplibregl-ctrl', + 'maplibregl-ctrl-group', + // Custom class, see later + 'layers-control', + ); + // Options + this._options = options; + // Map inputs + this._mapInputs = {}; + this.activeMap = null; + // Control inputs + this._controlInputs = {}; + this._activeControl = {}; + this._previousLayers = []; + } + + _createGroupHeader(name) { + const label = document.createElement('label'); + const text = document.createElement('span'); + text.innerText = name; + text.classList.add('layer-group-name'); + label.appendChild(text); + return label; + } + + _createGroupContainer() { + const div = document.createElement('div'); + div.classList.add('layer-group'); + return div; + } + + _createGroup(name, data) { + const container = this._createGroupContainer(); + const header = this._createGroupHeader(name); + container.appendChild(header); + for (const key of Object.keys(data.layers)) { + let input = null; + if (data.type === 'radio') { + input = this._createLabeledRadio(`${name}_${key}`, key, '_control_selection'); + } else if (data.type === 'checkbox') { + input = this._createLabeledCheckbox(`${name}_${key}`, key, '_control_selection'); + } + container.appendChild(input); + } + return container; + } + + applyDefaults() { + for (const name of Object.keys(this._options.controls)) { + const config = this._options.controls[name]; + if (typeof config.default === 'string') { + this._controlInputs[name][config.default].checked = true; + this._doControlAction(this._options.controls[name].layers[config.default]); + } else if (Array.isArray(config.default)) { + for (const id of config.default) { + this._controlInputs[name][id].checked = true; + this._doControlAction(this._options.controls[name].layers[id], true); + } + } + } + } + + _createSeparator() { + const div = document.createElement('div'); + div.classList.add('layers-control-separator'); + return div; + } + + _getLayers() { + return this._map.getLayersOrder().filter(name => !this._mapInputs.hasOwnProperty(name)); + } + + _getVisibleLayers() { + return this._getLayers().filter(layer => { + const visibility = this._map.getLayer(layer).getLayoutProperty('visibility') ?? 'visible'; + return visibility === 'visible'; + }); + } + + _doControlAction(config, value) { + switch(config.type) { + case 'source_filter': + const source = this._map.getSource(config.source); + const data = config.getter().filter(config.filter); + source.setData({ + type: 'FeatureCollection', + features: data, + }); + break; + case 'toggle_element': + if (config.element ?? false) { + document.querySelector(config.element).style.display = value ? 'block' : 'none'; + } + break; + case 'layer_toggle': + if (value) { + this._showLayer(config.layer); + } else { + this._hideLayer(config.layer); + } + break; + case 'layer_control': + this._previousLayers = this._getVisibleLayers(); + if (config.hideAllExcept ?? false) { + const layersToRemove = this._getVisibleLayers().filter(name => !config.hideAllExcept.includes(name)); + for (const id of layersToRemove) { + this._hideLayer(id); + } + for (const id of config.hideAllExcept) { + this._showLayer(id); + } + } + if (config.disableCluster ?? false) { + const style = this._map.getStyle() + style.sources[config.disableCluster].cluster = false; + this._map.setStyle(style); + } + break; + } + } + + _disableLayerTransitions(name) { + const layerType = this._map.getLayer(name)?.type; + if (layerType === 'fill' || layerType === 'line' || layerType === 'circle') { + this._map.setPaintProperty(name, `${layerType}-opacity-transition`, { duration: 0 }); + } else if (layerType === 'symbol') { + this._map.setPaintProperty(name, 'text-opacity-transition', { duration: 0 }); + this._map.setPaintProperty(name, 'icon-opacity-transition', { duration: 0 }); + this._map.setPaintProperty(name, 'text-opacity', 1); + this._map.setPaintProperty(name, 'icon-opacity', 1); + const layout = this._map.getLayoutProperty(name, 'text-field'); + if (layout) { + this._map.setLayoutProperty(name, 'text-optional', false); + } + } + } + + _hideLayer(name) { + console.debug('hideLayer: ', name); + this._disableLayerTransitions(name); + this._map.setLayoutProperty(name, 'visibility', 'none'); + } + + _showLayer(name) { + console.debug('showLayer: ', name); + this._disableLayerTransitions(name); + this._map.setLayoutProperty(name, 'visibility', 'visible'); + } + + _revertControlAction(config) { + switch(config.type) { + case 'source_filter': + const source = this._map.getSource(config.source); + source.setData({ + type: 'FeatureCollection', + features: config.getter(), + }); + break; + case 'toggle_element': + if (config.element ?? false) { + const el = document.querySelector(config.element).style.display; + document.querySelector(config.element).style.display = (el === 'block' ? 'none' : 'block'); + } + break; + case 'layer_toggle': + const visibility = this._map.getLayer(config.layer).getLayoutProperty('visibility') ?? 'visible'; + if (visibility === 'visible') { + this._hideLayer(config.layer); + } else { + this._showLayer(config.layer); + } + break; + case 'layer_control': + // i want to hide all layers in current layers that weren't in previous layers, so all layers in current layers that are not in previous layers + const toHide = this._getVisibleLayers().filter(layer => !this._previousLayers.includes(layer)); + const toShow = this._getLayers().filter(layer => this._previousLayers.includes(layer)); + for (const id of toShow) { + this._showLayer(id); + } + for (const id of toHide) { + this._hideLayer(id); + } + this._previousLayers = []; + if (config.disableCluster ?? false) { + const style = this._map.getStyle() + style.sources[config.disableCluster].cluster = true; + this._map.setStyle(style); + } + break; + } + } + + _createLabeledRadio(id, display, type) { + const label = document.createElement('label'); + const text = document.createElement('span'); + text.innerText = ` ${display}`; + const input = document.createElement('input'); + input.type = 'radio'; + input.name = type; + input.value = id; + input.addEventListener('change', (e) => { + if (type === '_map_control_selection') { + if (this.activeMap != e.target.value) { + this._map.setLayoutProperty(this.activeMap, 'visibility', 'none'); + this._map.setLayoutProperty(e.target.value, 'visibility', 'visible'); + this.activeMap = e.target.value; + } + } else if (type === '_control_selection') { + const [ parent, child ] = id.split('_'); + const layerConfig = this._options.controls[parent].layers[child]; + this._revertControlAction(this._activeControl[parent] ?? {type: ''}); + this._doControlAction(layerConfig); + this._activeControl[parent] = layerConfig; + } + }); + if (type === '_map_control_selection') { + this._mapInputs[id] = input; + } else if (type === '_control_selection') { + const [ parent, child ] = id.split('_'); + this._controlInputs[parent][child] = input; + } + label.appendChild(input); + label.appendChild(text); + return label; + } + + _createLabeledCheckbox(id, display, type) { + const label = document.createElement('label'); + const text = document.createElement('span'); + text.innerText = ` ${display}`; + const input = document.createElement('input'); + input.type = 'checkbox'; + input.name = id; + input.addEventListener('change', (e) => { + if (type === '_control_selection') { + const [ parent, child ] = id.split('_'); + const layerConfig = this._options.controls[parent].layers[child]; + this._revertControlAction(this._activeControl[parent] ?? {type: ''}); + this._doControlAction(layerConfig, e.target.checked); + this._activeControl[parent] = layerConfig; + } + }); + if (type === '_control_selection') { + const [ parent, child ] = id.split('_'); + this._controlInputs[parent][child] = input; + } + label.appendChild(input); + label.appendChild(text); + return label; + } + + onAdd(map) { + this._map = map; + map.on('load', () => { + // create map changer + for (const map of this._options.maps) { + const radioSelection = this._createLabeledRadio(map.id, map.display, '_map_control_selection'); + this._container.appendChild(radioSelection); + } + this._container.appendChild(this._createSeparator()); + // Create the checkboxes and add them to the container + for (const key of Object.keys(this._options.controls)) { + this._controlInputs[key] = {}; + this._activeControl[parent] = {}; + const group = this._createGroup(key, this._options.controls[key]); + this._container.appendChild(group); + } + this._options.maps.forEach(({ id, tiles, sourceExtraParams = {}, layerExtraParams = {} }) => { + map.addSource(id, { + ...sourceExtraParams, + type: 'raster', + tiles, + }); + map.addLayer({ ...layerExtraParams, id, source: id, type: 'raster' }); + if (this._options.initialMap === id) { + map.setLayoutProperty(id, 'visibility', 'visible'); + this._mapInputs[id].checked = true; + this.activeMap = id; + } else { + map.setLayoutProperty(id, 'visibility', 'none'); + } + }); + }); + return this._container; + } + + onRemove(map) { + // Not sure why we have to do this ourselves since we are not the ones + // adding us to the map. + // Copied from their example so keeping it in. + this._container.parentNode.removeChild(this._container); + // This might be to help garbage collection? Also from their example. + // Or perhaps to ensure calls to this object do not change the map still + // after removal. + this._map = undefined; + } + } \ No newline at end of file diff --git a/webapp/frontend/src/LegendControl.js b/webapp/frontend/src/LegendControl.js new file mode 100644 index 0000000..a7721d0 --- /dev/null +++ b/webapp/frontend/src/LegendControl.js @@ -0,0 +1,19 @@ +export default class LegendControl { + onAdd(map) { + this._map = map; + this._container = document.createElement('div'); + this._container.style.backgroundColor = 'white'; + this._container.style.padding = '12px'; + this._container.innerHTML = `
Legend
` + + `
MQTT Connected
` + + `
MQTT Disconnected
` + + `
Offline Too Long
`; + this._container.className = 'maplibregl-ctrl maplibregl-ctrl-group legend-control'; + return this._container; + } + + onRemove() { + this._container.parentNode.removeChild(this._container); + this._map = undefined; + } +} \ No newline at end of file diff --git a/webapp/frontend/src/assets/main.css b/webapp/frontend/src/assets/main.css index 31a5292..0a81b3c 100644 --- a/webapp/frontend/src/assets/main.css +++ b/webapp/frontend/src/assets/main.css @@ -1,13 +1,50 @@ @import "tailwindcss"; -@import "leaflet/dist/leaflet.css"; -@import "leaflet.markercluster/dist/MarkerCluster.css"; -@import "leaflet.markercluster/dist/MarkerCluster.Default.css"; -@import "leaflet-groupedlayercontrol/dist/leaflet.groupedlayercontrol.min.css"; +@import "maplibre-gl/dist/maplibre-gl.css"; +@import 'maplibre-gl-basemaps/lib/basemaps.css'; +@import '@mapbox-controls/zoom/src/index.css'; +@import "mapbox-gl/dist/mapbox-gl.css"; +@import "mapbox-gl-controls/lib/controls.css"; + +.layers-control { + line-height: 1.5; +} + +.layer-group-name { + margin-bottom: .2em; + margin-left: 3px; + font-weight: 700; +} + +.layer-group { + margin-bottom: .5em; +} + + .layers-control label { + font-size: 1.08333em; + display: block; + } + + .layers-control radio { + margin-top: 2px; + position: relative; + top: 1px; + } + + .layers-control-separator { + border-top: 1px solid #ddd; + height: 0; + margin: 5px -10px 5px -6px; + } + /* used to prevent ui flicker before vuejs loads */ [v-cloak] { display: none; } + .maplibregl-ctrl-basemaps:not(.closed) > .hidden { + display: unset !important; + } + .icon-mqtt-connected { background-color: #16a34a; border-radius: 25px; diff --git a/webapp/frontend/src/config.js b/webapp/frontend/src/config.js index 12de632..b027190 100644 --- a/webapp/frontend/src/config.js +++ b/webapp/frontend/src/config.js @@ -2,7 +2,7 @@ import { useStorage } from '@vueuse/core'; // static export const CURRENT_ANNOUNCEMENT_ID = 1; -export const BASE_PATH = ''; +export const BASE_PATH = 'http://localhost:9090'; // string export const temperatureFormat = useStorage('temperature-display', 'fahrenheit'); diff --git a/webapp/frontend/src/map.js b/webapp/frontend/src/map.js index 83800d7..ec5e719 100644 --- a/webapp/frontend/src/map.js +++ b/webapp/frontend/src/map.js @@ -1,18 +1,6 @@ -import moment from 'moment'; -import 'leaflet/dist/leaflet.js'; -const L = window.L; -import 'leaflet.markercluster/dist/leaflet.markercluster.js'; -import 'leaflet-groupedlayercontrol/dist/leaflet.groupedlayercontrol.min.js'; -import { - escapeString, - findNodeById, - getRegionFrequencyRange, - hasNodeUplinkedToMqttRecently, - formatPositionPrecision, - getTerrainProfileImage, -} from './utils.js'; -import { state } from './store.js'; import { markRaw } from 'vue'; +import moment from 'moment'; +import { hasNodeUplinkedToMqttRecently, getRegionFrequencyRange, escapeString, formatPositionPrecision } from './utils.js'; // state/config let instance = null; @@ -24,67 +12,60 @@ export function getMap() { } export const layerGroups = { - nodes: new L.LayerGroup(), - neighbors: new L.LayerGroup(), - waypoints: new L.LayerGroup(), - nodePositionHistory: new L.LayerGroup(), - nodeNeighbors: new L.LayerGroup(), - nodesRouter: L.markerClusterGroup({ - showCoverageOnHover: false, - disableClusteringAtZoom: 10, // zoom level where node clustering is disabled - }), - nodesClustered: L.markerClusterGroup({ - showCoverageOnHover: false, - disableClusteringAtZoom: 10, // zoom level where node clustering is disabled - }), - legend: new L.LayerGroup(), - none: new L.LayerGroup(), -}; - -export const tileLayers = { - "OpenStreetMap": L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { - maxZoom: 22, // increase from 18 to 22 - attribution: 'Tiles © OpenStreetMap | Data from Meshtastic', - }), - "OpenTopoMap": L.tileLayer('https://{s}.tile.opentopomap.org/{z}/{x}/{y}.png', { - maxZoom: 17, // open topo map doesn't have tiles closer than this - attribution: 'Tiles © OpenStreetMap', - }), - "Esri Satellite": L.tileLayer('https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', { - maxZoom: 21, // esri doesn't have tiles closer than this - attribution: 'Tiles © Esri | Data from Meshtastic' - }), - "Google Satellite": L.tileLayer('https://{s}.google.com/vt/lyrs=s&x={x}&y={y}&z={z}', { - maxZoom: 21, - subdomains: ['mt0', 'mt1', 'mt2', 'mt3'], - attribution: 'Tiles © Google | Data from Meshtastic' - }), - "Google Hybrid": L.tileLayer('https://{s}.google.com/vt/lyrs=s,h&x={x}&y={y}&z={z}', { - maxZoom: 21, - subdomains: ['mt0', 'mt1', 'mt2', 'mt3'], - attribution: 'Tiles © Google | Data from Meshtastic' - }), -}; - -export const icons = { - mqttConnected: L.divIcon({ - className: 'icon-mqtt-connected', - iconSize: [16, 16], // increase from 12px to 16px to make hover easier - }), - mqttDisconnected: L.divIcon({ - className: 'icon-mqtt-disconnected', - iconSize: [16, 16], // increase from 12px to 16px to make hover easier - }), - offline: L.divIcon({ - className: 'icon-offline', - iconSize: [16, 16], // increase from 12px to 16px to make hover easier - }), - positionHistory: L.divIcon({ - className: 'icon-position-history', - iconSize: [16, 16], // increase from 12px to 16px to make hover easier - }), + nodes: {}, + neighbors: {}, + waypoints: {}, + nodePositionHistory: {}, + nodeNeighbors: {}, + nodesRouter: {}, + nodesClustered: {}, + legend: {}, + none: {}, }; +export const tileLayers = [ + { + id: 'OpenStreetMap', + display: 'Open Street Map', + tiles: ['https://tile.openstreetmap.org/{z}/{x}/{y}.png'], + sourceExtraParams: { + tileSize: 256, + attribution: 'Tiles © OpenStreetMap | Data from Meshtastic', + maxzoom: 22 + } + }, + { + id: 'OpenTopoMap', + display: 'Open Topo Map', + tiles: ['https://tile.opentopomap.org/{z}/{x}/{y}.png'], + sourceExtraParams: { + tileSize: 256, + attribution: 'Tiles © OpenStreetMap"', + maxzoom: 17 + } + }, + { + id: 'esriSatellite', + display: 'ESRI Satellite', + tiles: ['https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}'], + sourceExtraParams: { + tileSize: 256, + attribution: 'Tiles © Esri | Data from Meshtastic', + maxzoom: 21 + } + }, + { + id: 'googleSatellite', + display: 'Google Satellite', + tiles: ['https://mt0.google.com/vt/lyrs=s&x={x}&y={y}&z={z}'], + sourceExtraParams: { + tileSize: 256, + attribution: 'Tiles © Google | Data from Meshtastic', + maxzoom: 21 + } + } +]; +export const icons = {mqttConnected: '#16a34a', mqttDisconnected: '#2563eb', offline: '#dc2626', positionHistory: '#a855f7'}; // tooltips export function getTooltipContentForWaypoint(waypoint) { @@ -182,109 +163,47 @@ export function getTooltipContentForNode(node) { } export function getNeighbourTooltipContent(type, node, neighbourNode, distanceInMeters, snr) { - // default to showing distance in meters - let distance = `${distanceInMeters} meters`; - - // scale to distance in kms - if (distanceInMeters >= 1000) { - const distanceInKilometers = (distanceInMeters / 1000).toFixed(2); - distance = `${distanceInKilometers} kilometers`; - } - - const terrainImageUrl = type === 'weHeard' ? getTerrainProfileImage(node, neighbourNode) : getTerrainProfileImage(neighbourNode, node); - - const templates = { - 'weHeard': `[${escapeString(node.short_name)}] ${escapeString(node.long_name)} heard [${escapeString(neighbourNode.short_name)}] ${escapeString(neighbourNode.long_name)}` - + `
SNR: ${snr}dB` - + `
Distance: ${distance}` - + `

ID: ${node.node_id} heard ${neighbourNode.node_id}` - + `
Hex ID: ${node.node_id_hex} heard ${neighbourNode.node_id_hex}` - + (node.neighbours_updated_at ? `
Updated: ${moment(new Date(node.neighbours_updated_at)).fromNow()}` : '') - + `

Terrain images from HeyWhatsThat.com` - + `
`, - 'theyHeard': `[${escapeString(neighbourNode.short_name)}] ${escapeString(neighbourNode.long_name)} heard [${escapeString(node.short_name)}] ${escapeString(node.long_name)}` - + `
SNR: ${snr}dB` - + `
Distance: ${distance}` - + `

ID: ${neighbourNode.node_id} heard ${node.node_id}` - + `
Hex ID: ${neighbourNode.node_id_hex} heard ${node.node_id_hex}` - + (neighbourNode.neighbours_updated_at ? `
Updated: ${moment(new Date(neighbourNode.neighbours_updated_at)).fromNow()}` : '') - + `

Terrain images from HeyWhatsThat.com` - + `
`, - } - - return templates[type]; + return ''; } // cleanup export function clearAllNodes() { - layerGroups.nodes.clearLayers(); - layerGroups.nodesClustered.clearLayers(); - layerGroups.nodesRouter.clearLayers(); + }; export function clearAllNeighbors() { - layerGroups.neighbors.clearLayers(); + }; export function clearAllWaypoints() { - layerGroups.waypoints.clearLayers(); + }; export function clearAllPositionHistory() { - layerGroups.nodePositionHistory.clearLayers(); + }; export function cleanUpPositionHistory() { - // close tooltips and popups - closeAllPopups(); - closeAllTooltips(); - // setup node neighbours layer - layerGroups.nodePositionHistory.clearLayers(); - layerGroups.nodePositionHistory.removeFrom(getMap()); - layerGroups.nodePositionHistory.addTo(getMap()); }; export function closeAllTooltips() { - getMap().eachLayer(function(layer) { - if (layer.options.pane === 'tooltipPane') { - layer.removeFrom(getMap()); - } - }); + }; export function closeAllPopups() { - getMap().eachLayer(function(layer) { - if (layer.options.pane === 'popupPane') { - layer.removeFrom(getMap()); - } - }); + }; export function cleanUpNodeNeighbors() { - // close tooltips and popups - closeAllPopups(); - closeAllTooltips(); - // setup node neighbours layer - layerGroups.nodeNeighbors.clearLayers(); - layerGroups.nodeNeighbors.removeFrom(getMap()); - layerGroups.nodeNeighbors.addTo(getMap()); + }; export function clearNodeOutline() { - if (state.selectedNodeOutlineCircle) { - state.selectedNodeOutlineCircle.removeFrom(getMap()); - state.selectedNodeOutlineCircle = null; - } + }; export function clearMap() { - closeAllPopups(); - closeAllTooltips(); - clearAllNodes(); - clearAllNeighbors(); - clearAllWaypoints(); - clearNodeOutline(); - cleanUpNodeNeighbors(); + }; \ No newline at end of file diff --git a/webapp/frontend/src/store.js b/webapp/frontend/src/store.js index 85d7c23..33b5769 100644 --- a/webapp/frontend/src/store.js +++ b/webapp/frontend/src/store.js @@ -5,6 +5,7 @@ export const state = reactive({ nodes: [], waypoints: [], nodeMarkers: {}, + waypointMarkers: [], // state searchText: '', @@ -17,6 +18,7 @@ export const state = reactive({ selectedNodeToShowNeighbours: null, selectedNodeToShowNeighbours: null, selectedNodeToShowNeighboursType: null, + currentPopup: null, // position history selectedNodeToShowPositionHistory: null, diff --git a/webapp/frontend/src/views/HomeView.vue b/webapp/frontend/src/views/HomeView.vue index e0ad1d5..e066ad7 100644 --- a/webapp/frontend/src/views/HomeView.vue +++ b/webapp/frontend/src/views/HomeView.vue @@ -11,12 +11,11 @@ import Announcement from '../components/Announcement.vue'; import axios from 'axios'; import moment from 'moment'; -import 'leaflet/dist/leaflet'; -const L = window.L; -import 'leaflet-geometryutil'; -import 'leaflet-arrowheads'; -import 'leaflet.markercluster'; -import 'leaflet-groupedlayercontrol/dist/leaflet.groupedlayercontrol.min.js'; +import maplibregl from 'maplibre-gl'; +import BasemapsControl from 'maplibre-gl-basemaps'; +import OpacityControl from 'maplibre-gl-opacity'; +import LegendControl from '../LegendControl.js'; +import LayerControl from '../LayerControl.js'; import { onMounted, useTemplateRef, ref, watch, markRaw } from 'vue'; import { state } from '../store.js'; import { @@ -290,6 +289,10 @@ window.showNodeNeighboursThatWeHeard = function(id) { } } +function isValidCoordinates(lat, lng) { + return lat >= -90 && lat <= 90 && lng >= -180 && lng <= 180; +} + function onNodesUpdated(nodes) { const now = moment(); state.nodes = []; @@ -319,12 +322,6 @@ function onNodesUpdated(nodes) { node.latitude = node.latitude / 10000000; node.longitude = node.longitude / 10000000; - // wrap longitude for shortest path, everything to left of australia should be shown on the right - let longitude = parseFloat(node.longitude); - if (longitude <= 100) { - longitude += 360; - } - // icon based on mqtt connection state let icon = icons.mqttDisconnected; @@ -342,116 +339,35 @@ function onNodesUpdated(nodes) { icon = icons.mqttConnected; } + if (!isValidCoordinates(node.latitude, node.longitude)) { + continue; + } + // create node marker - const marker = L.marker([node.latitude, longitude], { - icon: icon, - tagName: node.node_id, - // we want to show online nodes above offline, but without needing to use separate layer groups - zIndexOffset: nodeHasUplinkedToMqttRecently ? 1000 : -1000, - }).on('click', function(event) { - // close tooltip on click to prevent tooltip and popup showing at same time - event.target.closeTooltip(); - }); - - // add marker to node layer groups - marker.addTo(layerGroups.nodes); - layerGroups.nodesClustered.addLayer(marker); - - // add markers for routers and repeaters to routers layer group - if (node.role_name === 'ROUTER' - || node.role_name === 'ROUTER_CLIENT' - || node.role_name === 'ROUTER_LATE' - || node.role_name === 'REPEATER') { - layerGroups.nodesRouter.addLayer(marker); - } - - // show tooltip on desktop only - if (!isMobile()) { - marker.bindTooltip(getTooltipContentForNode(node), { - interactive: true, - }); - } - - // Push node marker to cache - state.nodeMarkers[node.node_id] = marker; - - // show node info tooltip when clicking node marker - marker.on('click', function(event) { - // close all other popups and tooltips - closeAllTooltips(); - closeAllPopups(); - - // find node - const node = findNodeById(event.target.options.tagName); - if (!node){ - return; + const marker = { + type: 'Feature', + properties: { + id: node.node_id, + role: node.role_name, + layer: 'nodes', + color: icon, + }, + geometry: { + type: 'Point', + coordinates: [node.longitude, node.latitude] } + }; - // show position precision outline - showNodeOutline(node.node_id); - - // open tooltip for node - getMap().openTooltip(getTooltipContentForNode(node), event.target.getLatLng(), { - interactive: true, // allow clicking buttons inside tooltip - permanent: true, // don't auto dismiss when clicking buttons inside tooltip - }); - }); + // add maker to cache + state.nodeMarkers[node.node_id] = marker; } - for(const node of nodes) { - // find current node - const currentNode = findNodeMarkerById(node.node_id); - if (!currentNode) { - continue; - } - - // add node neighbours - var polylineOffset = 0; - const neighbours = node.neighbours ?? []; - for( const neighbour of neighbours) { - // fixme: skipping zero snr? saw some crazy long neighbours with zero snr... - if(neighbour.snr === 0){ - continue; - } - const neighbourNode = findNodeById(neighbour.node_id); - if (!neighbourNode) { - continue; - } - const neighbourNodeMarker = findNodeMarkerById(neighbour.node_id); - if (neighbourNodeMarker) { - - // calculate distance in meters between nodes (rounded to 2 decimal places) - const distanceInMeters = currentNode.getLatLng().distanceTo(neighbourNodeMarker.getLatLng()).toFixed(2); - - // don't show this neighbour connection if further than config allows - if (neighboursMaxDistance.value != null && parseFloat(distanceInMeters) > neighboursMaxDistance.value) { - continue; - } - - // add neighbour line to map - const line = L.polyline([ - currentNode.getLatLng(), - neighbourNodeMarker.getLatLng(), - ], { - color: '#2563eb', - opacity: 0.75, - offset: polylineOffset, - }).addTo(layerGroups.neighbors); - - // increase offset so next neighbour does not overlay other neighbours from self - polylineOffset += 2; - - const tooltip = getNeighbourTooltipContent('weHeard', node, neighbourNode, distanceInMeters, neighbour.snr); - line.bindTooltip(tooltip, { - sticky: true, - opacity: 1, - interactive: true, - }).bindPopup(tooltip).on('click', function(event) { - // close tooltip on click to prevent tooltip and popup showing at same time - event.target.closeTooltip(); - }); - - } - } + // set data + const source = getMap().getSource('nodes'); + if (source) { + source.setData({ + type: 'FeatureCollection', + features: Object.values(state.nodeMarkers), + }); } } @@ -487,42 +403,37 @@ function onWaypointsUpdated(waypoints) { waypoint.latitude = waypoint.latitude / 10000000; waypoint.longitude = waypoint.longitude / 10000000; - // wrap longitude for shortest path, everything to left of australia should be shown on the right - let longitude = parseFloat(waypoint.longitude); - if (longitude <= 100) { - longitude += 360; - } - // determine emoji to show as marker icon const emoji = waypoint.icon === 0 ? 128205 : waypoint.icon; const emojiText = String.fromCodePoint(emoji); - let tooltip = getTooltipContentForWaypoint(waypoint); - - // create waypoint marker - const marker = L.marker([waypoint.latitude, longitude], { - icon: L.divIcon({ - className: 'waypoint-label', - iconSize: [26, 26], // increase from 12px to 26px - html: emojiText, - }), - }).bindPopup(tooltip).on('click', function(event) { - // close tooltip on click to prevent tooltip and popup showing at same time - event.target.closeTooltip(); - }); - - // show tooltip on desktop only - if (!isMobile()) { - marker.bindTooltip(tooltip, { - interactive: true, - }); + if (!isValidCoordinates(node.latitude, node.longitude)) { + continue; } - // add marker to waypoints layer groups - marker.addTo(layerGroups.waypoints) - - // add to cache state.waypoints.push(waypoint); + + // create waypoint marker + const marker = { + type: 'Feature', + properties: { + layer: 'waypoints', + }, + geometry: { + type: 'Point', + coordinates: [waypoint.longitude, waypoint.latitude] + } + }; + // add maker to cache + state.waypointMarkers.push(marker); + } + // set data + const source = getMap().getSource('waypoints'); + if (source) { + source.setData({ + type: 'FeatureCollection', + features: Object.values(state.waypointMarkers), + }); } } @@ -661,19 +572,6 @@ function reload(goToNodeId, zoom) { onNodesUpdated(response.data.nodes); // hide loading state.loading = false; - // fetch waypoints (after awaiting nodes, so we can use nodes cache in waypoint tooltips) - axios.get(buildPath('/api/v1/waypoints')).then(response => { - onWaypointsUpdated(response.data.waypoints); - }); - // go to node id if provided - if (goToNodeId) { - // go to node - if(goToNode(goToNodeId, false, zoom)) { - return; - } - // fallback to showing node details since we can't go to the node - window.showNodeDetails(goToNodeId); - } }); } @@ -732,159 +630,363 @@ function onSearchResultNodeClick(node) { } onMounted(() => { - // set map bounds to be a little more than full size to prevent panning off screen const bounds = [ [-100, 70], // top left [100, 500], // bottom right ]; - // create map positioned over AU and NZ - setMap(L.map(mapEl.value, { - maxBounds: bounds, - })); - // set view - getMap().setView([-15, 150], 2); - // remove leaflet link - getMap().attributionControl.setPrefix(''); - // use tile layer based on config - const selectedTileLayer = tileLayers[selectedTileLayerName.value] || tileLayers['OpenStreetMap']; - selectedTileLayer.addTo(getMap()); - - // handle baselayerchange to update tile layer preference - getMap().on('baselayerchange', function(event) { - selectedTileLayerName.value = event.name; + const map = new maplibregl.Map({ + container: mapEl.value, + attributionControl: false, + glyphs: 'https://demotiles.maplibre.org/font/{fontstack}/{range}.pbf', + style: { + version: 8, + sources: {}, + layers: [], + glyphs: 'https://demotiles.maplibre.org/font/{fontstack}/{range}.pbf', + }, + //center: [-15, 150], + center: [0, 0], + zoom: 2, + fadeDuration: 0, + renderWorldCopies: false + //maxBounds: [[-180, -85], [180, 85]] }); + setMap(map); + map.addControl(new maplibregl.AttributionControl({ compact: true }), 'bottom-right'); + // Add zoom and rotation controls to the map. + map.addControl(new maplibregl.NavigationControl({ + visualizePitch: false, + visualizeRoll: false, + showZoom: true, + showCompass: false, + }), 'top-left'); + const layerControl = new LayerControl({ + maps: tileLayers, + initialMap: 'OpenStreetMap', + controls: { + Nodes: { + type: 'radio', + default: 'Clustered', + layers: { + 'All': { + type: 'layer_control', + hideAllExcept: ['nodes'], + disableCluster: 'nodes', + }, + 'Routers': { + type: 'source_filter', + source: 'nodes', + getter: function() { return Object.values(state.nodeMarkers) }, + filter: function (node) { return ['ROUTER', 'ROUTER_CLIENT', 'ROUTER_LATE', 'REPEATER'].includes(node.properties.role); } + }, + 'Clustered': { + type: 'layer_control', + hideAllExcept: ['clusters', 'unclustered-points', 'cluster-count'], + }, + 'None': { + type: 'layer_control', + hideAllExcept: [], + }, + }, + }, + Overlays: { + type: 'checkbox', + default: ['Legend', 'Position History'], + layers: { + 'Legend': { + type: 'toggle_element', + element: '.legend-control', + }, + 'Neighbors': {}, + 'Waypoints': { + type: 'layer_toggle', + layer: 'waypoints', + }, + 'Position History': {}, + } + }, + } + }); + map.addControl(layerControl, 'top-right'); + map.addControl(new LegendControl(), 'bottom-left'); - // create legend - const legend = L.control({position: 'bottomleft'}); - legend.onAdd = function (map) { - const div = L.DomUtil.create('div', 'leaflet-control-layers'); - div.style.backgroundColor = 'white'; - div.style.padding = '12px'; - div.innerHTML = `
Legend
` - + `
MQTT Connected
` - + `
MQTT Disconnected
` - + `
Offline Too Long
`; - return div; - }; - // handle adding/remove legend on map (can't use L.Control as an overlay, so we toggle an empty L.LayerGroup) - getMap().on('overlayadd overlayremove', function(event) { - if (event.name === 'Legend') { - if (event.type === 'overlayadd') { - getMap().addControl(legend); - } else if(event.type === 'overlayremove') { - getMap().removeControl(legend); + map.doubleClickZoom.disable(); // optional but recommended for clean UX + + map.on('load', () => { + map.addSource('nodes', { + type: 'geojson', + cluster: true, + clusterMaxZoom: 14, + clusterRadius: 50, + data: { + type: 'FeatureCollection', + features: [], } - } - }); - // add layers to control ui - L.control.groupedLayers(tileLayers, { - 'Nodes': { - 'All': layerGroups.nodes, - 'Routers': layerGroups.nodesRouter, - 'Clustered': layerGroups.nodesClustered, - 'None': layerGroups.none, - }, - 'Overlays': { - 'Legend': layerGroups.legend, - 'Neighbors': layerGroups.neighbors, - 'Waypoints': layerGroups.waypoints, - 'Position History': layerGroups.nodePositionHistory, - }, - }, { - // make the "Nodes" group exclusive (use radio inputs instead of checkbox) - exclusiveGroups: ['Nodes'], - }).addTo(getMap()); - - // enable base layers - layerGroups.nodesClustered.addTo(getMap()); - - // enable overlay layers based on config - if (enabledOverlayLayers.value.includes('Legend')) { - layerGroups.legend.addTo(getMap()); - } - if (enabledOverlayLayers.value.includes('Neighbors')) { - layerGroups.neighbors.addTo(getMap()); - } - if (enabledOverlayLayers.value.includes('Waypoints')) { - layerGroups.waypoints.addTo(getMap()); - } - if (enabledOverlayLayers.value.includes('Position History')) { - layerGroups.nodePositionHistory.addTo(getMap()); - } - // update config when map overlay is added/removed - getMap().on('overlayremove', function(event) { - const layerName = event.name; - enabledOverlayLayers.value = enabledOverlayLayers.value.filter(function(enabledOverlayLayer) { - return enabledOverlayLayer !== layerName; }); - }); - - getMap().on('overlayadd', function(event) { - const layerName = event.name; - if (!enabledOverlayLayers.value.includes(layerName)) { - enabledOverlayLayers.value.push(layerName); - } - }); - - getMap().on('click', function(event) { - // remove outline when map clicked - clearNodeOutline(); - // clear search - state.searchText = ''; - state.mobileSearchVisible = false; - // do nothing when clicking inside tooltip - const clickedElement = event.originalEvent.target; - if (elementOrAnyAncestorHasClass(clickedElement, 'leaflet-tooltip')) { - return; - } - - closeAllTooltips(); - closeAllPopups(); - }); - - // auto update url when lat/lng/zoom changes - getMap().on('moveend zoomend', function() { - // check if user enabled auto updating position in url - if (!autoUpdatePositionInUrl.value) { - return; - } - - // get map info - const latLng = getMap().getCenter(); - const zoom = getMap().getZoom(); - - // construct new url - const url = new URL(window.location.href); - url.searchParams.set('lat', latLng.lat); - url.searchParams.set('lng', latLng.lng); - url.searchParams.set('zoom', zoom); - - // update current url - if(window.history.replaceState){ - window.history.replaceState(null, null, url.toString()); - } - }); - - // parse url params - const queryParams = new URLSearchParams(location.search); - const queryNodeId = queryParams.get('node_id'); - const queryLat = queryParams.get('lat'); - const queryLng = queryParams.get('lng'); - const queryZoom = queryParams.get('zoom'); - - // go to lat/lng if provided - if(queryLat && queryLng){ - const zoomLevel = queryZoom || goToNodeZoomLevel.value - getMap().flyTo([queryLat, queryLng], parseFloat(zoomLevel), { - animate: false, + map.addSource('waypoints', { + type: 'geojson', + data: { + type: 'FeatureCollection', + features: [], + } + }); + map.addLayer({ + id: 'clusters', + type: 'circle', + source: 'nodes', + filter: ['has', 'point_count'], + paint: { + 'circle-color': [ + 'step', + ['get', 'point_count'], + '#6ecc3999', // small clusters + 10, '#f0c20c99', + 30, '#f1801799' // larger clusters + ], + 'circle-radius': [ + 'step', + ['get', 'point_count'], + 15, 10, 20, 30, 25 + ] + } + }); + map.addLayer({ + id: 'cluster-count', + type: 'symbol', + source: 'nodes', + filter: ['has', 'point_count'], + layout: { + 'text-field': '{point_count_abbreviated}', + 'text-font': ['Open Sans Regular','Arial Unicode MS Regular'], + 'text-size': 12 + } + }); + map.addLayer({ + id: 'unclustered-points', + type: 'circle', + source: 'nodes', + filter: ['!', ['has', 'point_count']], + paint: { + 'circle-radius': 6, + 'circle-color': ['coalesce', ['get', 'color'], '#cccccc'], + 'circle-stroke-width': 1, + 'circle-stroke-color': '#ffffff' + } + }); + map.addLayer({ + id: 'nodes', + type: 'circle', + source: 'nodes', + layout: { + visibility: 'none', + }, + paint: { + 'circle-radius': 6, + 'circle-color': ['coalesce', ['get', 'color'], '#cccccc'], + 'circle-stroke-width': 1, + 'circle-stroke-color': '#ffffff' + } + }); + map.addLayer({ + id: 'waypoints', + type: 'circle', + source: 'waypoints', + layout: { + visibility: 'none', + }, + paint: { + 'circle-radius': 6, + 'circle-color': ['coalesce', ['get', 'color'], '#cccccc'], + 'circle-stroke-width': 1, + 'circle-stroke-color': '#ffffff' + } }); - } - // reload and go to provided node id - reload(queryNodeId, queryZoom); + map.on('mouseenter', 'clusters', () => { + map.getCanvas().style.cursor = 'pointer'; + }); + + map.on('mouseleave', 'clusters', () => { + map.getCanvas().style.cursor = ''; + }); + + map.on('click', 'clusters', async (e) => { + const features = map.queryRenderedFeatures(e.point, { + layers: ['clusters'] // Ensure you are targeting the 'clusters' layer + }); + if (!features.length) return; + const cluster = features[0]; // Get the clicked cluster feature + const clusterId = cluster.properties.cluster_id; + const zoom = await map.getSource('nodes').getClusterExpansionZoom(clusterId); + map.easeTo({ + center: features[0].geometry.coordinates, + zoom: zoom, + }); + }); + + // When a click event occurs on a feature in + // the unclustered-point layer, open a popup at + // the location of the feature, with + // description HTML from its properties. + map.on('click', 'unclustered-points', showPopupForEvent); + map.on('mouseenter', 'unclustered-points', showPopupForEvent); + map.on('click', 'nodes', showPopupForEvent); + map.on('mouseenter', 'nodes', showPopupForEvent); + + layerControl.applyDefaults(); + reload(); + }) }); +function measurePopupSize(htmlContent) { + const popupContainer = document.createElement('div'); + popupContainer.className = 'maplibregl-popup maplibregl-popup-anchor-bottom'; + popupContainer.style.position = 'absolute'; + popupContainer.style.top = '-9999px'; + popupContainer.style.left = '-9999px'; + popupContainer.style.visibility = 'hidden'; + + const content = document.createElement('div'); + content.className = 'maplibregl-popup-content'; + content.innerHTML = htmlContent; + + popupContainer.appendChild(content); + document.body.appendChild(popupContainer); + + const width = popupContainer.offsetWidth; + const height = popupContainer.offsetHeight; + + document.body.removeChild(popupContainer); + + return { width, height }; +} + +function showPopupForEvent(e) { + if (e.features[0].popup) { + return; // already has a popup open + } + const map = getMap(); + const coordinates = e.features[0].geometry.coordinates.slice(); + const nodeId = e.features[0].properties.id; + const html = getTooltipContentForNode(findNodeById(nodeId)); + + const mapContainer = map.getContainer(); + const mapWidth = mapContainer.offsetWidth; + const mapHeight = mapContainer.offsetHeight; + + // Get screen point of the marker + const screenPoint = map.project(coordinates); + + // Measure the popup size + const { width: popupWidth, height: popupHeight } = measurePopupSize(html); + + // Calculate available space around the marker + const padding = 10; + const headerHeight = document.querySelector('header')?.offsetHeight || 0; + + const space = { + left: screenPoint.x - padding, + right: mapWidth - screenPoint.x - padding, + top: screenPoint.y - headerHeight - padding, + bottom: mapHeight - screenPoint.y - padding, + }; + + const popupOptions = { + 'Top-Left': { // popup below, caret top left + anchor: 'top-left', + veriticalDeficit: Math.max(popupHeight - space.bottom, 0), + horizontalDeficit: Math.max(popupWidth - space.right, 0), + }, + 'Top-Right': { // popup below, caret top right + anchor: 'top-right', + veriticalDeficit: Math.max(popupHeight - space.bottom, 0), + horizontalDeficit: Math.max(popupWidth - space.left, 0), + }, + 'Bottom-Right': { // popup above, caret bottom right + anchor: 'bottom-right', + veriticalDeficit: Math.max(popupHeight - space.top, 0), + horizontalDeficit: Math.max(popupWidth - space.left, 0), + }, + 'Bottom-Left': { // popup above, caret bottom left + anchor: 'bottom-left', + veriticalDeficit: Math.max(popupHeight - space.top, 0), + horizontalDeficit: Math.max(popupWidth - space.right, 0), + }, + 'Left': { + anchor: 'right', + veriticalDeficit: Math.max(Math.max(((popupHeight / 2) - space.bottom), 0) + Math.max(((popupHeight/2) - space.top), 0), 0), + horizontalDeficit: Math.max(popupWidth - space.left, 0), + }, + 'Right': { + anchor: 'left', + veriticalDeficit: Math.max(Math.max(((popupHeight / 2) - space.bottom), 0) + Math.max(((popupHeight/2) - space.top), 0), 0), + horizontalDeficit: Math.max(popupWidth - space.right, 0), + }, + 'Above': { + anchor: 'bottom', + veriticalDeficit: Math.max(popupHeight - space.top, 0), + horizontalDeficit: Math.max(Math.max(((popupWidth / 2) - space.left), 0) + Math.max(((popupWidth / 2) - space.right), 0), 0), + }, + 'Below': { + anchor: 'top', + veriticalDeficit: Math.max(popupHeight - space.bottom, 0), + horizontalDeficit: Math.max(Math.max(((popupWidth / 2) - space.left), 0) + Math.max(((popupWidth / 2) - space.right), 0), 0), + }, + }; + + let bestOption = null; + let bestScore = -Infinity; + const preferredAnchors = ['Left', 'Right', 'Above', 'Below']; + + Object.entries(popupOptions).forEach(([key, option]) => { + const verticalCutoff = option.veriticalDeficit / popupHeight; + const horizontalCutoff = option.horizontalDeficit / popupWidth; + const totalCutoff = verticalCutoff + horizontalCutoff; + const visibilityScore = 1 - totalCutoff; + + option.visibilityScore = visibilityScore; // for debugging/logging + + if ( + visibilityScore > bestScore || + ( + Math.abs(visibilityScore - bestScore) < 0.01 && // within tolerance + bestOption && preferredAnchors.includes(key) && !preferredAnchors.includes(bestOption) + ) + ) { + bestScore = visibilityScore; + bestOption = key; + } + }); + + const popup = new maplibregl.Popup({ + closeButton: true, + closeOnClick: true, + anchor: popupOptions[bestOption].anchor, + }); + + popup + .setLngLat(coordinates) + .setHTML(html) + .addTo(map); + + if (e.type === 'mouseenter') { + const removePopup = () => { + popup.remove(); + getMap().off('mousemove', onMouseMove); + getMap().getCanvas().style.cursor = ''; + }; + const onMouseMove = (e) => { + const features = getMap().queryRenderedFeatures(e.point, { layers: ['unclustered-points'] }); + if (!features.length) { + removePopup(); + } + }; + getMap().on('mousemove', onMouseMove); + } + e.features[0].popup = popup; +} + onMounted(() => { if (lastSeenAnnouncementId.value !== CURRENT_ANNOUNCEMENT_ID) { state.announcementVisible = true; @@ -898,12 +1000,14 @@ onMounted(() => {