diff --git a/.gitignore b/.gitignore index c423abf15..a4fbc0453 100644 --- a/.gitignore +++ b/.gitignore @@ -70,3 +70,5 @@ build !app/frontend/.env !local-infrastructure/.env .history +realm-export.json +chefs_build \ No newline at end of file diff --git a/app/frontend/package-lock.json b/app/frontend/package-lock.json index c1730e50d..897cfee5a 100644 --- a/app/frontend/package-lock.json +++ b/app/frontend/package-lock.json @@ -22,6 +22,9 @@ "font-awesome": "^4.7.0", "formiojs": "^4.14.13", "keycloak-js": "^21.1.1", + "leaflet": "^1.9.4", + "leaflet-draw": "^1.0.4", + "leaflet-geosearch": "^4.0.0", "lodash": "^4.17.21", "mitt": "^3.0.0", "moment": "^2.29.4", @@ -591,6 +594,12 @@ "vue": ">= 3.0.0 < 4" } }, + "node_modules/@googlemaps/js-api-loader": { + "version": "1.16.8", + "resolved": "https://registry.npmjs.org/@googlemaps/js-api-loader/-/js-api-loader-1.16.8.tgz", + "integrity": "sha512-CROqqwfKotdO6EBjZO/gQGVTbeDps5V7Mt9+8+5Q+jTg5CRMi3Ii/L9PmV3USROrt2uWxtGzJHORmByxyo9pSQ==", + "optional": true + }, "node_modules/@humanwhocodes/config-array": { "version": "0.11.10", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.10.tgz", @@ -2862,9 +2871,12 @@ } }, "node_modules/function-bind": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz", - "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==" + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } }, "node_modules/functions-have-names": { "version": "1.2.3", @@ -3785,6 +3797,25 @@ "js-sha256": "^0.9.0" } }, + "node_modules/leaflet": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz", + "integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==" + }, + "node_modules/leaflet-draw": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/leaflet-draw/-/leaflet-draw-1.0.4.tgz", + "integrity": "sha512-rsQ6saQO5ST5Aj6XRFylr5zvarWgzWnrg46zQ1MEOEIHsppdC/8hnN8qMoFvACsPvTioAuysya/TVtog15tyAQ==" + }, + "node_modules/leaflet-geosearch": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/leaflet-geosearch/-/leaflet-geosearch-4.0.0.tgz", + "integrity": "sha512-a92VNY9gxyv3oyEDqIWoCNoBllajWRYejztzOSNmpLRtzpA6JtGgy/wwl9tsB8+6Eek1fe+L6+W0MDEOaidbXA==", + "optionalDependencies": { + "@googlemaps/js-api-loader": "^1.16.6", + "leaflet": "^1.6.0" + } + }, "node_modules/levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", diff --git a/app/frontend/package.json b/app/frontend/package.json index e405c8cc5..72078a176 100644 --- a/app/frontend/package.json +++ b/app/frontend/package.json @@ -43,6 +43,9 @@ "font-awesome": "^4.7.0", "formiojs": "^4.14.13", "keycloak-js": "^21.1.1", + "leaflet": "^1.9.4", + "leaflet-draw": "^1.0.4", + "leaflet-geosearch": "^4.0.0", "lodash": "^4.17.21", "mitt": "^3.0.0", "moment": "^2.29.4", diff --git a/app/frontend/src/utils/constants.js b/app/frontend/src/utils/constants.js index 05bb2441f..9c7ee6511 100755 --- a/app/frontend/src/utils/constants.js +++ b/app/frontend/src/utils/constants.js @@ -323,6 +323,7 @@ export const FormDesignerBuilderOptions = Object.freeze({ orgbook: true, bcaddress: true, simplebcaddress: true, + map: true, }, }, }); diff --git a/components/package-lock.json b/components/package-lock.json index 5cea1c98e..1cf47ccc6 100644 --- a/components/package-lock.json +++ b/components/package-lock.json @@ -9,8 +9,14 @@ "version": "1.0.0", "license": "Apache-2.0", "dependencies": { + "@types/leaflet": "^1.9.12", + "@types/leaflet-draw": "^1.0.11", "autocompleter": "^7.0.1", + "css-loader": "^7.1.2", "formiojs": "^4.14.6", + "leaflet": "^1.9.4", + "leaflet-draw": "^1.0.4", + "leaflet-geosearch": "^4.0.0", "lodash": "^4.17.21", "native-promise-only": "^0.8.1", "path-browserify": "^1.0.1", @@ -19,6 +25,7 @@ "devDependencies": { "@types/chai": "^4.3.1", "@types/ejs": "^3.1.1", + "@types/google.maps": "^3.58.1", "@types/mocha": "^9.1.1", "@types/node": "^16.11.8", "@types/sinon": "^10.0.12", @@ -736,6 +743,12 @@ "resolved": "https://registry.npmjs.org/@formio/vanilla-text-mask/-/vanilla-text-mask-5.1.1.tgz", "integrity": "sha512-7MhrbMypySPi7RLchg0ys7HnS3Wqddbq/btAijKB1nA94TE7AOOLhpZJWcNm3kOlX0Y3nHfoavj/HP7vsvF34Q==" }, + "node_modules/@googlemaps/js-api-loader": { + "version": "1.16.8", + "resolved": "https://registry.npmjs.org/@googlemaps/js-api-loader/-/js-api-loader-1.16.8.tgz", + "integrity": "sha512-CROqqwfKotdO6EBjZO/gQGVTbeDps5V7Mt9+8+5Q+jTg5CRMi3Ii/L9PmV3USROrt2uWxtGzJHORmByxyo9pSQ==", + "optional": true + }, "node_modules/@gulpjs/messages": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@gulpjs/messages/-/messages-1.1.0.tgz", @@ -995,11 +1008,38 @@ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.5.tgz", "integrity": "sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==" }, + "node_modules/@types/geojson": { + "version": "7946.0.14", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.14.tgz", + "integrity": "sha512-WCfD5Ht3ZesJUsONdhvm84dmzWOiOzOAqOncN0++w0lBw1o8OuDNJF2McvvCef/yBqb/HYRahp1BYtODFQ8bRg==" + }, + "node_modules/@types/google.maps": { + "version": "3.58.1", + "resolved": "https://registry.npmjs.org/@types/google.maps/-/google.maps-3.58.1.tgz", + "integrity": "sha512-X9QTSvGJ0nCfMzYOnaVs/k6/4L+7F5uCS+4iUmkLEls6J9S/Phv+m/i3mDeyc49ZBgwab3EFO1HEoBY7k98EGQ==", + "dev": true + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==" }, + "node_modules/@types/leaflet": { + "version": "1.9.12", + "resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.12.tgz", + "integrity": "sha512-BK7XS+NyRI291HIo0HCfE18Lp8oA30H1gpi1tf0mF3TgiCEzanQjOqNZ4x126SXzzi2oNSZhZ5axJp1k0iM6jg==", + "dependencies": { + "@types/geojson": "*" + } + }, + "node_modules/@types/leaflet-draw": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@types/leaflet-draw/-/leaflet-draw-1.0.11.tgz", + "integrity": "sha512-dyedtNm3aSmnpi6FM6VSl28cQuvP+MD7pgpXyO3Q1ZOCvrJKmzaDq0P3YZTnnBs61fQCKSnNYmbvCkDgFT9FHQ==", + "dependencies": { + "@types/leaflet": "*" + } + }, "node_modules/@types/mocha": { "version": "9.1.1", "resolved": "https://registry.npmjs.org/@types/mocha/-/mocha-9.1.1.tgz", @@ -1954,6 +1994,62 @@ "custom-event": "^1.0.0" } }, + "node_modules/css-loader": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/css-loader/-/css-loader-7.1.2.tgz", + "integrity": "sha512-6WvYYn7l/XEGN8Xu2vWFt9nVzrCn39vKyTEFf/ExEyoksJjjSZV/0/35XPlMbpnr6VGhZIUg5yJrL8tGfes/FA==", + "dependencies": { + "icss-utils": "^5.1.0", + "postcss": "^8.4.33", + "postcss-modules-extract-imports": "^3.1.0", + "postcss-modules-local-by-default": "^4.0.5", + "postcss-modules-scope": "^3.2.0", + "postcss-modules-values": "^4.0.0", + "postcss-value-parser": "^4.2.0", + "semver": "^7.5.4" + }, + "engines": { + "node": ">= 18.12.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "@rspack/core": "0.x || 1.x", + "webpack": "^5.27.0" + }, + "peerDependenciesMeta": { + "@rspack/core": { + "optional": true + }, + "webpack": { + "optional": true + } + } + }, + "node_modules/css-loader/node_modules/semver": { + "version": "7.6.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.2.tgz", + "integrity": "sha512-FNAIBWCx9qcRhoHcgcJ0gvU7SN1lYU2ZXuSfl04bSC5OpvDHFyJCjdNHomPXxjQlCBU67YW64PzY7/VIEH7F2w==", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/custom-event": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/custom-event/-/custom-event-1.0.1.tgz", @@ -3148,6 +3244,17 @@ "node": ">=0.10.0" } }, + "node_modules/icss-utils": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/icss-utils/-/icss-utils-5.1.0.tgz", + "integrity": "sha512-soFhflCVWLfRNOPU3iv5Z9VUdT44xFRbzjLsEzSr5AQmgqPMTHdU3PMT1Cf1ssx8fLNJDA1juftYl+PUcv3MqA==", + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, "node_modules/idb": { "version": "6.1.5", "resolved": "https://registry.npmjs.org/idb/-/idb-6.1.5.tgz", @@ -3915,6 +4022,25 @@ "node": ">=10.13.0" } }, + "node_modules/leaflet": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz", + "integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==" + }, + "node_modules/leaflet-draw": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/leaflet-draw/-/leaflet-draw-1.0.4.tgz", + "integrity": "sha512-rsQ6saQO5ST5Aj6XRFylr5zvarWgzWnrg46zQ1MEOEIHsppdC/8hnN8qMoFvACsPvTioAuysya/TVtog15tyAQ==" + }, + "node_modules/leaflet-geosearch": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/leaflet-geosearch/-/leaflet-geosearch-4.0.0.tgz", + "integrity": "sha512-a92VNY9gxyv3oyEDqIWoCNoBllajWRYejztzOSNmpLRtzpA6JtGgy/wwl9tsB8+6Eek1fe+L6+W0MDEOaidbXA==", + "optionalDependencies": { + "@googlemaps/js-api-loader": "^1.16.6", + "leaflet": "^1.6.0" + } + }, "node_modules/liftoff": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/liftoff/-/liftoff-5.0.0.tgz", @@ -5118,10 +5244,9 @@ } }, "node_modules/postcss": { - "version": "8.4.39", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.39.tgz", - "integrity": "sha512-0vzE+lAiG7hZl1/9I8yzKLx3aR9Xbof3fBHKunvMfOCYAtMhrsnccJY2iTURb9EZd5+pLuiNV9/c/GZJOHsgIw==", - "dev": true, + "version": "8.4.41", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.41.tgz", + "integrity": "sha512-TesUflQ0WKZqAvg52PWL6kHgLKP6xB6heTOdoYM0Wt2UHyxNa4K25EZZMgKns3BH1RLVbZCREPpLY0rhnNoHVQ==", "funding": [ { "type": "opencollective", @@ -5145,11 +5270,82 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/postcss-modules-extract-imports": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/postcss-modules-extract-imports/-/postcss-modules-extract-imports-3.1.0.tgz", + "integrity": "sha512-k3kNe0aNFQDAZGbin48pL2VNidTF0w4/eASDsxlyspobzU3wZQLOGj7L9gfRe0Jo9/4uud09DsjFNH7winGv8Q==", + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/postcss-modules-local-by-default": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/postcss-modules-local-by-default/-/postcss-modules-local-by-default-4.0.5.tgz", + "integrity": "sha512-6MieY7sIfTK0hYfafw1OMEG+2bg8Q1ocHCpoWLqOKj3JXlKu4G7btkmM/B7lFubYkYWmRSPLZi5chid63ZaZYw==", + "dependencies": { + "icss-utils": "^5.0.0", + "postcss-selector-parser": "^6.0.2", + "postcss-value-parser": "^4.1.0" + }, + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/postcss-modules-scope": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/postcss-modules-scope/-/postcss-modules-scope-3.2.0.tgz", + "integrity": "sha512-oq+g1ssrsZOsx9M96c5w8laRmvEu9C3adDSjI8oTcbfkrTE8hx/zfyobUoWIxaKPO8bt6S62kxpw5GqypEw1QQ==", + "dependencies": { + "postcss-selector-parser": "^6.0.4" + }, + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/postcss-modules-values": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/postcss-modules-values/-/postcss-modules-values-4.0.0.tgz", + "integrity": "sha512-RDxHkAiEGI78gS2ofyvCsu7iycRv7oqw5xMWn9iMoR0N/7mf9D50ecQqUo5BZ9Zh2vH4bCUR/ktCqbB9m8vJjQ==", + "dependencies": { + "icss-utils": "^5.0.0" + }, + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.0.tgz", + "integrity": "sha512-UMz42UD0UY0EApS0ZL9o1XnLhSTtvvvLe5Dc2H2O56fvRZi+KulDyf5ctDhhtYJBGKStV2FL1fy6253cmLgqVQ==", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==" + }, "node_modules/postcss/node_modules/nanoid": { "version": "3.3.7", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz", "integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==", - "dev": true, "funding": [ { "type": "github", @@ -5737,7 +5933,6 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.0.tgz", "integrity": "sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg==", - "dev": true, "engines": { "node": ">=0.10.0" } @@ -6478,8 +6673,7 @@ "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", - "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=", - "dev": true + "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=" }, "node_modules/uuid": { "version": "3.4.0", diff --git a/components/package.json b/components/package.json index 5098b5065..13ce65d7d 100644 --- a/components/package.json +++ b/components/package.json @@ -47,8 +47,14 @@ "components" ], "dependencies": { + "@types/leaflet": "^1.9.12", + "@types/leaflet-draw": "^1.0.11", "autocompleter": "^7.0.1", + "css-loader": "^7.1.2", "formiojs": "^4.14.6", + "leaflet": "^1.9.4", + "leaflet-draw": "^1.0.4", + "leaflet-geosearch": "^4.0.0", "lodash": "^4.17.21", "native-promise-only": "^0.8.1", "path-browserify": "^1.0.1", @@ -57,6 +63,7 @@ "devDependencies": { "@types/chai": "^4.3.1", "@types/ejs": "^3.1.1", + "@types/google.maps": "^3.58.1", "@types/mocha": "^9.1.1", "@types/node": "^16.11.8", "@types/sinon": "^10.0.12", diff --git a/components/src/components/Map/Common/Constants.d.ts b/components/src/components/Map/Common/Constants.d.ts new file mode 100644 index 000000000..0b0a10000 --- /dev/null +++ b/components/src/components/Map/Common/Constants.d.ts @@ -0,0 +1,4 @@ +export declare abstract class Constants { + static readonly DEFAULT_HELP_LINK: string; + static readonly ADV: string; +} diff --git a/components/src/components/Map/Common/Constants.js b/components/src/components/Map/Common/Constants.js new file mode 100644 index 000000000..0ca555292 --- /dev/null +++ b/components/src/components/Map/Common/Constants.js @@ -0,0 +1,4 @@ +export class Constants { + static DEFAULT_HELP_LINK = 'https://github.com/bcgov/common-hosted-form-service/wiki'; + static ADV = ''; +} diff --git a/components/src/components/Map/Common/marker-icon.png b/components/src/components/Map/Common/marker-icon.png new file mode 100644 index 000000000..950edf246 Binary files /dev/null and b/components/src/components/Map/Common/marker-icon.png differ diff --git a/components/src/components/Map/Component.form.ts b/components/src/components/Map/Component.form.ts new file mode 100644 index 000000000..6376c87af --- /dev/null +++ b/components/src/components/Map/Component.form.ts @@ -0,0 +1,46 @@ +import baseEditForm from 'formiojs/components/_classes/component/Component.form'; +import EditData from './editForm/Component.edit.data'; +import EditDisplay from './editForm/Component.edit.display'; +import AdvancedEditLogic from '../Common/Advanced.edit.logic'; +export default function (...extend) { + return baseEditForm( + [ + EditDisplay, + EditData, + { + key: 'display', + ignore: true, + }, + { + key: 'data', + ignore: true, + }, + { + key: 'validation', + ignore: true, + }, + { + key: 'api', + components: [ + { + key: 'tags', + ignore: true, + }, + { + key: 'properties', + ignore: true, + }, + ], + }, + { + key: 'logic', + components: AdvancedEditLogic, + }, + { + key: 'layout', + ignore: true, + }, + ], + ...extend + ); +} diff --git a/components/src/components/Map/Component.ts b/components/src/components/Map/Component.ts new file mode 100644 index 000000000..e66474baf --- /dev/null +++ b/components/src/components/Map/Component.ts @@ -0,0 +1,182 @@ +import { Components } from 'formiojs'; +const FieldComponent = (Components as any).components.field; +import MapService from './services/MapService'; +import baseEditForm from './Component.form'; +import * as L from 'leaflet'; + +const DEFAULT_CENTER: [number, number] = [ + 53.96717190097409, -123.98320425388914, +]; // Ensure CENTER is a tuple with exactly two elements +const DEFAULT_CONTAINER_HEIGHT = '400px'; + +export default class Component extends (FieldComponent as any) { + static schema(...extend) { + return FieldComponent.schema({ + type: 'map', + label: 'Map', + key: 'map', + input: true, + defaultvalue: { features: [] }, + ...extend, + }); + } + + static get builderInfo() { + return { + title: 'Map', + group: 'basic', + icon: 'map', + weight: 70, + schema: Component.schema(), + }; + } + + static editForm = baseEditForm; + + componentID: string; + mapService: MapService; + + constructor(component, options, data) { + super(component, options, data); + this.componentID = super.elementInfo().component.id; + } + + render() { + return super.render( + `
` + ); + } + + attach(element) { + const superAttach = super.attach(element); + this.loadMap(); + return superAttach; + } + + loadMap() { + const mapContainer = document.getElementById(`map-${this.componentID}`); + const form = document.getElementsByClassName('formio'); + + const drawOptions = { + rectangle: null, + circle: false, + polyline: false, + polygon: false, + circlemarker: false, + marker: false, + }; + // marker: false, + // circlemarker: false, + // polygon: false, + // polyline: false, + // circle: false, + // rectangle: null, + // set marker type from user choice + if (this.component.markerType) { + for (const [key, value] of Object.entries(this.component.markerType)) { + drawOptions[key] = value; + } + } + + // Set drawing options based on markerType + if (this.component?.markerType?.rectangle) { + drawOptions.rectangle = { showArea: false }; // fixes a bug in Leaflet.Draw + } else { + drawOptions.rectangle = false; + } + + const { + numPoints, + defaultZoom, + allowSubmissions, + center, + defaultValue, + myLocation, + bcGeocoder, + } = this.component; + + const { readOnly: viewMode } = this.options; + + let initialCenter; + if (center && center.features && center.features[0]) { + initialCenter = center.features[0].coordinates; + } else { + initialCenter = DEFAULT_CENTER; + } + + this.mapService = new MapService({ + mapContainer, + drawOptions, + center: initialCenter, + form, + numPoints, + defaultZoom, + readOnlyMap: !allowSubmissions, // if allow submissions, read only is false + defaultValue, + onDrawnItemsChange: this.saveDrawnItems.bind(this), + viewMode, + myLocation, + bcGeocoder, + }); + + // Load existing data if available + if (this.dataValue && this.dataValue.features) { + try { + this.mapService.loadDrawnItems(this.dataValue.features); + } catch (error) { + console.error('Failed to parse dataValue:', error); + } + } + } + + saveDrawnItems(drawnItems: L.Layer[]) { + const features = drawnItems.map((layer: any) => { + if (layer instanceof L.Marker) { + return { + type: 'marker', + coordinates: layer.getLatLng(), + }; + } else if (layer instanceof L.Rectangle) { + return { + type: 'rectangle', + bounds: layer.getBounds(), + }; + } else if (layer instanceof L.Circle) { + return { + type: 'circle', + coordinates: layer.getLatLng(), + radius: layer.getRadius(), + }; + } else if (layer instanceof L.Polygon) { + return { + type: 'polygon', + coordinates: layer.getLatLngs(), + }; + } else if (layer instanceof L.Polyline) { + return { + type: 'polyline', + coordinates: layer.getLatLngs(), + }; + } + }); + + this.setValue({ features }); + } + + setValue(value) { + super.setValue(value); + + // Additional logic to render the saved data on the map if necessary + if (this.mapService && value && value.features) { + try { + this.mapService.loadDrawnItems(value.features); + } catch (error) { + console.error('Failed to parse value:', error); + } + } + } + + getValue() { + return this.dataValue; + } +} diff --git a/components/src/components/Map/editForm/Component.edit.data.ts b/components/src/components/Map/editForm/Component.edit.data.ts new file mode 100644 index 000000000..1725808ff --- /dev/null +++ b/components/src/components/Map/editForm/Component.edit.data.ts @@ -0,0 +1,122 @@ +export default { + key: 'customData', + label: 'Data', + weight: 20, + components: [ + { + html: '

Default Values

', + key: 'simplecontent1', + type: 'content', + input: false, + tableView: false, + label: 'Text/Images', + }, + { + type: 'map', + label: 'Default Value', + key: 'defaultValue', + weight: 5, + placeholder: 'Default Value', + tooltip: + 'This will be the value for this field, before user interaction.', + input: true, + }, + + { + label: 'Default Zoom Level', + description: + 'Zoom Levels are from 0 (Most zoomed out) to 18 (most zoomed in).', + defaultValue: 5, + delimiter: false, + requireDecimal: false, + validate: { + isUseForCopy: false, + min: 0, + max: 18, + }, + key: 'defaultZoom', + type: 'number', + input: true, + }, + { + key: 'center', + type: 'map', + input: true, + label: 'Default Center', + numPoints: 1, + tableView: false, + markerType: { marker: true }, + defaultZoom: 5, + allowSubmissions: true, + description: + 'Please select the desired default center using a single marker', + }, + { + html: '

Submitter Options

', + key: 'simplecontent1', + type: 'content', + input: false, + tableView: false, + label: 'Text/Images', + }, + { + label: 'Allow submitters to add input on the map', + description: + 'This allows for the user to view and scroll the map, but not add any input', + key: 'allowSubmissions', + type: 'checkbox', + defaultValue: true, + input: true, + }, + { + label: 'Marker Type ', + values: [ + { + label: 'Add a point marker (drop a pin)', + value: 'marker', + }, + { + label: 'Add circular area of interest with a point and custom radius', + value: 'circle', + }, + { + label: 'Add a polygon', + value: 'polygon', + }, + { + label: 'Add a line', + value: 'polyline', + }, + ], + defaultValue: 'marker', + key: 'markerType', + type: 'selectboxes', + input: true, + }, + { + label: 'How many Markers per Submission?', + key: 'numPoints', + type: 'number', + defaultValue: 1, + input: true, + }, + { + label: 'Enable Submitter "My Location" button', + description: + 'This allows for the user to center the map on their location.', + key: 'myLocation', + type: 'checkbox', + input: true, + defaultValue: true, + }, + { + label: 'Enable BC Address Autocomplete', + description: + 'This allows for the user to enter an address and have results appear in a dropdown. The user can then select the result which fits best and have the map center on that location', + key: 'bcGeocoder', + type: 'checkbox', + input: true, + defaultValue: true, + }, + ], +}; diff --git a/components/src/components/Map/editForm/Component.edit.display.ts b/components/src/components/Map/editForm/Component.edit.display.ts new file mode 100644 index 000000000..6072f1634 --- /dev/null +++ b/components/src/components/Map/editForm/Component.edit.display.ts @@ -0,0 +1,63 @@ +export default { + key: 'customDisplay', + label: 'Display', + weight: 10, + components: [ + { + key: 'label', + label: 'Label', + type: 'textfield', + input: true, + tooltip: 'The label for this field.', + }, + { + key: 'hideLabel', + label: 'Hide Label', + type: 'checkbox', + tooltip: + 'Check to hide the label for this component. This allows you to show the label in the form Builder, but not when it is rendered', + input: true, + }, + { + key: 'labelPosition', + label: 'Label Position', + type: 'select', + input: true, + data: { + values: [ + { value: 'top', label: 'Top' }, + { value: 'left-left', label: 'Left (Left-aligned)' }, + { value: 'left-right', label: 'Left (Right-aligned)' }, + { value: 'right-left', label: 'Right (Left-aligned)' }, + { value: 'right-right', label: 'Right (Right-aligned)' }, + { value: 'bottom', label: 'Bottom' }, + ], + }, + tooltip: 'Position of the label relative to the field.', + }, + { + key: 'description', + label: 'Text Description (optional)', + type: 'textarea', + input: true, + placeholder: 'This will appear below the map', + tooltip: 'Enter a description for the map component here', + }, + { + key: 'tooltip', + label: 'Tooltip', + type: 'textarea', + input: true, + tooltip: 'Add a tooltip to provide additional information.', + placeholder: 'Add a tooltip beside the label', + }, + { + key: 'customClass', + label: 'Custom CSS Class', + type: 'textfield', + input: true, + tooltip: + 'Assign one or more CSS class names to customize the appearance of this component.', + }, + ], +}; diff --git a/components/src/components/Map/editForm/Component.edit.validation.ts b/components/src/components/Map/editForm/Component.edit.validation.ts new file mode 100644 index 000000000..1cec573db --- /dev/null +++ b/components/src/components/Map/editForm/Component.edit.validation.ts @@ -0,0 +1,4 @@ +import common from '../../Common/Simple.edit.validation'; +export default [ + ...common, +]; diff --git a/components/src/components/Map/services/BCGeocoderProvider.ts b/components/src/components/Map/services/BCGeocoderProvider.ts new file mode 100644 index 000000000..3fa9db72c --- /dev/null +++ b/components/src/components/Map/services/BCGeocoderProvider.ts @@ -0,0 +1,27 @@ +import { OpenStreetMapProvider } from 'leaflet-geosearch'; +import { EndpointArgument } from 'leaflet-geosearch/dist/providers/provider'; + +export class BCGeocoderProvider extends OpenStreetMapProvider { + endpoint = ({ query, type }: EndpointArgument): string => { + return this.getUrl(import.meta.env.VITE_CHEFS_GEO_ADDRESS_APIURL, { + addressString: query as string, + }); + }; + parse = ({ data }) => { + return data.features + .filter(function (feature) { + if (!feature.geometry.coordinates) return false; + if (feature.properties.fullAddress === 'BC') return false; + return true; + }) + .map(function (feature) { + return { + x: feature.geometry.coordinates[0], + y: feature.geometry.coordinates[1], + label: feature.properties.fullAddress, + matchPrecision: feature.properties.matchPrecision, + raw: feature, + }; + }); + }; +} diff --git a/components/src/components/Map/services/MapService.ts b/components/src/components/Map/services/MapService.ts new file mode 100644 index 000000000..3698d1e0e --- /dev/null +++ b/components/src/components/Map/services/MapService.ts @@ -0,0 +1,274 @@ +import * as L from 'leaflet'; +import * as GeoSearch from 'leaflet-geosearch'; +import { BCGeocoderProvider } from '../services/BCGeocoderProvider'; +import 'leaflet-draw'; +import 'leaflet/dist/leaflet.css'; +import 'leaflet-draw/dist/leaflet.draw-src.css'; +import 'leaflet-geosearch/dist/geosearch.css'; + +const DEFAULT_MAP_LAYER_URL = + 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png'; +const DEFAULT_LAYER_ATTRIBUTION = + '© OpenStreetMap contributors'; +const DEFAULT_MAP_ZOOM = 5; +const DECIMALS_LATLNG = 5; // the number of decimals of latitude and longitude to be displayed in the marker popup +const COMPONENT_EDIT_CLASS = 'component-edit-tabs'; +const READ_ONLY_CLASS = 'formio-read-only'; +const CUSTOM_MARKER_PATH = 'https://unpkg.com/leaflet@1.9.4/dist/images/'; + +L.Icon.Default.imagePath = CUSTOM_MARKER_PATH; + +interface MapServiceOptions { + mapContainer: HTMLElement; + center: [number, number]; // Ensure center is a tuple with exactly two elements + drawOptions: any; + form: HTMLCollectionOf; + numPoints: number; + defaultZoom?: number; + readOnlyMap?: boolean; + onDrawnItemsChange: (items: any) => void; // Support both single and multiple items + viewMode?: boolean; + myLocation?: boolean; + bcGeocoder: boolean; +} + +class MapService { + options; + map; + drawnItems; + + constructor(options) { + this.options = options; + + if (options.mapContainer) { + const { map, drawnItems } = this.initializeMap(options); + this.map = map; + + // this.map = map; + this.drawnItems = drawnItems; + + map.invalidateSize(); + // Triggering a resize event after map initialization + setTimeout(() => window.dispatchEvent(new Event('resize')), 0); + // Event listener for drawn objects + map.on('draw:created', (e) => { + const layer = e.layer; + if (drawnItems.getLayers().length === options.numPoints) { + map.closePopup(); + L.popup() + .setLatLng(map.getCenter()) + .setContent( + `

