UNPKG

@goongmaps/goong-geocoder

Version:

A geocoder control for Goong JS

800 lines (682 loc) 28.3 kB
'use strict'; var Typeahead = require('suggestions'); var debounce = require('lodash.debounce'); var extend = require('xtend'); var EventEmitter = require('events').EventEmitter; var GoongClient = require('@goongmaps/goong-sdk'); var goongAutocomplete = require('@goongmaps/goong-sdk/services/autocomplete'); /** * A geocoder component using the [Goong Places API](https://docs.goong.io/docs/rest/place/) * @class GoongGeocoder * @param {Object} options * @param {String} options.accessToken Required. An API Key created at https://account.goong.io * @param {String} [options.origin=https://rsapi.goong.io] Use to set a custom API origin. * @param {Object} [options.goongjs] A [goongjs](https://docs.goong.io/docs/api) instance to use when creating [Markers](https://docs.goong.io/docs/example/custom-marker-icons/). Required if `options.marker` is `true`. * @param {Number} [options.zoom=16] On geocoded result what zoom level should the map animate to. * @param {Boolean|Object} [options.flyTo=true] If `false`, animating the map to a selected result is disabled. If `true`, animating the map will use the default animation parameters. * @param {String} [options.placeholder=Search] Override the default placeholder attribute value. * @param {Object} [options.proximity] a proximity argument: this is * a geographical point given as an object with `latitude` and `longitude` * properties. Search results closer to this point will be given * higher priority. * @param {Boolean} [options.trackProximity=true] If `true`, the geocoder proximity will automatically update based on the map view. * @param {Boolean} [options.collapsed=false] If `true`, the geocoder control will collapse until hovered or in focus. * @param {Boolean} [options.clearAndBlurOnEsc=false] If `true`, the geocoder control will clear it's contents and blur when user presses the escape key. * @param {Boolean} [options.clearOnBlur=false] If `true`, the geocoder control will clear its value when the input blurs. * @param {Number} [options.minLength=2] Minimum number of characters to enter before results are shown. * @param {Number} [options.limit=5] Maximum number of results to show. * @param {Number} [options.radius=3000] Distance by kilometers around search location * @param {Boolean|Object} [options.marker=true] If `true`, a [Marker](https://docs.goong.io/docs/example/custom-marker-icons/) will be added to the map at the location of the user-selected result using a default set of Marker options. If the value is an object, the marker will be constructed using these options. If `false`, no marker will be added to the map. Requires that `options.goongjs` also be set. * @param {Function} [options.render] A function that specifies how the results should be rendered in the dropdown menu. This function should accepts a single [Predictions](https://docs.goong.io/docs/rest/place/#places-search-by-keyword-with-autocomplete) object as input and return a string. Any HTML in the returned string will be rendered. * @param {Function} [options.getItemValue] A function that specifies how the selected result should be rendered in the search bar. This function should accept a single [Place Detail](https://docs.goong.io/docs/rest/place/#get-place-detail-by-id) object as input and return a string. HTML tags in the output string will not be rendered. Defaults to `(item) => item.formatted_address`. * @example * var geocoder = new GoongGeocoder({ accessToken: goongjs.accessToken }); * map.addControl(geocoder); * @return {GoongGeocoder} `this` * */ function GoongGeocoder(options) { this._eventEmitter = new EventEmitter(); this.options = extend({}, this.options, options); this.inputString = ''; this.fresh = true; this.lastSelected = null; } GoongGeocoder.prototype = { options: { zoom: 16, flyTo: true, trackProximity: true, minLength: 2, limit: 5, radius: 3000, origin: 'https://rsapi.goong.io', marker: true, goongjs: null, collapsed: false, clearAndBlurOnEsc: false, clearOnBlur: false, getItemValue: function getItemValue(item) { return item.description; }, render: function render(item) { var placeName = item.structured_formatting; return '<div class="mapboxgl-ctrl-geocoder--suggestion"><div class="mapboxgl-ctrl-geocoder--suggestion-title">' + placeName.main_text + '</div><div class="mapboxgl-ctrl-geocoder--suggestion-address">' + placeName.secondary_text + '</div></div>'; } }, request: null, /** * Add the geocoder to a container. The container can be either a `goongjs.Map` or a reference to an HTML `class` or `id`. * * If the container is a `goongjs.Map`, this function will behave identically to `Map.addControl(geocoder)`. * If the container is an HTML `id` or `class`, the geocoder will be appended to that element. * * This function will throw an error if the container is not either a map or a `class`/`id` reference. * It will also throw an error if the referenced HTML element cannot be found in the `document.body`. * * For example, if the HTML body contains the element `<div id='geocoder-container'></div>`, the following script will append the geocoder to `#geocoder-container`: * * ```javascript * var geocoder = new GoongGeocoder({ accessToken: goongjs.accessToken }); * geocoder.addTo('#geocoder-container'); * ``` * @param {String|goongjs.Map} container A reference to the container to which to add the geocoder */ addTo: function (container) { // if the container is a map, add the control like normal if (container._controlContainer) { // it's a goongjs map, add like normal container.addControl(this); } // if the container is not a map, but an html element, then add the control to that element else if (typeof container == 'string' && (container.startsWith('#') || container.startsWith('.'))) { var parent = document.querySelectorAll(container); if (parent.length == 0) { throw new Error("Element ", container, "not found."); } if (parent.length > 1) { throw new Error("Geocoder can only be added to a single html element"); } parent.forEach(function (parentEl) { var el = this.onAdd(); //returns the input elements, which are then added to the requested html container parentEl.appendChild(el); }.bind(this)); } else { throw new Error("Error: addTo Container must be a goong-js map or a html element reference"); } }, onAdd: function (map) { if (map && typeof map != 'string') { this._map = map; } this.autoCompleteService = goongAutocomplete( GoongClient({ accessToken: this.options.accessToken, origin: this.options.origin }) ); this._onChange = this._onChange.bind(this); this._onKeyDown = this._onKeyDown.bind(this); this._onPaste = this._onPaste.bind(this); this._onBlur = this._onBlur.bind(this); this._showButton = this._showButton.bind(this); this._hideButton = this._hideButton.bind(this); this._onQueryResult = this._onQueryResult.bind(this); this.clear = this.clear.bind(this); this._updateProximity = this._updateProximity.bind(this); this._collapse = this._collapse.bind(this); this._unCollapse = this._unCollapse.bind(this); this._clear = this._clear.bind(this); this._clearOnBlur = this._clearOnBlur.bind(this); var el = this.container = document.createElement('div'); el.className = 'mapboxgl-ctrl-geocoder mapboxgl-ctrl'; var searchIcon = this.createIcon('search', '<path d="M7.4 2.5c-2.7 0-4.9 2.2-4.9 4.9s2.2 4.9 4.9 4.9c1 0 1.8-.2 2.5-.8l3.7 3.7c.2.2.4.3.8.3.7 0 1.1-.4 1.1-1.1 0-.3-.1-.5-.3-.8L11.4 10c.4-.8.8-1.6.8-2.5.1-2.8-2.1-5-4.8-5zm0 1.6c1.8 0 3.2 1.4 3.2 3.2s-1.4 3.2-3.2 3.2-3.3-1.3-3.3-3.1 1.4-3.3 3.3-3.3z"/>'); this._inputEl = document.createElement('input'); this._inputEl.type = 'text'; this._inputEl.className = 'mapboxgl-ctrl-geocoder--input'; this.setPlaceholder(); if (this.options.collapsed) { this._collapse(); this.container.addEventListener('mouseenter', this._unCollapse); this.container.addEventListener('mouseleave', this._collapse); this._inputEl.addEventListener('focus', this._unCollapse); } if (this.options.collapsed || this.options.clearOnBlur) { this._inputEl.addEventListener('blur', this._onBlur); } this._inputEl.addEventListener('keydown', debounce(this._onKeyDown, 200)); this._inputEl.addEventListener('paste', this._onPaste); this._inputEl.addEventListener('change', this._onChange); this.container.addEventListener('mouseenter', this._showButton); this.container.addEventListener('mouseleave', this._hideButton); this._inputEl.addEventListener('keyup', function () { }.bind(this)); var actions = document.createElement('div'); actions.classList.add('mapboxgl-ctrl-geocoder--pin-right'); this._clearEl = document.createElement('button'); this._clearEl.setAttribute('aria-label', 'Clear'); this._clearEl.addEventListener('click', this.clear); this._clearEl.className = 'mapboxgl-ctrl-geocoder--button'; var buttonIcon = this.createIcon('close', '<path d="M3.8 2.5c-.6 0-1.3.7-1.3 1.3 0 .3.2.7.5.8L7.2 9 3 13.2c-.3.3-.5.7-.5 1 0 .6.7 1.3 1.3 1.3.3 0 .7-.2 1-.5L9 10.8l4.2 4.2c.2.3.7.3 1 .3.6 0 1.3-.7 1.3-1.3 0-.3-.2-.7-.3-1l-4.4-4L15 4.6c.3-.2.5-.5.5-.8 0-.7-.7-1.3-1.3-1.3-.3 0-.7.2-1 .3L9 7.1 4.8 2.8c-.3-.1-.7-.3-1-.3z"/>'); this._clearEl.appendChild(buttonIcon); this._loadingEl = this.createIcon('loading', '<path fill="#333" d="M4.4 4.4l.8.8c2.1-2.1 5.5-2.1 7.6 0l.8-.8c-2.5-2.5-6.7-2.5-9.2 0z"/><path opacity=".1" d="M12.8 12.9c-2.1 2.1-5.5 2.1-7.6 0-2.1-2.1-2.1-5.5 0-7.7l-.8-.8c-2.5 2.5-2.5 6.7 0 9.2s6.6 2.5 9.2 0 2.5-6.6 0-9.2l-.8.8c2.2 2.1 2.2 5.6 0 7.7z"/>'); actions.appendChild(this._clearEl); actions.appendChild(this._loadingEl); el.appendChild(searchIcon); el.appendChild(this._inputEl); el.appendChild(actions); this._typeahead = new Typeahead(this._inputEl, [], { filter: false, minLength: this.options.minLength, limit: this.options.limit }); this.setRenderFunction(this.options.render); this._typeahead.getItemValue = this.options.getItemValue; this.mapMarker = null; this._handleMarker = this._handleMarker.bind(this); if (this._map) { if (this.options.trackProximity) { this._updateProximity(); this._map.on('moveend', this._updateProximity); } this._goongjs = this.options.goongjs; if (!this._goongjs && this.options.marker) { // eslint-disable-next-line no-console console.error("No goongjs detected in options. Map markers are disabled. Please set options.goongjs."); this.options.marker = false; } } return el; }, createIcon: function (name, path) { var icon = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); icon.setAttribute('class', 'mapboxgl-ctrl-geocoder--icon mapboxgl-ctrl-geocoder--icon-' + name); icon.setAttribute('viewBox', '0 0 18 18'); icon.setAttribute('xml:space', 'preserve'); icon.setAttribute('width', 18); icon.setAttribute('height', 18); icon.innerHTML = path; return icon; }, onRemove: function () { this.container.parentNode.removeChild(this.container); if (this.options.trackProximity && this._map) { this._map.off('moveend', this._updateProximity); } this._removeMarker(); this._map = null; return this; }, _onPaste: function (e) { var value = (e.clipboardData || window.clipboardData).getData('text'); if (value.length >= this.options.minLength) { this._geocode(value); } }, _onKeyDown: function (e) { var ESC_KEY_CODE = 27, TAB_KEY_CODE = 9; if (e.keyCode === ESC_KEY_CODE && this.options.clearAndBlurOnEsc) { this._clear(e); return this._inputEl.blur(); } // if target has shadowRoot, then get the actual active element inside the shadowRoot var target = e.target && e.target.shadowRoot ? e.target.shadowRoot.activeElement : e.target; var value = target ? target.value : ''; if (!value) { this.fresh = true; // the user has removed all the text if (e.keyCode !== TAB_KEY_CODE) this.clear(e); return this._clearEl.style.display = 'none'; } // TAB, ESC, LEFT, RIGHT, ENTER, UP, DOWN if (e.metaKey || [TAB_KEY_CODE, ESC_KEY_CODE, 37, 39, 13, 38, 40].indexOf(e.keyCode) !== -1) return; if (target.value.length >= this.options.minLength) { this._geocode(target.value); } }, _showButton: function () { if (this._typeahead.selected) this._clearEl.style.display = 'block'; }, _hideButton: function () { if (this._typeahead.selected) this._clearEl.style.display = 'none'; }, _onBlur: function (e) { if (this.options.clearOnBlur) { this._clearOnBlur(e); } if (this.options.collapsed) { this._collapse(); } }, _onChange: function () { var selected = this._typeahead.selected; if (selected && JSON.stringify(selected) !== this.lastSelected) { if (!this.options.flyTo) { return; } var request = this.autoCompleteService.placeDetail({ placeid: selected.place_id }).send(); request.then( function (response) { this._clearEl.style.display = 'none'; var detail = response.body; var flyOptions; var defaultFlyOptions = { zoom: this.options.zoom }; flyOptions = extend({}, defaultFlyOptions, this.options.flyTo); // ensure that center is not overriden by custom options var lat = detail.result.geometry.location.lat var lng = detail.result.geometry.location.lng flyOptions.center = [lng, lat]; if (this._map) { this._map.flyTo(flyOptions); } if (this.options.marker && this._goongjs) { this._handleMarker(detail); } // After selecting a result, re-focus the textarea and set // cursor at start. this._inputEl.focus(); this._inputEl.scrollLeft = 0; this._inputEl.setSelectionRange(0, 0); this.lastSelected = JSON.stringify(selected); this._eventEmitter.emit('result', { result: detail }); }.bind(this)); request.catch( function (error) { this._eventEmitter.emit('error', { error: error }); }.bind(this)); return request; } }, _geocode: function (searchInput) { this._loadingEl.style.display = 'block'; this._eventEmitter.emit('loading', { query: searchInput }); this.inputString = searchInput; var request; var config = { input: searchInput, radius: this.options.radius }; if (this.options.trackProximity && this._map && this.options.proximity && this.options.proximity.latitude && this.options.proximity.longitude) { config = extend(config, { location: this.options.proximity.latitude + "," + this.options.proximity.longitude }) } request = this.autoCompleteService.search(config).send(); request.then( function (response) { var res = response.body; this._loadingEl.style.display = 'none'; if (this.fresh) { this.fresh = false; } if (res.predictions.length) { this._clearEl.style.display = 'block'; this._eventEmitter.emit('results', res); this._typeahead.update(res.predictions); } else { this._clearEl.style.display = 'none'; this._typeahead.selected = null; this._renderNoResults(); this._eventEmitter.emit('results', res); } }.bind(this)); request.catch( function (error) { if (error && error.status === 0) return; this._loadingEl.style.display = 'none'; this._clearEl.style.display = 'none'; this._typeahead.selected = null; this._renderError(); this._eventEmitter.emit('results', { predictions: [] }); this._eventEmitter.emit('error', { error: error }); }.bind(this) ); return request; }, /** * Shared logic for clearing input * @param {Event} [ev] the event that triggered the clear, if available * @private * */ _clear: function (ev) { if (ev) ev.preventDefault(); this._inputEl.value = ''; this._typeahead.selected = null; this._typeahead.clear(); this._onChange(); this._clearEl.style.display = 'none'; this._removeMarker(); this.lastSelected = null; this._eventEmitter.emit('clear'); this.fresh = true; }, /** * Clear and then focus the input. * @param {Event} [ev] the event that triggered the clear, if available * */ clear: function (ev) { this._clear(ev); this._inputEl.focus(); }, /** * Clear the input, without refocusing it. Used to implement clearOnBlur * constructor option. * @param {Event} [ev] the blur event * @private */ _clearOnBlur: function (ev) { var ctx = this; /* * If relatedTarget is not found, assume user targeted the suggestions list. * In that case, do not clear on blur. There are other edge cases where * ev.relatedTarget could be null. Clicking on list always results in null * relatedtarget because of upstream behavior in `suggestions`. */ if (ev.relatedTarget) { ctx._clear(ev); } }, _onQueryResult: function (response) { var results = response.result; this._typeahead.selected = results; this._inputEl.value = results.geometry.name; }, _updateProximity: function () { // proximity is designed for local scale, if the user is looking at the whole world, // it doesn't make sense to factor in the arbitrary centre of the map if (!this._map) { return; } if (this._map.getZoom() > 9) { var center = this._map.getCenter().wrap(); this.setProximity({ longitude: center.lng, latitude: center.lat }); } else { this.setProximity(null); } }, _collapse: function () { // do not collapse if input is in focus if (!this._inputEl.value && this._inputEl !== document.activeElement) this.container.classList.add('mapboxgl-ctrl-geocoder--collapsed'); }, _unCollapse: function () { this.container.classList.remove('mapboxgl-ctrl-geocoder--collapsed'); }, /** * Set & query the input * @param {string} searchInput location name or other search input * @returns {GoongGeocoder} this */ query: function (searchInput) { this._geocode(searchInput).then(this._onQueryResult); return this; }, _renderError: function () { var errorMessage = "<div class='goong-js-geocoder--error'>There was an error reaching the server</div>"; this._renderMessage(errorMessage); }, _renderNoResults: function () { var errorMessage = "<div class='goong-js-geocoder--error mapboxgl-gl-geocoder--no-results'>No results found</div>"; this._renderMessage(errorMessage); }, _renderMessage: function (msg) { this._typeahead.update([]); this._typeahead.selected = null; this._typeahead.clear(); this._typeahead.renderError(msg); }, /** * Get the text to use as the search bar placeholder * * If placeholder is provided in options, then use options.placeholder * Otherwise use the default * * @returns {String} the value to use as the search bar placeholder * @private */ _getPlaceholderText: function () { if (this.options.placeholder) return this.options.placeholder; return 'Search'; }, /** * Set input * @param {string} searchInput location name or other search input * @returns {GoongGeocoder} this */ setInput: function (searchInput) { // Set input value to passed value and clear everything else. this._inputEl.value = searchInput; this._typeahead.selected = null; this._typeahead.clear(); if (searchInput.length >= this.options.minLength) { this._geocode(searchInput); } return this; }, /** * Get input * @returns current input */ getInput: function() { return this._inputEl.value; }, /** * Set proximity * @param {Object} proximity The new `options.proximity` value. This is a geographical point given as an object with `latitude` and `longitude` properties. * @returns {GoongGeocoder} this */ setProximity: function (proximity) { this.options.proximity = proximity; return this; }, /** * Get proximity * @returns {Object} The geocoder proximity */ getProximity: function () { return this.options.proximity; }, /** * Set the render function used in the results dropdown * @param {Function} fn The function to use as a render function. This function accepts a single [Predictions](https://docs.goong.io/rest/guide#get-points-by-keyword) object as input and returns a string. * @returns {GoongGeocoder} this */ setRenderFunction: function (fn) { if (fn && typeof fn == "function") { this._typeahead.render = fn; } return this; }, /** * Get the function used to render the results dropdown * * @returns {Function} the render function */ getRenderFunction: function () { return this._typeahead.render; }, /** * Get the zoom level the map will move to * @returns {Number} the map zoom */ getZoom: function () { return this.options.zoom; }, /** * Set the zoom level * @param {Number} zoom The zoom level that the map should animate to * @returns {GoongGeocoder} this */ setZoom: function (zoom) { this.options.zoom = zoom; return this; }, /** * Get the parameters used to fly to the selected response, if any * @returns {Boolean|Object} The `flyTo` option */ getFlyTo: function () { return this.options.flyTo; }, /** * Set the flyTo options * @param {Boolean|Object} flyTo If false, animating the map to a selected result is disabled. If true, animating the map will use the default animation parameters */ setFlyTo: function (flyTo) { this.options.flyTo = flyTo; return this; }, /** * Get the value of the placeholder string * @returns {String} The input element's placeholder value */ getPlaceholder: function () { return this.options.placeholder; }, /** * Set the value of the input element's placeholder * @param {String} placeholder the text to use as the input element's placeholder * @returns {GoongGeocoder} this */ setPlaceholder: function (placeholder) { this.placeholder = placeholder ? placeholder : this._getPlaceholderText(); this._inputEl.placeholder = this.placeholder; this._inputEl.setAttribute('aria-label', this.placeholder); return this; }, /** * Get the minimum number of characters typed to trigger results used in the plugin * @returns {Number} The minimum length in characters before a search is triggered */ getMinLength: function () { return this.options.minLength; }, /** * Set the minimum number of characters typed to trigger results used by the plugin * @param {Number} minLength the minimum length in characters * @returns {GoongGeocoder} this */ setMinLength: function (minLength) { this.options.minLength = minLength; if (this._typeahead) this._typeahead.minLength = minLength; return this; }, /** * Get the limit value for the number of results to display used by the plugin * @returns {Number} The limit value for the number of results to display used by the plugin */ getLimit: function () { return this.options.limit; }, /** * Set the limit value for the number of results to display used by the plugin * @param {Number} limit the number of search results to return * @returns {GoongGeocoder} */ setLimit: function (limit) { this.options.limit = limit; if (this._typeahead) this._typeahead.options.limit = limit; return this; }, /** * Get the radius value for the number of results to display used by the plugin * @returns {Number} The limit value for the number of results to display used by the plugin */ getRadius: function () { return this.options.radius; }, /** * Set the limit value for the number of results to display used by the plugin * @param {Number} radius the number of search results to return * @returns {GoongGeocoder} */ setRadius: function (radius) { this.options.radius = radius; if (this._typeahead) this._typeahead.options.radius = radius; return this; }, /** * Set the geocoding endpoint used by the plugin. * @param {Function} origin A function which accepts an HTTPS URL to specify the endpoint to query results from. * @returns {GoongGeocoder} this */ setOrigin: function (origin) { this.options.origin = origin; this.autoCompleteService = goongAutocomplete( GoongClient({ accessToken: this.options.accessToken, origin: this.options.origin }) ); return this; }, /** * Get the geocoding endpoint the plugin is currently set to * @returns {Function} the endpoint URL */ getOrigin: function () { return this.options.origin; }, /** * Handle the placement of a result marking the response result * @private * @param {Object} response the selected geojson feature * @returns {GoongGeocoder} this */ _handleMarker: function (response) { // clean up any old marker that might be present if (!this._map) { return; } this._removeMarker(); var defaultMarkerOptions = { color: '#469af7' }; var markerOptions = extend({}, defaultMarkerOptions, this.options.marker); this.mapMarker = new this._goongjs.Marker(markerOptions); this.mapMarker.setLngLat([response.result.geometry.location.lng, response.result.geometry.location.lat]).addTo(this._map); return this; }, /** * Handle the removal of a result marker * @private */ _removeMarker: function () { if (this.mapMarker) { this.mapMarker.remove(); this.mapMarker = null; } }, /** * Subscribe to events that happen within the plugin. * @param {String} type name of event. Available events and the data passed into their respective event objects are: * * - __clear__ `Emitted when the input is cleared` * - __loading__ `{ query } Emitted when the geocoder is looking up a query` * - __results__ `{ results } Fired when the geocoder returns a response` * - __result__ `{ result } Fired when input is set` * - __error__ `{ error } Error as string` * @param {Function} fn function that's called when the event is emitted. * @returns {GoongGeocoder} this; */ on: function (type, fn) { this._eventEmitter.on(type, fn); return this; }, /** * Remove an event * @returns {GoongGeocoder} this * @param {String} type Event name. * @param {Function} fn Function that should unsubscribe to the event emitted. */ off: function (type, fn) { this._eventEmitter.removeListener(type, fn); // this.eventManager.remove(); return this; } }; module.exports = GoongGeocoder;