UNPKG

@mapbox/mapbox-gl-geocoder

Version:
457 lines (416 loc) 15.4 kB
'use strict'; var nanoid = require('nanoid').nanoid; /** * Construct a new mapbox event client to send interaction events to the mapbox event service * @param {Object} options options with which to create the service * @param {String} options.accessToken the mapbox access token to make requests * @param {Number} [options.flushInterval=1000] the number of ms after which to flush the event queue * @param {Number} [options.maxQueueSize=100] the number of events to queue before flushing * @private */ function MapboxEventManager(options) { this.origin = options.origin || 'https://api.mapbox.com'; this.endpoint = 'events/v2'; this.access_token = options.accessToken; this.version = '0.3.0' this.pluginSessionID = this.generateSessionID(); this.sessionIncrementer = 0; this.userAgent = this.getUserAgent(); this.options = options; this.send = this.send.bind(this); // parse global options to be sent with each request this.countries = (options.countries) ? options.countries.split(",") : null; this.types = (options.types) ? options.types.split(",") : null; this.bbox = (options.bbox) ? options.bbox : null; this.language = (options.language) ? options.language.split(",") : null; this.limit = (options.limit) ? +options.limit : null; this.locale = navigator.language || null; this.enableEventLogging = this.shouldEnableLogging(options); this.eventQueue = new Array(); this.flushInterval = options.flushInterval || 1000; this.maxQueueSize = options.maxQueueSize || 100; this.timer = (this.flushInterval) ? setTimeout(this.flush.bind(this), this.flushInterval) : null; // keep some state to deduplicate requests if necessary this.lastSentInput = ""; this.lastSentIndex = 0; } MapboxEventManager.prototype = { /** * Send a search.select event to the mapbox events service * This event marks the array index of the item selected by the user out of the array of possible options * @private * @param {Object} selected the geojson feature selected by the user * @param {Object} geocoder a mapbox-gl-geocoder instance * @returns {Promise} */ select: function(selected, geocoder){ var payload = this.getEventPayload('search.select', geocoder, { selectedFeature: selected }); if (!payload) return; // reject malformed event if ((payload.resultIndex === this.lastSentIndex && payload.queryString === this.lastSentInput) || payload.resultIndex == -1) { // don't log duplicate events if the user re-selected the same feature on the same search return; } this.lastSentIndex = payload.resultIndex; this.lastSentInput = payload.queryString; return this.push(payload) }, /** * Send a search-start event to the mapbox events service * This turnstile event marks when a user starts a new search * @private * @param {Object} geocoder a mapbox-gl-geocoder instance * @returns {Promise} */ start: function(geocoder){ var payload = this.getEventPayload('search.start', geocoder); if (!payload) return; // reject malformed event return this.push(payload); }, /** * Send a search-keyevent event to the mapbox events service * This event records each keypress in sequence * @private * @param {Object} keyEvent the keydown event to log * @param {Object} geocoder a mapbox-gl-geocoder instance * */ keyevent: function(keyEvent, geocoder){ //pass invalid event if (!keyEvent.key) return; // don't send events for keys that don't change the input // TAB, ESC, LEFT, RIGHT, ENTER, UP, DOWN if (keyEvent.metaKey || [9, 27, 37, 39, 13, 38, 40].indexOf(keyEvent.keyCode) !== -1) return; var payload = this.getEventPayload('search.keystroke', geocoder, { key: keyEvent.key }); if (!payload) return; // reject malformed event return this.push(payload); }, /** * Send an event to the events service * * The event is skipped if the instance is not enabled to send logging events * * @private * @param {Object} payload the http POST body of the event * @param {Function} [callback] a callback function to invoke when the send has completed * @returns {Promise} */ send: function (payload, callback) { if (!this.enableEventLogging) { if (callback) return callback(); return; } var options = this.getRequestOptions(payload); this.request(options, function(err){ if (err) return this.handleError(err, callback); if (callback) { return callback(); } }.bind(this)) }, /** * Get http request options * @private * @param {*} payload */ getRequestOptions: function(payload){ if (!Array.isArray(payload)) payload = [payload]; var options = { // events must be sent with POST method: "POST", host: this.origin, path: this.endpoint + "?access_token=" + this.access_token, headers: { 'Content-Type': 'application/json' }, body:JSON.stringify(payload) //events are arrays } return options }, /** * Get the event payload to send to the events service * Most payload properties are shared across all events * @private * @param {String} event the name of the event to send to the events service. Valid options are 'search.start', 'search.select', 'search.feedback'. * @param {Object} geocoder a mapbox-gl-geocoder instance * @param {Object} eventArgs Additional arguments needed for certain event types * @param {Object} eventArgs.key The key pressed by the user * @param {Object} eventArgs.selectedFeature GeoJSON Feature selected by the user * @returns {Object} an event payload */ getEventPayload: function (event, geocoder, eventArgs = {}) { // Make sure required arguments are present for certain event types if ( (event === 'search.select' && !eventArgs.selectedFeature) || (event === 'search.keystroke' && !eventArgs.key) ) { return null; } // Handle proximity, whether null, lat/lng coordinate object, or 'ip' var proximity; if (!geocoder.options.proximity) { proximity = null; } else if (typeof geocoder.options.proximity === 'object') { proximity = [geocoder.options.proximity.longitude, geocoder.options.proximity.latitude]; } else if (geocoder.options.proximity === 'ip') { var ipProximityHeader = geocoder._headers ? geocoder._headers['ip-proximity'] : null; if (ipProximityHeader && typeof ipProximityHeader === 'string') { proximity = ipProximityHeader.split(',').map(parseFloat); } else { proximity = [999,999]; // Alias for 'ip' in event logs } } else { proximity = geocoder.options.proximity; } var zoom = (geocoder._map) ? geocoder._map.getZoom() : undefined; var payload = { event: event, version: this.getEventSchemaVersion(event), created: +new Date(), sessionIdentifier: this.getSessionId(), country: this.countries, userAgent: this.userAgent, language: this.language, bbox: this.bbox, types: this.types, endpoint: 'mapbox.places', autocomplete: geocoder.options.autocomplete, fuzzyMatch: geocoder.options.fuzzyMatch, proximity: proximity, limit: geocoder.options.limit, routing: geocoder.options.routing, worldview: geocoder.options.worldview, mapZoom: zoom, keyboardLocale: this.locale } // get the text in the search bar if (event === "search.select"){ payload.queryString = geocoder.inputString; } else if (event != "search.select" && geocoder._inputEl){ payload.queryString = geocoder._inputEl.value; } else { payload.queryString = geocoder.inputString; } // add additional properties for certain event types if (['search.keystroke', 'search.select'].includes(event)) { payload.path = 'geocoding/v5/mapbox.places'; } if (event === 'search.keystroke' && eventArgs.key) { payload.lastAction = eventArgs.key; } else if (event === 'search.select' && eventArgs.selectedFeature) { var selected = eventArgs.selectedFeature; var resultIndex = this.getSelectedIndex(selected, geocoder); payload.resultIndex = resultIndex; payload.resultPlaceName = selected.place_name; payload.resultId = selected.id; if (selected.properties) { payload.resultMapboxId = selected.properties.mapbox_id; } if (geocoder._typeahead) { var results = geocoder._typeahead.data; if (results && results.length > 0) { payload.suggestionIds = this.getSuggestionIds(results); payload.suggestionNames = this.getSuggestionNames(results); payload.suggestionTypes = this.getSuggestionTypes(results); payload.suggestionSources = this.getSuggestionSources(results); } } } // Finally, validate that required properties are present for API compatibility if (!this.validatePayload(payload)) { return null; } return payload; }, /** * Wraps the request function for easier testing * Make an http request and invoke a callback * @private * @param {Object} opts options describing the http request to be made * @param {Function} callback the callback to invoke when the http request is completed */ request: function (opts, callback) { var xhttp = new XMLHttpRequest(); xhttp.onreadystatechange = function() { if (this.readyState == 4 ) { if (this.status == 204){ //success return callback(null); }else { return callback(this.statusText); } } }; xhttp.open(opts.method, opts.host + '/' + opts.path, true); for (var header in opts.headers){ var headerValue = opts.headers[header]; xhttp.setRequestHeader(header, headerValue) } xhttp.send(opts.body); }, /** * Handle an error that occurred while making a request * @param {Object} err an error instance to log * @private */ handleError: function (err, callback) { if (callback) return callback(err); }, /** * Generate a session ID to be returned with all of the searches made by this geocoder instance * ID is random and cannot be tracked across sessions * @private */ generateSessionID: function () { return nanoid(); }, /** * Get the a unique session ID for the current plugin session and increment the session counter. * * @returns {String} The session ID */ getSessionId: function(){ return this.pluginSessionID + '.' + this.sessionIncrementer; }, /** * Get a user agent string to send with the request to the events service * @private */ getUserAgent: function () { return 'mapbox-gl-geocoder.' + this.version + "." + navigator.userAgent; }, /** * Get the 0-based numeric index of the item that the user selected out of the list of options * @private * @param {Object} selected the geojson feature selected by the user * @param {Object} geocoder a Mapbox-GL-Geocoder instance * @returns {Number} the index of the selected result */ getSelectedIndex: function(selected, geocoder){ if (!geocoder._typeahead) return; var results = geocoder._typeahead.data; var selectedID = selected.id; var resultIDs = results.map(function (feature) { return feature.id; }); var selectedIdx = resultIDs.indexOf(selectedID); return selectedIdx; }, getSuggestionIds: function (results) { return results.map(function (feature) { if (feature.properties) { return feature.properties.mapbox_id || ''; } return feature.id || ''; }); }, getSuggestionNames: function (results) { return results.map(function (feature) { return feature.place_name || ''; }); }, getSuggestionTypes: function (results) { return results.map(function (feature) { if (feature.place_type && Array.isArray(feature.place_type)) { return feature.place_type[0] || ''; } return ''; }); }, getSuggestionSources: function (results) { return results.map(function (feature) { return feature._source || ''; }); }, /** * Get the correct schema version for the event * @private * @param {String} event Name of the event * @returns */ getEventSchemaVersion: function(event) { if (['search.keystroke', 'search.select'].includes(event)) { return '2.2'; } else { return '2.0'; } }, /** * Checks if a payload has all the required properties for the event type * @private * @param {Object} payload * @returns */ validatePayload: function(payload) { if (!payload || !payload.event) return false; var searchStartRequiredProps = ['event', 'created', 'sessionIdentifier', 'queryString']; var searchKeystrokeRequiredProps = ['event', 'created', 'sessionIdentifier', 'queryString', 'lastAction']; var searchSelectRequiredProps = ['event', 'created', 'sessionIdentifier', 'queryString', 'resultIndex', 'path', 'suggestionIds']; var event = payload.event; if (event === 'search.start') { return this.objectHasRequiredProps(payload, searchStartRequiredProps); } else if (event === 'search.keystroke') { return this.objectHasRequiredProps(payload, searchKeystrokeRequiredProps); } else if (event === 'search.select') { return this.objectHasRequiredProps(payload, searchSelectRequiredProps); } return true; }, /** * Checks of an object has all the required properties * @private * @param {Object} obj * @param {Array<String>} requiredProps * @returns */ objectHasRequiredProps: function(obj, requiredProps) { return requiredProps.every(function(prop) { if (prop === 'queryString') { return typeof obj[prop] === 'string' && obj[prop].length > 0; } return obj[prop] !== undefined; }); }, /** * Check whether events should be logged * Clients using a localGeocoder or an origin other than mapbox should not have events logged * @private */ shouldEnableLogging: function(options){ if (options.enableEventLogging === false) return false; if (options.origin && options.origin !== 'https://api.mapbox.com') return false; return true; }, /** * Flush out the event queue by sending events to the events service * @private */ flush: function(){ if (this.eventQueue.length > 0){ this.send(this.eventQueue); this.eventQueue = new Array(); } // //reset the timer if (this.timer) clearTimeout(this.timer); if (this.flushInterval) this.timer = setTimeout(this.flush.bind(this), this.flushInterval) }, /** * Push event into the pending queue * @param {Object} evt the event to send to the events service * @param {Boolean} forceFlush indicates that the event queue should be flushed after adding this event regardless of size of the queue * @private */ push: function(evt, forceFlush){ this.eventQueue.push(evt); if (this.eventQueue.length >= this.maxQueueSize || forceFlush){ this.flush(); } }, /** * Flush any remaining events from the queue before it is removed * @private */ remove: function(){ this.flush(); } } module.exports = MapboxEventManager;