diff --git a/docker-compose.yml b/docker-compose.yml index 66bee13..881611b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -91,3 +91,12 @@ services: timeout: 5s retries: 3 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 diff --git a/package-lock.json b/package-lock.json index bc477b5..003aa10 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,6 +19,7 @@ "jspdf": "^4.2.0", "jszip": "^3.10.1", "lucide-react": "^0.564.0", + "maplibre-gl": "^5.21.0", "minio": "^8.0.6", "next": "16.1.6", "next-auth": "^4.24.13", @@ -1641,6 +1642,111 @@ "@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": { "version": "1.26.0", "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.26.0.tgz", @@ -4015,6 +4121,12 @@ "dev": true, "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": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -4118,6 +4230,15 @@ "dev": true, "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": { "version": "4.0.5", "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz", @@ -6293,6 +6414,12 @@ "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": { "version": "0.4.17", "resolved": "https://registry.npmjs.org/eciesjs/-/eciesjs-0.4.17.tgz", @@ -7794,6 +7921,12 @@ "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": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", @@ -8938,6 +9071,12 @@ "dev": true, "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": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", @@ -9009,6 +9148,12 @@ "setimmediate": "^1.0.5" } }, + "node_modules/kdbush": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/kdbush/-/kdbush-4.0.2.tgz", + "integrity": "sha512-WbCVYJ27Sz8zi9Q7Q0xHC+05iwkm3Znipc2XTlrnJbsHMYktW4hPhXUE8Ys1engBrvffoSCqbil1JQAa7clRpA==", + "license": "ISC" + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -9454,6 +9599,40 @@ "@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": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -9596,7 +9775,6 @@ "version": "1.2.8", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", - "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" @@ -9723,6 +9901,12 @@ "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": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-2.0.0.tgz", @@ -10555,6 +10739,18 @@ "devOptional": true, "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": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz", @@ -10671,6 +10867,12 @@ "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": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/powershell-utils/-/powershell-utils-0.1.0.tgz", @@ -10821,6 +11023,12 @@ "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": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -11085,6 +11293,12 @@ ], "license": "MIT" }, + "node_modules/quickselect": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/quickselect/-/quickselect-3.0.0.tgz", + "integrity": "sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g==", + "license": "ISC" + }, "node_modules/radix-ui": { "version": "1.4.3", "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" } }, + "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": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", @@ -11586,6 +11809,12 @@ "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": { "version": "1.1.3", "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": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -12667,6 +12905,12 @@ "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": { "version": "7.0.23", "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.23.tgz", diff --git a/package.json b/package.json index 28f6ab1..8e68656 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "jspdf": "^4.2.0", "jszip": "^3.10.1", "lucide-react": "^0.564.0", + "maplibre-gl": "^5.21.0", "minio": "^8.0.6", "next": "16.1.6", "next-auth": "^4.24.13", diff --git a/prisma/gisuat-postgis-setup.sql b/prisma/gisuat-postgis-setup.sql new file mode 100644 index 0000000..32b26bc --- /dev/null +++ b/prisma/gisuat-postgis-setup.sql @@ -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) +-- ============================================================================= diff --git a/src/app/(modules)/geoportal/page.tsx b/src/app/(modules)/geoportal/page.tsx new file mode 100644 index 0000000..ec67fde --- /dev/null +++ b/src/app/(modules)/geoportal/page.tsx @@ -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 ( + }> +
+
+

+ {t("geoportal.title")} +

+

+ {t("geoportal.description")} +

+
+ +
+
+ ); +} + +function ModuleDisabled() { + return ( +
+

Modulul Geoportal este dezactivat.

+
+ ); +} diff --git a/src/config/flags.ts b/src/config/flags.ts index 500a8bf..234759b 100644 --- a/src/config/flags.ts +++ b/src/config/flags.ts @@ -122,6 +122,14 @@ export const DEFAULT_FLAGS: FeatureFlag[] = [ category: "module", overridable: true, }, + { + key: "module.geoportal", + enabled: true, + label: "Geoportal", + description: "Harta interactiva cu parcele, cladiri si limite UAT", + category: "module", + overridable: true, + }, // System flags { diff --git a/src/config/modules.ts b/src/config/modules.ts index 6401686..ceb8e99 100644 --- a/src/config/modules.ts +++ b/src/config/modules.ts @@ -16,6 +16,7 @@ import { aiChatConfig } from "@/modules/ai-chat/config"; import { hotDeskConfig } from "@/modules/hot-desk/config"; import { visualCopilotConfig } from "@/modules/visual-copilot/config"; import { parcelSyncConfig } from "@/modules/parcel-sync/config"; +import { geoportalConfig } from "@/modules/geoportal/config"; /** * Toate configurările modulelor ArchiTools, ordonate după navOrder. @@ -34,6 +35,7 @@ export const MODULE_CONFIGS: ModuleConfig[] = [ tagManagerConfig, // navOrder: 40 | tools miniUtilitiesConfig, // navOrder: 41 | tools parcelSyncConfig, // navOrder: 42 | tools + geoportalConfig, // navOrder: 43 | tools promptGeneratorConfig, // navOrder: 50 | ai aiChatConfig, // navOrder: 51 | ai visualCopilotConfig, // navOrder: 52 | ai diff --git a/src/core/i18n/locales/ro.ts b/src/core/i18n/locales/ro.ts index b0bdc39..2be2a0d 100644 --- a/src/core/i18n/locales/ro.ts +++ b/src/core/i18n/locales/ro.ts @@ -116,4 +116,9 @@ export const ro: Labels = { description: "Sincronizare parcele cadastrale ANCPI cu bază de date GIS locală", }, + geoportal: { + title: "Geoportal", + description: + "Harta interactiva cu parcele cadastrale, cladiri si limite UAT", + }, }; diff --git a/src/modules/geoportal/components/geoportal-module.tsx b/src/modules/geoportal/components/geoportal-module.tsx new file mode 100644 index 0000000..af07837 --- /dev/null +++ b/src/modules/geoportal/components/geoportal-module.tsx @@ -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: () => ( +
+

