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:
+
+
+
+ 5-Year Flood
+
+
+ 25-Year Flood
+
+
+ 100-Year Flood
+
+
+
+ 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) => (
+
+
+
+
+
+
+
+
+ {mapStyle.style === 'satellite' ? (
+
+ ) : (
+
+ )}
+
+
+);
+
+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 ? (
+
toggleFloodSimulation(true, true)}
+ >
+ Repeat
+
+ ) : (
+
toggleFloodSimulation(false)}
+ >
+ Pause
+
+ )
+ ) : (
+
toggleFloodSimulation(true)}
+ >
+ Resume
+
+ )}
+
+ Stop
+
+ >
+ ) : (
+ <>
+
({
+ label: val,
+ value: val,
+ }))}
+ onSelect={data => setSelectedFloodYear(data?.value as FloodYearEnum)}
+ clearable={false}
+ searchable={false}
+ />
+
+ {Object.values(HAZARD_LEVEL).map(({ color, label }, idx) => (
+
+ ))}
+
+ toggleFloodSimulation(true, true)}
+ >
+ Run flood simulation
+
+ >
+ )}
+
+);
+
+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);
+};