diff --git a/.env.example b/.env.example index a12311fe4..1a72353d9 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,4 @@ VITE_MEILISEARCH_HOST=https://search.bettergov.ph VITE_MEILISEARCH_PORT=443 -VITE_MEILISEARCH_SEARCH_API_KEY= # Meilisearch Search API Key \ No newline at end of file +VITE_MEILISEARCH_SEARCH_API_KEY= # Meilisearch Search API Key +VITE_MAPBOX_ACCESS_TOKEN= \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 2294d36ec..e7b80a312 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,6 +26,7 @@ "jsdom": "^26.1.0", "leaflet": "^1.9.4", "lucide-react": "^0.513.0", + "mapbox-gl": "^3.16.0", "meilisearch": "^0.50.0", "nuqs": "^2.6.0", "react": "19.1.0", @@ -75,221 +76,12 @@ "vite": "^5.4.2" } }, - "node_modules/@algolia/abtesting": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@algolia/abtesting/-/abtesting-1.3.0.tgz", - "integrity": "sha512-KqPVLdVNfoJzX5BKNGM9bsW8saHeyax8kmPFXul5gejrSPN3qss7PgsFH5mMem7oR8tvjvNkia97ljEYPYCN8Q==", - "license": "MIT", - "peer": true, - "dependencies": { - "@algolia/client-common": "5.37.0", - "@algolia/requester-browser-xhr": "5.37.0", - "@algolia/requester-fetch": "5.37.0", - "@algolia/requester-node-http": "5.37.0" - }, - "engines": { - "node": ">= 14.0.0" - } - }, - "node_modules/@algolia/client-abtesting": { - "version": "5.37.0", - "resolved": "https://registry.npmjs.org/@algolia/client-abtesting/-/client-abtesting-5.37.0.tgz", - "integrity": "sha512-Dp2Zq+x9qQFnuiQhVe91EeaaPxWBhzwQ6QnznZQnH9C1/ei3dvtmAFfFeaTxM6FzfJXDLvVnaQagTYFTQz3R5g==", - "license": "MIT", - "peer": true, - "dependencies": { - "@algolia/client-common": "5.37.0", - "@algolia/requester-browser-xhr": "5.37.0", - "@algolia/requester-fetch": "5.37.0", - "@algolia/requester-node-http": "5.37.0" - }, - "engines": { - "node": ">= 14.0.0" - } - }, - "node_modules/@algolia/client-analytics": { - "version": "5.37.0", - "resolved": "https://registry.npmjs.org/@algolia/client-analytics/-/client-analytics-5.37.0.tgz", - "integrity": "sha512-wyXODDOluKogTuZxRII6mtqhAq4+qUR3zIUJEKTiHLe8HMZFxfUEI4NO2qSu04noXZHbv/sRVdQQqzKh12SZuQ==", - "license": "MIT", - "peer": true, - "dependencies": { - "@algolia/client-common": "5.37.0", - "@algolia/requester-browser-xhr": "5.37.0", - "@algolia/requester-fetch": "5.37.0", - "@algolia/requester-node-http": "5.37.0" - }, - "engines": { - "node": ">= 14.0.0" - } - }, - "node_modules/@algolia/client-common": { - "version": "5.37.0", - "resolved": "https://registry.npmjs.org/@algolia/client-common/-/client-common-5.37.0.tgz", - "integrity": "sha512-GylIFlPvLy9OMgFG8JkonIagv3zF+Dx3H401Uo2KpmfMVBBJiGfAb9oYfXtplpRMZnZPxF5FnkWaI/NpVJMC+g==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">= 14.0.0" - } - }, - "node_modules/@algolia/client-insights": { - "version": "5.37.0", - "resolved": "https://registry.npmjs.org/@algolia/client-insights/-/client-insights-5.37.0.tgz", - "integrity": "sha512-T63afO2O69XHKw2+F7mfRoIbmXWGzgpZxgOFAdP3fR4laid7pWBt20P4eJ+Zn23wXS5kC9P2K7Bo3+rVjqnYiw==", - "license": "MIT", - "peer": true, - "dependencies": { - "@algolia/client-common": "5.37.0", - "@algolia/requester-browser-xhr": "5.37.0", - "@algolia/requester-fetch": "5.37.0", - "@algolia/requester-node-http": "5.37.0" - }, - "engines": { - "node": ">= 14.0.0" - } - }, - "node_modules/@algolia/client-personalization": { - "version": "5.37.0", - "resolved": "https://registry.npmjs.org/@algolia/client-personalization/-/client-personalization-5.37.0.tgz", - "integrity": "sha512-1zOIXM98O9zD8bYDCJiUJRC/qNUydGHK/zRK+WbLXrW1SqLFRXECsKZa5KoG166+o5q5upk96qguOtE8FTXDWQ==", - "license": "MIT", - "peer": true, - "dependencies": { - "@algolia/client-common": "5.37.0", - "@algolia/requester-browser-xhr": "5.37.0", - "@algolia/requester-fetch": "5.37.0", - "@algolia/requester-node-http": "5.37.0" - }, - "engines": { - "node": ">= 14.0.0" - } - }, - "node_modules/@algolia/client-query-suggestions": { - "version": "5.37.0", - "resolved": "https://registry.npmjs.org/@algolia/client-query-suggestions/-/client-query-suggestions-5.37.0.tgz", - "integrity": "sha512-31Nr2xOLBCYVal+OMZn1rp1H4lPs1914Tfr3a34wU/nsWJ+TB3vWjfkUUuuYhWoWBEArwuRzt3YNLn0F/KRVkg==", - "license": "MIT", - "peer": true, - "dependencies": { - "@algolia/client-common": "5.37.0", - "@algolia/requester-browser-xhr": "5.37.0", - "@algolia/requester-fetch": "5.37.0", - "@algolia/requester-node-http": "5.37.0" - }, - "engines": { - "node": ">= 14.0.0" - } - }, - "node_modules/@algolia/client-search": { - "version": "5.37.0", - "resolved": "https://registry.npmjs.org/@algolia/client-search/-/client-search-5.37.0.tgz", - "integrity": "sha512-DAFVUvEg+u7jUs6BZiVz9zdaUebYULPiQ4LM2R4n8Nujzyj7BZzGr2DCd85ip4p/cx7nAZWKM8pLcGtkTRTdsg==", - "license": "MIT", - "peer": true, - "dependencies": { - "@algolia/client-common": "5.37.0", - "@algolia/requester-browser-xhr": "5.37.0", - "@algolia/requester-fetch": "5.37.0", - "@algolia/requester-node-http": "5.37.0" - }, - "engines": { - "node": ">= 14.0.0" - } - }, "node_modules/@algolia/events": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/@algolia/events/-/events-4.0.1.tgz", "integrity": "sha512-FQzvOCgoFXAbf5Y6mYozw2aj5KCJoA3m4heImceldzPSMbdyS4atVjJzXKMsfX3wnZTFYwkkt8/z8UesLHlSBQ==", "license": "MIT" }, - "node_modules/@algolia/ingestion": { - "version": "1.37.0", - "resolved": "https://registry.npmjs.org/@algolia/ingestion/-/ingestion-1.37.0.tgz", - "integrity": "sha512-pkCepBRRdcdd7dTLbFddnu886NyyxmhgqiRcHHaDunvX03Ij4WzvouWrQq7B7iYBjkMQrLS8wQqSP0REfA4W8g==", - "license": "MIT", - "peer": true, - "dependencies": { - "@algolia/client-common": "5.37.0", - "@algolia/requester-browser-xhr": "5.37.0", - "@algolia/requester-fetch": "5.37.0", - "@algolia/requester-node-http": "5.37.0" - }, - "engines": { - "node": ">= 14.0.0" - } - }, - "node_modules/@algolia/monitoring": { - "version": "1.37.0", - "resolved": "https://registry.npmjs.org/@algolia/monitoring/-/monitoring-1.37.0.tgz", - "integrity": "sha512-fNw7pVdyZAAQQCJf1cc/ih4fwrRdQSgKwgor4gchsI/Q/ss9inmC6bl/69jvoRSzgZS9BX4elwHKdo0EfTli3w==", - "license": "MIT", - "peer": true, - "dependencies": { - "@algolia/client-common": "5.37.0", - "@algolia/requester-browser-xhr": "5.37.0", - "@algolia/requester-fetch": "5.37.0", - "@algolia/requester-node-http": "5.37.0" - }, - "engines": { - "node": ">= 14.0.0" - } - }, - "node_modules/@algolia/recommend": { - "version": "5.37.0", - "resolved": "https://registry.npmjs.org/@algolia/recommend/-/recommend-5.37.0.tgz", - "integrity": "sha512-U+FL5gzN2ldx3TYfQO5OAta2TBuIdabEdFwD5UVfWPsZE5nvOKkc/6BBqP54Z/adW/34c5ZrvvZhlhNTZujJXQ==", - "license": "MIT", - "peer": true, - "dependencies": { - "@algolia/client-common": "5.37.0", - "@algolia/requester-browser-xhr": "5.37.0", - "@algolia/requester-fetch": "5.37.0", - "@algolia/requester-node-http": "5.37.0" - }, - "engines": { - "node": ">= 14.0.0" - } - }, - "node_modules/@algolia/requester-browser-xhr": { - "version": "5.37.0", - "resolved": "https://registry.npmjs.org/@algolia/requester-browser-xhr/-/requester-browser-xhr-5.37.0.tgz", - "integrity": "sha512-Ao8GZo8WgWFABrU7iq+JAftXV0t+UcOtCDL4mzHHZ+rQeTTf1TZssr4d0vIuoqkVNnKt9iyZ7T4lQff4ydcTrw==", - "license": "MIT", - "peer": true, - "dependencies": { - "@algolia/client-common": "5.37.0" - }, - "engines": { - "node": ">= 14.0.0" - } - }, - "node_modules/@algolia/requester-fetch": { - "version": "5.37.0", - "resolved": "https://registry.npmjs.org/@algolia/requester-fetch/-/requester-fetch-5.37.0.tgz", - "integrity": "sha512-H7OJOXrFg5dLcGJ22uxx8eiFId0aB9b0UBhoOi4SMSuDBe6vjJJ/LeZyY25zPaSvkXNBN3vAM+ad6M0h6ha3AA==", - "license": "MIT", - "peer": true, - "dependencies": { - "@algolia/client-common": "5.37.0" - }, - "engines": { - "node": ">= 14.0.0" - } - }, - "node_modules/@algolia/requester-node-http": { - "version": "5.37.0", - "resolved": "https://registry.npmjs.org/@algolia/requester-node-http/-/requester-node-http-5.37.0.tgz", - "integrity": "sha512-npZ9aeag4SGTx677eqPL3rkSPlQrnzx/8wNrl1P7GpWq9w/eTmRbOq+wKrJ2r78idlY0MMgmY/mld2tq6dc44g==", - "license": "MIT", - "peer": true, - "dependencies": { - "@algolia/client-common": "5.37.0" - }, - "engines": { - "node": ">= 14.0.0" - } - }, "node_modules/@ampproject/remapping": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", @@ -746,7 +538,7 @@ "version": "4.20250607.0", "resolved": "https://registry.npmjs.org/@cloudflare/workers-types/-/workers-types-4.20250607.0.tgz", "integrity": "sha512-OYmKNzC2eQy6CNj+j0go8Ut3SezjsprCgJyEaBzJql+473WAN9ndVnNZy9lj/tTyLV6wzpQkZWmRAKGDmacvkg==", - "devOptional": true, + "dev": true, "license": "MIT OR Apache-2.0" }, "node_modules/@commitlint/cli": { @@ -2417,6 +2209,52 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "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-supported": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@mapbox/mapbox-gl-supported/-/mapbox-gl-supported-3.0.0.tgz", + "integrity": "sha512-2XghOwu16ZwPJLOFVuIOaLbN0iKMn867evzXFyf0P22dqugezfJwLmdanAgU25ITvz1TvOfVP4jsDImlDJzcWg==" + }, + "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==" + }, + "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==" + }, + "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==" + }, + "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==", + "dependencies": { + "@mapbox/point-geometry": "~1.1.0", + "@types/geojson": "^7946.0.16", + "pbf": "^4.0.1" + } + }, + "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==", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/@meilisearch/instant-meilisearch": { "version": "0.26.0", "resolved": "https://registry.npmjs.org/@meilisearch/instant-meilisearch/-/instant-meilisearch-0.26.0.tgz", @@ -3801,9 +3639,16 @@ "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/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==", + "dependencies": { + "@types/geojson": "*" + } + }, "node_modules/@types/glob": { "version": "8.1.0", "resolved": "https://registry.npmjs.org/@types/glob/-/glob-8.1.0.tgz", @@ -3843,6 +3688,11 @@ "@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==" + }, "node_modules/@types/minimatch": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-5.1.2.tgz", @@ -3860,11 +3710,16 @@ "undici-types": "~7.12.0" } }, + "node_modules/@types/pbf": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@types/pbf/-/pbf-3.0.5.tgz", + "integrity": "sha512-j3pOPiEcWZ34R6a6mN07mUkM4o4Lwf6hPNt8eilOeZhTFbxFXmKhvXl9Y28jotFPaI1bpPDJsbCprUoNke6OrA==" + }, "node_modules/@types/prop-types": { "version": "15.7.13", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.13.tgz", "integrity": "sha512-hCZTSvwbzWGvhqxp/RqVqwU999pBf2vp7hzIjiYOsl8wqOmUxkQ6ddw1cV3l8811+kdUFus/q4d1Y3E3SyEifA==", - "devOptional": true + "dev": true }, "node_modules/@types/qs": { "version": "6.14.0", @@ -3876,7 +3731,7 @@ "version": "18.3.11", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.11.tgz", "integrity": "sha512-r6QZ069rFTjrEYgFdOck1gK7FLVsgJE7tTz0pQBczlBNUhBNk0MQH4UbnFSwjpQLMkLzgqvBBa+qGpLje16eTQ==", - "devOptional": true, + "dev": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.0.2" @@ -3886,7 +3741,7 @@ "version": "18.3.0", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.0.tgz", "integrity": "sha512-EhwApuTmMBmXuFOikhQLIBUn6uFg81SwLMOAUgodJF14SOBOCMdU04gDoYi0WOJJHD144TL32z4yDqCW3dnkQg==", - "devOptional": true, + "dev": true, "dependencies": { "@types/react": "*" } @@ -3901,6 +3756,14 @@ "react-leaflet": "*" } }, + "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==", + "dependencies": { + "@types/geojson": "*" + } + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.8.1", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.8.1.tgz", @@ -4215,32 +4078,6 @@ "url": "https://github.com/sponsors/epoberezkin" } }, - "node_modules/algoliasearch": { - "version": "5.37.0", - "resolved": "https://registry.npmjs.org/algoliasearch/-/algoliasearch-5.37.0.tgz", - "integrity": "sha512-y7gau/ZOQDqoInTQp0IwTOjkrHc4Aq4R8JgpmCleFwiLl+PbN2DMWoDUWZnrK8AhNJwT++dn28Bt4NZYNLAmuA==", - "license": "MIT", - "peer": true, - "dependencies": { - "@algolia/abtesting": "1.3.0", - "@algolia/client-abtesting": "5.37.0", - "@algolia/client-analytics": "5.37.0", - "@algolia/client-common": "5.37.0", - "@algolia/client-insights": "5.37.0", - "@algolia/client-personalization": "5.37.0", - "@algolia/client-query-suggestions": "5.37.0", - "@algolia/client-search": "5.37.0", - "@algolia/ingestion": "1.37.0", - "@algolia/monitoring": "1.37.0", - "@algolia/recommend": "5.37.0", - "@algolia/requester-browser-xhr": "5.37.0", - "@algolia/requester-fetch": "5.37.0", - "@algolia/requester-node-http": "5.37.0" - }, - "engines": { - "node": ">= 14.0.0" - } - }, "node_modules/algoliasearch-helper": { "version": "3.25.0", "resolved": "https://registry.npmjs.org/algoliasearch-helper/-/algoliasearch-helper-3.25.0.tgz", @@ -4666,6 +4503,11 @@ "node": ">=4" } }, + "node_modules/cheap-ruler": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/cheap-ruler/-/cheap-ruler-4.0.0.tgz", + "integrity": "sha512-0BJa8f4t141BYKQyn9NSQt1PguFQXMXwZiA5shfoaBYHAb2fFk2RAX+tiWMoQU+Agtzt3mdt0JtuyshAXqZ+Vw==" + }, "node_modules/chownr": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", @@ -5114,6 +4956,11 @@ "url": "https://github.com/sponsors/fb55" } }, + "node_modules/csscolorparser": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/csscolorparser/-/csscolorparser-1.0.3.tgz", + "integrity": "sha512-umPSgYwZkdFoUrH5hIq5kf0wPSXiro51nPw0j2K/c83KflkPSTBGMz6NJvMB+07VlL0y7VPo6QJcDjcgKTTm3w==" + }, "node_modules/csso": { "version": "5.0.5", "resolved": "https://registry.npmjs.org/csso/-/csso-5.0.5.tgz", @@ -5626,6 +5473,11 @@ "node": ">= 0.4" } }, + "node_modules/earcut": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/earcut/-/earcut-3.0.2.tgz", + "integrity": "sha512-X7hshQbLyMJ/3RPhyObLARM2sNxxmRALLKx1+NVFFnQ9gKzmCrxm9+uLIAdBcvc8FNLpctqlQ2V6AE92Ol9UDQ==" + }, "node_modules/eastasianwidth": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", @@ -6518,6 +6370,11 @@ "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==" + }, "node_modules/get-caller-file": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", @@ -6648,6 +6505,11 @@ "node": ">=16" } }, + "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==" + }, "node_modules/glob": { "version": "10.4.5", "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", @@ -6782,6 +6644,11 @@ "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", "dev": true }, + "node_modules/grid-index": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/grid-index/-/grid-index-1.1.0.tgz", + "integrity": "sha512-HZRwumpOGUrHyxO5bqKZL0B0GlUpwtCAzZ42sgxUPniu33R1LSFH5yrIcBCHjkctCAh3mtWKcKd9J4vDDdeVHA==" + }, "node_modules/has-bigints": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", @@ -7777,6 +7644,11 @@ "node": ">=4.0" } }, + "node_modules/kdbush": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/kdbush/-/kdbush-4.0.2.tgz", + "integrity": "sha512-WbCVYJ27Sz8zi9Q7Q0xHC+05iwkm3Znipc2XTlrnJbsHMYktW4hPhXUE8Ys1engBrvffoSCqbil1JQAa7clRpA==" + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -8400,6 +8272,55 @@ "dev": true, "license": "ISC" }, + "node_modules/mapbox-gl": { + "version": "3.16.0", + "resolved": "https://registry.npmjs.org/mapbox-gl/-/mapbox-gl-3.16.0.tgz", + "integrity": "sha512-rluV1Zp/0oHf1Y9BV+nePRNnKyTdljko3E19CzO5rBqtQaNUYS0ePCMPRtxOuWRwSdKp3f9NWJkOCjemM8nmjw==", + "dependencies": { + "@mapbox/jsonlint-lines-primitives": "^2.0.2", + "@mapbox/mapbox-gl-supported": "^3.0.0", + "@mapbox/point-geometry": "^1.1.0", + "@mapbox/tiny-sdf": "^2.0.6", + "@mapbox/unitbezier": "^0.0.1", + "@mapbox/vector-tile": "^2.0.4", + "@mapbox/whoots-js": "^3.1.0", + "@types/geojson": "^7946.0.16", + "@types/geojson-vt": "^3.2.5", + "@types/mapbox__point-geometry": "^0.1.4", + "@types/pbf": "^3.0.5", + "@types/supercluster": "^7.1.3", + "cheap-ruler": "^4.0.0", + "csscolorparser": "~1.0.3", + "earcut": "^3.0.1", + "geojson-vt": "^4.0.2", + "gl-matrix": "^3.4.4", + "grid-index": "^1.1.0", + "kdbush": "^4.0.2", + "martinez-polygon-clipping": "^0.7.4", + "murmurhash-js": "^1.0.0", + "pbf": "^4.0.1", + "potpack": "^2.0.0", + "quickselect": "^3.0.0", + "serialize-to-js": "^3.1.2", + "supercluster": "^8.0.1", + "tinyqueue": "^3.0.0" + } + }, + "node_modules/martinez-polygon-clipping": { + "version": "0.7.4", + "resolved": "https://registry.npmjs.org/martinez-polygon-clipping/-/martinez-polygon-clipping-0.7.4.tgz", + "integrity": "sha512-jBEwrKtA0jTagUZj2bnmb4Yg2s4KnJGRePStgI7bAVjtcipKiF39R4LZ2V/UT61jMYWrTcBhPazexeqd6JAVtw==", + "dependencies": { + "robust-predicates": "^2.0.4", + "splaytree": "^0.1.4", + "tinyqueue": "^1.2.0" + } + }, + "node_modules/martinez-polygon-clipping/node_modules/tinyqueue": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/tinyqueue/-/tinyqueue-1.2.3.tgz", + "integrity": "sha512-Qz9RgWuO9l8lT+Y9xvbzhPT2efIUIFd69N7eF7tJ9lnQl0iLj1M7peK7IoUGZL9DJHw9XftqLreccfxcQgYLxA==" + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -8584,6 +8505,11 @@ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" }, + "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==" + }, "node_modules/mustache": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/mustache/-/mustache-4.2.0.tgz", @@ -9048,6 +8974,17 @@ "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", "license": "MIT" }, + "node_modules/pbf": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/pbf/-/pbf-4.0.1.tgz", + "integrity": "sha512-SuLdBvS42z33m8ejRbInMapQe8n0D3vN/Xd5fmWM3tufNgRQFBpaW2YVJxQZV4iPNqb0vEFvssMEo5w9c6BTIA==", + "dependencies": { + "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", @@ -9164,6 +9101,11 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/potpack": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/potpack/-/potpack-2.1.0.tgz", + "integrity": "sha512-pcaShQc1Shq0y+E7GqJqvZj8DTthWV1KeHGdi0Z6IAin2Oi3JnLCOfwnCo84qc+HAp52wT9nK9H7FAJp5a44GQ==" + }, "node_modules/preact": { "version": "10.26.8", "resolved": "https://registry.npmjs.org/preact/-/preact-10.26.8.tgz", @@ -9222,6 +9164,11 @@ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "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==" + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -9262,6 +9209,11 @@ } ] }, + "node_modules/quickselect": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/quickselect/-/quickselect-3.0.0.tgz", + "integrity": "sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g==" + }, "node_modules/react": { "version": "19.1.0", "resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz", @@ -9652,6 +9604,14 @@ "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" } }, + "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==", + "dependencies": { + "protocol-buffers-schema": "^3.3.1" + } + }, "node_modules/restore-cursor": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", @@ -9686,6 +9646,11 @@ "dev": true, "license": "MIT" }, + "node_modules/robust-predicates": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-2.0.4.tgz", + "integrity": "sha512-l4NwboJM74Ilm4VKfbAtFeGq7aEjWL+5kVFcmgFA2MrdnQWx9iE/tUGvxY5HyMI7o/WpSIUFLbC5fbeaHgSCYg==" + }, "node_modules/rollup": { "version": "4.24.0", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.24.0.tgz", @@ -9844,6 +9809,14 @@ "semver": "bin/semver.js" } }, + "node_modules/serialize-to-js": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/serialize-to-js/-/serialize-to-js-3.1.2.tgz", + "integrity": "sha512-owllqNuDDEimQat7EPG0tH7JjO090xKNzUtYz6X+Sk2BXDnOCilDdNLwjWeFywG9xkJul1ULvtUQa9O4pUaY0w==", + "engines": { + "node": ">=4.0.0" + } + }, "node_modules/set-function-length": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", @@ -10131,6 +10104,11 @@ "node": ">=0.10.0" } }, + "node_modules/splaytree": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/splaytree/-/splaytree-0.1.4.tgz", + "integrity": "sha512-D50hKrjZgBzqD3FT2Ek53f2dcDLAQT8SSGrzj3vidNH5ISRgceeGVJ2dQIthKOuayqFXfFjXheHNo4bbt9LhRQ==" + }, "node_modules/split2": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", @@ -10391,6 +10369,14 @@ "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==", + "dependencies": { + "kdbush": "^4.0.2" + } + }, "node_modules/supports-color": { "version": "5.5.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", @@ -10558,6 +10544,11 @@ "dev": true, "license": "MIT" }, + "node_modules/tinyqueue": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/tinyqueue/-/tinyqueue-3.0.0.tgz", + "integrity": "sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g==" + }, "node_modules/tldts": { "version": "6.1.86", "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", @@ -11271,7 +11262,7 @@ "version": "5.5.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.3.tgz", "integrity": "sha512-/hreyEujaB0w76zKo6717l3L0o/qEUtRgdvUBvlkhoWeOVMjMuHNHk0BRBzikzuGDqNmPQbg5ifMEqsHLiIUcQ==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", diff --git a/package.json b/package.json index bbdda507c..a2c13dfff 100644 --- a/package.json +++ b/package.json @@ -52,6 +52,7 @@ "jsdom": "^26.1.0", "leaflet": "^1.9.4", "lucide-react": "^0.513.0", + "mapbox-gl": "^3.16.0", "meilisearch": "^0.50.0", "nuqs": "^2.6.0", "react": "19.1.0", diff --git a/src/enum/map.enum.ts b/src/enum/map.enum.ts new file mode 100644 index 000000000..5b01cbc93 --- /dev/null +++ b/src/enum/map.enum.ts @@ -0,0 +1,11 @@ +export enum FloodYearEnum { + FIVE_YEAR = '5-Year Flood', + TWENTY_FIVE_YEAR = '25-Year Flood', + ONE_HUNDRED_YEAR = '100-Year Flood', +} + +export enum HazardLevelEnum { + LOW = 1, + MEDIUM = 2, + HIGH = 3, +} diff --git a/src/pages/flood-control-projects/components/About.tsx b/src/pages/flood-control-projects/components/About.tsx new file mode 100644 index 000000000..889cdeb22 --- /dev/null +++ b/src/pages/flood-control-projects/components/About.tsx @@ -0,0 +1,53 @@ +import { InfoIcon } from 'lucide-react'; + +const About = () => ( +
+
+
+ +

