/* Copyright (c) 2015-present The Open Source Geospatial Foundation
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
/**
* Creates a combo box that handles results from a geocoding service. By
* default it uses OSM Nominatim, but the component offers all config options
* to overwrite in order to other custom services.
* If the user enters a valid address in the search box, the combo's store will
* be populated with records that match the
* address. By default, records have the following fields:
*
* * name - `String` The formatted address.
* * extent - `ol.Extent` The extent of the matching address
* * bounds - `ol.Coordinate` The point coordinate of the matching address
*
* **CAUTION: This class is only usable in applications using the classic
* toolkit of ExtJS 6.**
*
* @class GeoExt.form.field.GeocoderComboBox
*/
Ext.define('GeoExt.form.field.GeocoderComboBox', {
extend: 'Ext.form.field.ComboBox',
alias: [
'widget.gx_geocoder_combo',
'widget.gx_geocoder_combobox',
'widget.gx_geocoder_field',
],
requires: ['Ext.data.JsonStore'],
mixins: ['GeoExt.mixin.SymbolCheck'],
/**
* The OpenLayers map to work on. If not provided the selection of an
* address would have no effect.
*
* @cfg {ol.Map}
*/
map: null,
/**
* Vector layer to visualize the selected address.
* Will be created if not provided.
*
* @cfg {ol.layer.Vector} locationLayer The layer used for displaying the selected address.
* @property {ol.layer.Vector} locationLayer The layer used for displaying the selected address.
*/
locationLayer: null,
/**
* The style of the #locationLayer. Only has an effect if the layer is not
* passed in while creation.
*
* @cfg {ol.style.Style}
*/
locationLayerStyle: null,
/**
* The store used for this combo box. Default is a
* store with the url configured as #url
* config.
*
* @cfg {Ext.data.JsonStore} store The store used for this combo box.
* @property {Ext.data.JsonStore} store The store used for this combo box.
*/
store: null,
/**
* The property in the JSON response of the geocoding service used in
* the store's proxy as root object.
*
* @cfg {string}
*/
proxyRootProperty: null,
/**
* The field to display in the combobox result. Default is
* "name" for instant use with the default store for this component.
*
* @cfg {string}
*/
displayField: 'name',
/**
* The field in the GeoCoder service repsonse to be used as mapping for the
* 'name' field in the #store.
* Ignored when a store is passed in.
*
* @cfg {string}
*/
displayValueMapping: 'display_name',
/**
* Field from selected record to use when the combo's
* #getValue method is called. Default is "extent". This field is
* supposed to contain an ol.Extent.
* By setting this to 'coordinate' a field holding an ol.Coordinate is used.
*
* @cfg {string}
*/
valueField: 'extent',
/**
* The query parameter for the user entered search text.
* Default is 'q' for instant use with OSM Nominatim.
*
* @cfg {string}
*/
queryParam: 'q',
/**'Search'
* Text to display for an empty field.
*
* @cfg {String}
*/
emptyText: 'Search for a location',
/**
* Minimum number of entered characters to trigger a search.
*
* @cfg {number}
*/
minChars: 3,
/**
* Delay before the search occurs in ms.
*
* @cfg {number}
*/
queryDelay: 100,
/**
* URL template for querying the geocoding service. If a store is
* configured, this will be ignored. Note that the #queryParam will be used
* to append the user's combo box input to the url.
*
* @cfg {string}
*/
url: 'https://nominatim.openstreetmap.org/search?format=json',
/**
* The SRS used by the geocoder service.
*
* @cfg {string}
*/
srs: 'EPSG:4326',
/**
* Zoom level when zooming to a location (#valueField='coordinate')
* Not used when zooming to extent.
*
* @cfg {number}
*/
zoom: 10,
/**
* Flag to steer if selected address feature is drawn on #map
* (by #locationLayer).
*
* @cfg {boolean}
*/
showLocationOnMap: true,
/**
* Flag to restrict nomination query to current map extent
*
* @cfg {boolean}
*/
restrictToMapExtent: false,
/**
* @private
*/
initComponent: function () {
const me = this;
me.updateExtraParams = me.updateExtraParams.bind(me);
if (!me.store) {
me.store = Ext.create('Ext.data.JsonStore', {
fields: [
{name: 'name', mapping: me.displayValueMapping},
{name: 'extent', convert: me.convertToExtent},
{name: 'coordinate', convert: me.convertToCoordinate},
],
proxy: {
type: 'ajax',
url: me.url,
reader: {
type: 'json',
rootProperty: me.proxyRootProperty,
},
},
});
}
if (!me.locationLayer) {
me.locationLayer = new ol.layer.Vector({
source: new ol.source.Vector(),
style:
me.locationLayerStyle !== null ? me.locationLayerStyle : undefined,
});
if (me.map) {
me.map.addLayer(me.locationLayer);
}
}
me.callParent(arguments);
me.on({
unRestrictMapExtent: me.unRestrictExtent,
restrictToMapExtent: me.restrictExtent,
select: me.onSelect,
focus: me.onFocus,
scope: me,
});
if (me.restrictToMapExtent) {
me.restrictExtent();
}
},
/**
* Handle restriction to viewbox: register moveend event
* and update params of AJAX proxy
*/
restrictExtent: function () {
const me = this;
me.map.on('moveend', me.updateExtraParams);
me.updateExtraParams();
},
/**
* Update viewbox parameter based on the current map extent
*/
updateExtraParams: function () {
const me = this;
const mapSize = me.map.getSize();
const mv = me.map.getView();
const extent = mv.calculateExtent(mapSize);
me.addMapExtentParams(extent, mv.getProjection());
},
/**
* Update map extent params of AJAX proxy.
*
* By default, 'viewbox' and 'bounded' are updated since Nominatim is the
* default geocoder in this class. If no projection is passed the one of
* the map view is used.
*
* @param {ol.Extent} extent The extend to restrict the geocoder to
* @param {ol.proj.Projection} projection The projection of given extent
*/
addMapExtentParams: function (extent, projection) {
const me = this;
if (!projection) {
projection = me.map.getView().getProjection();
}
let ll = ol.proj.transform([extent[0], extent[1]], projection, 'EPSG:4326');
let ur = ol.proj.transform([extent[2], extent[3]], projection, 'EPSG:4326');
ll = Ext.Array.map(ll, function (val) {
return Math.min(Math.max(val, -180), 180);
});
ur = Ext.Array.map(ur, function (val) {
return Math.min(Math.max(val, -180), 180);
});
const viewBoxStr = [ll.join(','), ur.join(',')].join(',');
if (me.store && me.store.getProxy()) {
me.store.getProxy().setExtraParam('viewbox', viewBoxStr);
me.store.getProxy().setExtraParam('bounded', '1');
}
},
/**
* Cleanup if extent restriction is omitted.
* -> moveend event from map
* -> call removeMapExtentParams to reset params set in store
*/
unRestrictExtent: function () {
const me = this;
// unbinding moveend event
me.map.un('moveend', me.updateExtraParams);
// cleanup params in store
me.removeMapExtentParams();
},
/**
* Remove restriction to viewbox, in particular remove viewbox
* and bounded parameters from AJAX proxy for nominatim queries
*/
removeMapExtentParams: function () {
const me = this;
if (me.store && me.store.getProxy()) {
me.store.getProxy().setExtraParam('viewbox', undefined);
me.store.getProxy().setExtraParam('bounded', undefined);
}
},
/**
* Function to convert the data delivered by the geocoder service to an
* ol.Extent ([minx, miny, maxx, maxy]).
* Default implementation converts the Nominatim response.
* Can be overwritten to work with other services.
*
* @param {Mixed} v The data value as read by the Reader
* @param {Ext.data.Model} rec The data record containing raw data
* @return {ol.Extent} The created ol.Extent
*/
convertToExtent: function (v, rec) {
const rawExtent = rec.get('boundingbox');
const minx = parseFloat(rawExtent[2], 10);
const miny = parseFloat(rawExtent[0], 10);
const maxx = parseFloat(rawExtent[3], 10);
const maxy = parseFloat(rawExtent[1], 10);
return [minx, miny, maxx, maxy];
},
/**
* Function to convert the data delivered by the geocoder service to an
* ol.Coordinate ([x, y]).
* Default implementation converts the Nominatim response.
* Can be overwritten to work with other services.
*
* @param {Mixed} v The data value as read by the Reader
* @param {Ext.data.Model} rec The data record containing raw data
* @return {ol.Coordinate} The created ol.Coordinate
*/
convertToCoordinate: function (v, rec) {
return [parseFloat(rec.get('lon'), 10), parseFloat(rec.get('lat'), 10)];
},
/**
* Draws the selected address feature on the map.
*
* @param {ol.Coordinate | ol.Extent} coordOrExtent Location feature to be
* drawn on the map
*/
drawLocationFeatureOnMap: function (coordOrExtent) {
const me = this;
let geom;
if (coordOrExtent.length === 2) {
geom = new ol.geom.Point(coordOrExtent);
} else if (coordOrExtent.length === 4) {
geom = ol.geom.Polygon.fromExtent(coordOrExtent);
}
if (geom) {
const feat = new ol.Feature({
geometry: geom,
});
me.locationLayer.getSource().clear();
me.locationLayer.getSource().addFeature(feat);
}
},
/**
* Removes the drawn address feature from the map.
*/
removeLocationFeature: function () {
this.locationLayer.getSource().clear();
},
/**
* Handles the 'focus' event of this ComboBox.
*/
onFocus: function () {
const me = this;
me.clearValue();
me.removeLocationFeature();
},
/**
* Handles the 'select' event of this ComboBox.
* Zooms to the selected address and draws the address feature on the map
* (if configured in #showLocationOnMap)
*
* @param {GeoExt.form.field.GeocoderComboBox} combo [description]
* @param {Ext.data.Model} record [description]
*
* @private
*/
onSelect: function (combo, record) {
const me = this;
if (!me.map) {
Ext.Logger.warn(
'No map configured in ' +
'GeoExt.form.field.GeocoderComboBox. Skip zoom to selection.',
);
return;
}
const value = record.get(me.valueField);
let projValue;
const olMapView = me.map.getView();
const targetProj = olMapView.getProjection().getCode();
if (value.length === 2) {
// point based value
projValue = ol.proj.transform(value, me.srs, targetProj);
// adjust the map
olMapView.setCenter(projValue);
olMapView.setZoom(me.zoom);
} else if (value.length === 4) {
// bbox based value
projValue = ol.proj.transformExtent(value, me.srs, targetProj);
// adjust the map
olMapView.fit(projValue);
}
if (me.showLocationOnMap) {
me.drawLocationFeatureOnMap(projValue);
}
},
});