Only ${options.numPoints} features per submission

` + ) + .addTo(map); + } else { + drawnItems.addLayer(layer); + } + this.bindPopupToLayer(layer); + options.onDrawnItemsChange(drawnItems.getLayers()); + }); + map.on(L.Draw.Event.DELETED, (e) => { + options.onDrawnItemsChange(drawnItems.getLayers()); + }); + map.on(L.Draw.Event.EDITSTOP, (e) => { + options.onDrawnItemsChange(drawnItems.getLayers()); + }); + map.on('resize', () => { + map.invalidateSize(); + }); + } + } + + initializeMap(options: MapServiceOptions) { + const { + mapContainer, + center, + drawOptions, + form, + defaultZoom, + readOnlyMap, + viewMode, + myLocation, + bcGeocoder, + } = options; + + if (drawOptions.rectangle) { + drawOptions.rectangle.showArea = false; + } + // Check to see if there is the formio read only class in the current page, and set notEditable to true if the map is inside a read-only page + + // if the user chooses it to be read-only, and the + const map = L.map(mapContainer, { + zoomAnimation: viewMode, + }).setView(center, defaultZoom || DEFAULT_MAP_ZOOM); + L.tileLayer(DEFAULT_MAP_LAYER_URL, { + attribution: DEFAULT_LAYER_ATTRIBUTION, + }).addTo(map); + + // Initialize Draw Layer + const drawnItems = new L.FeatureGroup(); + + map.addLayer(drawnItems); + + if (myLocation) { + const myLocationButton = L.Control.extend({ + options: { + position: 'bottomright', + }, + onAdd(map) { + const container = L.DomUtil.create( + 'div', + 'leaflet-bar leaflet-control' + ); + const button = L.DomUtil.create( + 'a', + 'leaflet-control-button', + container + ); + button.innerHTML = ''; + L.DomEvent.disableClickPropagation(button); + L.DomEvent.on(button, 'click', () => { + if ('geolocation' in navigator) { + navigator.geolocation.getCurrentPosition((position) => { + map.setView( + [position.coords.latitude, position.coords.longitude], + 14 + ); + L.popup() + .setLatLng([ + position.coords.latitude, + position.coords.longitude, + ]) + .setContent( + `(${position.coords.latitude}, ${position.coords.longitude})` + ) + .openOn(map); + }); + } + }); + container.title = 'Click to center the map on your location'; + return container; + }, + }); + const myLocationControl = new myLocationButton(); + myLocationControl.addTo(map); + } + + if (bcGeocoder) { + const geocoderControl = new (GeoSearch.GeoSearchControl as any)({ + provider: new BCGeocoderProvider(), + style: 'bar', + position: 'bottomleft', + showMarker: false, + }); + map.addControl(geocoderControl); + map.on('geosearch/showlocation', (e) => { + L.popup() + .setLatLng([(e as any).location.y, (e as any).location.x]) + .setContent(`${(e as any).location.label}`) + .openOn(map); + }); + } + + // Add Drawing Controllers + if (!readOnlyMap) { + if (!viewMode) { + const drawControl = new L.Control.Draw({ + draw: drawOptions, + edit: { + featureGroup: drawnItems, + }, + }); + map.addControl(drawControl); + } + } + + // Checking to see if the map should be interactable + const componentEditNode = + document.getElementsByClassName(COMPONENT_EDIT_CLASS); + if (form) { + if (form[0]?.classList.contains('formbuilder')) { + map.invalidateSize(); + map.dragging.disable(); + map.scrollWheelZoom.disable(); + if (this.hasChildNode(componentEditNode[0], mapContainer)) { + map.dragging.enable(); + map.scrollWheelZoom.enable(); + } + } + } + return { map, drawnItems }; + } + bindPopupToLayer(layer) { + if (layer instanceof L.Marker) { + layer + .bindPopup( + `

