widget/geo/geopicker.js

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;
            if ( !( geopoint === '' && index === array.length - 1 ) ) {
                newValue += geopoint;
                if ( index !== array.length - 1 ) {
                    newValue += ';';
                }
            } else {
                // remove trailing semi-colon
                newValue = newValue.substring( 0, newValue.lastIndexOf( ';' ) );
            }
        } );

        return newValue;
    }

    set value( value ) {
        value.trim().split( ';' ).forEach( ( el, i ) => {
            // console.debug( 'adding loaded point', el.trim().split( ' ' ) );
            this.points[ i ] = el.trim().split( ' ' );
            this.points[ i ].forEach( ( str, i, arr ) => {
                arr[ i ] = Number( str );
            } );
        } );
    }

    /**
     * Enables a disabled widget
     */
    enable() {
        $( this.element )
            .next( '.widget' )
            .removeClass( 'readonly' )
            .find( 'input, select, textarea' ).prop( 'disabled', false )
            .end()
            .find( '.btn:not(.show-map-btn):not(.hide-map-btn), .btn-icon-only, .addpoint' ).prop( 'disabled', false );

        // ensure all tiles are displayed, https://github.com/kobotoolbox/enketo-express/issues/188
        if ( this.map ) {
            this.map.invalidateSize();
        }
    }

    /**
     * Disables the widget
     */
    disable() {
        $( this.element )
            .next( '.widget' )
            .addClass( 'readonly' )
            .find( 'input, select, textarea' ).prop( 'disabled', true )
            .end()
            .find( '.btn:not(.show-map-btn):not(.hide-map-btn), .btn-icon-only, .addpoint' ).prop( 'disabled', true );
    }

    /**
     * Updates the widget if the value has updated programmatically (e.g. due to a calculation)
     */
    update() {
        /**
         * It is somewhat complex to properly update, especially when the widget is currently
         * showing a list of geotrace/geoshape points. Hence we use the inefficient but robust
         * method to re-initialize instead.
         */
        const widget = this.element.parentElement.querySelector( '.widget' );
        if ( widget ) {
            widget.remove();
            this.loadMap = undefined;
            this.map = undefined;
            this.polyline = undefined;
            this.polygon = undefined;
            this._init();
        }
    }
}

export default Geopicker;