Se incarca harta...

+
+ ), + } +); + +/* ------------------------------------------------------------------ */ +/* Component */ +/* ------------------------------------------------------------------ */ + +export function GeoportalModule() { + const mapHandleRef = useRef(null); + const [layerVisibility, setLayerVisibility] = useState( + 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 ( +
+ {/* Header */} +
+ +
+

Geoportal

+

+ Harta interactiva cu parcele cadastrale, cladiri si limite UAT +

+
+
+ + {/* Map container */} +
+ + + {/* Layer panel overlay */} +
+ +
+
+
+ ); +} diff --git a/src/modules/geoportal/components/layer-panel.tsx b/src/modules/geoportal/components/layer-panel.tsx new file mode 100644 index 0000000..dfff856 --- /dev/null +++ b/src/modules/geoportal/components/layer-panel.tsx @@ -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 ( +
+ {/* Header */} + + + {/* Layer list */} + {!collapsed && ( +
+ {LAYER_GROUPS.map((group) => { + const isVisible = visibility[group.id] !== false; + return ( +
+ {/* Color swatch */} +
+ + {/* Label + description */} +
+ +

+ {group.description} +

+
+ + {/* Toggle */} + + handleToggle(group.id, checked) + } + className="shrink-0" + /> +
+ ); + })} +
+ )} +
+ ); +} + +/** 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; +} diff --git a/src/modules/geoportal/components/map-viewer.tsx b/src/modules/geoportal/components/map-viewer.tsx new file mode 100644 index 0000000..3df9cf3 --- /dev/null +++ b/src/modules/geoportal/components/map-viewer.tsx @@ -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 { + const rows: string[] = []; + for (const [key, value] of Object.entries(properties)) { + if (value == null || value === "") continue; + const displayKey = key.replace(/_/g, " "); + rows.push( + `${displayKey}${String(value)}` + ); + } + if (rows.length === 0) return "

Fara atribute

"; + return `${rows.join("")}
`; +} + +/* ------------------------------------------------------------------ */ +/* Component */ +/* ------------------------------------------------------------------ */ + +export const MapViewer = forwardRef( + function MapViewer( + { + center, + zoom, + martinUrl, + className, + onFeatureClick, + layerVisibility, + }, + ref + ) { + const containerRef = useRef(null); + const mapRef = useRef(null); + const popupRef = useRef(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 = { + 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: + '© OpenStreetMap', + }, + }, + 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; + 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 ( +
+
+ {!mapReady && ( +
+

Se incarca harta...

+
+ )} +
+ ); + } +); diff --git a/src/modules/geoportal/config.ts b/src/modules/geoportal/config.ts new file mode 100644 index 0000000..2c00441 --- /dev/null +++ b/src/modules/geoportal/config.ts @@ -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"], +}; diff --git a/src/modules/geoportal/index.ts b/src/modules/geoportal/index.ts new file mode 100644 index 0000000..5c074e6 --- /dev/null +++ b/src/modules/geoportal/index.ts @@ -0,0 +1,3 @@ +export { GeoportalModule } from "./components/geoportal-module"; +export { MapViewer } from "./components/map-viewer"; +export { geoportalConfig } from "./config"; diff --git a/src/modules/geoportal/types.ts b/src/modules/geoportal/types.ts new file mode 100644 index 0000000..9b8575a --- /dev/null +++ b/src/modules/geoportal/types.ts @@ -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; + coordinates: [number, number]; +}; + +/** Layer visibility state */ +export type LayerVisibility = Record; + +/** Map view state */ +export type MapViewState = { + center: [number, number]; + zoom: number; +}; diff --git a/src/modules/parcel-sync/components/parcel-sync-module.tsx b/src/modules/parcel-sync/components/parcel-sync-module.tsx index 07b8491..6a97016 100644 --- a/src/modules/parcel-sync/components/parcel-sync-module.tsx +++ b/src/modules/parcel-sync/components/parcel-sync-module.tsx @@ -61,12 +61,29 @@ import { } from "../services/eterra-layers"; import type { ParcelDetail } from "@/app/api/eterra/search/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 { EpayConnect, type EpaySessionStatus } from "./epay-connect"; import { EpayOrderButton } from "./epay-order-button"; 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: () => ( +
+

Se incarca harta...

+
+ ), + } +); + /* ------------------------------------------------------------------ */ /* Types */ /* ------------------------------------------------------------------ */ @@ -2070,6 +2087,10 @@ export function ParcelSyncModule() { Extrase CF + + + Harta +
@@ -4765,6 +4786,15 @@ export function ParcelSyncModule() { + + {/* ═══════════════════════════════════════════════════════ */} + {/* Tab 6: Harta (MapLibre GL) */} + {/* ═══════════════════════════════════════════════════════ */} + +
+ +
+
); }