enketo-core
Version:
Extensible Enketo form engine
1,428 lines (1,262 loc) • 57.1 kB
JavaScript
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