@mapbox/mapbox-gl-geocoder
Version:
A geocoder control for Mapbox GL JS
457 lines (416 loc) • 15.4 kB
JavaScript
'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;