feat: add Geoportal module with MapLibre GL JS + Martin vector tiles
Phase 1 of the geoportal implementation: Infrastructure: - Martin vector tile server in docker-compose (port 3010) - PostGIS setup SQL for GisUat: native geom column, Esri→PostGIS trigger, GiST index, gis_uats view for Martin auto-discovery Geoportal module (src/modules/geoportal/): - map-viewer.tsx: MapLibre GL JS canvas with OSM base, Martin MVT sources (gis_uats, gis_terenuri, gis_cladiri), click-to-inspect, zoom-level-aware layer visibility, layer styling - layer-panel.tsx: collapsible sidebar with layer toggles - geoportal-module.tsx: standalone page wrapper - Module registered in config/modules.ts, flags.ts, i18n ParcelSync integration: - 6th tab "Harta" with lazy-loaded MapViewer (ssr: false) - Centered on selected UAT Dependencies: maplibre-gl v5.21.0 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -91,3 +91,12 @@ services:
|
|||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 3
|
retries: 3
|
||||||
start_period: 10s
|
start_period: 10s
|
||||||
|
|
||||||
|
martin:
|
||||||
|
image: ghcr.io/maplibre/martin:v0.15.0
|
||||||
|
container_name: martin
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "3010:3000"
|
||||||
|
environment:
|
||||||
|
- DATABASE_URL=postgresql://architools_user:stictMyFon34!_gonY@10.10.10.166:5432/architools_db
|
||||||
|
|||||||
Generated
+245
-1
@@ -19,6 +19,7 @@
|
|||||||
"jspdf": "^4.2.0",
|
"jspdf": "^4.2.0",
|
||||||
"jszip": "^3.10.1",
|
"jszip": "^3.10.1",
|
||||||
"lucide-react": "^0.564.0",
|
"lucide-react": "^0.564.0",
|
||||||
|
"maplibre-gl": "^5.21.0",
|
||||||
"minio": "^8.0.6",
|
"minio": "^8.0.6",
|
||||||
"next": "16.1.6",
|
"next": "16.1.6",
|
||||||
"next-auth": "^4.24.13",
|
"next-auth": "^4.24.13",
|
||||||
@@ -1641,6 +1642,111 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
"@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/point-geometry": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@mapbox/point-geometry/-/point-geometry-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-YGcBz1cg4ATXDCM/71L9xveh4dynfGmcLDqufR+nQQy3fKwsAZsWd/x4621/6uJaeB9mwOHE6hPeDgXz9uViUQ==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
|
"node_modules/@mapbox/tiny-sdf": {
|
||||||
|
"version": "2.0.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/@mapbox/tiny-sdf/-/tiny-sdf-2.0.7.tgz",
|
||||||
|
"integrity": "sha512-25gQLQMcpivjOSA40g3gO6qgiFPDpWRoMfd+G/GoppPIeP6JDaMMkMrEJnMZhKyyS6iKwVt5YKu02vCUyJM3Ug==",
|
||||||
|
"license": "BSD-2-Clause"
|
||||||
|
},
|
||||||
|
"node_modules/@mapbox/unitbezier": {
|
||||||
|
"version": "0.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@mapbox/unitbezier/-/unitbezier-0.0.1.tgz",
|
||||||
|
"integrity": "sha512-nMkuDXFv60aBr9soUG5q+GvZYL+2KZHVvsqFCzqnkGEf46U2fvmytHaEVc1/YZbiLn8X+eR3QzX1+dwDO1lxlw==",
|
||||||
|
"license": "BSD-2-Clause"
|
||||||
|
},
|
||||||
|
"node_modules/@mapbox/vector-tile": {
|
||||||
|
"version": "2.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@mapbox/vector-tile/-/vector-tile-2.0.4.tgz",
|
||||||
|
"integrity": "sha512-AkOLcbgGTdXScosBWwmmD7cDlvOjkg/DetGva26pIRiZPdeJYjYKarIlb4uxVzi6bwHO6EWH82eZ5Nuv4T5DUg==",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"@mapbox/point-geometry": "~1.1.0",
|
||||||
|
"@types/geojson": "^7946.0.16",
|
||||||
|
"pbf": "^4.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@mapbox/whoots-js": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@mapbox/whoots-js/-/whoots-js-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-Es6WcD0nO5l+2BOQS4uLfNPYQaNDfbot3X1XUoloz+x0mPDS3eeORZJl06HXjwBG1fOGwCRnzK88LMdxKRrd6Q==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@maplibre/geojson-vt": {
|
||||||
|
"version": "6.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@maplibre/geojson-vt/-/geojson-vt-6.0.4.tgz",
|
||||||
|
"integrity": "sha512-HYv3POhMRCdhP3UPPATM/hfcy6/WuVIf5FKboH8u/ZuFMTnAIcSVlq5nfOqroLokd925w2QtE7YwquFOIacwVQ==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"kdbush": "^4.0.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@maplibre/maplibre-gl-style-spec": {
|
||||||
|
"version": "24.7.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@maplibre/maplibre-gl-style-spec/-/maplibre-gl-style-spec-24.7.0.tgz",
|
||||||
|
"integrity": "sha512-Ed7rcKYU5iELfablg9Mj+TVCsXsPBgdMyXPRAxb2v7oWg9YJnpQdZ5msDs1LESu/mtXy3Z48Vdppv2t/x5kAhw==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"@mapbox/jsonlint-lines-primitives": "~2.0.2",
|
||||||
|
"@mapbox/unitbezier": "^0.0.1",
|
||||||
|
"json-stringify-pretty-compact": "^4.0.0",
|
||||||
|
"minimist": "^1.2.8",
|
||||||
|
"quickselect": "^3.0.0",
|
||||||
|
"rw": "^1.3.3",
|
||||||
|
"tinyqueue": "^3.0.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"gl-style-format": "dist/gl-style-format.mjs",
|
||||||
|
"gl-style-migrate": "dist/gl-style-migrate.mjs",
|
||||||
|
"gl-style-validate": "dist/gl-style-validate.mjs"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@maplibre/mlt": {
|
||||||
|
"version": "1.1.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/@maplibre/mlt/-/mlt-1.1.8.tgz",
|
||||||
|
"integrity": "sha512-8vtfYGidr1rNkv5IwIoU2lfe3Oy+Wa8HluzQYcQi9cveU9K3pweAal/poQj4GJ0K/EW4bTQp2wVAs09g2yDRZg==",
|
||||||
|
"license": "(MIT OR Apache-2.0)",
|
||||||
|
"dependencies": {
|
||||||
|
"@mapbox/point-geometry": "^1.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@maplibre/vt-pbf": {
|
||||||
|
"version": "4.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@maplibre/vt-pbf/-/vt-pbf-4.3.0.tgz",
|
||||||
|
"integrity": "sha512-jIvp8F5hQCcreqOOpEt42TJMUlsrEcpf/kI1T2v85YrQRV6PPXUcEXUg5karKtH6oh47XJZ4kHu56pUkOuqA7w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@mapbox/point-geometry": "^1.1.0",
|
||||||
|
"@mapbox/vector-tile": "^2.0.4",
|
||||||
|
"@maplibre/geojson-vt": "^5.0.4",
|
||||||
|
"@types/geojson": "^7946.0.16",
|
||||||
|
"@types/supercluster": "^7.1.3",
|
||||||
|
"pbf": "^4.0.1",
|
||||||
|
"supercluster": "^8.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@maplibre/vt-pbf/node_modules/@maplibre/geojson-vt": {
|
||||||
|
"version": "5.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@maplibre/geojson-vt/-/geojson-vt-5.0.4.tgz",
|
||||||
|
"integrity": "sha512-KGg9sma45S+stfH9vPCJk1J0lSDLWZgCT9Y8u8qWZJyjFlP8MNP1WGTxIMYJZjDvVT3PDn05kN1C95Sut1HpgQ==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/@modelcontextprotocol/sdk": {
|
"node_modules/@modelcontextprotocol/sdk": {
|
||||||
"version": "1.26.0",
|
"version": "1.26.0",
|
||||||
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.26.0.tgz",
|
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.26.0.tgz",
|
||||||
@@ -4015,6 +4121,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/geojson": {
|
||||||
|
"version": "7946.0.16",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
|
||||||
|
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/json-schema": {
|
"node_modules/@types/json-schema": {
|
||||||
"version": "7.0.15",
|
"version": "7.0.15",
|
||||||
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
|
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
|
||||||
@@ -4118,6 +4230,15 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/supercluster": {
|
||||||
|
"version": "7.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/supercluster/-/supercluster-7.1.3.tgz",
|
||||||
|
"integrity": "sha512-Z0pOY34GDFl3Q6hUFYf3HkTwKEE02e7QgtJppBt+beEAxnyOpJua+voGFvxINBHa06GwLFFym7gRPY2SiKIfIA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/geojson": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/tough-cookie": {
|
"node_modules/@types/tough-cookie": {
|
||||||
"version": "4.0.5",
|
"version": "4.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz",
|
||||||
@@ -6293,6 +6414,12 @@
|
|||||||
"node": ">= 0.4"
|
"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==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/eciesjs": {
|
"node_modules/eciesjs": {
|
||||||
"version": "0.4.17",
|
"version": "0.4.17",
|
||||||
"resolved": "https://registry.npmjs.org/eciesjs/-/eciesjs-0.4.17.tgz",
|
"resolved": "https://registry.npmjs.org/eciesjs/-/eciesjs-0.4.17.tgz",
|
||||||
@@ -7794,6 +7921,12 @@
|
|||||||
"giget": "dist/cli.mjs"
|
"giget": "dist/cli.mjs"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/gl-matrix": {
|
||||||
|
"version": "3.4.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/gl-matrix/-/gl-matrix-3.4.4.tgz",
|
||||||
|
"integrity": "sha512-latSnyDNt/8zYUB6VIJ6PCh2jBjJX6gnDsoCZ7LyW7GkqrD51EWwa9qCoGixj8YqBtETQK/xY7OmpTF8xz1DdQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/glob-parent": {
|
"node_modules/glob-parent": {
|
||||||
"version": "6.0.2",
|
"version": "6.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
|
||||||
@@ -8938,6 +9071,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/json-stringify-pretty-compact": {
|
||||||
|
"version": "4.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/json-stringify-pretty-compact/-/json-stringify-pretty-compact-4.0.0.tgz",
|
||||||
|
"integrity": "sha512-3CNZ2DnrpByG9Nqj6Xo8vqbjT4F6N+tb4Gb28ESAZjYZ5yqvmc56J+/kuIwkaAMOyblTQhUW7PxMkUb8Q36N3Q==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/json5": {
|
"node_modules/json5": {
|
||||||
"version": "2.2.3",
|
"version": "2.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
|
||||||
@@ -9009,6 +9148,12 @@
|
|||||||
"setimmediate": "^1.0.5"
|
"setimmediate": "^1.0.5"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/kdbush": {
|
||||||
|
"version": "4.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/kdbush/-/kdbush-4.0.2.tgz",
|
||||||
|
"integrity": "sha512-WbCVYJ27Sz8zi9Q7Q0xHC+05iwkm3Znipc2XTlrnJbsHMYktW4hPhXUE8Ys1engBrvffoSCqbil1JQAa7clRpA==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/keyv": {
|
"node_modules/keyv": {
|
||||||
"version": "4.5.4",
|
"version": "4.5.4",
|
||||||
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
||||||
@@ -9454,6 +9599,40 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/maplibre-gl": {
|
||||||
|
"version": "5.21.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-5.21.0.tgz",
|
||||||
|
"integrity": "sha512-n0v4J/Ge0EG8ix/z3TY3ragtJYMqzbtSnj1riOC0OwQbzwp0lUF2maS1ve1z8HhitQCKtZZiZJhb8to36aMMfQ==",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"@mapbox/jsonlint-lines-primitives": "^2.0.2",
|
||||||
|
"@mapbox/point-geometry": "^1.1.0",
|
||||||
|
"@mapbox/tiny-sdf": "^2.0.7",
|
||||||
|
"@mapbox/unitbezier": "^0.0.1",
|
||||||
|
"@mapbox/vector-tile": "^2.0.4",
|
||||||
|
"@mapbox/whoots-js": "^3.1.0",
|
||||||
|
"@maplibre/geojson-vt": "^6.0.4",
|
||||||
|
"@maplibre/maplibre-gl-style-spec": "^24.7.0",
|
||||||
|
"@maplibre/mlt": "^1.1.8",
|
||||||
|
"@maplibre/vt-pbf": "^4.3.0",
|
||||||
|
"@types/geojson": "^7946.0.16",
|
||||||
|
"earcut": "^3.0.2",
|
||||||
|
"gl-matrix": "^3.4.4",
|
||||||
|
"kdbush": "^4.0.2",
|
||||||
|
"murmurhash-js": "^1.0.0",
|
||||||
|
"pbf": "^4.0.1",
|
||||||
|
"potpack": "^2.1.0",
|
||||||
|
"quickselect": "^3.0.0",
|
||||||
|
"tinyqueue": "^3.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.14.0",
|
||||||
|
"npm": ">=8.1.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/maplibre/maplibre-gl-js?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/math-intrinsics": {
|
"node_modules/math-intrinsics": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||||
@@ -9596,7 +9775,6 @@
|
|||||||
"version": "1.2.8",
|
"version": "1.2.8",
|
||||||
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
|
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
|
||||||
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
|
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
@@ -9723,6 +9901,12 @@
|
|||||||
"url": "https://opencollective.com/express"
|
"url": "https://opencollective.com/express"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/murmurhash-js": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/murmurhash-js/-/murmurhash-js-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-TvmkNhkv8yct0SVBSy+o8wYzXjE4Zz3PCesbfs8HiCXXdcTuocApFv11UWlNFWKYsP2okqrhb7JNlSm9InBhIw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/mute-stream": {
|
"node_modules/mute-stream": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-2.0.0.tgz",
|
||||||
@@ -10555,6 +10739,18 @@
|
|||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/pbf": {
|
||||||
|
"version": "4.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/pbf/-/pbf-4.0.1.tgz",
|
||||||
|
"integrity": "sha512-SuLdBvS42z33m8ejRbInMapQe8n0D3vN/Xd5fmWM3tufNgRQFBpaW2YVJxQZV4iPNqb0vEFvssMEo5w9c6BTIA==",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"resolve-protobuf-schema": "^2.1.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"pbf": "bin/pbf"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/perfect-debounce": {
|
"node_modules/perfect-debounce": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz",
|
||||||
@@ -10671,6 +10867,12 @@
|
|||||||
"node": ">=4"
|
"node": ">=4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/potpack": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/potpack/-/potpack-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-pcaShQc1Shq0y+E7GqJqvZj8DTthWV1KeHGdi0Z6IAin2Oi3JnLCOfwnCo84qc+HAp52wT9nK9H7FAJp5a44GQ==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/powershell-utils": {
|
"node_modules/powershell-utils": {
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/powershell-utils/-/powershell-utils-0.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/powershell-utils/-/powershell-utils-0.1.0.tgz",
|
||||||
@@ -10821,6 +11023,12 @@
|
|||||||
"react-is": "^16.13.1"
|
"react-is": "^16.13.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/protocol-buffers-schema": {
|
||||||
|
"version": "3.6.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.6.0.tgz",
|
||||||
|
"integrity": "sha512-TdDRD+/QNdrCGCE7v8340QyuXd4kIWIgapsE2+n/SaGiSSbomYl4TjHlvIoCWRpE7wFt02EpB35VVA2ImcBVqw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/proxy-addr": {
|
"node_modules/proxy-addr": {
|
||||||
"version": "2.0.7",
|
"version": "2.0.7",
|
||||||
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
|
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
|
||||||
@@ -11085,6 +11293,12 @@
|
|||||||
],
|
],
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/quickselect": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/quickselect/-/quickselect-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/radix-ui": {
|
"node_modules/radix-ui": {
|
||||||
"version": "1.4.3",
|
"version": "1.4.3",
|
||||||
"resolved": "https://registry.npmjs.org/radix-ui/-/radix-ui-1.4.3.tgz",
|
"resolved": "https://registry.npmjs.org/radix-ui/-/radix-ui-1.4.3.tgz",
|
||||||
@@ -11476,6 +11690,15 @@
|
|||||||
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
|
"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==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"protocol-buffers-schema": "^3.3.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/restore-cursor": {
|
"node_modules/restore-cursor": {
|
||||||
"version": "5.1.0",
|
"version": "5.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz",
|
||||||
@@ -11586,6 +11809,12 @@
|
|||||||
"queue-microtask": "^1.2.2"
|
"queue-microtask": "^1.2.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/rw": {
|
||||||
|
"version": "1.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz",
|
||||||
|
"integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==",
|
||||||
|
"license": "BSD-3-Clause"
|
||||||
|
},
|
||||||
"node_modules/safe-array-concat": {
|
"node_modules/safe-array-concat": {
|
||||||
"version": "1.1.3",
|
"version": "1.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz",
|
||||||
@@ -12444,6 +12673,15 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/supercluster": {
|
||||||
|
"version": "8.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/supercluster/-/supercluster-8.0.1.tgz",
|
||||||
|
"integrity": "sha512-IiOea5kJ9iqzD2t7QJq/cREyLHTtSmUT6gQsweojg9WH2sYJqZK9SswTu6jrscO6D1G5v5vYZ9ru/eq85lXeZQ==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"kdbush": "^4.0.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/supports-color": {
|
"node_modules/supports-color": {
|
||||||
"version": "7.2.0",
|
"version": "7.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
|
||||||
@@ -12667,6 +12905,12 @@
|
|||||||
"url": "https://github.com/sponsors/jonschlinkert"
|
"url": "https://github.com/sponsors/jonschlinkert"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/tinyqueue": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/tinyqueue/-/tinyqueue-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/tldts": {
|
"node_modules/tldts": {
|
||||||
"version": "7.0.23",
|
"version": "7.0.23",
|
||||||
"resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.23.tgz",
|
"resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.23.tgz",
|
||||||
|
|||||||
@@ -20,6 +20,7 @@
|
|||||||
"jspdf": "^4.2.0",
|
"jspdf": "^4.2.0",
|
||||||
"jszip": "^3.10.1",
|
"jszip": "^3.10.1",
|
||||||
"lucide-react": "^0.564.0",
|
"lucide-react": "^0.564.0",
|
||||||
|
"maplibre-gl": "^5.21.0",
|
||||||
"minio": "^8.0.6",
|
"minio": "^8.0.6",
|
||||||
"next": "16.1.6",
|
"next": "16.1.6",
|
||||||
"next-auth": "^4.24.13",
|
"next-auth": "^4.24.13",
|
||||||
|
|||||||
@@ -0,0 +1,177 @@
|
|||||||
|
-- =============================================================================
|
||||||
|
-- PostGIS native geometry setup for GisUat (UAT boundaries)
|
||||||
|
-- Run once manually: PGPASSWORD='...' psql -h 10.10.10.166 -p 5432 \
|
||||||
|
-- -U architools_user -d architools_db -f prisma/gisuat-postgis-setup.sql
|
||||||
|
--
|
||||||
|
-- Idempotent — safe to re-run.
|
||||||
|
--
|
||||||
|
-- What this does:
|
||||||
|
-- 1. Ensures PostGIS extension
|
||||||
|
-- 2. Adds native geometry column (geom) if missing
|
||||||
|
-- 3. Creates function to convert Esri ring JSON -> PostGIS geometry
|
||||||
|
-- 4. Creates trigger to auto-convert on INSERT/UPDATE
|
||||||
|
-- 5. Backfills existing rows
|
||||||
|
-- 6. Creates GiST spatial index
|
||||||
|
-- 7. Creates Martin/QGIS-friendly view 'gis_uats'
|
||||||
|
--
|
||||||
|
-- After running both SQL scripts (postgis-setup.sql + this file), Martin
|
||||||
|
-- will auto-discover these views (any table/view with a 'geom' geometry column):
|
||||||
|
-- - gis_features (master: all GisFeature rows with geometry)
|
||||||
|
-- - gis_terenuri (parcels from GisFeature)
|
||||||
|
-- - gis_cladiri (buildings from GisFeature)
|
||||||
|
-- - gis_documentatii (expertize/zone/receptii from GisFeature)
|
||||||
|
-- - gis_administrativ (limite UAT/intravilan/arii speciale from GisFeature)
|
||||||
|
-- - gis_uats (UAT boundaries from GisUat) <-- this script
|
||||||
|
--
|
||||||
|
-- All geometries are in EPSG:3844 (Stereo70).
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
-- 1. Ensure PostGIS extension
|
||||||
|
CREATE EXTENSION IF NOT EXISTS postgis;
|
||||||
|
|
||||||
|
-- 2. Add native geometry column (idempotent)
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.columns
|
||||||
|
WHERE table_name = 'GisUat' AND column_name = 'geom'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE "GisUat" ADD COLUMN geom geometry(Geometry, 3844);
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- 3. Function: convert Esri ring JSON { rings: number[][][] } -> PostGIS geometry
|
||||||
|
-- Esri rings format: each ring is an array of [x, y] coordinate pairs.
|
||||||
|
-- First ring = exterior, subsequent rings = holes.
|
||||||
|
-- Multiple outer rings (non-holes) would need MultiPolygon, but UAT boundaries
|
||||||
|
-- from eTerra typically have a single polygon with possible holes.
|
||||||
|
--
|
||||||
|
-- Strategy: build WKT POLYGON/MULTIPOLYGON from the rings array, then
|
||||||
|
-- use ST_GeomFromText with SRID 3844.
|
||||||
|
CREATE OR REPLACE FUNCTION gis_uat_esri_to_geom(geom_json jsonb)
|
||||||
|
RETURNS geometry AS $$
|
||||||
|
DECLARE
|
||||||
|
rings jsonb;
|
||||||
|
ring jsonb;
|
||||||
|
coord jsonb;
|
||||||
|
ring_count int;
|
||||||
|
coord_count int;
|
||||||
|
i int;
|
||||||
|
j int;
|
||||||
|
wkt_ring text;
|
||||||
|
wkt text;
|
||||||
|
first_x double precision;
|
||||||
|
first_y double precision;
|
||||||
|
last_x double precision;
|
||||||
|
last_y double precision;
|
||||||
|
BEGIN
|
||||||
|
-- Extract the rings array from the JSON
|
||||||
|
rings := geom_json -> 'rings';
|
||||||
|
|
||||||
|
IF rings IS NULL OR jsonb_array_length(rings) = 0 THEN
|
||||||
|
RETURN NULL;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
ring_count := jsonb_array_length(rings);
|
||||||
|
|
||||||
|
-- Build WKT POLYGON with all rings (first = exterior, rest = holes)
|
||||||
|
wkt := 'POLYGON(';
|
||||||
|
|
||||||
|
FOR i IN 0 .. ring_count - 1 LOOP
|
||||||
|
ring := rings -> i;
|
||||||
|
coord_count := jsonb_array_length(ring);
|
||||||
|
|
||||||
|
IF coord_count < 3 THEN
|
||||||
|
CONTINUE; -- skip degenerate rings
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF i > 0 THEN
|
||||||
|
wkt := wkt || ', ';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
wkt_ring := '(';
|
||||||
|
|
||||||
|
FOR j IN 0 .. coord_count - 1 LOOP
|
||||||
|
coord := ring -> j;
|
||||||
|
IF j > 0 THEN
|
||||||
|
wkt_ring := wkt_ring || ', ';
|
||||||
|
END IF;
|
||||||
|
wkt_ring := wkt_ring || (coord ->> 0) || ' ' || (coord ->> 1);
|
||||||
|
|
||||||
|
-- Track first and last coordinates to check ring closure
|
||||||
|
IF j = 0 THEN
|
||||||
|
first_x := (coord ->> 0)::double precision;
|
||||||
|
first_y := (coord ->> 1)::double precision;
|
||||||
|
END IF;
|
||||||
|
IF j = coord_count - 1 THEN
|
||||||
|
last_x := (coord ->> 0)::double precision;
|
||||||
|
last_y := (coord ->> 1)::double precision;
|
||||||
|
END IF;
|
||||||
|
END LOOP;
|
||||||
|
|
||||||
|
-- Close the ring if not already closed
|
||||||
|
IF first_x != last_x OR first_y != last_y THEN
|
||||||
|
wkt_ring := wkt_ring || ', ' || first_x::text || ' ' || first_y::text;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
wkt_ring := wkt_ring || ')';
|
||||||
|
wkt := wkt || wkt_ring;
|
||||||
|
END LOOP;
|
||||||
|
|
||||||
|
wkt := wkt || ')';
|
||||||
|
|
||||||
|
RETURN ST_GeomFromText(wkt, 3844);
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql IMMUTABLE;
|
||||||
|
|
||||||
|
-- 4. Trigger function: auto-convert Esri JSON -> native PostGIS on INSERT/UPDATE
|
||||||
|
CREATE OR REPLACE FUNCTION gis_uat_sync_geom()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
BEGIN
|
||||||
|
IF NEW.geometry IS NOT NULL THEN
|
||||||
|
BEGIN
|
||||||
|
NEW.geom := gis_uat_esri_to_geom(NEW.geometry::jsonb);
|
||||||
|
EXCEPTION WHEN OTHERS THEN
|
||||||
|
-- Invalid geometry JSON -> leave geom NULL rather than fail the write
|
||||||
|
NEW.geom := NULL;
|
||||||
|
END;
|
||||||
|
ELSE
|
||||||
|
NEW.geom := NULL;
|
||||||
|
END IF;
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
-- 5. Attach trigger (drop + recreate for idempotency)
|
||||||
|
DROP TRIGGER IF EXISTS trg_gis_uat_sync_geom ON "GisUat";
|
||||||
|
CREATE TRIGGER trg_gis_uat_sync_geom
|
||||||
|
BEFORE INSERT OR UPDATE OF geometry ON "GisUat"
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION gis_uat_sync_geom();
|
||||||
|
|
||||||
|
-- 6. Backfill: convert existing Esri JSON geometries to native PostGIS
|
||||||
|
UPDATE "GisUat"
|
||||||
|
SET geom = gis_uat_esri_to_geom(geometry::jsonb)
|
||||||
|
WHERE geometry IS NOT NULL AND geom IS NULL;
|
||||||
|
|
||||||
|
-- 7. GiST spatial index for fast spatial queries
|
||||||
|
CREATE INDEX IF NOT EXISTS gis_uat_geom_idx
|
||||||
|
ON "GisUat" USING GIST (geom);
|
||||||
|
|
||||||
|
-- 8. Martin/QGIS-friendly view
|
||||||
|
CREATE OR REPLACE VIEW gis_uats AS
|
||||||
|
SELECT
|
||||||
|
siruta,
|
||||||
|
name,
|
||||||
|
county,
|
||||||
|
"workspacePk" AS workspace_pk,
|
||||||
|
"areaValue" AS area_value,
|
||||||
|
geom
|
||||||
|
FROM "GisUat"
|
||||||
|
WHERE geom IS NOT NULL;
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
-- Done! Martin will auto-discover gis_uats at http://localhost:3010/gis_uats
|
||||||
|
-- QGIS: PostgreSQL -> 10.10.10.166:5432 / architools_db -> gis_uats view
|
||||||
|
-- SRID: 3844 (Stereo70)
|
||||||
|
-- =============================================================================
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { FeatureGate } from "@/core/feature-flags";
|
||||||
|
import { useI18n } from "@/core/i18n";
|
||||||
|
import { GeoportalModule } from "@/modules/geoportal";
|
||||||
|
|
||||||
|
export default function GeoportalPage() {
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FeatureGate flag="module.geoportal" fallback={<ModuleDisabled />}>
|
||||||
|
<div className="mx-auto max-w-7xl space-y-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight">
|
||||||
|
{t("geoportal.title")}
|
||||||
|
</h1>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
{t("geoportal.description")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<GeoportalModule />
|
||||||
|
</div>
|
||||||
|
</FeatureGate>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ModuleDisabled() {
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-7xl py-12 text-center text-muted-foreground">
|
||||||
|
<p>Modulul Geoportal este dezactivat.</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -122,6 +122,14 @@ export const DEFAULT_FLAGS: FeatureFlag[] = [
|
|||||||
category: "module",
|
category: "module",
|
||||||
overridable: true,
|
overridable: true,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: "module.geoportal",
|
||||||
|
enabled: true,
|
||||||
|
label: "Geoportal",
|
||||||
|
description: "Harta interactiva cu parcele, cladiri si limite UAT",
|
||||||
|
category: "module",
|
||||||
|
overridable: true,
|
||||||
|
},
|
||||||
|
|
||||||
// System flags
|
// System flags
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { aiChatConfig } from "@/modules/ai-chat/config";
|
|||||||
import { hotDeskConfig } from "@/modules/hot-desk/config";
|
import { hotDeskConfig } from "@/modules/hot-desk/config";
|
||||||
import { visualCopilotConfig } from "@/modules/visual-copilot/config";
|
import { visualCopilotConfig } from "@/modules/visual-copilot/config";
|
||||||
import { parcelSyncConfig } from "@/modules/parcel-sync/config";
|
import { parcelSyncConfig } from "@/modules/parcel-sync/config";
|
||||||
|
import { geoportalConfig } from "@/modules/geoportal/config";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Toate configurările modulelor ArchiTools, ordonate după navOrder.
|
* Toate configurările modulelor ArchiTools, ordonate după navOrder.
|
||||||
@@ -34,6 +35,7 @@ export const MODULE_CONFIGS: ModuleConfig[] = [
|
|||||||
tagManagerConfig, // navOrder: 40 | tools
|
tagManagerConfig, // navOrder: 40 | tools
|
||||||
miniUtilitiesConfig, // navOrder: 41 | tools
|
miniUtilitiesConfig, // navOrder: 41 | tools
|
||||||
parcelSyncConfig, // navOrder: 42 | tools
|
parcelSyncConfig, // navOrder: 42 | tools
|
||||||
|
geoportalConfig, // navOrder: 43 | tools
|
||||||
promptGeneratorConfig, // navOrder: 50 | ai
|
promptGeneratorConfig, // navOrder: 50 | ai
|
||||||
aiChatConfig, // navOrder: 51 | ai
|
aiChatConfig, // navOrder: 51 | ai
|
||||||
visualCopilotConfig, // navOrder: 52 | ai
|
visualCopilotConfig, // navOrder: 52 | ai
|
||||||
|
|||||||
@@ -116,4 +116,9 @@ export const ro: Labels = {
|
|||||||
description:
|
description:
|
||||||
"Sincronizare parcele cadastrale ANCPI cu bază de date GIS locală",
|
"Sincronizare parcele cadastrale ANCPI cu bază de date GIS locală",
|
||||||
},
|
},
|
||||||
|
geoportal: {
|
||||||
|
title: "Geoportal",
|
||||||
|
description:
|
||||||
|
"Harta interactiva cu parcele cadastrale, cladiri si limite UAT",
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useRef, useCallback } from "react";
|
||||||
|
import dynamic from "next/dynamic";
|
||||||
|
import { Globe } from "lucide-react";
|
||||||
|
import { LayerPanel, getDefaultVisibility } from "./layer-panel";
|
||||||
|
import type { MapViewerHandle } from "./map-viewer";
|
||||||
|
import type { ClickedFeature, LayerVisibility } from "../types";
|
||||||
|
|
||||||
|
/* MapLibre uses WebGL — must disable SSR */
|
||||||
|
const MapViewer = dynamic(
|
||||||
|
() =>
|
||||||
|
import("./map-viewer").then((m) => ({
|
||||||
|
default: m.MapViewer,
|
||||||
|
})),
|
||||||
|
{
|
||||||
|
ssr: false,
|
||||||
|
loading: () => (
|
||||||
|
<div className="flex items-center justify-center h-full bg-muted/30">
|
||||||
|
<p className="text-sm text-muted-foreground">Se incarca harta...</p>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Component */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
export function GeoportalModule() {
|
||||||
|
const mapHandleRef = useRef<MapViewerHandle>(null);
|
||||||
|
const [layerVisibility, setLayerVisibility] = useState<LayerVisibility>(
|
||||||
|
getDefaultVisibility
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleFeatureClick = useCallback((feature: ClickedFeature) => {
|
||||||
|
// Feature click is handled by the MapViewer popup internally.
|
||||||
|
// This callback is available for future integration (e.g., detail panel).
|
||||||
|
void feature;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleVisibilityChange = useCallback((vis: LayerVisibility) => {
|
||||||
|
setLayerVisibility(vis);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Globe className="h-6 w-6 text-muted-foreground" />
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold">Geoportal</h2>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Harta interactiva cu parcele cadastrale, cladiri si limite UAT
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Map container */}
|
||||||
|
<div className="relative h-[calc(100vh-12rem)] min-h-[500px] rounded-lg border overflow-hidden">
|
||||||
|
<MapViewer
|
||||||
|
ref={mapHandleRef}
|
||||||
|
className="h-full w-full"
|
||||||
|
onFeatureClick={handleFeatureClick}
|
||||||
|
layerVisibility={layerVisibility}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Layer panel overlay */}
|
||||||
|
<div className="absolute top-3 left-3 z-10">
|
||||||
|
<LayerPanel
|
||||||
|
visibility={layerVisibility}
|
||||||
|
onVisibilityChange={handleVisibilityChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useCallback } from "react";
|
||||||
|
import { Layers, ChevronRight, ChevronDown } from "lucide-react";
|
||||||
|
import { Switch } from "@/shared/components/ui/switch";
|
||||||
|
import { Label } from "@/shared/components/ui/label";
|
||||||
|
import { Button } from "@/shared/components/ui/button";
|
||||||
|
import { cn } from "@/shared/lib/utils";
|
||||||
|
import type { LayerVisibility } from "../types";
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Layer definitions */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
type LayerGroupDef = {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
color: string;
|
||||||
|
defaultVisible: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const LAYER_GROUPS: LayerGroupDef[] = [
|
||||||
|
{
|
||||||
|
id: "uats",
|
||||||
|
label: "Limite UAT",
|
||||||
|
description: "Unitati administrativ-teritoriale",
|
||||||
|
color: "#7c3aed",
|
||||||
|
defaultVisible: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "terenuri",
|
||||||
|
label: "Terenuri",
|
||||||
|
description: "Parcele cadastrale (zoom >= 13)",
|
||||||
|
color: "#22c55e",
|
||||||
|
defaultVisible: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "cladiri",
|
||||||
|
label: "Cladiri",
|
||||||
|
description: "Constructii (zoom >= 14)",
|
||||||
|
color: "#3b82f6",
|
||||||
|
defaultVisible: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Props */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
type LayerPanelProps = {
|
||||||
|
visibility: LayerVisibility;
|
||||||
|
onVisibilityChange: (visibility: LayerVisibility) => void;
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Component */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
export function LayerPanel({
|
||||||
|
visibility,
|
||||||
|
onVisibilityChange,
|
||||||
|
className,
|
||||||
|
}: LayerPanelProps) {
|
||||||
|
const [collapsed, setCollapsed] = useState(false);
|
||||||
|
|
||||||
|
const handleToggle = useCallback(
|
||||||
|
(groupId: string, checked: boolean) => {
|
||||||
|
onVisibilityChange({ ...visibility, [groupId]: checked });
|
||||||
|
},
|
||||||
|
[visibility, onVisibilityChange]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"bg-background/95 backdrop-blur-sm border rounded-lg shadow-lg",
|
||||||
|
"transition-all duration-200",
|
||||||
|
collapsed ? "w-10" : "w-64",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className={cn(
|
||||||
|
"w-full flex items-center gap-2 px-2.5 py-2 h-auto",
|
||||||
|
collapsed ? "justify-center" : "justify-start"
|
||||||
|
)}
|
||||||
|
onClick={() => setCollapsed(!collapsed)}
|
||||||
|
title={collapsed ? "Afiseaza layere" : "Ascunde layere"}
|
||||||
|
>
|
||||||
|
<Layers className="h-4 w-4 shrink-0" />
|
||||||
|
{!collapsed && (
|
||||||
|
<>
|
||||||
|
<span className="text-sm font-medium flex-1 text-left">
|
||||||
|
Straturi
|
||||||
|
</span>
|
||||||
|
{collapsed ? (
|
||||||
|
<ChevronRight className="h-3.5 w-3.5" />
|
||||||
|
) : (
|
||||||
|
<ChevronDown className="h-3.5 w-3.5" />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{/* Layer list */}
|
||||||
|
{!collapsed && (
|
||||||
|
<div className="px-3 pb-3 space-y-3">
|
||||||
|
{LAYER_GROUPS.map((group) => {
|
||||||
|
const isVisible = visibility[group.id] !== false;
|
||||||
|
return (
|
||||||
|
<div key={group.id} className="flex items-start gap-3">
|
||||||
|
{/* Color swatch */}
|
||||||
|
<div
|
||||||
|
className="mt-0.5 h-4 w-4 rounded-sm border shrink-0"
|
||||||
|
style={{
|
||||||
|
backgroundColor: isVisible ? group.color : "transparent",
|
||||||
|
borderColor: group.color,
|
||||||
|
opacity: isVisible ? 1 : 0.4,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Label + description */}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<Label
|
||||||
|
htmlFor={`layer-${group.id}`}
|
||||||
|
className="text-sm font-medium cursor-pointer leading-tight"
|
||||||
|
>
|
||||||
|
{group.label}
|
||||||
|
</Label>
|
||||||
|
<p className="text-xs text-muted-foreground leading-tight mt-0.5">
|
||||||
|
{group.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Toggle */}
|
||||||
|
<Switch
|
||||||
|
id={`layer-${group.id}`}
|
||||||
|
checked={isVisible}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
handleToggle(group.id, checked)
|
||||||
|
}
|
||||||
|
className="shrink-0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns the default visibility state */
|
||||||
|
export function getDefaultVisibility(): LayerVisibility {
|
||||||
|
const vis: LayerVisibility = {};
|
||||||
|
for (const g of LAYER_GROUPS) {
|
||||||
|
vis[g.id] = g.defaultVisible;
|
||||||
|
}
|
||||||
|
return vis;
|
||||||
|
}
|
||||||
@@ -0,0 +1,410 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRef, useEffect, useState, useCallback, useImperativeHandle, forwardRef } from "react";
|
||||||
|
import maplibregl from "maplibre-gl";
|
||||||
|
import "maplibre-gl/dist/maplibre-gl.css";
|
||||||
|
import { cn } from "@/shared/lib/utils";
|
||||||
|
import type { ClickedFeature, LayerVisibility } from "../types";
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Constants */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Martin tile URL.
|
||||||
|
* Env var NEXT_PUBLIC_MARTIN_URL should be set in docker-compose.yml:
|
||||||
|
* - NEXT_PUBLIC_MARTIN_URL=http://10.10.10.166:3010
|
||||||
|
* For production via Traefik, use https://tools.beletage.ro/tiles
|
||||||
|
*/
|
||||||
|
const DEFAULT_MARTIN_URL = process.env.NEXT_PUBLIC_MARTIN_URL ?? "http://10.10.10.166:3010";
|
||||||
|
|
||||||
|
/** Default center: Romania roughly centered */
|
||||||
|
const DEFAULT_CENTER: [number, number] = [23.8, 46.1];
|
||||||
|
const DEFAULT_ZOOM = 7;
|
||||||
|
|
||||||
|
/** Source/layer IDs used on the map */
|
||||||
|
const SOURCES = {
|
||||||
|
uats: "gis_uats",
|
||||||
|
terenuri: "gis_terenuri",
|
||||||
|
cladiri: "gis_cladiri",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/** Map layer IDs (prefixed to avoid collisions) */
|
||||||
|
const LAYER_IDS = {
|
||||||
|
uatsFill: "layer-uats-fill",
|
||||||
|
uatsLine: "layer-uats-line",
|
||||||
|
uatsLabel: "layer-uats-label",
|
||||||
|
terenuriFill: "layer-terenuri-fill",
|
||||||
|
terenuriLine: "layer-terenuri-line",
|
||||||
|
cladiriFill: "layer-cladiri-fill",
|
||||||
|
cladiriLine: "layer-cladiri-line",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Props */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
export type MapViewerHandle = {
|
||||||
|
getMap: () => maplibregl.Map | null;
|
||||||
|
setLayerVisibility: (visibility: LayerVisibility) => void;
|
||||||
|
flyTo: (center: [number, number], zoom?: number) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
type MapViewerProps = {
|
||||||
|
center?: [number, number];
|
||||||
|
zoom?: number;
|
||||||
|
martinUrl?: string;
|
||||||
|
className?: string;
|
||||||
|
onFeatureClick?: (feature: ClickedFeature) => void;
|
||||||
|
/** External layer visibility control */
|
||||||
|
layerVisibility?: LayerVisibility;
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Helpers */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
function formatPopupContent(properties: Record<string, unknown>): string {
|
||||||
|
const rows: string[] = [];
|
||||||
|
for (const [key, value] of Object.entries(properties)) {
|
||||||
|
if (value == null || value === "") continue;
|
||||||
|
const displayKey = key.replace(/_/g, " ");
|
||||||
|
rows.push(
|
||||||
|
`<tr><td style="font-weight:600;padding:2px 8px 2px 0;vertical-align:top;white-space:nowrap;color:#64748b">${displayKey}</td><td style="padding:2px 0">${String(value)}</td></tr>`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (rows.length === 0) return "<p style='color:#94a3b8'>Fara atribute</p>";
|
||||||
|
return `<table style="font-size:13px;line-height:1.4">${rows.join("")}</table>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Component */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
export const MapViewer = forwardRef<MapViewerHandle, MapViewerProps>(
|
||||||
|
function MapViewer(
|
||||||
|
{
|
||||||
|
center,
|
||||||
|
zoom,
|
||||||
|
martinUrl,
|
||||||
|
className,
|
||||||
|
onFeatureClick,
|
||||||
|
layerVisibility,
|
||||||
|
},
|
||||||
|
ref
|
||||||
|
) {
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const mapRef = useRef<maplibregl.Map | null>(null);
|
||||||
|
const popupRef = useRef<maplibregl.Popup | null>(null);
|
||||||
|
const [mapReady, setMapReady] = useState(false);
|
||||||
|
|
||||||
|
const resolvedMartinUrl = martinUrl ?? DEFAULT_MARTIN_URL;
|
||||||
|
|
||||||
|
/* ---- Imperative handle ---- */
|
||||||
|
useImperativeHandle(ref, () => ({
|
||||||
|
getMap: () => mapRef.current,
|
||||||
|
setLayerVisibility: (vis: LayerVisibility) => {
|
||||||
|
applyLayerVisibility(vis);
|
||||||
|
},
|
||||||
|
flyTo: (c: [number, number], z?: number) => {
|
||||||
|
mapRef.current?.flyTo({ center: c, zoom: z ?? 14, duration: 1500 });
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
/* ---- Apply layer visibility ---- */
|
||||||
|
const applyLayerVisibility = useCallback((vis: LayerVisibility) => {
|
||||||
|
const map = mapRef.current;
|
||||||
|
if (!map || !map.isStyleLoaded()) return;
|
||||||
|
|
||||||
|
const mapping: Record<string, string[]> = {
|
||||||
|
uats: [LAYER_IDS.uatsFill, LAYER_IDS.uatsLine, LAYER_IDS.uatsLabel],
|
||||||
|
terenuri: [LAYER_IDS.terenuriFill, LAYER_IDS.terenuriLine],
|
||||||
|
cladiri: [LAYER_IDS.cladiriFill, LAYER_IDS.cladiriLine],
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const [group, layerIds] of Object.entries(mapping)) {
|
||||||
|
const visible = vis[group] !== false; // default visible
|
||||||
|
for (const lid of layerIds) {
|
||||||
|
try {
|
||||||
|
map.setLayoutProperty(lid, "visibility", visible ? "visible" : "none");
|
||||||
|
} catch {
|
||||||
|
// layer might not exist yet
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/* ---- Sync external visibility prop ---- */
|
||||||
|
useEffect(() => {
|
||||||
|
if (mapReady && layerVisibility) {
|
||||||
|
applyLayerVisibility(layerVisibility);
|
||||||
|
}
|
||||||
|
}, [mapReady, layerVisibility, applyLayerVisibility]);
|
||||||
|
|
||||||
|
/* ---- Map initialization ---- */
|
||||||
|
useEffect(() => {
|
||||||
|
if (!containerRef.current) return;
|
||||||
|
|
||||||
|
const map = new maplibregl.Map({
|
||||||
|
container: containerRef.current,
|
||||||
|
style: {
|
||||||
|
version: 8,
|
||||||
|
sources: {
|
||||||
|
osm: {
|
||||||
|
type: "raster",
|
||||||
|
tiles: [
|
||||||
|
"https://a.tile.openstreetmap.org/{z}/{x}/{y}.png",
|
||||||
|
"https://b.tile.openstreetmap.org/{z}/{x}/{y}.png",
|
||||||
|
"https://c.tile.openstreetmap.org/{z}/{x}/{y}.png",
|
||||||
|
],
|
||||||
|
tileSize: 256,
|
||||||
|
attribution:
|
||||||
|
'© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
layers: [
|
||||||
|
{
|
||||||
|
id: "osm-tiles",
|
||||||
|
type: "raster",
|
||||||
|
source: "osm",
|
||||||
|
minzoom: 0,
|
||||||
|
maxzoom: 19,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
center: center ?? DEFAULT_CENTER,
|
||||||
|
zoom: zoom ?? DEFAULT_ZOOM,
|
||||||
|
maxZoom: 20,
|
||||||
|
});
|
||||||
|
|
||||||
|
mapRef.current = map;
|
||||||
|
|
||||||
|
/* ---- Controls ---- */
|
||||||
|
map.addControl(new maplibregl.NavigationControl(), "top-right");
|
||||||
|
map.addControl(new maplibregl.ScaleControl({ unit: "metric" }), "bottom-left");
|
||||||
|
map.addControl(
|
||||||
|
new maplibregl.GeolocateControl({
|
||||||
|
positionOptions: { enableHighAccuracy: true },
|
||||||
|
trackUserLocation: false,
|
||||||
|
}),
|
||||||
|
"top-right"
|
||||||
|
);
|
||||||
|
|
||||||
|
/* ---- Add Martin sources + layers on load ---- */
|
||||||
|
map.on("load", () => {
|
||||||
|
// --- UAT boundaries ---
|
||||||
|
map.addSource(SOURCES.uats, {
|
||||||
|
type: "vector",
|
||||||
|
tiles: [`${resolvedMartinUrl}/${SOURCES.uats}/{z}/{x}/{y}.pbf`],
|
||||||
|
minzoom: 0,
|
||||||
|
maxzoom: 16,
|
||||||
|
});
|
||||||
|
|
||||||
|
map.addLayer({
|
||||||
|
id: LAYER_IDS.uatsFill,
|
||||||
|
type: "fill",
|
||||||
|
source: SOURCES.uats,
|
||||||
|
"source-layer": SOURCES.uats,
|
||||||
|
paint: {
|
||||||
|
"fill-color": "#8b5cf6",
|
||||||
|
"fill-opacity": 0.05,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
map.addLayer({
|
||||||
|
id: LAYER_IDS.uatsLine,
|
||||||
|
type: "line",
|
||||||
|
source: SOURCES.uats,
|
||||||
|
"source-layer": SOURCES.uats,
|
||||||
|
paint: {
|
||||||
|
"line-color": "#7c3aed",
|
||||||
|
"line-width": 1.5,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
map.addLayer({
|
||||||
|
id: LAYER_IDS.uatsLabel,
|
||||||
|
type: "symbol",
|
||||||
|
source: SOURCES.uats,
|
||||||
|
"source-layer": SOURCES.uats,
|
||||||
|
layout: {
|
||||||
|
"text-field": ["coalesce", ["get", "name"], ["get", "uat_name"], ""],
|
||||||
|
"text-size": 12,
|
||||||
|
"text-anchor": "center",
|
||||||
|
"text-allow-overlap": false,
|
||||||
|
},
|
||||||
|
paint: {
|
||||||
|
"text-color": "#5b21b6",
|
||||||
|
"text-halo-color": "#ffffff",
|
||||||
|
"text-halo-width": 1.5,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Terenuri (parcels) ---
|
||||||
|
map.addSource(SOURCES.terenuri, {
|
||||||
|
type: "vector",
|
||||||
|
tiles: [`${resolvedMartinUrl}/${SOURCES.terenuri}/{z}/{x}/{y}.pbf`],
|
||||||
|
minzoom: 10,
|
||||||
|
maxzoom: 18,
|
||||||
|
});
|
||||||
|
|
||||||
|
map.addLayer({
|
||||||
|
id: LAYER_IDS.terenuriFill,
|
||||||
|
type: "fill",
|
||||||
|
source: SOURCES.terenuri,
|
||||||
|
"source-layer": SOURCES.terenuri,
|
||||||
|
minzoom: 13,
|
||||||
|
paint: {
|
||||||
|
"fill-color": "#22c55e",
|
||||||
|
"fill-opacity": 0.4,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
map.addLayer({
|
||||||
|
id: LAYER_IDS.terenuriLine,
|
||||||
|
type: "line",
|
||||||
|
source: SOURCES.terenuri,
|
||||||
|
"source-layer": SOURCES.terenuri,
|
||||||
|
minzoom: 13,
|
||||||
|
paint: {
|
||||||
|
"line-color": "#1a1a1a",
|
||||||
|
"line-width": 0.8,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Cladiri (buildings) ---
|
||||||
|
map.addSource(SOURCES.cladiri, {
|
||||||
|
type: "vector",
|
||||||
|
tiles: [`${resolvedMartinUrl}/${SOURCES.cladiri}/{z}/{x}/{y}.pbf`],
|
||||||
|
minzoom: 12,
|
||||||
|
maxzoom: 18,
|
||||||
|
});
|
||||||
|
|
||||||
|
map.addLayer({
|
||||||
|
id: LAYER_IDS.cladiriFill,
|
||||||
|
type: "fill",
|
||||||
|
source: SOURCES.cladiri,
|
||||||
|
"source-layer": SOURCES.cladiri,
|
||||||
|
minzoom: 14,
|
||||||
|
paint: {
|
||||||
|
"fill-color": "#3b82f6",
|
||||||
|
"fill-opacity": 0.5,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
map.addLayer({
|
||||||
|
id: LAYER_IDS.cladiriLine,
|
||||||
|
type: "line",
|
||||||
|
source: SOURCES.cladiri,
|
||||||
|
"source-layer": SOURCES.cladiri,
|
||||||
|
minzoom: 14,
|
||||||
|
paint: {
|
||||||
|
"line-color": "#1e3a5f",
|
||||||
|
"line-width": 0.6,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Apply initial visibility if provided
|
||||||
|
if (layerVisibility) {
|
||||||
|
applyLayerVisibility(layerVisibility);
|
||||||
|
}
|
||||||
|
|
||||||
|
setMapReady(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ---- Click handler ---- */
|
||||||
|
const clickableLayers = [
|
||||||
|
LAYER_IDS.terenuriFill,
|
||||||
|
LAYER_IDS.cladiriFill,
|
||||||
|
LAYER_IDS.uatsFill,
|
||||||
|
];
|
||||||
|
|
||||||
|
map.on("click", (e) => {
|
||||||
|
const features = map.queryRenderedFeatures(e.point, {
|
||||||
|
layers: clickableLayers,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Close existing popup
|
||||||
|
if (popupRef.current) {
|
||||||
|
popupRef.current.remove();
|
||||||
|
popupRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (features.length === 0) return;
|
||||||
|
|
||||||
|
const first = features[0];
|
||||||
|
if (!first) return;
|
||||||
|
|
||||||
|
const props = (first.properties ?? {}) as Record<string, unknown>;
|
||||||
|
const sourceLayer = first.sourceLayer ?? first.source ?? "";
|
||||||
|
|
||||||
|
// Notify parent
|
||||||
|
if (onFeatureClick) {
|
||||||
|
onFeatureClick({
|
||||||
|
layerId: first.layer?.id ?? "",
|
||||||
|
sourceLayer,
|
||||||
|
properties: props,
|
||||||
|
coordinates: [e.lngLat.lng, e.lngLat.lat],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show popup
|
||||||
|
const popup = new maplibregl.Popup({
|
||||||
|
maxWidth: "360px",
|
||||||
|
closeButton: true,
|
||||||
|
closeOnClick: true,
|
||||||
|
})
|
||||||
|
.setLngLat(e.lngLat)
|
||||||
|
.setHTML(formatPopupContent(props))
|
||||||
|
.addTo(map);
|
||||||
|
|
||||||
|
popupRef.current = popup;
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ---- Cursor change on hover ---- */
|
||||||
|
for (const lid of clickableLayers) {
|
||||||
|
map.on("mouseenter", lid, () => {
|
||||||
|
map.getCanvas().style.cursor = "pointer";
|
||||||
|
});
|
||||||
|
map.on("mouseleave", lid, () => {
|
||||||
|
map.getCanvas().style.cursor = "";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Cleanup ---- */
|
||||||
|
return () => {
|
||||||
|
if (popupRef.current) {
|
||||||
|
popupRef.current.remove();
|
||||||
|
popupRef.current = null;
|
||||||
|
}
|
||||||
|
map.remove();
|
||||||
|
mapRef.current = null;
|
||||||
|
setMapReady(false);
|
||||||
|
};
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [resolvedMartinUrl]);
|
||||||
|
|
||||||
|
/* ---- Sync center/zoom prop changes ---- */
|
||||||
|
useEffect(() => {
|
||||||
|
if (!mapReady || !mapRef.current) return;
|
||||||
|
if (center) {
|
||||||
|
mapRef.current.flyTo({
|
||||||
|
center,
|
||||||
|
zoom: zoom ?? mapRef.current.getZoom(),
|
||||||
|
duration: 1500,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [center, zoom, mapReady]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn("relative w-full h-full min-h-[400px]", className)}>
|
||||||
|
<div ref={containerRef} className="absolute inset-0" />
|
||||||
|
{!mapReady && (
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center bg-muted/50">
|
||||||
|
<p className="text-sm text-muted-foreground">Se incarca harta...</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import type { ModuleConfig } from "@/core/module-registry/types";
|
||||||
|
|
||||||
|
export const geoportalConfig: ModuleConfig = {
|
||||||
|
id: "geoportal",
|
||||||
|
name: "Geoportal",
|
||||||
|
description: "Harta interactiva cu parcele cadastrale, cladiri si limite UAT",
|
||||||
|
icon: "globe",
|
||||||
|
route: "/geoportal",
|
||||||
|
category: "tools",
|
||||||
|
featureFlag: "module.geoportal",
|
||||||
|
visibility: "all",
|
||||||
|
version: "0.1.0",
|
||||||
|
dependencies: ["parcel-sync"],
|
||||||
|
storageNamespace: "geoportal",
|
||||||
|
navOrder: 43,
|
||||||
|
tags: ["gis", "harta", "parcele", "cladiri", "uat", "maplibre"],
|
||||||
|
};
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export { GeoportalModule } from "./components/geoportal-module";
|
||||||
|
export { MapViewer } from "./components/map-viewer";
|
||||||
|
export { geoportalConfig } from "./config";
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
/**
|
||||||
|
* Geoportal module types.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Martin vector tile source definition */
|
||||||
|
export type MartinSource = {
|
||||||
|
id: string;
|
||||||
|
/** Human-readable label (Romanian) */
|
||||||
|
label: string;
|
||||||
|
/** Martin source name (matches PostGIS table/view) */
|
||||||
|
sourceName: string;
|
||||||
|
/** Minimum zoom level for visibility */
|
||||||
|
minZoom: number;
|
||||||
|
/** Maximum zoom level for visibility */
|
||||||
|
maxZoom: number;
|
||||||
|
/** Whether layer is visible by default */
|
||||||
|
defaultVisible: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Layer style configuration */
|
||||||
|
export type LayerStyle = {
|
||||||
|
fillColor: string;
|
||||||
|
fillOpacity: number;
|
||||||
|
lineColor: string;
|
||||||
|
lineWidth: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Feature attributes from a clicked map feature */
|
||||||
|
export type ClickedFeature = {
|
||||||
|
layerId: string;
|
||||||
|
sourceLayer: string;
|
||||||
|
properties: Record<string, unknown>;
|
||||||
|
coordinates: [number, number];
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Layer visibility state */
|
||||||
|
export type LayerVisibility = Record<string, boolean>;
|
||||||
|
|
||||||
|
/** Map view state */
|
||||||
|
export type MapViewState = {
|
||||||
|
center: [number, number];
|
||||||
|
zoom: number;
|
||||||
|
};
|
||||||
@@ -61,12 +61,29 @@ import {
|
|||||||
} from "../services/eterra-layers";
|
} from "../services/eterra-layers";
|
||||||
import type { ParcelDetail } from "@/app/api/eterra/search/route";
|
import type { ParcelDetail } from "@/app/api/eterra/search/route";
|
||||||
import type { OwnerSearchResult } from "@/app/api/eterra/search-owner/route";
|
import type { OwnerSearchResult } from "@/app/api/eterra/search-owner/route";
|
||||||
import { User, FileText, Archive } from "lucide-react";
|
import { User, FileText, Archive, Map as MapIcon } from "lucide-react";
|
||||||
|
import dynamic from "next/dynamic";
|
||||||
import { UatDashboard } from "./uat-dashboard";
|
import { UatDashboard } from "./uat-dashboard";
|
||||||
import { EpayConnect, type EpaySessionStatus } from "./epay-connect";
|
import { EpayConnect, type EpaySessionStatus } from "./epay-connect";
|
||||||
import { EpayOrderButton } from "./epay-order-button";
|
import { EpayOrderButton } from "./epay-order-button";
|
||||||
import { EpayTab } from "./epay-tab";
|
import { EpayTab } from "./epay-tab";
|
||||||
|
|
||||||
|
/* MapLibre uses WebGL — must disable SSR */
|
||||||
|
const MapViewer = dynamic(
|
||||||
|
() =>
|
||||||
|
import("@/modules/geoportal/components/map-viewer").then((m) => ({
|
||||||
|
default: m.MapViewer,
|
||||||
|
})),
|
||||||
|
{
|
||||||
|
ssr: false,
|
||||||
|
loading: () => (
|
||||||
|
<div className="flex items-center justify-center h-64 bg-muted/30 rounded-lg">
|
||||||
|
<p className="text-sm text-muted-foreground">Se incarca harta...</p>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
/* Types */
|
/* Types */
|
||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
@@ -2070,6 +2087,10 @@ export function ParcelSyncModule() {
|
|||||||
<FileText className="h-4 w-4" />
|
<FileText className="h-4 w-4" />
|
||||||
Extrase CF
|
Extrase CF
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="map" className="gap-1.5">
|
||||||
|
<MapIcon className="h-4 w-4" />
|
||||||
|
Harta
|
||||||
|
</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -4765,6 +4786,15 @@ export function ParcelSyncModule() {
|
|||||||
<TabsContent value="extracts" className="space-y-4">
|
<TabsContent value="extracts" className="space-y-4">
|
||||||
<EpayTab />
|
<EpayTab />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* ═══════════════════════════════════════════════════════ */}
|
||||||
|
{/* Tab 6: Harta (MapLibre GL) */}
|
||||||
|
{/* ═══════════════════════════════════════════════════════ */}
|
||||||
|
<TabsContent value="map" className="space-y-4">
|
||||||
|
<div className="relative h-[600px] rounded-lg border overflow-hidden">
|
||||||
|
<MapViewer className="h-full w-full" />
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user