UNPKG

enketo-core

Version:

Extensible Enketo form engine

1,428 lines (1,262 loc) 57.1 kB
import $ from 'jquery'; import config from 'enketo/config'; import L from 'leaflet'; import { t } from 'enketo/translator'; import dialog from 'enketo/dialog'; import support from '../../js/support'; import types from '../../js/types'; import Widget from '../../js/widget'; import { getScript } from '../../js/utils'; import { elementDataStore as data } from '../../js/dom-utils'; // Leaflet extensions. import 'leaflet-draw'; // ESLint is, correctly, looking at the dependency's package.json `module` field, // but that specifies a module path which does not exist in their build. // Somewhat surprisingly, ESBuild handles this gracefully. // eslint-disable-next-line import/no-unresolved import 'leaflet.gridlayer.googlemutant'; import { getCurrentPosition } from '../../js/geolocation'; let googleMapsScriptRequest; const defaultZoom = 15; // MapBox TileJSON format const maps = config && config.maps && config.maps.length > 0 ? config.maps : [ { name: 'streets', maxzoom: 24, tiles: ['https://tile.openstreetmap.org/{z}/{x}/{y}.png'], attribution: '© <a href="http://openstreetmap.org">OpenStreetMap</a> | <a href="www.openstreetmap.org/copyright">Terms</a>', }, ]; let searchSource = 'https://maps.googleapis.com/maps/api/geocode/json?address={address}&sensor=true&key={api_key}'; const googleApiKey = config.googleApiKey || config.google_api_key; const iconSingle = L.divIcon({ iconSize: [16, 24], className: 'enketo-geopoint-marker', }); const iconMulti = L.divIcon({ iconSize: [16, 16], className: 'enketo-geopoint-circle-marker', }); const iconMultiActive = L.divIcon({ iconSize: [16, 16], className: 'enketo-geopoint-circle-marker-active', }); /** * @typedef LatLngArray * @description An array of two (or four) elements, `[0]` latitude and `[1]` longitude. * @property {string|number} 0 - Latitude * @property {string|number} 1 - Longitude * @property {string|number} [2] - Altitude * @property {string|number} [3] - Accuracy */ /** * @typedef LatLngObj * @property {number} lat - Latitude * @property {number} long - Longitude * @property {number} [alt] - Altitude * @property {number} [acc] - Accuracy */ /** * @augments Widget */ class Geopicker extends Widget { /** * @type {string} */ static get selector() { return '.question input[data-type-xml="geopoint"]:not([data-setvalue]):not([data-setgeopoint]), .question input[data-type-xml="geotrace"]:not([data-setvalue]):not([data-setgeopoint]), .question input[data-type-xml="geoshape"]:not([data-setvalue]):not([data-setgeopoint])'; } /** * @param {Element} element - The element to instantiate the widget on * @return {boolean} To instantiate or not to instantiate, that is the question. */ static condition(element) { // Allow geopicker and ArcGIS geopicker to be used in same form return !data.has(element, 'ArcGisGeopicker'); } _init() { const loadedVal = this.originalInputValue; const that = this; this.$form = $(this.element).closest('form.or'); this.$question = $(this.element).closest('.question'); this.mapId = Math.round(Math.random() * 10000000); this._addDomElements(); this.currentIndex = 0; this.points = []; // load default value if (loadedVal) { this.value = loadedVal; } // handle point input changes this.$widget .find('[name="lat"], [name="long"], [name="alt"], [name="acc"]') .on('change change.bymap change.bysearch', (event) => { const lat = that.$lat.val() ? Number(that.$lat.val()) : ''; const lng = that.$lng.val() ? Number(that.$lng.val()) : ''; // we need to avoid a missing alt in case acc is not empty! const alt = that.$alt.val() ? Number(that.$alt.val()) : ''; const acc = that.$acc.val() ? Number(that.$acc.val()) : ''; const latLng = { lat, lng, }; event.stopImmediatePropagation(); // if the points array contains empty points, skip the intersection check, it will be done before closing the polygon if ( event.namespace !== 'bymap' && event.namespace !== 'bysearch' && that.polyline && that.props.type === 'geoshape' && !that.containsEmptyPoints(that.points, that.currentIndex) && that.updatedPolylineWouldIntersect( latLng, that.currentIndex ) ) { that._showIntersectError(); that._updateInputs( that.points[that.currentIndex], 'nochange' ); } else { that._editPoint([lat, lng, alt, acc]); if (event.namespace !== 'bysearch' && that.$search) { that.$search.val(''); } } }); // handle KML input changes this.$kmlInput.on('change', function (event) { const $addPointBtn = that.$points.find('.addpoint'); const $progress = $(this) .prev('.paste-progress') .removeClass('hide'); const { value } = event.target; const coords = that._convertKmlCoordinatesToLeafletCoordinates(value); // reset textarea event.target.value = ''; setTimeout(() => { // mimic manual input point-by-point coords.forEach((latLng, index) => { that._updateInputs(latLng); if (index < coords.length - 1) { $addPointBtn.click(); } }); // remove progress bar; $progress.remove(); // switch to points input mode that._switchInputType('points'); }, 10); }); // handle input switcher this.$widget.find('.toggle-input-type-btn').on('click', () => { const type = that.$inputGroup.hasClass('kml-input-mode') ? 'points' : 'kml'; that._switchInputType(type); return false; }); // handle original input changes $(this.element) .on('change', function () { that.$kmlInput.prop('disabled', !!this.value); }) .on('applyfocus', () => { that.$widget[0].querySelector('input').focus(); }); // handle point switcher this.$points.on('click', '.point', function () { that._setCurrent(that.$points.find('.point').index($(this))); that._switchInputType('points'); return false; }); // handle addpoint button click this.$points.find('.addpoint').on('click', () => { that._addPoint(); return false; }); // handle polygon close button click this.$widget.find('.close-chain-btn').on('click', () => { that._closePolygon(); return false; }); // handle point remove click this.$widget.find('.btn-remove').on('click', () => { if (that.points.length < 2) { that._updateInputs([]); } else { dialog .confirm(t('geopicker.removePoint')) .then((confirmed) => { if (confirmed) { that._removePoint(); } }) .catch(() => { // Ignore error }); } }); // handle fullscreen map button click this.$map.find('.show-map-btn').on('click', () => { that.$widget.find('.search-bar').removeClass('hide-search'); that.$widget.addClass('full-screen'); that._updateMap(); return false; }); // ensure all tiles are displayed when revealing page, https://github.com/kobotoolbox/enketo-express/issues/188 // remove handler once it has been used this.$form.on(`pageflip.map${this.mapId}`, (event) => { if (that.map && $.contains(event.target, that.element)) { that.map.invalidateSize(); that.$form.off(`pageflip.map${that.mapId}`); } }); // add wide class if question is wide if (this.props.wide) { this.$widget.addClass('wide'); } // copy hide-input class from question to widget and add show/hide input controller this.$widget .toggleClass( 'hide-input', this.$question.hasClass('or-appearance-hide-input') ) .find('.toggle-input-visibility-btn') .on('click', function () { that.$widget.toggleClass('hide-input'); $(this).toggleClass( 'open', that.$widget.hasClass('hide-input') ); if (that.map) { that.map.invalidateSize(false); } }) .toggleClass('open', that.$widget.hasClass('hide-input')); // hide map controller this.$widget.find('.hide-map-btn').on('click', () => { that.$widget.find('.search-bar').addClass('hide-search'); that.$widget .removeClass('full-screen') .find('.map-canvas') .removeClass('leaflet-container') .find('.leaflet-google-layer') .remove(); if (that.map) { that.map.remove(); that.map = undefined; this.loadMap = undefined; that.polygon = undefined; that.polyline = undefined; } return false; }); // enable search if (this.props.search) { this._enableSearch(); } // enable detection if (this.props.detect) { this._enableDetection(); } if (this.props.readonly) { this.disable(); } // create "point buttons" if (loadedVal) { this.points.forEach(() => { that._addPointBtn(); }); } else { this._addPoint(); } // set map location on load if (!loadedVal) { // set worldview in case permissions take too long (e.g. in FF); this._updateMap([0, 0], 1); if (this.props.detect) { getCurrentPosition() .then(({ position }) => { that._updateMap( [ position.coords.latitude, position.coords.longitude, ], defaultZoom ); }) .catch(() => { // Ignore error }); } } else { // center map around first loaded geopoint value // this._updateMap( L.latLng( this.points[ 0 ][ 0 ], this.points[ 0 ][ 1 ] ) ); this._updateMap(); this._setCurrent(this.currentIndex); } } /** * @param {string} type - Type of input to switch to */ _switchInputType(type) { if (type === 'kml') { this.$inputGroup.addClass('kml-input-mode'); } else if (type === 'points') { this.$inputGroup.removeClass('kml-input-mode'); } } /** * Adds a point button in the point navigation bar */ _addPointBtn() { this.$points .find('.addpoint') .before('<a href="#" class="point" aria-label="point"> </a>'); } /** * Adds the DOM elements */ _addDomElements() { const map = `<div class="map-canvas-wrapper"><div class=map-canvas id="map${this.mapId}"></div></div>`; const points = '<div class="points"><button type="button" class="addpoint">+</button></div>'; const kml = ` <a href="#" class="toggle-input-type-btn"> <span class="kml-input">KML</span> <span class="points-input" data-i18n="geopicker.points">${t( 'geopicker.points' )}</span> </a> <label class="geo kml"> <span data-i18n="geopicker.kmlcoords">${t( 'geopicker.kmlcoords' )}</span> <progress class="paste-progress hide"></progress> <textarea class="ignore" name="kml" placeholder="${t( 'geopicker.kmlpaste' )}" data-i18n="geopicker.kmlpaste"></textarea> <span class="disabled-msg">remove all points to enable</span> </label>`; const close = `<button type="button" class="close-chain-btn btn btn-default btn-xs" data-i18n="geopicker.closepolygon">${t( 'geopicker.closepolygon' )}</button>`; const mapBtn = '<button type="button" class="show-map-btn btn btn-default">Map</button>'; this.$widget = $( `<div class="geopicker widget"> <div class="search-bar hide-search no-map no-detect"> <button type="button" class="hide-map-btn btn btn-default"><span class="icon icon-arrow-left"> </span></button> <button name="geodetect" type="button" class="btn btn-default" title="detect current location" data-placement="top"><span class="icon icon-crosshairs"> </span></button> <div class="input-group"> <input class="geo ignore" name="search" type="text" placeholder="${t( 'geopicker.searchPlaceholder' )}" data-i18n="geopicker.searchPlaceholder" disabled="disabled"/> <button type="button" class="btn btn-default search-btn"><i class="icon icon-search"> </i></button> </div> </div> <div class="geo-inputs"> <label class="geo lat"> <span data-i18n="geopicker.latitude">${t( 'geopicker.latitude' )}</span> <input class="ignore" name="lat" type="number" step="0.000001" min="-90" max="90"/> </label> <label class="geo long"> <span data-i18n="geopicker.longitude">${t( 'geopicker.longitude' )}</span> <input class="ignore" name="long" type="number" step="0.000001" min="-180" max="180"/> </label> <label class="geo alt"> <span data-i18n="geopicker.altitude">${t( 'geopicker.altitude' )}</span> <input class="ignore" name="alt" type="number" step="0.1" /> </label> <label class="geo acc"> <span data-i18n="geopicker.accuracy">${t( 'geopicker.accuracy' )}</span> <input class="ignore" name="acc" type="number" step="0.1" /> </label> <button type="button" class="btn-icon-only btn-remove" aria-label="remove"><span class="icon icon-trash"> </span></button> </div> </div>` ); // add the detection button if (this.props.detect) { this.$widget.find('.search-bar').removeClass('no-detect'); this.$detect = this.$widget.find('button[name="geodetect"]'); } this.$search = this.$widget.find('[name="search"]'); this.$inputGroup = this.$widget.find('.geo-inputs'); // add the map canvas if (this.props.map) { this.$widget.find('.search-bar').removeClass('no-map').after(map); this.$map = this.$widget.find('.map-canvas'); // add the hide/show inputs button this.$map .parent() .append( '<button type="button" class="toggle-input-visibility-btn" aria-label="toggle input"> </button>' ); } else { this.$map = $(); } // touchscreen maps if (this.props.touch && this.props.map) { this.$map.append(mapBtn); } // unhide search bar // TODO: can be done in CSS? if (!this.props.touch) { this.$widget.find('.search-bar').removeClass('hide-search'); } // if geoshape or geotrace if (this.props.type !== 'geopoint') { // add points bar this.$points = $(points); this.$widget.prepend(this.$points); // add polygon 'close' button if (this.props.type === 'geoshape') { this.$inputGroup.append(close); } // add KML paste textarea; const $kml = $(kml); this.$kmlInput = $kml.find('[name="kml"]'); this.$inputGroup.prepend($kml); } else { this.$points = $(); this.$kmlInput = $(); } this.$lat = this.$widget.find('[name="lat"]'); this.$lng = this.$widget.find('[name="long"]'); this.$alt = this.$widget.find('[name="alt"]'); this.$acc = this.$widget.find('[name="acc"]'); $(this.element) .hide() .after(this.$widget) .parent() .addClass('clearfix'); } /** * Updates the value in the original input element. * * @return {boolean} Whether the value was changed. */ _updateValue() { this._markAsValid(); const oldValue = this.originalInputValue; const newValue = this.value; // console.log( 'updating value by joining', this.points, 'old value', oldValue, 'new value', newValue ); if (oldValue !== newValue) { this.originalInputValue = newValue; return true; } return false; } /** * Checks an Openrosa geopoint for validity. This function is used to provide more detailed * error feedback than provided by the form controller. This can be used to pinpoint the exact * invalid geopoints in a list of geopoints (the form controller only validates the total list). * * @param {string} geopoint - Geopoint to check * @return {boolean} Whether geopoint is valid. */ _isValidGeopoint(geopoint) { return geopoint ? types.geopoint.validate(geopoint) : false; } /** * Validates a list of latLng Arrays or Objects. * * @param {Array<LatLngArray|LatLngObj>} latLngs - Array of latLng objects or arrays. * @return {boolean} Whether list is valid or not. */ _isValidLatLngList(latLngs) { const that = this; return latLngs.every( (latLng, index, array) => that._isValidLatLng(latLng) || (latLng.join() === '' && index === array.length - 1) ); } /** * @param {LatLngArray|LatLngObj} latLng - Geo array or object to clean */ _cleanLatLng(latLng) { if (Array.isArray(latLng)) { return [latLng[0], latLng[1]]; } return latLng; } /** * Validates an individual latlng Array or Object * * @param {LatLngArray|LatLngObj} latLng - latLng object or array * @return {boolean} Whether latLng is valid or not */ _isValidLatLng(latLng) { const lat = typeof latLng[0] === 'number' ? latLng[0] : typeof latLng.lat === 'number' ? latLng.lat : null; const lng = typeof latLng[1] === 'number' ? latLng[1] : typeof latLng.lng === 'number' ? latLng.lng : null; // This conversion seems backwards, but it is helpful to have only one place where geopoints are validated. return types.geopoint.validate([lat, lng].join(' ')); } /** * Marks a point as invalid in the points navigation bar * * @param {number} index - Index of point */ _markAsInvalid(index) { this.$points.find('.point').eq(index).addClass('has-error'); } /** * Marks all points as valid in the points navigation bar */ _markAsValid() { this.$points.find('.point').removeClass('has-error'); } /** * Changes the current point in the list of points * * @param {number} index - The index to set to current */ _setCurrent(index) { this.currentIndex = index; this.$points .find('.point') .removeClass('active') .eq(index) .addClass('active'); this._updateInputs(this.points[index], ''); // make sure that the current marker is marked as active if (this.map && (!this.props.touch || this._inFullScreenMode())) { this._updateMarkers(); } // console.debug( 'set current index to ', this.currentIndex ); } /** * Enables geo detection using the built-in browser geoLocation functionality */ _enableDetection() { const that = this; const options = { enableHighAccuracy: true, maximumAge: 0, }; this.$detect.click((event) => { event.preventDefault(); getCurrentPosition(options) .then((result) => { if ( that.polyline && that.props.type === 'geoshape' && that.updatedPolylineWouldIntersect( result, that.currentIndex ) ) { that._showIntersectError(); } else { const { lat, lng, position } = result; // that.points[that.currentIndex] = [ position.coords.latitude, position.coords.longitude ]; // that._updateMap( ); that._updateInputs([ lat, lng, position.coords.altitude, position.coords.accuracy, ]); // if current index is last of points, automatically create next point if ( that.currentIndex === that.points.length - 1 && that.props.type !== 'geopoint' ) { that._addPoint(); } } }) .catch(() => { console.error('error occurred trying to obtain position'); }); return false; }); } /** * Enables search functionality using the Google Maps API v3 * This only changes the map view. It does not record geopoints. */ _enableSearch() { const that = this; if (googleApiKey) { searchSource = searchSource.replace('{api_key}', googleApiKey); } else { searchSource = searchSource.replace('&key={api_key}', ''); } this.$search.prop('disabled', false).on('change', function (event) { let address = $(this).val(); event.stopImmediatePropagation(); if (address) { address = address.split(/\s+/).join('+'); $.get( searchSource.replace('{address}', address), (response) => { let latLng; if ( response.results && response.results.length > 0 && response.results[0].geometry && response.results[0].geometry.location ) { latLng = response.results[0].geometry.location; that._updateMap( [latLng.lat, latLng.lng], defaultZoom ); that.$search .closest('.input-group') .removeClass('has-error'); } else { // TODO: add error message that.$search .closest('.input-group') .addClass('has-error'); console.warn(`Location "${address}" not found`); } }, 'json' ) .fail(() => { // TODO: add error message that.$search .closest('.input-group') .addClass('has-error'); console.error( 'Error. Geocoding service may not be available or app is offline' ); }) .always(() => { // Ignore error }); } }); } /** * @return {boolean} Whether map is available for manipulation */ _dynamicMapAvailable() { return !!this.map; } /** * @return {boolean} Whether map is in fullscreen mode */ _inFullScreenMode() { return this.$widget.hasClass('full-screen'); } /** * Updates the map to either show the provided coordinates (in the center), with the provided zoom level * or update any markers, polylines, or polygons. * * @param {LatLngArray|LatLngObj} latLng - Latitude and longitude coordinates * @param {number} [zoom] - zoom level */ _updateMap(latLng, zoom) { const that = this; // check if the widget is supposed to have a map if (!this.props.map) { return; } // determine zoom level if (!zoom) { if (this.map) { // note: there are conditions where getZoom returns undefined! zoom = this.map.getZoom() || defaultZoom; } else { zoom = defaultZoom; } } // update last requested map coordinates to be used to initialize map in mobile fullscreen view if (latLng) { this.lastLatLng = latLng; } // update the map if it is visible if (!this.props.touch || this._inFullScreenMode()) { this.loadMap = this.loadMap || this._addDynamicMap(); this.loadMap .then(() => { that._updateDynamicMapView(latLng, zoom); }) .catch(() => { // Ignore error }); } } /** * @return {Promise} A Promise that resolves with undefined. */ _addDynamicMap() { const that = this; return this._getLayers().then((layers) => { const options = { layers: that._getDefaultLayer(layers), }; that.map = L.map(`map${that.mapId}`, options).on('click', (e) => { let latLng; let indexToPlacePoint; if (that.props.readonly) { return false; } latLng = e.latlng; indexToPlacePoint = that.$lat.val() && that.$lng.val() ? that.points.length : that.currentIndex; // reduce precision to 6 decimals latLng.lat = Math.round(latLng.lat * 1000000) / 1000000; latLng.lng = Math.round(latLng.lng * 1000000) / 1000000; // Skip intersection check if points contain empties. It will be done later, before the polygon is closed. if ( that.props.type === 'geoshape' && !that.containsEmptyPoints(that.points, indexToPlacePoint) && that.updatedPolylineWouldIntersect( latLng, indexToPlacePoint ) ) { that._showIntersectError(); } else if ( !that.$lat.val() || !that.$lng.val() || that.props.type === 'geopoint' ) { that._updateInputs(latLng, 'change.bymap'); } else if (that.$lat.val() && that.$lng.val()) { that._addPoint(); that._updateInputs(latLng, 'change.bymap'); } else { // do nothing if the field has a current marker // instead the user will have to drag to change it by map } }); this.map.on('load', () => { this.map.on('zoomend', (event) => { const zoom = event.target.getZoom(); if (zoom != null) { this.lastZoom = zoom; } }); }); // watch out, default "Leaflet" link clicks away from page, loosing all data that.map.attributionControl.setPrefix(''); // add layer control if (layers.length > 1) { L.control .layers(that._getBaseLayers(layers), null) .addTo(that.map); } // change default leaflet layer control button that.$widget .find('.leaflet-control-layers-toggle') .append('<span class="icon icon-globe"></span>'); // Add ignore and option-label class to Leaflet-added input elements and their labels // something weird seems to happen. It seems the layercontrol is added twice (second replacing first) // which means the classes are not present in the final control. // Using the baselayerchange event handler is a trick that seems to work. that.map.on('baselayerchange', () => { that.$widget .find('.leaflet-control-container input') .addClass('ignore no-unselect') .next('span') .addClass('option-label'); }); }); } /** * @param {LatLngArray|LatLngObj} latLng - Latitude and longitude coordinates * @param {number} [zoom] - zoom level */ _updateDynamicMapView(latLng, zoom) { if (!latLng) { this._updatePolyline(); this._updateMarkers(); if (this.points.length === 1 && this.points[0].toString() === '') { if (this.lastLatLng) { this.map.setView( this.lastLatLng, this.lastZoom || defaultZoom ); } else { this.map.setView(L.latLng(0, 0), zoom || defaultZoom); } } } else { this.map.setView(latLng, zoom || defaultZoom); } } /** * Displays intersect error */ _showIntersectError() { dialog.alert(t('geopicker.bordersintersectwarning')); } /** * Obtains the tile layers according to the definition in the app configuration. * * @return {Promise} A promise that resolves with the map layers. */ _getLayers() { const that = this; const tasks = []; maps.forEach((map, index) => { if ( typeof map.tiles === 'string' && /^GOOGLE_(SATELLITE|ROADMAP|HYBRID|TERRAIN)/.test(map.tiles) ) { tasks.push(that._getGoogleTileLayer(map, index)); } else if (map.tiles) { tasks.push(that._getLeafletTileLayer(map, index)); } else { console.error( 'Configuration error for map tiles. Not a valid tile layer: ', map ); } }); return Promise.all(tasks); } /** * Asynchronously (fake) obtains a Leaflet/Mapbox tilelayer * * @param {object} map - Map layer as defined in the apps configuration. * @param {number} index - The index of the layer. * @return {Promise} A promise that resolves with a Leaflet tile layer. */ _getLeafletTileLayer(map, index) { let url; const options = this._getTileOptions(map, index); // randomly pick a tile source from the array and store it in the maps config // so it will be re-used when the form is reset or multiple geo widgets are created map.tileIndex = map.tileIndex === undefined ? Math.round(Math.random() * 100) % map.tiles.length : map.tileIndex; url = map.tiles[map.tileIndex]; return Promise.resolve(L.tileLayer(url, options)); } /** * Asynchronously obtains a Google Maps tilelayer * * @param {object} map - Map layer as defined in the apps configuration. * @param {number} index - The index of the layer. * @return {Promise} A promise that resolves with a Google Maps layer. */ _getGoogleTileLayer(map, index) { const options = this._getTileOptions(map, index); // valid values for type are 'roadmap', 'satellite', 'terrain' and 'hybrid' options.type = map.tiles.substring(7).toLowerCase(); return this._loadGoogleMapsScript().then(() => L.gridLayer.googleMutant(options) ); } /** * Creates the tile layer options object from the maps configuration and defaults. * * @param {object} map - Map layer as defined in the apps configuration. * @param {number} index - The index of the layer. * @return {{id: string, maxZoom: number, minZoom: number, name: string, attribution: string}} Tilelayer options object */ _getTileOptions(map, index) { const name = map.name || `map-${index + 1}`; return { id: map.id || name, maxZoom: map.maxzoom || 18, minZoom: map.minzoom || 0, name, attribution: map.attribution || '', }; } /** * Loader for the Google Maps script that can be called multiple times, but will ensure the * script is only requested once. * * @return {Promise} A promise that resolves with undefined. */ _loadGoogleMapsScript() { // request Google maps script only once, using a variable outside of the scope of the current widget // in case multiple widgets exist in the same form if (!googleMapsScriptRequest) { // create deferred object, also outside of the scope of the current widget googleMapsScriptRequest = new Promise((resolve) => { let apiKeyQueryParam; let loadUrl; // create a global callback to be called by the Google Maps script once this has loaded window.gmapsLoaded = () => { // resolve the deferred object resolve(); }; // make the request for the Google Maps script asynchronously apiKeyQueryParam = googleApiKey ? `&key=${googleApiKey}` : ''; loadUrl = `https://maps.google.com/maps/api/js?v=weekly${apiKeyQueryParam}&libraries=places&callback=gmapsLoaded`; getScript(loadUrl); }); } // return the promise of the deferred object outside of the scope of the current widget return googleMapsScriptRequest; } /** * @param {Array<object>} layers - Map layers * @return {object} Default layer */ _getDefaultLayer(layers) { let defaultLayer; const that = this; layers.reverse().some((layer) => { defaultLayer = layer; return that.props.appearances.some( (appearance) => appearance === layer.options.name ); }); return defaultLayer; } /** * @param {Array<object>} layers - Map layers * @return {Array<object>} Base layers */ _getBaseLayers(layers) { const baseLayers = {}; layers.forEach((layer) => { baseLayers[layer.options.name] = layer; }); return baseLayers; } /** * Updates the markers on the dynamic map from the current list of points. */ _updateMarkers() { const coords = []; const markers = []; const that = this; // console.debug( 'updating markers', this.points ); if (this.markerLayer) { this.markerLayer.clearLayers(); } if (this.points.length < 2 && this.points[0].join() === '') { return; } this.points.forEach((latLng, index) => { const icon = that.props.type === 'geopoint' ? iconSingle : index === that.currentIndex ? iconMultiActive : iconMulti; if (that._isValidLatLng(latLng)) { coords.push(that._cleanLatLng(latLng)); markers.push( L.marker(that._cleanLatLng(latLng), { icon, clickable: !that.props.readonly, draggable: !that.props.readonly, alt: index, opacity: 0.9, }) .on('click', (e) => { if ( e.target.options.alt === 0 && that.props.type === 'geoshape' ) { that._closePolygon(); } else { that._setCurrent(e.target.options.alt); } }) .on('dragend', (e) => { const latLng = e.target.getLatLng(); const index = e.target.options.alt; // reduce precision to 6 decimals latLng.lat = Math.round(latLng.lat * 1000000) / 1000000; latLng.lng = Math.round(latLng.lng * 1000000) / 1000000; if ( that.polyline && that.props.type === 'geoshape' && that.updatedPolylineWouldIntersect( latLng, index ) ) { that._showIntersectError(); that._updateMarkers(); } else { // first set the current index the point dragged that._setCurrent(index); that._updateInputs(latLng, 'change.bymap'); that._updateMap(); } }) ); } else { console.warn('this latLng was not considered valid', latLng); } }); // console.log( 'markers to update', markers ); if (markers.length > 0) { this.markerLayer = L.layerGroup(markers).addTo(this.map); // change the view to fit all the markers // don't use this for multiple markers, it messed up map clicks to place points if ( this.points.length === 1 || !this._isValidLatLngList(this.points) ) { // center the map, keep zoom level unchanged this.map.setView(coords[0], this.lastZoom || defaultZoom); } } } /** * Updates the polyline on the dynamic map from the current list of points */ _updatePolyline() { let polylinePoints; const that = this; if (this.props.type === 'geopoint') { return; } // console.log( 'updating polyline' ); if (this.points.length < 2 || !this._isValidLatLngList(this.points)) { // remove quirky line remainder if (this.map) { if (this.polyline) { this.map.removeLayer(this.polyline); } if (this.polygon) { this.map.removeLayer(this.polygon); } } this.polyline = null; this.polygon = null; // console.log( 'list of points invalid' ); return; } if (this.props.type === 'geoshape') { this._updatePolygon(); } polylinePoints = this.points[this.points.length - 1].join('') !== '' ? this.points : this.points.slice(0, this.points.length - 1); polylinePoints = polylinePoints.map((point) => that._cleanLatLng(point) ); if (!this.polyline) { this.polyline = L.polyline(polylinePoints, { color: 'red', }); this.map.addLayer(this.polyline); } else { this.polyline.setLatLngs(polylinePoints); } // possible bug in Leaflet, using timeout to work around setTimeout(() => { that.map.fitBounds(that.polyline.getBounds()); }, 0); } /** * Updates the polygon on the dynamic map from the current list of points. * A polygon is a type of polyline. This function is ALWAYS called by _updatePolyline. */ _updatePolygon() { let polygonPoints; const that = this; if (this.props.type === 'geopoint' || this.props.type === 'geotrace') { return; } // console.log( 'updating polygon' ); polygonPoints = this.points[this.points.length - 1].join('') !== '' ? this.points : this.points.slice(0, this.points.length - 1); polygonPoints = polygonPoints.map((point) => that._cleanLatLng(point)); if (!this.polygon) { // console.log( 'creating new polygon' ); this.polygon = L.polygon(polygonPoints, { color: 'red', stroke: false, }); this.map.addLayer(this.polygon); } else { // console.log( 'updating existing polygon', this.points ); this.polygon.setLatLngs(polygonPoints); } this._updateArea(polygonPoints); } /** * Updates the area in m2 shown inside a polygon. * * @param {Array<LatLngObj>} points - A polygon. */ _updateArea(points) { let area; let readableArea; if (points.length > 2) { const latLngs = points.map((point) => ({ lat: point[0], lng: point[1], })); area = L.GeometryUtil.geodesicArea(latLngs); readableArea = L.GeometryUtil.readableArea(area, true); L.popup({ className: 'enketo-area-popup', }) .setLatLng(this.polygon.getBounds().getCenter()) .setContent(readableArea) .openOn(this.map); } else { this.map.closePopup(); } } /** * Adds a point. */ _addPoint() { this._addPointBtn(); this.points.push([]); this._setCurrent(this.points.length - 1); this._updateValue(); } /** * Edits a point in the list of points. * * @param {LatLngArray|LatLngObj} latLng - LatLng object or array. * @return {boolean} Whether point changed. */ _editPoint(latLng) { let changed; this.points[this.currentIndex] = latLng; changed = this._updateValue(); if (changed) { this._updateMap(); } return changed; } /** * Removes the current point. */ _removePoint() { let newIndex = this.currentIndex; this.points.splice(this.currentIndex, 1); this._updateValue(); this.$points.find('.point').eq(this.currentIndex).remove(); if (typeof this.points[this.currentIndex] === 'undefined') { newIndex = this.currentIndex - 1; } this._setCurrent(newIndex); // this will call updateMarkers for the second time which is not so efficient this._updateMap(); } /** * Closes polygon */ _closePolygon() { const lastPoint = this.points[this.points.length - 1]; // console.debug( 'closing polygon' ); // check if chain can be closed if ( this.points.length < 3 || (this.points.length === 3 && !this._isValidLatLng(this.points[2])) || JSON.stringify(this.points[0]) === JSON.stringify(lastPoint) ) { return; } // determine which point the make the closing point // if the last point is not a valid point, assume the user wants to use this to close // otherwise create a new point. if (!this._isValidLatLng(lastPoint)) { // console.log( 'current last point is not a valid point, so will use this as closing point' ); this.currentIndex = this.points.length - 1; } else { // console.log( 'current last point is valid, so will create a new one to use to close' ); this._addPoint(); } // final check to see if there are intersections if ( this.polyline && !this.containsEmptyPoints(this.points, this.points.length) && this.updatedPolylineWouldIntersect( this.points[0], this.currentIndex ) ) { return this._showIntersectError(); } this._updateInputs(this.points[0]); } /** * Updates the (fake) input element for latitude, longitude, altitude and accuracy. * * @param {LatLngArray|LatLngObj} coords - Latitude, longitude, altitude and accuracy. * @param {string} [ev] - Event to dispatch. */ _updateInputs(coords, ev) { const lat = coords[0] || coords.lat || ''; const lng = coords[1] || coords.lng || ''; const alt = coords[2] || coords.alt || ''; const acc = coords[3] || coords.acc || ''; ev = typeof ev !== 'undefined' ? ev : 'change'; this.$lat.val(lat || ''); this.$lng.val(lng || ''); this.$alt.val(alt || ''); this.$acc.val(acc || '').trigger(ev); } /** * Converts the contents of a single KML <coordinates> element (may inlude the coordinates tags as well) to an array * of geopoint coordinates used in the ODK XForm format. Note that the KML format does not allow spaces within a tuple of coordinates * only between. Separator between KML tuples can be newline, space or a combination. * It only extracts the value of the first <coordinates> element or, if <coordinates> are not included from the whole string. * * @param {string} kmlCoordinates - KML coordinates XML element or its content * @return {Array<Array<number>>} Array of geopoint coordinates */ _convertKmlCoordinatesToLeafletCoordinates(kmlCoordinates) { const coordinates = []; const reg = /<\s?coordinates>(([^<]|\n)*)<\/\s?coordinates\s?>/; const tags = reg.test(kmlCoordinates); kmlCoordinates = tags ? kmlCoordinates.match(reg)[1] : kmlCoordinates; kmlCoordinates .trim() .split(/\s+/) .forEach((item) => { const coordinate = []; item.split(',').forEach((c, index) => { const value = Number(c); if (index === 0) { coordinate[1] = value; } else if (index === 1) { coordinate[0] = value; } else if (index === 2) { coordinate[2] = value; } }); coordinates.push(coordinate); }); return coordinates