About This Data

+
+

+ This map displays flood control infrastructure projects across the + Philippines. Click on a region to filter projects by that area. Zoom in + to see individual project locations. You can also use the filters to + narrow down projects by year, type of work, and search terms. +

+

+ Additionally, the map incorporates Project NOAH flood hazard data: +

+ +

+ These layers visualize flood-prone areas based on historical and modeled + data, helping to understand potential flood risks in different regions. + Visit{' '} + + Project NOAH + {' '} + to view high-resolution hazard maps on flooding, storm surge, and + landslides. +

+
+

+ Source: Department of Public Works and Highways (DPWH) Flood Control + Information System, NOAH Studio +

+
+); + +export default About; diff --git a/src/pages/flood-control-projects/components/MapControls.tsx b/src/pages/flood-control-projects/components/MapControls.tsx new file mode 100644 index 000000000..b01fb05b8 --- /dev/null +++ b/src/pages/flood-control-projects/components/MapControls.tsx @@ -0,0 +1,52 @@ +import { MapIcon, SatelliteIcon, ZoomInIcon, ZoomOutIcon } from 'lucide-react'; +import Button from '../../../components/ui/Button'; +import { IMapStyle } from '../types'; + +interface IMapControlsProps { + mapStyle: IMapStyle; + handleZoomIn: () => void; + handleZoomOut: () => void; + handleSwitchMapStyle: () => void; +} + +const MapControls = ({ + mapStyle, + handleZoomIn, + handleZoomOut, + handleSwitchMapStyle, +}: IMapControlsProps) => ( +
+ + + +
+); + +export default MapControls; diff --git a/src/pages/flood-control-projects/components/SimulationControls.tsx b/src/pages/flood-control-projects/components/SimulationControls.tsx new file mode 100644 index 000000000..e20d4bc1c --- /dev/null +++ b/src/pages/flood-control-projects/components/SimulationControls.tsx @@ -0,0 +1,98 @@ +import SelectPicker from '@/components/ui/SelectPicker'; +import { FloodYearEnum } from '@/enum/map.enum'; +import Button from '../../../components/ui/Button'; +import { HAZARD_LEVEL, MAX_SIMULATION_FLOOD_DEPTH } from '../constants'; + +interface ISimulationControlsProps { + simulation: { + simulating: boolean; + floodDepth: number; + }; + selectedFloodYear: FloodYearEnum; + setSelectedFloodYear: (year: FloodYearEnum) => void; + toggleFloodSimulation: (simulate: boolean, reset?: boolean) => void; + handleStopSimulation: () => void; +} + +const SimulationControls = ({ + simulation, + selectedFloodYear, + setSelectedFloodYear, + toggleFloodSimulation, + handleStopSimulation, +}: ISimulationControlsProps) => ( +
+ {simulation.simulating || simulation.floodDepth ? ( + <> +
+

{selectedFloodYear}

+

+ Flood depth: ≈ {simulation.floodDepth.toFixed(1)} m +

+
+ {simulation.simulating ? ( + simulation.floodDepth >= MAX_SIMULATION_FLOOD_DEPTH ? ( + + ) : ( + + ) + ) : ( + + )} + + + ) : ( + <> + ({ + label: val, + value: val, + }))} + onSelect={data => setSelectedFloodYear(data?.value as FloodYearEnum)} + clearable={false} + searchable={false} + /> +
+ {Object.values(HAZARD_LEVEL).map(({ color, label }, idx) => ( +
+
+

{label}

+
+ ))} +
+ + + )} +
+); + +export default SimulationControls; diff --git a/src/pages/flood-control-projects/constants.ts b/src/pages/flood-control-projects/constants.ts new file mode 100644 index 000000000..07e893d29 --- /dev/null +++ b/src/pages/flood-control-projects/constants.ts @@ -0,0 +1,210 @@ +import { FloodYearEnum, HazardLevelEnum } from '@/enum/map.enum'; +import { IMapboxTileSet } from '@/types/map.type'; + +export const MAX_SIMULATION_FLOOD_DEPTH = 6; + +export const HAZARD_LEVEL: Record< + HazardLevelEnum, + { color: string; label: string } +> = { + 1: { label: 'Low', color: 'rgba(255, 235, 100, 0.6)' }, + 2: { label: 'Medium', color: 'rgba(255, 165, 0, 0.6)' }, + 3: { label: 'High', color: 'rgba(220, 50, 50, 0.6)' }, +}; + +export const HAZARD_BASE: Record = { + 1: 0, // low hazard – higher ground + 2: 0.5, // medium hazard – mid ground + 3: 1, // high hazard – lowest ground, floods first +}; + +export const FLOOD_YEAR_CONFIG: Record< + FloodYearEnum, + { minDepth: number; maxDepth: number } +> = { + '5-Year Flood': { + minDepth: 0.3, // minimum depth to appear in high hazard + maxDepth: 1.5, + }, + '25-Year Flood': { + minDepth: 0.5, + maxDepth: 3, + }, + '100-Year Flood': { + minDepth: 1, + maxDepth: 6, + }, +}; + +// NOTE: can also be moved to data folder as json but I've temporarily placed it here for type safety +export const MAPBOX_TILESET: Record = { + '5-Year Flood': [ + { tileSetId: 'upri-noah.ph_fh_5yr_tls', sourceLayer: 'PH010000000_FH_5yr' }, + { tileSetId: 'upri-noah.ph_fh_5yr_tls', sourceLayer: 'PH020000000_FH_5yr' }, + { tileSetId: 'upri-noah.ph_fh_5yr_tls', sourceLayer: 'PH030000000_FH_5yr' }, + { tileSetId: 'upri-noah.ph_fh_5yr_tls', sourceLayer: 'PH040000000_FH_5yr' }, + { tileSetId: 'upri-noah.ph_fh_5yr_tls', sourceLayer: 'PH050000000_FH_5yr' }, + { tileSetId: 'upri-noah.ph_fh_5yr_tls', sourceLayer: 'PH060000000_FH_5yr' }, + { tileSetId: 'upri-noah.ph_fh_5yr_tls', sourceLayer: 'PH070000000_FH_5yr' }, + { tileSetId: 'upri-noah.ph_fh_5yr_tls', sourceLayer: 'PH080000000_FH_5yr' }, + { tileSetId: 'upri-noah.ph_fh_5yr_tls', sourceLayer: 'PH090000000_FH_5yr' }, + { tileSetId: 'upri-noah.ph_fh_5yr_tls', sourceLayer: 'PH100000000_FH_5yr' }, + { tileSetId: 'upri-noah.ph_fh_5yr_tls', sourceLayer: 'PH110000000_FH_5yr' }, + { tileSetId: 'upri-noah.ph_fh_5yr_tls', sourceLayer: 'PH120000000_FH_5yr' }, + { tileSetId: 'upri-noah.ph_fh_5yr_tls', sourceLayer: 'PH130000000_FH_5yr' }, + { tileSetId: 'upri-noah.ph_fh_5yr_tls', sourceLayer: 'PH140000000_FH_5yr' }, + { tileSetId: 'upri-noah.ph_fh_5yr_tls', sourceLayer: 'PH150000000_FH_5yr' }, + { tileSetId: 'upri-noah.ph_fh_5yr_tls', sourceLayer: 'PH160000000_FH_5yr' }, + { tileSetId: 'upri-noah.ph_fh_5yr_tls', sourceLayer: 'PH170000000_FH_5yr' }, + { tileSetId: 'upri-noah.ph_fh_5yr_tls', sourceLayer: 'PH180000000_FH_5yr' }, + ], + '25-Year Flood': [ + { + tileSetId: 'upri-noah.ph_fh_25yr_tls', + sourceLayer: 'PH010000000_FH_25yr', + }, + { + tileSetId: 'upri-noah.ph_fh_25yr_tls', + sourceLayer: 'PH020000000_FH_25yr', + }, + { + tileSetId: 'upri-noah.ph_fh_25yr_tls', + sourceLayer: 'PH030000000_FH_25yr', + }, + { + tileSetId: 'upri-noah.ph_fh_25yr_tls', + sourceLayer: 'PH040000000_FH_25yr', + }, + { + tileSetId: 'upri-noah.ph_fh_25yr_tls', + sourceLayer: 'PH050000000_FH_25yr', + }, + { + tileSetId: 'upri-noah.ph_fh_25yr_tls', + sourceLayer: 'PH060000000_FH_25yr', + }, + { + tileSetId: 'upri-noah.ph_fh_25yr_tls', + sourceLayer: 'PH070000000_FH_25yr', + }, + { + tileSetId: 'upri-noah.ph_fh_25yr_tls', + sourceLayer: 'PH080000000_FH_25yr', + }, + { + tileSetId: 'upri-noah.ph_fh_25yr_tls', + sourceLayer: 'PH090000000_FH_25yr', + }, + { + tileSetId: 'upri-noah.ph_fh_25yr_tls', + sourceLayer: 'PH100000000_FH_25yr', + }, + { + tileSetId: 'upri-noah.ph_fh_25yr_tls', + sourceLayer: 'PH110000000_FH_25yr', + }, + { + tileSetId: 'upri-noah.ph_fh_25yr_tls', + sourceLayer: 'PH120000000_FH_25yr', + }, + { + tileSetId: 'upri-noah.ph_fh_25yr_tls', + sourceLayer: 'PH130000000_FH_25yr', + }, + { + tileSetId: 'upri-noah.ph_fh_25yr_tls', + sourceLayer: 'PH140000000_FH_25yr', + }, + { + tileSetId: 'upri-noah.ph_fh_25yr_tls', + sourceLayer: 'PH150000000_FH_25yr', + }, + { + tileSetId: 'upri-noah.ph_fh_25yr_tls', + sourceLayer: 'PH160000000_FH_25yr', + }, + { + tileSetId: 'upri-noah.ph_fh_25yr_tls', + sourceLayer: 'PH170000000_FH_25yr', + }, + { + tileSetId: 'upri-noah.ph_fh_25yr_tls', + sourceLayer: 'PH180000000_FH_25yr', + }, + ], + + '100-Year Flood': [ + { + tileSetId: 'upri-noah.ph_fh_100yr_tls', + sourceLayer: 'PH010000000_FH_100yr', + }, + { + tileSetId: 'upri-noah.ph_fh_100yr_tls', + sourceLayer: 'PH020000000_FH_100yr', + }, + { + tileSetId: 'upri-noah.ph_fh_100yr_tls', + sourceLayer: 'PH030000000_FH_100yr', + }, + { + tileSetId: 'upri-noah.ph_fh_100yr_tls', + sourceLayer: 'PH040000000_FH_100yr', + }, + { + tileSetId: 'upri-noah.ph_fh_100yr_tls', + sourceLayer: 'PH050000000_FH_100yr', + }, + { + tileSetId: 'upri-noah.ph_fh_100yr_tls', + sourceLayer: 'PH060000000_FH_100yr', + }, + { + tileSetId: 'upri-noah.ph_fh_100yr_tls', + sourceLayer: 'PH070000000_FH_100yr', + }, + { + tileSetId: 'upri-noah.ph_fh_100yr_tls', + sourceLayer: 'PH080000000_FH_100yr', + }, + { + tileSetId: 'upri-noah.ph_fh_100yr_tls', + sourceLayer: 'PH090000000_FH_100yr', + }, + { + tileSetId: 'upri-noah.ph_fh_100yr_tls', + sourceLayer: 'PH100000000_FH_100yr', + }, + { + tileSetId: 'upri-noah.ph_fh_100yr_tls', + sourceLayer: 'PH110000000_FH_100yr', + }, + { + tileSetId: 'upri-noah.ph_fh_100yr_tls', + sourceLayer: 'PH120000000_FH_100yr', + }, + { + tileSetId: 'upri-noah.ph_fh_100yr_tls', + sourceLayer: 'PH130000000_FH_100yr', + }, + { + tileSetId: 'upri-noah.ph_fh_100yr_tls', + sourceLayer: 'PH140000000_FH_100yr', + }, + { + tileSetId: 'upri-noah.ph_fh_100yr_tls', + sourceLayer: 'PH150000000_FH_100yr', + }, + { + tileSetId: 'upri-noah.ph_fh_100yr_tls', + sourceLayer: 'PH160000000_FH_100yr', + }, + { + tileSetId: 'upri-noah.ph_fh_100yr_tls', + sourceLayer: 'PH170000000_FH_100yr', + }, + { + tileSetId: 'upri-noah.ph_fh_100yr_tls', + sourceLayer: 'PH180000000_FH_100yr', + }, + ], +}; diff --git a/src/pages/flood-control-projects/hooks/useMapbox.ts b/src/pages/flood-control-projects/hooks/useMapbox.ts new file mode 100644 index 000000000..a63b0dbf3 --- /dev/null +++ b/src/pages/flood-control-projects/hooks/useMapbox.ts @@ -0,0 +1,665 @@ +import { FloodYearEnum } from '@/enum/map.enum'; +import { IMapStyle } from '@/types/map.type'; +import mapboxgl, { LngLatBounds } from 'mapbox-gl'; +import 'mapbox-gl/dist/mapbox-gl.css'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import { + FloodControlProject, + IMapFloodSimulationState, + RegionData, + RegionProperties, +} from '../types'; +import { + HAZARD_BASE, + HAZARD_LEVEL, + MAPBOX_TILESET, + MAX_SIMULATION_FLOOD_DEPTH, +} from '../constants'; +import { getExtrusionHeight, mapIdGenerator } from '../utils'; + +const MAPBOX_ACCESS_TOKEN = + import.meta.env.VITE_MAPBOX_ACCESS_TOKEN || 'your_mapbox_token'; + +const { + generateFloodYearLayerId, + generateFloodYearSourceId, + generateFloodSimulationLayerId, + generateFloodSimulationSourceId, +} = mapIdGenerator(); + +interface IUseMapboxProps { + mapData: GeoJSON.FeatureCollection; + selectedFloodYear: FloodYearEnum; + setGeoSearch: React.Dispatch< + React.SetStateAction<{ lat: number; lng: number; radius: number } | null> + >; + setSelectedRegion: React.Dispatch>; + setHoveredRegionName: React.Dispatch>; + filteredProjects: FloodControlProject[]; + mapStyle: IMapStyle; + simulation: IMapFloodSimulationState; + setSimulation: React.Dispatch>; + setMapStyle: React.Dispatch>; + selectedRegion: RegionData | null; + hoveredRegionName: string | null; +} + +const MIN_ZOOM_FOR_FLOOD_PROJECTS = 7; + +export const useMapbox = ({ + mapData, + selectedFloodYear, + setGeoSearch, + setSelectedRegion, + setHoveredRegionName, + filteredProjects, + mapStyle, + simulation, + setSimulation, + setMapStyle, + selectedRegion, + hoveredRegionName, +}: IUseMapboxProps) => { + const mapContainer = useRef(null); + const map = useRef(null); + const [isMapLoaded, setIsMapLoaded] = useState(false); + const [zoomLevel, setZoomLevel] = useState(6); + + // Calculate region center and radius from bounds + const calculateGeoSearchParams = useCallback((bounds: LngLatBounds) => { + const center = bounds.getCenter(); + const northEast = bounds.getNorthEast(); + const southWest = bounds.getSouthWest(); + + // Calculate approximate radius in meters + // Use the larger of width or height to ensure coverage + const latDistance = Math.abs(northEast.lat - southWest.lat) * 111000; // ~111km per degree + const lngDistance = + Math.abs(northEast.lng - southWest.lng) * + 111000 * + Math.cos((center.lat * Math.PI) / 180); + const radius = Math.max(latDistance, lngDistance) / 2; + + return { + lat: center.lat, + lng: center.lng, + radius: Math.max(radius * 0.6, 10000), // minimum 10km radius + }; + }, []); + + const removeFloodYearTileSet = useCallback(() => { + if (!map.current || !isMapLoaded) return; + + Object.values(FloodYearEnum).forEach(year => { + const tilesets = MAPBOX_TILESET[year]; + + tilesets.forEach(({ sourceLayer }) => { + const sourceId = generateFloodYearSourceId(year, sourceLayer); + const layerId = generateFloodYearLayerId(year, sourceLayer); + + if (map.current?.getLayer(layerId)) { + map.current.removeLayer(layerId); + } + if (map.current?.getSource(sourceId)) { + map.current.removeSource(sourceId); + } + }); + }); + }, [isMapLoaded]); + + const addFloodYearTileSet = useCallback( + (floodYear: FloodYearEnum) => { + if (!map.current || !isMapLoaded) return; + + removeFloodYearTileSet(); + + MAPBOX_TILESET[floodYear]?.forEach(element => { + const sourceId = generateFloodYearSourceId( + floodYear, + element.sourceLayer + ); + const layerId = generateFloodYearLayerId( + floodYear, + element.sourceLayer + ); + + if (!map.current?.getSource(sourceId)) { + map.current?.addSource(sourceId, { + type: 'vector', + url: `mapbox://${element.tileSetId}`, + }); + } + + if (!map.current?.getLayer(layerId)) { + map.current?.addLayer({ + id: layerId, + type: 'fill', + source: sourceId, + 'source-layer': element.sourceLayer, + minzoom: 10, + paint: { + 'fill-opacity': 0.8, + 'fill-color': [ + 'match', + ['get', 'Var'], + 1, + HAZARD_LEVEL[1].color, + 2, + HAZARD_LEVEL[2].color, + 3, + HAZARD_LEVEL[3].color, + 'rgba(255,255,255,0)', + ], + }, + }); + } + }); + }, + [isMapLoaded, removeFloodYearTileSet] + ); + + const toggleFloodSimulation = useCallback( + (simulate: boolean, reset?: boolean) => { + if (!simulation.floodDepth) { + map.current?.easeTo({ + pitch: 75, + duration: 3000, + zoom: 17.5, + }); + + removeFloodYearTileSet(); + } + setSimulation((curr: IMapFloodSimulationState) => ({ + ...curr, + simulating: simulate, + floodDepth: reset ? 0.1 : curr.floodDepth, + })); + setMapStyle((curr: IMapStyle) => ({ + ...curr, + showRain: simulate, + })); + }, + [simulation.floodDepth, removeFloodYearTileSet, setSimulation, setMapStyle] + ); + + const handleZoomIn = () => map.current?.zoomIn(); + const handleZoomOut = () => map.current?.zoomOut(); + const handleSwitchMapStyle = () => + setMapStyle((curr: IMapStyle) => ({ + ...curr, + style: curr.style === 'satellite' ? 'standard' : 'satellite', + })); + + const handleStopSimulation = useCallback(() => { + MAPBOX_TILESET[selectedFloodYear].forEach(data => { + const layerId = generateFloodSimulationLayerId( + selectedFloodYear, + data.sourceLayer + ); + + const sourceId = generateFloodSimulationSourceId( + selectedFloodYear, + data.sourceLayer + ); + + if (map.current?.getLayer(layerId)) { + map.current?.removeLayer(layerId); + } + if (map.current?.getSource(sourceId)) { + map.current?.removeSource(sourceId); + } + }); + + addFloodYearTileSet(selectedFloodYear); + + setSimulation((curr: IMapFloodSimulationState) => ({ + ...curr, + simulating: false, + floodDepth: 0, + })); + setMapStyle((curr: IMapStyle) => ({ + ...curr, + showRain: false, + })); + }, [selectedFloodYear, addFloodYearTileSet, setSimulation, setMapStyle]); + + useEffect(() => { + if (map.current) return; + if (!mapContainer.current) return; + + mapboxgl.accessToken = MAPBOX_ACCESS_TOKEN; + map.current = new mapboxgl.Map({ + container: mapContainer.current, + style: 'mapbox://styles/mapbox/standard?optimize=true', + center: [121.774, 12.8797], + zoom: 5, + antialias: true, + }); + + map.current.on('load', () => { + if (!map.current) return; + map.current.addSource('regions', { + type: 'geojson', + data: mapData, + }); + + map.current.addLayer({ + id: 'region-fill', + type: 'fill', + source: 'regions', + maxzoom: 15, + paint: { + 'fill-color': '#EDE9FE', + 'fill-opacity': 0.5, + }, + }); + + map.current.addLayer({ + id: 'region-line', + type: 'line', + source: 'regions', + paint: { + 'line-color': '#A78BFA', + 'line-width': 1, + }, + }); + + map.current.addSource('satellite', { + type: 'raster', + url: 'mapbox://mapbox.satellite', + tileSize: 256, + }); + + map.current.addLayer({ + id: 'satellite-layer', + type: 'raster', + source: 'satellite', + layout: { visibility: 'none' }, + }); + + map.current.addSource('terrain', { + type: 'raster-dem', + url: 'mapbox://mapbox.mapbox-terrain-dem-v1', + tileSize: 256, + maxzoom: 15, + }); + map.current.setTerrain({ source: 'terrain', exaggeration: 1 }); + + addFloodYearTileSet(FloodYearEnum.FIVE_YEAR); + + map.current.addSource('projects', { + type: 'geojson', + data: { + type: 'FeatureCollection', + features: [], + }, + }); + + map.current.loadImage('/marker-icon-2x.webp', (error, image) => { + if (error || !image) throw error; + if (!map.current?.hasImage('custom-pin')) { + map.current?.addImage('custom-pin', image); + } + + map.current?.addLayer({ + id: 'projects-layer', + type: 'symbol', + source: 'projects', + layout: { + 'icon-image': 'custom-pin', + 'icon-size': 0.3, + 'icon-allow-overlap': true, + }, + }); + }); + + setIsMapLoaded(true); + + map.current.on('click', 'region-fill', e => { + if (e.features && e.features.length > 0) { + const feature = e.features[0] as unknown as GeoJSON.Feature< + GeoJSON.Geometry, + RegionProperties + >; + if (!feature.properties) return; + const props = feature.properties; + const regionName = props.name; + + let coords: number[][] = []; + if (feature.geometry.type === 'Polygon') { + coords = feature.geometry.coordinates[0]; + } else if (feature.geometry.type === 'MultiPolygon') { + coords = feature.geometry.coordinates.flat(2); + } + + const bounds = new mapboxgl.LngLatBounds(); + coords.forEach(coord => bounds.extend(coord as [number, number])); + + const geoParams = calculateGeoSearchParams(bounds); + setGeoSearch(geoParams); + + const regionDetails = { + id: regionName, + name: regionName, + loading: true, + }; + + setSelectedRegion(regionDetails); + + if (map.current && map.current.getZoom() <= 12) { + map.current.fitBounds(bounds, { padding: 50, duration: 2000 }); + } + } + }); + + map.current.on('mousemove', 'region-fill', e => { + if (map.current && map.current.getZoom() > 8) { + setHoveredRegionName(null); + return; + } + if (e.features && e.features.length > 0) { + const feature = e.features[0] as unknown as GeoJSON.Feature< + GeoJSON.Geometry, + RegionProperties + >; + setHoveredRegionName(feature.properties?.name || null); + if (map.current) { + map.current.getCanvas().style.cursor = 'pointer'; + } + } + }); + + map.current.on('mouseleave', 'region-fill', () => { + setHoveredRegionName(null); + if (map.current) { + map.current.getCanvas().style.cursor = ''; + } + }); + + map.current.on('zoomend', () => { + if (map.current) { + setZoomLevel(map.current.getZoom()); + } + }); + + map.current.on('click', 'projects-layer', e => { + if (e.features && e.features.length > 0) { + const feature = e.features[0]; + const project = feature.properties as FloodControlProject; + const coordinates = (feature.geometry as GeoJSON.Point).coordinates; + + new mapboxgl.Popup({ offset: 25 }) + .setLngLat(coordinates as [number, number]) + .setHTML( + `
+

+ ${project.ProjectDescription || 'Unnamed Project'} +

+

+ Region: ${project.Region || 'N/A'} +

+

+ Province: ${project.Province || 'N/A'} +

+

+ Municipality: ${project.Municipality || 'N/A'} +

+

+ Contractor: ${project.Contractor || 'N/A'} +

+

+ Cost: ₱${ + project.ContractCost + ? Number(project.ContractCost).toLocaleString() + : 'N/A' + } +

+

+ Year: ${project.InfraYear || 'N/A'} +

+
` + ) + .addTo(map.current!); + } + }); + }); + }, [ + mapData, + calculateGeoSearchParams, + addFloodYearTileSet, + setGeoSearch, + setSelectedRegion, + setHoveredRegionName, + ]); + + useEffect(() => { + if (!map.current || !isMapLoaded) return; + + const source = map.current.getSource('projects') as mapboxgl.GeoJSONSource; + + if (zoomLevel <= MIN_ZOOM_FOR_FLOOD_PROJECTS || !selectedRegion) { + source.setData(''); + return; + } + const geojson: GeoJSON.FeatureCollection< + GeoJSON.Point, + FloodControlProject + > = { + type: 'FeatureCollection', + features: filteredProjects + .map((project: FloodControlProject) => { + if (!project.Latitude || !project.Longitude) return null; + const lat = parseFloat(project.Latitude); + const lng = parseFloat(project.Longitude); + if (isNaN(lat) || isNaN(lng)) return null; + + return { + type: 'Feature', + geometry: { + type: 'Point', + coordinates: [lng, lat], + }, + properties: project, + }; + }) + .filter( + ( + feature + ): feature is GeoJSON.Feature => + feature !== null + ), + }; + + if (source) { + source.setData(geojson); + } + }, [filteredProjects, isMapLoaded, zoomLevel, selectedRegion]); + + useEffect(() => { + if (!map.current || !isMapLoaded) return; + + map.current.setLayoutProperty( + 'satellite-layer', + 'visibility', + mapStyle.style === 'satellite' ? 'visible' : 'none' + ); + + if (mapStyle.showRain) { + map.current?.setRain({ + density: ['interpolate', ['linear'], ['zoom'], 11, 0.0, 13, 0.5], + intensity: 0.5, + color: '#a8adbc', + opacity: 0.5, + vignette: ['interpolate', ['linear'], ['zoom'], 11, 0.0, 13, 1.0], + 'vignette-color': '#464646', + direction: [0, 80], + 'droplet-size': [1.2, 10.2], + 'distortion-strength': 0.2, + 'center-thinning': 0, + }); + } else { + map.current?.setRain(null); + } + }, [mapStyle, isMapLoaded]); + + useEffect(() => { + if (!map.current || !isMapLoaded) return; + + map.current.setPaintProperty('region-fill', 'fill-color', [ + 'case', + ['==', ['get', 'name'], selectedRegion?.id || ''], + '#6D28D9', + ['==', ['get', 'name'], hoveredRegionName || ''], + '#A78BFA', + '#EDE9FE', + ]); + + map.current.setPaintProperty('region-line', 'line-color', [ + 'case', + ['==', ['get', 'name'], selectedRegion?.id || ''], + '#4C1D95', + ['==', ['get', 'name'], hoveredRegionName || ''], + '#4C1D95', + '#A78BFA', + ]); + + map.current.setPaintProperty('region-line', 'line-width', [ + 'case', + [ + 'any', + ['==', ['get', 'name'], selectedRegion?.id || ''], + ['==', ['get', 'name'], hoveredRegionName || ''], + ], + 2, + 1, + ]); + }, [selectedRegion, hoveredRegionName, isMapLoaded]); + + useEffect(() => { + if (!map.current || !isMapLoaded || !simulation.floodDepth) return; + let timeout: NodeJS.Timeout; + if (simulation.simulating) { + if (simulation.floodDepth >= MAX_SIMULATION_FLOOD_DEPTH) { + setMapStyle((curr: IMapStyle) => ({ ...curr, showRain: false })); + return; + } + + MAPBOX_TILESET[selectedFloodYear].forEach(element => { + const simulationLayerId = generateFloodSimulationLayerId( + selectedFloodYear, + element.sourceLayer + ); + + const simulationSourceId = generateFloodSimulationSourceId( + selectedFloodYear, + element.sourceLayer + ); + + if (!map.current?.getSource(simulationSourceId)) { + map.current?.addSource(simulationSourceId, { + type: 'vector', + url: `mapbox://${element.tileSetId}`, + }); + } + + if (!map.current?.getLayer(simulationLayerId)) { + map.current?.addLayer({ + id: simulationLayerId, + type: 'fill-extrusion', + source: simulationSourceId, + 'source-layer': element.sourceLayer, + paint: { + 'fill-extrusion-color': + mapStyle.style === 'satellite' + ? 'rgba(30, 144, 255, 0.35)' + : 'rgb(152, 220, 254)', + + 'fill-extrusion-opacity': 0.8, + 'fill-extrusion-base': [ + 'match', + ['get', 'Var'], + 1, + HAZARD_BASE[1], + 2, + HAZARD_BASE[2], + 3, + HAZARD_BASE[3], + 0, + ], + 'fill-extrusion-height': 0, + }, + }); + } + if (map.current?.getLayer(simulationLayerId)) { + map.current?.setPaintProperty( + simulationLayerId, + 'fill-extrusion-height', + [ + 'match', + ['get', 'Var'], + 1, + getExtrusionHeight({ + hazardLevel: 1, + floodYear: selectedFloodYear, + floodDepth: simulation.floodDepth, + }), + + 2, + getExtrusionHeight({ + hazardLevel: 2, + floodYear: selectedFloodYear, + floodDepth: simulation.floodDepth, + }), + 3, + getExtrusionHeight({ + hazardLevel: 3, + floodYear: selectedFloodYear, + floodDepth: simulation.floodDepth, + }), + 0, + ] + ); + } + }); + + timeout = setTimeout(() => { + setSimulation((curr: IMapFloodSimulationState) => ({ + ...curr, + floodDepth: curr.floodDepth + 0.1, + })); + }, 1250); + } + + return () => clearTimeout(timeout); + }, [ + simulation, + isMapLoaded, + mapStyle.style, + selectedFloodYear, + setSimulation, + setMapStyle, + ]); + + useEffect(() => { + if (zoomLevel <= MIN_ZOOM_FOR_FLOOD_PROJECTS) { + setGeoSearch(null); + setSelectedRegion(null); + } + }, [zoomLevel, setGeoSearch, setSelectedRegion]); + + useEffect(() => { + addFloodYearTileSet(selectedFloodYear); + }, [selectedFloodYear, addFloodYearTileSet]); + + return { + mapContainer, + map, + isMapLoaded, + zoomLevel, + handleZoomIn, + handleZoomOut, + handleSwitchMapStyle, + toggleFloodSimulation, + handleStopSimulation, + }; +}; diff --git a/src/pages/flood-control-projects/map.tsx b/src/pages/flood-control-projects/map.tsx index 99bc27b8e..58d912b43 100644 --- a/src/pages/flood-control-projects/map.tsx +++ b/src/pages/flood-control-projects/map.tsx @@ -1,66 +1,36 @@ -import { useState, useEffect, useCallback, useRef, FC } from 'react'; -import { Helmet } from 'react-helmet-async'; -import { InstantSearch, Configure, useHits } from 'react-instantsearch'; -import { instantMeiliSearch } from '@meilisearch/instant-meilisearch'; import 'instantsearch.css/themes/satellite.css'; -import { exportMeilisearchData } from '../../lib/exportData'; -import { DownloadIcon, InfoIcon, ZoomInIcon, ZoomOutIcon } from 'lucide-react'; +import { DownloadIcon } from 'lucide-react'; +import 'mapbox-gl/dist/mapbox-gl.css'; +import { FC, useCallback, useEffect, useState } from 'react'; +import { Helmet } from 'react-helmet-async'; +import { Configure, InstantSearch, useHits } from 'react-instantsearch'; import Button from '../../components/ui/Button'; -import { MapContainer, TileLayer, GeoJSON, Marker, Popup } from 'react-leaflet'; -import L, { LatLngExpression, GeoJSON as LeafletGeoJSON, Layer } from 'leaflet'; -import 'leaflet/dist/leaflet.css'; +import { exportMeilisearchData } from '../../lib/exportData'; import FloodControlProjectsTab from './tab'; // Import region data +import { FloodYearEnum } from '@/enum/map.enum'; +import { instantMeiliSearch } from '@meilisearch/instant-meilisearch'; import philippinesRegionsData from '../../data/philippines-regions.json'; - -// Define types for our data - -// Define types for region data and GeoJSON properties -interface RegionData { - id: string; - name: string; - description?: string; - population?: string; - capital?: string; - area?: string; - provinces?: string[]; - wikipedia?: string; - loading?: boolean; - projectCount?: number; - totalCost?: number; -} - -interface RegionProperties { - name: string; // Region name from GeoJSON - capital?: string; - population?: string; - provinces?: string[]; - // Add other properties from your GeoJSON if needed -} - -interface FloodControlProject { - GlobalID?: string; - objectID?: string; - ProjectDescription?: string; - InfraYear?: string; - Region?: string; - Province?: string; - Municipality?: string; - TypeofWork?: string; - Contractor?: string; - ContractCost?: string; - Latitude?: string; - Longitude?: string; -} +import About from './components/About'; +import MapControls from './components/MapControls'; +import SimulationControls from './components/SimulationControls'; +import { useMapbox } from './hooks/useMapbox'; +import { + IFloodControlProject, + IMapFloodSimulationState, + IMapStyle, + IRegionData, + IRegionProperties, +} from './types'; // Custom component to access Meilisearch hits for map const MapHitsComponent = ({ onHitsUpdate, }: { - onHitsUpdate: (hits: FloodControlProject[]) => void; + onHitsUpdate: (hits: IFloodControlProject[]) => void; }) => { - const { hits } = useHits(); + const { hits } = useHits(); useEffect(() => { onHitsUpdate(hits); @@ -103,28 +73,56 @@ const FloodControlProjectsMap: FC = () => { } | null>(null); // Map states - const [selectedRegion, setSelectedRegion] = useState(null); + const [selectedFloodYear, setSelectedFloodYear] = useState( + FloodYearEnum.FIVE_YEAR + ); + const [selectedRegion, setSelectedRegion] = useState( + null + ); const [hoveredRegionName, setHoveredRegionName] = useState( null ); const [mapData] = useState< - GeoJSON.FeatureCollection + GeoJSON.FeatureCollection >( philippinesRegionsData as GeoJSON.FeatureCollection< GeoJSON.Geometry, - RegionProperties + IRegionProperties > ); - const [mapProjects, setMapProjects] = useState([]); - const [zoomLevel, setZoomLevel] = useState(6); - const mapRef = useRef(null); - const geoJsonLayerRef = useRef(null); - - const initialCenter: LatLngExpression = [12.8797, 121.774]; // Philippines center - const initialZoom = 6; - - // Export data function - const handleExportData = async () => { + const [simulation, setSimulation] = useState({ + floodDepth: 0, + simulating: false, + }); + const [mapStyle, setMapStyle] = useState({ + style: 'standard', + showRain: false, + }); + const [mapProjects, setMapProjects] = useState([]); + + const { + mapContainer, + handleZoomIn, + handleZoomOut, + handleSwitchMapStyle, + toggleFloodSimulation, + handleStopSimulation, + } = useMapbox({ + mapData, + selectedFloodYear, + setGeoSearch, + setSelectedRegion, + setHoveredRegionName, + filteredProjects: mapProjects, + mapStyle, + simulation, + setSimulation, + setMapStyle, + selectedRegion, + hoveredRegionName, + }); + + const handleExportData = useCallback(async () => { // Set loading state setIsExporting(true); @@ -147,7 +145,7 @@ const FloodControlProjectsMap: FC = () => { // Reset loading state setIsExporting(false); } - }; + }, []); // Build filter string for Meilisearch const buildFilterString = (): string => { @@ -155,7 +153,7 @@ const FloodControlProjectsMap: FC = () => { }; // Build geo search parameters for Meilisearch aroundLatLng - const buildGeoSearchParams = () => { + const buildGeoSearchParams = useCallback(() => { if (!geoSearch) return {}; // Use Meilisearch's aroundLatLng functionality @@ -163,68 +161,14 @@ const FloodControlProjectsMap: FC = () => { aroundLatLng: `${geoSearch.lat}, ${geoSearch.lng}`, aroundRadius: Math.round(geoSearch.radius), // Convert to meters (integer) }; - }; - - const getRegionName = ( - feature: GeoJSON.Feature - ): string => { - const props = feature.properties; - return props?.name || ''; - }; - - // Style for GeoJSON features - const regionStyle = ( - feature?: GeoJSON.Feature - ) => { - if (!feature) return {}; - const regionName = getRegionName(feature); - const isSelected = selectedRegion?.id === regionName; - const isHovered = hoveredRegionName === regionName; - - return { - fillColor: isSelected ? '#6D28D9' : isHovered ? '#A78BFA' : '#EDE9FE', - weight: isSelected || isHovered ? 2 : 1, - opacity: 1, - color: isSelected || isHovered ? '#4C1D95' : '#A78BFA', - fillOpacity: 0.7, - }; - }; - - // Calculate region center and radius from bounds - const calculateGeoSearchParams = useCallback((bounds: L.LatLngBounds) => { - const center = bounds.getCenter(); - const northEast = bounds.getNorthEast(); - const southWest = bounds.getSouthWest(); - - // Calculate approximate radius in meters - // Use the larger of width or height to ensure coverage - const latDistance = Math.abs(northEast.lat - southWest.lat) * 111000; // ~111km per degree - const lngDistance = - Math.abs(northEast.lng - southWest.lng) * - 111000 * - Math.cos((center.lat * Math.PI) / 180); - const radius = Math.max(latDistance, lngDistance) / 2; - - return { - lat: center.lat, - lng: center.lng, - radius: Math.max(radius * 0.6, 10000), // minimum 5km radius - }; - }, []); - - // Note: Client-side filtering is no longer needed since we use Meilisearch's aroundLatLng - - // Since we're now using Meilisearch's native geo search, - // filteredProjects is just the mapProjects returned from the search - const filteredProjects = mapProjects; + }, [geoSearch]); - // Update region statistics when filtered projects change useEffect(() => { if (selectedRegion && !selectedRegion.loading) { - const projects = filteredProjects; + const projects = mapProjects; const totalProjects = projects.length; const totalCost = projects.reduce( - (sum: number, project: FloodControlProject) => { + (sum: number, project: IFloodControlProject) => { const cost = parseFloat(project.ContractCost || '0'); return sum + (isNaN(cost) ? 0 : cost); }, @@ -232,7 +176,7 @@ const FloodControlProjectsMap: FC = () => { ); const uniqueContractors = new Set( projects - .map((project: FloodControlProject) => project.Contractor) + .map((project: IFloodControlProject) => project.Contractor) .filter(Boolean) ).size; @@ -248,76 +192,7 @@ const FloodControlProjectsMap: FC = () => { : null ); } - }, [filteredProjects, selectedRegion]); - - // Handle region click - const onRegionClick = useCallback( - (feature: GeoJSON.Feature) => { - if (!feature.properties) return; - const props = feature.properties; - const regionName = props.name; - - // Get the bounding box of the region and calculate geo search parameters - const bounds = L.geoJSON(feature.geometry).getBounds(); - const geoParams = calculateGeoSearchParams(bounds); - setGeoSearch(geoParams); - - // Set loading state first - const regionDetails: RegionData = { - id: regionName, - name: regionName, - loading: true, - }; - setSelectedRegion(regionDetails); - - // Only zoom/fit bounds if we're not already zoomed in (zoom level <= 8) - if (mapRef.current && feature.geometry && zoomLevel <= 8) { - mapRef.current.fitBounds(bounds, { padding: [20, 20] }); - // Force zoom to at least level 9 to show project pins - setTimeout(() => { - if (mapRef.current && mapRef.current.getZoom() < 9) { - mapRef.current.setZoom(9); - } - setZoomLevel(mapRef.current?.getZoom() || 9); - }, 500); - } else if (mapRef.current) { - // If already zoomed in, just update the zoom level state without changing the view - setZoomLevel(mapRef.current.getZoom()); - } - }, - [calculateGeoSearchParams, zoomLevel] - ); - - // Event handlers for each feature - const onEachFeature = ( - feature: GeoJSON.Feature, - layer: Layer - ) => { - layer.on({ - click: () => onRegionClick(feature), - mouseover: e => { - // Disable hover effects when zoomed in (zoom level > 8) - if (zoomLevel <= 8) { - setHoveredRegionName(getRegionName(feature)); - // e.target.setStyle(regionStyle(feature)) // Re-apply style with hover state - e.target.bringToFront(); - } - }, - mouseout: e => { - // Only reset hover state if we're not zoomed in - if (zoomLevel <= 8) { - setHoveredRegionName(null); - // Reset to default style or selected style if it's the selected region - if (geoJsonLayerRef.current) { - geoJsonLayerRef.current.resetStyle(e.target); - } - } - }, - }); - }; - - const handleZoomIn = () => mapRef.current?.zoomIn(); - const handleZoomOut = () => mapRef.current?.zoomOut(); + }, [mapProjects, selectedRegion]); return (
@@ -329,10 +204,8 @@ const FloodControlProjectsMap: FC = () => { /> - {/* Simplified layout with minimal filters */}
- {/* Page header */}

Flood Control Projects Map @@ -350,10 +223,8 @@ const FloodControlProjectsMap: FC = () => {

- {/* View Tabs */} - {/* Hidden InstantSearch for data fetching only */} { {/* Map View - separate from InstantSearch to prevent flickering */}
- { - if (mapRef.current) { - mapRef.current.on('zoomend', () => { - if (mapRef.current) { - setZoomLevel(mapRef.current.getZoom()); - } - }); - } - }} - > - - - {mapData && mapData.features && ( - - )} - - {/* Show project markers when zoomed in or region is selected */} - {(zoomLevel > 8 || selectedRegion) && - filteredProjects.map((project: FloodControlProject) => { - // Check if we have valid coordinates - if (!project.Latitude || !project.Longitude) return null; - - const lat = parseFloat(project.Latitude); - const lng = parseFloat(project.Longitude); - - // Validate coordinates - if (isNaN(lat) || isNaN(lng)) return null; - - return ( - - -
-

- {project.ProjectDescription || 'Unnamed Project'} -

-

- Region: {project.Region || 'N/A'} -

-

- Province:{' '} - {project.Province || 'N/A'} -

-

- Municipality:{' '} - {project.Municipality || 'N/A'} -

-

- Contractor:{' '} - {project.Contractor || 'N/A'} -

-

- Cost: ₱ - {project.ContractCost - ? Number(project.ContractCost).toLocaleString() - : 'N/A'} -

-

- Year:{' '} - {project.InfraYear || 'N/A'} -

-
-
-
- ); - })} -
- - {/* Zoom Controls */} -
- - -
- +
+ + {/* Region Details Panel */} {/* {selectedRegion && (
@@ -569,27 +344,8 @@ const FloodControlProjectsMap: FC = () => { )} */}
- {/* Data Source Information */} -
-
- -

- About This Data -

-
-

- This map displays flood control infrastructure projects across the - Philippines. Click on a region to filter projects by that area. - Zoom in to see individual project locations. You can also use the - filters to narrow down projects by year, type of work, and search - terms. -

-

- Source: Department of Public Works and Highways (DPWH) Flood - Control Information System -

-
+
diff --git a/src/pages/flood-control-projects/types.ts b/src/pages/flood-control-projects/types.ts new file mode 100644 index 000000000..af9b4e15f --- /dev/null +++ b/src/pages/flood-control-projects/types.ts @@ -0,0 +1,52 @@ +export interface IFloodControlProject { + GlobalID?: string; + objectID?: string; + ProjectDescription?: string; + InfraYear?: string; + Region?: string; + Province?: string; + Municipality?: string; + TypeofWork?: string; + Contractor?: string; + ContractCost?: string; + Latitude?: string; + Longitude?: string; +} + +export interface IMapFloodSimulationState { + simulating: boolean; + floodDepth: number; +} + +export interface IRegionData { + id: string; + name: string; + description?: string; + population?: string; + capital?: string; + area?: string; + provinces?: string[]; + wikipedia?: string; + loading?: boolean; + projectCount?: number; + totalCost?: number; +} + +export interface IRegionProperties { + name: string; + capital?: string; + population?: string; + provinces?: string[]; +} + +export interface IMapboxTileSet { + tileSetId: string; + sourceLayer: string; +} + +export type TStyle = 'standard' | 'satellite'; + +export interface IMapStyle { + style: TStyle; + showRain: boolean; +} diff --git a/src/pages/flood-control-projects/utils.ts b/src/pages/flood-control-projects/utils.ts index 71021cc63..f9180c3b5 100644 --- a/src/pages/flood-control-projects/utils.ts +++ b/src/pages/flood-control-projects/utils.ts @@ -1,3 +1,6 @@ +import { FloodYearEnum } from '@/enum/map.enum'; +import { FLOOD_YEAR_CONFIG, HAZARD_BASE } from './constants'; + // Define types (copied from shared-components.tsx) export type FilterState = { InfraYear: string; @@ -49,3 +52,51 @@ export const buildFilterString = (filters: FilterState): string => { return filterStrings.join(' AND '); }; + +export const mapIdGenerator = () => { + const generateFloodYearSourceId = ( + year: FloodYearEnum, + sourceLayer: string + ) => `flood-${year}-source-${sourceLayer}`; + const generateFloodYearLayerId = (year: FloodYearEnum, sourceLayer: string) => + `flood-${year}-layer-${sourceLayer}`; + + const generateFloodSimulationSourceId = ( + year: FloodYearEnum, + sourceLayer: string + ) => `flood-simulation-${year}-source-${sourceLayer}`; + const generateFloodSimulationLayerId = ( + year: FloodYearEnum, + sourceLayer: string + ) => `flood-simulation-${year}-layer-${sourceLayer}`; + + return { + generateFloodYearSourceId, + generateFloodYearLayerId, + generateFloodSimulationSourceId, + generateFloodSimulationLayerId, + }; +}; + +export const getExtrusionHeight = ({ + floodDepth, + floodYear, + hazardLevel, +}: { + hazardLevel: number; + floodDepth: number; + floodYear: FloodYearEnum; +}) => { + const base = HAZARD_BASE[hazardLevel]; // relative ground + const { minDepth, maxDepth } = FLOOD_YEAR_CONFIG[floodYear]; + + // Map the current floodDepth into the realistic range for this layer + // The layer starts appearing when floodDepth >= minDepth + if (floodDepth < minDepth) return 0; + + // Cap at maxDepth + const effectiveDepth = Math.min(floodDepth, maxDepth); + + // Relative extrusion height for this hazard layer + return Math.max(effectiveDepth - base, 0); +};