UNPKG

samagra-x-enketo-core

Version:

Extensible Enketo form engine

1,308 lines (1,123 loc) 52.4 kB
import $ from 'jquery'; import Widget from '../../js/widget'; import config from 'enketo/config'; import L from 'leaflet'; import { t } from 'enketo/translator'; import support from '../../js/support'; import types from '../../js/types'; import dialog from 'enketo/dialog'; import { getScript } from '../../js/utils'; import { elementDataStore as data } from '../../js/dom-utils'; 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://{s}.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: 24, className: 'enketo-geopoint-marker' } ); const iconMulti = L.divIcon( { iconSize: 16, className: 'enketo-geopoint-circle-marker' } ); const iconMultiActive = L.divIcon( { iconSize: 16, className: 'enketo-geopoint-circle-marker-active' } ); // Leaflet extensions. import 'leaflet-draw'; import 'leaflet.gridlayer.googlemutant'; import { getCurrentPosition } from '../../js/geolocation'; /** * @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-setgeopoint]), .question input[data-type-xml="geotrace"], .question input[data-type-xml="geoshape"]'; } /** * @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.value; 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( () => {} ); } } ); // 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( () => {} ); } } 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; } else { 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( () => { } ); } } ); } /** * @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; this.lastZoom = zoom; } // 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( () => {} ); } } /** * @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 } } } ); // 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, 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(), 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; } /** * Check if a polyline created from the current collection of points * where one point is added or edited would have intersections. * * @param {LatLngArray|LatLngObj} latLng - An object or array notation of point. * @param {number} index - Index of point to test. * @return {boolean} Whether polyline would have intersections. */ updatedPolylineWouldIntersect( latLng, index ) { const pointsToTest = []; let polylinePoints; let polylineToTest; let intersects; const that = this; if ( this.points < 3 ) { return false; } // create a deep copy of the current points $.extend( true, pointsToTest, this.points ); // edit/add one point pointsToTest[ index ] = [ latLng[ 0 ] || latLng.lat, latLng[ 1 ] || latLng.lng ]; // check whether last point is empty and remove it if so polylinePoints = ( pointsToTest[ pointsToTest.length - 1 ].join( '' ) !== '' ) ? pointsToTest : pointsToTest.slice( 0, pointsToTest.length - 1 ); // remove last one if closed // This introduces a bug as it enables creating a spiral that is closed // with an intersection. if ( polylinePoints[ 0 ][ 0 ] === polylinePoints[ polylinePoints.length - 1 ][ 0 ] && polylinePoints[ 0 ][ 1 ] === polylinePoints[ polylinePoints.length - 1 ][ 1 ] ) { polylinePoints = polylinePoints.slice( 0, polylinePoints.length - 1 ); } polylinePoints = polylinePoints.map( point => that._cleanLatLng( point ) ); // create polyline polylineToTest = L.polyline( polylinePoints, { color: 'white' } ); // add to map because the Polyline draw extension expects this this.map.addLayer( polylineToTest ); // check for intersection intersects = polylineToTest.intersects(); // clean up this.map.removeLayer( polylineToTest ); return intersects; } /** * Checks whether the array of points contains empty ones. * * @param {Array<LatLngArray>} points - Array of geopoints * @param {number} [allowedIndex] - The index in which an empty value is allowed. * @return {boolean} Whether the array contains empty points. */ containsEmptyPoints( points, allowedIndex ) { return points.some( ( point, index ) => index !== allowedIndex && ( !point[ 0 ] || !point[ 1 ] ) ); } /** * Gets the widget properties and features. * * @return {{search: boolean, detect: boolean, map: boolean, updateMapFn: string, type: string}} The widget properties object */ get props() { const props = this._props; props.detect = !!navigator.geolocation; props.map = !support.touch || props.appearances.includes( 'maps' ) || props.appearances.includes( 'placement-map' ); props.search = props.map; props.touch = support.touch; props.wide = this.question.clientWidth / this.element.closest( 'form.or' ).clientWidth > 0.8; return props; } /** * @type {string} */ get value() { let newValue = ''; // all points should be valid geopoints and only the last item may be empty this.points.forEach( ( point, index, array ) => { let geopoint; const lat = typeof point[ 0 ] === 'number' ? point[ 0 ] : ( typeof point.lat === 'number' ? point.lat : null ); const lng = typeof point[ 1 ] === 'number' ? point[ 1 ] : ( typeof point.lng === 'number' ? point.lng : null ); const alt = typeof point[ 2 ] === 'number' ? point[ 2 ] : 0.0; const acc = typeof point[ 3 ] === 'number' ? point[ 3 ] : 0.0; geopoint = ( lat && lng ) ? `${lat} ${lng} ${alt} ${acc}` : ''; // only last item may be empty // TODO: it is not great to have markAsInvalid functionality in the value getter. if ( !this._isValidGeopoint( geopoint ) && !( geopoint === '' && index === array.length - 1 ) ) { this._markAsInvalid( index ); } // newGeoTraceValue += geopoint;