var _ = require('underscore'); var AnalysisPoller = require('./analysis/analysis-poller'); var AnonymousMapSerializer = require('./windshaft/map-serializer/anonymous-map-serializer/anonymous-map-serializer'); var Backbone = require('backbone'); var CartoDBLayerGroup = require('./geo/cartodb-layer-group'); var DataviewsCollection = require('./dataviews/dataviews-collection'); var LayersCollection = require('./geo/map/layers'); var ModelUpdater = require('./windshaft-integration/model-updater'); var NamedMapSerializer = require('./windshaft/map-serializer/named-map-serializer/named-map-serializer'); var Request = require('./windshaft/request'); var Response = require('./windshaft/response'); var WindshaftClient = require('./windshaft/client'); var AnalysisService = require('./analysis/analysis-service'); var WindshaftError = require('./windshaft/error'); var RELOAD_DEBOUNCE_TIME_IN_MILIS = 100; /** * * Creates a new Engine. * An engine is the core of a carto app. * * With the help of external services the engine will: * * - Keep the state of the layers and dataviews. * - Serialize the state and send requests to the server. * - Parse the server response and update the internal models. * - Notify errors or successful operations. * * @param {Object} params - The parameters to initialize the engine. * @param {string} params.apiKey - Api key used to be autenticate in the windshaft server. * @param {string} params.authToken - Token used to be autenticate in the windshaft server. * @param {string} params.username - Name of the user registered in the windshaft server. * @param {string} params.serverUrl - Url of the windshaft server. * @param {boolean} params.templateName - While we dont remove named maps we must explicitly say when the map is named. Defaults to false. * @param {boolean} params.client - Token used to get map view statistics. * @constructor */ function Engine (params) { if (!params) throw new Error('new Engine() called with no parameters'); this._isNamedMap = params.templateName !== undefined; // Variables for the reload debounce this._timeout = null; this._stackCalls = []; this._batchOptions = {}; this._windshaftSettings = { urlTemplate: params.serverUrl, userName: params.username, client: params.client, apiKey: params.apiKey, authToken: params.authToken, templateName: params.templateName }; this._windshaftClient = new WindshaftClient(this._windshaftSettings); // This object will be responsible of triggering the engine events. this._eventEmmitter = _.extend({}, Backbone.Events); this._analysisPoller = new AnalysisPoller(); this._layersCollection = new LayersCollection(); this._dataviewsCollection = new DataviewsCollection(); this._cartoLayerGroup = new CartoDBLayerGroup( { apiKey: params.apiKey, authToken: params.authToken }, { layersCollection: this._layersCollection } ); this._bindCartoLayerGroupError(); this._modelUpdater = new ModelUpdater({ dataviewsCollection: this._dataviewsCollection, layerGroupModel: this._cartoLayerGroup, layersCollection: this._layersCollection }); } /** * Return the cartoLayergroup attached to the engine */ Engine.prototype.getLayerGroup = function () { return this._cartoLayerGroup; }; /** * Returns the API key attached to the engine */ Engine.prototype.getApiKey = function () { return this._windshaftSettings && this._windshaftSettings.apiKey; }; /** * Returns the Auth token attached to the engine */ Engine.prototype.getAuthToken = function () { return this._windshaftSettings && this._windshaftSettings.authToken; }; /** * Bind a callback function to an event. The callback will be invoked whenever the event is fired. * * @param {string} event - The name of the event that triggers the callback execution. * @param {function} callback - A function to be executed when the event is fired. * @param {function} [context] - The context value for this when the callback is invoked. * @example * // Define a callback to be executed once the map is reloaded. * function onReload(event) { * console.log(event); // "reload-success" * } * // Attach the callback to the RELOAD_SUCCESS event. * engine.on(Engine.Events.RELOAD_SUCCESS, onReload); * // Call the reload method and wait. * engine.reload(); * */ Engine.prototype.on = function (event, callback, context) { this._eventEmmitter.on(event, callback, context); }; /** * Remove a previously-bound callback function from an event. * * @param {string} event - The name of the event that triggers the callback execution. * @param {function} callback - A function callback to be removed when the event is fired. * @param {function} [context] - The context value for this when the callback is invoked. * @example * // Remove the the `displayMap` listener function so it wont be executed anymore when the engine fires the `load` event. * engine.off(Engine.Events.RELOAD_SUCCESS, onReload); * */ Engine.prototype.off = function (event, callback, context) { this._eventEmmitter.off(event, callback, context); }; /** * This is the most important function of the engine. * Generate a payload from the current state, send it to the windshaft server * and update the internal models with the server response. * * Once the response has arrived trigger a 'reload-succes' or 'reload-error' event. * * @param {string} options.sourceId - The sourceId triggering the reload event. This is usefull to prevent uneeded requests and save data. * @param {boolean} options.forceFetch - Forces dataviews to fetch data from server after a reload * @param {boolean} options.includeFilters - Boolean flag to control if the filters need to be added in the payload. * * @fires Engine#Engine:RELOAD_STARTED * @fires Engine#Engine:RELOAD_SUCCESS * @fires Engine#Engine:RELOAD_ERROR * */ Engine.prototype.reload = function (options) { options = options || {}; // Using a debouncer to optimize consecutive calls to reload the map. // This allows to change multiple map parameters reloading the map only once, // and therefore avoid the "You are over platform's limits" Windshaft error. return new Promise(function (resolve, reject) { this._batchOptions = _.pick({ sourceId: options.sourceId, forceFetch: this._batchOptions.forceFetch || options.forceFetch, includeFilters: options.includeFilters }, _.negate(_.isUndefined)); this._stackCalls.push({ success: options.success, error: options.error, resolve: resolve, reject: reject }); var later = function () { this._timeout = null; this._performReload(this._batchOptions) .then(function () { // Resolve stacked callbacks and promises this._stackCalls.forEach(function (call) { call.success && call.success(); call.resolve(); }); // Reset stack this._stackCalls = []; this._batchOptions = {}; }.bind(this)) .catch(function (windshaftError) { // Reject stacked callbacks and promises this._stackCalls.forEach(function (call) { call.error && call.error(windshaftError); call.reject(windshaftError); }); // Reset stack this._stackCalls = []; this._batchOptions = {}; }.bind(this)); }.bind(this); clearTimeout(this._timeout); this._timeout = setTimeout(later, RELOAD_DEBOUNCE_TIME_IN_MILIS); }.bind(this)); }; Engine.prototype._performReload = function (options) { return new Promise(function (resolve, reject) { // Build Windshaft options callbacks var windshaftOptions = this._buildWindshaftOptions(options, // Windshaft success callback function (serverResponse) { this._onReloadSuccess(serverResponse, options.sourceId, options.forceFetch); resolve(); }.bind(this), // Windshaft error callback function (errors) { var windshaftError = this._onReloadError(errors); reject(windshaftError); }.bind(this) ); try { var params = this._buildParams(windshaftOptions.includeFilters); var payload = this._getSerializer().serialize(this._layersCollection, this._dataviewsCollection); var request = new Request(payload, params, windshaftOptions); // Trigger STARTED event this._eventEmmitter.trigger(Engine.Events.RELOAD_STARTED); // Perform the request this._windshaftClient.instantiateMap(request); } catch (error) { // Convert error in a windshaftError var windshaftError = new WindshaftError({ message: error.message }); this._manageClientError(windshaftError, windshaftOptions); } }.bind(this)); }; /** * * Add a layer to the engine layersCollection * * @param {layer} layer - A new layer to be added to the engine. * * @public */ Engine.prototype.addLayer = function (layer) { this._layersCollection.add(layer); }; /** * * Remove a layer from the engine layersCollection * * @param {layer} layer - A new layer to be removed from the engine. * * @public */ Engine.prototype.removeLayer = function (layer) { this._layersCollection.remove(layer); }; /** * * Move a layer in the engine layersCollection * * @param {layer} layer - A new layer to be moved in the engine. * @param {number} toIndex - Final index for the layer. * * @public */ Engine.prototype.moveLayer = function (layer, toIndex) { var fromIndex = this._layersCollection.indexOf(layer); if (fromIndex >= 0 && fromIndex !== toIndex) { this._layersCollection.models.splice(toIndex, 0, this._layersCollection.models.splice(fromIndex, 1)[0]); // Equivalent to: // this._layersCollection.remove(layer, { silent: true }); // this._layersCollection.add(layer, { at: toIndex }); } }; /** * * Add a dataview to the engine dataviewsCollection * * @param {Dataview} dataview - A new dataview to be added to the engine. * * @public */ Engine.prototype.addDataview = function (dataview) { this._dataviewsCollection.add(dataview); }; /** * * Remove a dataview from the engine dataviewsCollection * * @param {Dataview} dataview - The Dataview to be removed to the engine. * * @public */ Engine.prototype.removeDataview = function (dataview) { this._dataviewsCollection.remove(dataview); }; /** * Callback executed when the windhsaft client returns a successful response. * Update internal models and trigger a RELOAD_SUCCESS event. * @private */ Engine.prototype._onReloadSuccess = function (serverResponse, sourceId, forceFetch) { var responseWrapper = new Response(this._windshaftSettings, serverResponse); this._modelUpdater.updateModels(responseWrapper, sourceId, forceFetch); this._restartAnalysisPolling(); // Trigger RELOAD_SUCCESS event this._eventEmmitter.trigger(Engine.Events.RELOAD_SUCCESS); }; /** * Callback executed when the windhsaft client returns a failed response. * Update internal models setting errors and trigger a RELOAD_ERROR event. * @private */ Engine.prototype._onReloadError = function (errors) { var windshaftError = this._getSimpleWindshaftError(errors); this._modelUpdater.setErrors(errors); // Trigger RELOAD_ERROR event this._eventEmmitter.trigger(Engine.Events.RELOAD_ERROR, windshaftError); return windshaftError; }; /** * Helper to get windhsaft request options. * @private */ Engine.prototype._buildWindshaftOptions = function (options, successCallback, errorCallback) { return _.extend({ includeFilters: true, success: successCallback, error: errorCallback }, _.pick(options, 'sourceId', 'forceFetch', 'includeFilters')); }; /** * Helper to get windhsaft request parameters. * @param {boolean} includeFilters - Boolean flag to control if the filters need to be added in the payload. * @private */ Engine.prototype._buildParams = function (includeFilters) { var params = {}; if (__ENV__ === 'production') { params.client = this._windshaftSettings.client; } if (includeFilters && !_.isEmpty(this._dataviewsCollection.getFilters())) { params.filters = this._dataviewsCollection.getFilters(); } if (this._windshaftSettings.apiKey) { params.api_key = this._windshaftSettings.apiKey; return params; } if (this._windshaftSettings.authToken) { params.auth_token = this._windshaftSettings.authToken; return params; } console.warn('Engine initialized with no apiKeys neither authToken'); }; /** * Reset the analysis nodes in the poller * @private */ Engine.prototype._restartAnalysisPolling = function () { var analysisNodes = AnalysisService.getUniqueAnalysisNodes(this._layersCollection, this._dataviewsCollection); this._analysisPoller.resetAnalysisNodes(analysisNodes); }; /** * Get the instance of the serializer service depending on is an anonymous or a named map. * @private */ Engine.prototype._getSerializer = function () { return this._isNamedMap ? NamedMapSerializer : AnonymousMapSerializer; }; /** * Manage and propagate the client error * @private */ Engine.prototype._manageClientError = function (windshaftError, windshaftOptions) { this._modelUpdater.setErrors([windshaftError]); windshaftOptions.error && windshaftOptions.error([windshaftError]); }; /** * Listen to errors in cartoLayerGroup */ Engine.prototype._bindCartoLayerGroupError = function () { this._cartoLayerGroup.on('all', function (change, error) { if (change.lastIndexOf('error:', 0) === 0) { error = new WindshaftError(error); this._eventEmmitter.trigger(Engine.Events.LAYER_ERROR, error); } }, this); }; Engine.prototype._getSimpleWindshaftError = function (errors) { var error = _.find(errors, function (error) { return error.isGlobalError(); }); if (!error && errors && errors.length > 0) { error = errors[0]; } return error; }; /** * Events fired by the engine * * @readonly * @enum {string} */ Engine.Events = { /** * Reload started event, fired every time the reload process starts. */ RELOAD_STARTED: 'reload-started', /** * Reload success event, fired every time the reload function succeed. */ RELOAD_SUCCESS: 'reload-success', /** * Reload error event, fired every time the reload function fails. */ RELOAD_ERROR: 'reload-error', /** * Error event, fired every time a tile or limit error happens. */ LAYER_ERROR: 'layer-error' }; module.exports = Engine; /** * Reload started event, fired every time the reload process starts. * * @event Engine#Engine:RELOAD_STARTED * @type {string} */ /** * Reload success event, fired every time the reload function succeed. * * @event Engine#Engine:RELOAD_SUCCESS * @type {string} */ /** * Reload success event, fired every time the reload function fails. * * @event Engine#Engine:RELOAD_ERROR * @type {string} */ /** * Layer group error event, fired every time an error with layer group happends (tile or limit). * * @event Engine#Engine:LAYER_ERROR * @type {string} */