(${layer.getLatLng().lat.toFixed(DECIMALS_LATLNG)},${layer + .getLatLng() + .lng.toFixed(DECIMALS_LATLNG)})

` + ) + .openPopup(); + } else if (layer instanceof L.Circle) { + layer + .bindPopup( + `

(${layer.getLatLng().lat.toFixed(DECIMALS_LATLNG)},${layer + .getLatLng() + .lng.toFixed(DECIMALS_LATLNG)})

` + ) + .openPopup(); + } else if (layer instanceof L.Rectangle || layer instanceof L.Polygon) { + const bounds = layer.getBounds(); + const center = bounds.getCenter(); + layer + .bindPopup( + `

(${center.lat.toFixed(DECIMALS_LATLNG)},${center.lng.toFixed( + DECIMALS_LATLNG + )})

` + ) + .openPopup(); + } + } + + loadDrawnItems(items) { + const { drawnItems } = this; + if (!drawnItems) { + console.error('drawnItems is undefined'); + return; + } + drawnItems.clearLayers(); + if (!Array.isArray(items)) { + items = [items]; + } + items.forEach((item) => { + let layer; + if (item.type === 'marker') { + layer = L.marker(item.coordinates); + } else if (item.type === 'rectangle') { + layer = L.rectangle(item.bounds); + } else if (item.type === 'circle') { + layer = L.circle(item.coordinates, { radius: item.radius }); + } else if (item.type === 'polygon') { + layer = L.polygon(item.coordinates); + } else if (item.type === 'polyline') { + layer = L.polyline(item.coordinates); + } + if (layer) { + drawnItems.addLayer(layer); + this.bindPopupToLayer(layer); + } + }); + } + + hasChildNode(parent, targetNode) { + if (parent === targetNode) { + return true; + } + for (let i = 0; i < parent?.childNodes?.length; i++) { + if (this.hasChildNode(parent.childNodes[i], targetNode)) { + return true; + } + } + return false; + } +} +export default MapService; diff --git a/components/src/components/index.ts b/components/src/components/index.ts index 8361f59d1..ae1f2cf05 100755 --- a/components/src/components/index.ts +++ b/components/src/components/index.ts @@ -45,6 +45,7 @@ import simplesignatureadvanced from './SimpleSignatureAdvanced/Component'; import simplebuttonadvanced from './SimpleButtonAdvanced/Component'; import bcaddress from './BCAddress/Component'; import simplebcaddress from './SimpleBCAddress/Component'; +import map from './Map/Component'; export default { orgbook, @@ -93,5 +94,6 @@ export default { simplesignatureadvanced, simplebuttonadvanced, bcaddress, - simplebcaddress + simplebcaddress, + map, }; diff --git a/components/webpack.config.js b/components/webpack.config.js index c82fc573d..446560860 100755 --- a/components/webpack.config.js +++ b/components/webpack.config.js @@ -1,6 +1,14 @@ const path = require('path'); module.exports = { + module: { + rules: [ + { + test: /\.css$/i, + use: ["css-loader"], + }, + ], + }, entry: path.join(path.resolve(__dirname, 'lib'), 'index.js'), output: { library: 'BcGovFormioComponents',