Initial commit

This commit is contained in:
zhongjin
2020-06-13 18:34:34 +08:00
commit 52aaa9f15d
655 changed files with 96796 additions and 0 deletions

View File

@@ -0,0 +1,229 @@
var _ = require('underscore');
var Backbone = require('backbone');
var Model = require('../core/model');
var util = require('../core/util');
var REQUIRED_OPTS = [
'camshaftReference',
'engine'
];
var STATUS = {
PENDING: 'pending',
WAITING: 'waiting',
RUNNING: 'running',
FAILED: 'failed',
READY: 'ready'
};
var AnalysisModel = Model.extend({
initialize: function (attrs, opts) {
opts = opts || {};
util.checkRequiredOpts(opts, REQUIRED_OPTS, 'AnalysisModel');
this._camshaftReference = opts.camshaftReference;
this._engine = opts.engine;
this._initBinds();
// A hash that tracks which models (layers / dataviews) have
// this analysis model as their "source"
this._referencedBy = {};
},
url: function () {
var url = this.get('url');
if (url) {
if (this.get('apiKey')) {
url += '?api_key=' + this.get('apiKey');
} else if (this.get('authToken')) {
var authToken = this.get('authToken');
if (authToken instanceof Array) {
var tokens = _.map(authToken, function (token) {
return 'auth_token[]=' + token;
});
url += '?' + tokens.join('&');
} else {
url += '?auth_token=' + authToken;
}
}
return url;
}
},
setOk: function () {
this.unset('error');
},
setError: function (error) {
this.set({
error: error,
status: STATUS.FAILED
});
},
_initBinds: function () {
this.bind('change:type', function () {
this.unbind(null, null, this);
this._initBinds();
this._reload();
}, this);
_.each(this.getParamNames(), function (paramName) {
this.bind('change:' + paramName, this._reload, this);
}, this);
this.bind('change:status', function () {
// If the status changed from any other status to "ready"
// and this analysis is the "source" of any layer or dataview,
// vis has to be reloaded.
if (this._hadStatus() && this.isReady() && this.isSourceOfAnyModel()) {
this._reload();
}
}, this);
},
_hadStatus: function () {
return this.previous('status');
},
_reload: function () {
this._engine.reload({
error: this._onMapReloadError.bind(this)
});
},
_onMapReloadError: function () {
this.set('status', STATUS.FAILED);
},
remove: function () {
this.trigger('destroy', this);
this.stopListening();
},
findAnalysisById: function (analysisId) {
if (this.get('id') === analysisId) {
return this;
}
var sources = _.chain(this._getSourceNames())
.map(function (sourceName) {
var source = this.get(sourceName);
if (source) {
return source.findAnalysisById(analysisId);
}
}, this)
.compact()
.value();
return sources[0];
},
_getSourceNames: function () {
return this._camshaftReference.getSourceNamesForAnalysisType(this.get('type'));
},
isDone: function () {
return this._hasStatus([ STATUS.READY, STATUS.FAILED ]);
},
isLoading: function () {
return this._hasStatus([ STATUS.PENDING, STATUS.WAITING, STATUS.RUNNING ]);
},
isReady: function () {
return this._hasStatus(STATUS.READY);
},
isFailed: function () {
return this._hasStatus(STATUS.FAILED);
},
_hasStatus: function (statuses) {
if (!_.isArray(statuses)) {
statuses = [ statuses ];
}
return _.contains(statuses, this._getStatus());
},
_getStatus: function () {
return this.get('status');
},
toJSON: function () {
var json = _.pick(this.attributes, 'id', 'type');
json.params = _.pick(this.attributes, this.getParamNames());
var sourceNames = this._getSourceNames();
_.each(sourceNames, function (sourceName) {
var source = {};
var sourceInfo = this.get(sourceName);
if (sourceInfo) {
source[sourceName] = sourceInfo.toJSON();
_.extend(json.params, source);
}
}, this);
return json;
},
getParamNames: function () {
return this._camshaftReference.getParamNamesForAnalysisType(this.get('type'));
},
/**
* Return an Array with the complete node list for this analysis.
*/
getNodes: function () {
// Add current node to the list
var nodes = [this];
// Recursively iterate through the inputs ( source nodes have no inputs )
if (this.get('type') !== 'source') {
_.forEach(this._getSourceNames(), function (sourceName) {
var source = this.get(sourceName);
if (source) {
nodes = nodes.concat(source.getNodes());
}
}, this);
}
return nodes;
},
/**
* Return a Collection with the complete node list for this analysis.
*/
getNodesCollection: function () {
return new Backbone.Collection(this.getNodes());
},
/**
* Compare two analysisModels.
*/
equals: function (analysisModel) {
if (!(analysisModel instanceof AnalysisModel)) {
return false;
}
// Since all analysis are created using the analysisFactory different ids ensure different nodes.
return this.get('id') === analysisModel.get('id');
},
markAsSourceOf: function (model) {
this._referencedBy[model.cid] = true;
},
isSourceOfAnyModel: function () {
return Object.keys(this._referencedBy).length > 0;
},
isSourceOf: function (model) {
return !!this._referencedBy[model.cid];
},
unmarkAsSourceOf: function (model) {
delete this._referencedBy[model.cid];
}
}, {
STATUS: STATUS
});
module.exports = AnalysisModel;

View File

@@ -0,0 +1,60 @@
var _ = require('underscore');
var BackbonePoller = require('backbone-poller');
function AnalysisPoller () {
this._pollers = [];
}
AnalysisPoller.CONFIG = {
START_DELAY: 1000,
MAX_DELAY: Infinity,
DELAY_MULTIPLIER: 1.5
};
AnalysisPoller.prototype.resetAnalysisNodes = function (analysisModels) {
this.reset();
_.each(analysisModels, function (analysisModel) {
this._poll(analysisModel);
}, this);
};
AnalysisPoller.prototype._poll = function (analysisModel) {
if (this._canBePolled(analysisModel)) {
var poller = this._createPoller(analysisModel);
poller.start();
}
};
AnalysisPoller.prototype._canBePolled = function (analysisModel) {
return analysisModel.url() && !analysisModel.isDone();
};
AnalysisPoller.prototype._findPoller = function (analysisModel) {
return _.find(this._pollers, function (poller) {
return poller.model === analysisModel;
});
};
AnalysisPoller.prototype._createPoller = function (analysisModel) {
var pollerOptions = {
delay: [
AnalysisPoller.CONFIG.START_DELAY,
AnalysisPoller.CONFIG.MAX_DELAY,
AnalysisPoller.CONFIG.DELAY_MULTIPLIER
],
condition: function (analysisModel) {
return !analysisModel.isDone();
}
};
var poller = BackbonePoller.get(analysisModel, pollerOptions);
this._pollers.push(poller);
return poller;
};
AnalysisPoller.prototype.reset = function () {
BackbonePoller.reset();
this._pollers = [];
};
module.exports = AnalysisPoller;

View File

@@ -0,0 +1,155 @@
var _ = require('underscore');
var Backbone = require('backbone');
var Analysis = require('./analysis-model');
var camshaftReference = require('./camshaft-reference');
var LayerTypes = require('../geo/map/layer-types.js');
var AnalysisService = function (opts) {
opts = opts || {};
if (!opts.engine) {
throw new Error('engine is required');
}
this._engine = opts.engine;
this._apiKey = opts.apiKey;
this._authToken = opts.authToken;
this._camshaftReference = opts.camshaftReference || camshaftReference; // For testing purposes
this._analysisNodes = new Backbone.Collection();
};
/**
* Recursively generates a graph of analyses and returns the "root" node.
* For each node definition in the analysisDefinition:
* - If a node had been already created this method updates the attributes of the existing node.
* - Otherwise create a new node and index it by id into the `_analysisNodes` object.
*/
AnalysisService.prototype.analyse = function (analysisDefinition) {
analysisDefinition = _.clone(analysisDefinition);
var analysis = this.findNodeById(analysisDefinition.id);
var analysisAttrs = this._getAnalysisAttributesFromAnalysisDefinition(analysisDefinition);
if (analysis) {
analysis.set(analysisAttrs);
} else {
if (this._apiKey) {
analysisAttrs.apiKey = this._apiKey;
}
if (this._authToken) {
analysisAttrs.authToken = this._authToken;
}
analysis = new Analysis(analysisAttrs, {
camshaftReference: this._camshaftReference,
engine: this._engine
});
this._analysisNodes.add(analysis);
analysis.bind('destroy', this._onAnalysisRemoved, this);
}
return analysis;
};
/**
* This function is used to iterate over the analysis graph.
* It uses the camshaft reference to extract those parameters which are analysis nodes. And call analyse on them.
*
* This function wont be needed if we split the analysis definition in `params` and `inputs`. Where all analysis
* are garanted to be in the inputs object.
*/
AnalysisService.prototype._getAnalysisAttributesFromAnalysisDefinition = function (analysisDefinition) {
var analysisNodes = {};
var analysisType = analysisDefinition.type;
var sourceNamesForAnalysisType = this._camshaftReference.getSourceNamesForAnalysisType(analysisType);
_.each(sourceNamesForAnalysisType, function (sourceName) {
var sourceParams = analysisDefinition.params[sourceName];
if (sourceParams) {
analysisNodes[sourceName] = this.analyse(sourceParams);
}
}, this);
return _.omit(_.extend(analysisDefinition, analysisDefinition.params, analysisNodes), 'params');
};
/**
* Create a source analysis
* This function is used because some legacy viz.json files contains layers without `source` and have a `query` field instead.
* This query is translated into a analysis of type `source`.
*/
AnalysisService.prototype.createAnalysisForLayer = function (layerId, layerQuery) {
return this.analyse({
id: layerId,
type: 'source',
params: {
query: layerQuery
}
});
};
AnalysisService.prototype.findNodeById = function (id) {
return this._analysisNodes.get(id);
};
AnalysisService.prototype._onAnalysisRemoved = function (analysis) {
this._analysisNodes.remove(analysis);
analysis.unbind('destroy', this._onAnalysisRemoved);
};
/**
* Return all the analysis nodes without duplicates.
* The analyses are obtained from the layers and dataviews collections.
* @example
* We have the following analyses: (a0->a1->a2), (b0->a2)
* This method will give us: (a0->a1->a2), (a1->a2), (a2), (b0->a2)
*/
AnalysisService.getUniqueAnalysisNodes = function (layersCollection, dataviewsCollection) {
var uniqueAnalysisNodes = {};
var analysisList = AnalysisService.getAnalysisList(layersCollection, dataviewsCollection);
_.each(analysisList, function (analysis) {
analysis.getNodesCollection().each(function (analysisNode) {
uniqueAnalysisNodes[analysisNode.get('id')] = analysisNode;
});
});
return _.map(uniqueAnalysisNodes, function (analisis) { return analisis; }, this);
};
/**
* Return a list with all the analyses contained in the given collections.
* @example
* We have the following analyses: (a0->a1->a2), (b0->a2)
* This method will give us: (a0->a1->a2), (b0->a2)
*/
AnalysisService.getAnalysisList = function (layersCollection, dataviewsCollection) {
var layerAnalyses = _getAnalysesFromLayers(layersCollection);
var dataviewsAnalyses = _getAnalysesFromDataviews(dataviewsCollection);
return layerAnalyses.concat(dataviewsAnalyses);
};
function _getAnalysesFromLayers (layersCollection) {
var layers = _getCartoDBAndTorqueLayers(layersCollection);
return _.chain(layers)
.map(function (layer) {
return layer.getSource();
})
.compact()
.value();
}
function _getAnalysesFromDataviews (dataviewsCollection) {
return dataviewsCollection.chain()
.map(function (dataview) {
return dataview.getSource();
})
.compact()
.value();
}
function _getCartoDBAndTorqueLayers (layersCollection) {
return layersCollection.filter(function (layer) {
// Carto and torque layers are supposed to have a source
return LayerTypes.isCartoDBLayer(layer) || LayerTypes.isTorqueLayer(layer);
});
}
module.exports = AnalysisService;

View File

@@ -0,0 +1,51 @@
var camshaftReference = require('camshaft-reference').getVersion('latest');
var PARAM_TYPES = {
NODE: 'node',
NUMBER: 'number',
STRING: 'string',
ENUM: 'enum'
};
var SOURCE_ANALYSIS_TYPE = 'source';
var ANALYSIS_TYPE_TO_SOURCE_PARAM_NAMES_MAP = {};
ANALYSIS_TYPE_TO_SOURCE_PARAM_NAMES_MAP[SOURCE_ANALYSIS_TYPE] = [];
var ANALYSIS_TYPE_TO_PARAM_NAMES_MAP = {};
var analysesReference = camshaftReference.analyses;
if (!analysesReference) {
throw new Error('Error loading the reference for Camshaft analyses');
}
// Populate the analysis source and param names maps.
for (var analysisType in analysesReference) {
var analysisParams = analysesReference[analysisType].params;
for (var paramName in analysisParams) {
ANALYSIS_TYPE_TO_PARAM_NAMES_MAP[analysisType] = ANALYSIS_TYPE_TO_PARAM_NAMES_MAP[analysisType] || [];
ANALYSIS_TYPE_TO_PARAM_NAMES_MAP[analysisType].push(paramName);
ANALYSIS_TYPE_TO_SOURCE_PARAM_NAMES_MAP[analysisType] = ANALYSIS_TYPE_TO_SOURCE_PARAM_NAMES_MAP[analysisType] || [];
var paramType = analysisParams[paramName].type;
if (paramType === PARAM_TYPES.NODE) {
ANALYSIS_TYPE_TO_SOURCE_PARAM_NAMES_MAP[analysisType].push(paramName);
}
}
}
module.exports = {
getSourceNamesForAnalysisType: function (analysisType) {
var sourceNames = ANALYSIS_TYPE_TO_SOURCE_PARAM_NAMES_MAP[analysisType];
if (!sourceNames) {
throw new Error('source names for analysis of type ' + analysisType + " couldn't be found");
}
return sourceNames;
},
getParamNamesForAnalysisType: function (analysisType) {
var paramNames = ANALYSIS_TYPE_TO_PARAM_NAMES_MAP[analysisType];
if (!paramNames) {
throw new Error('param names for analysis of type ' + analysisType + " couldn't be found");
}
return paramNames;
}
};

171
src/api/create-vis.js Normal file
View File

@@ -0,0 +1,171 @@
var _ = require('underscore');
var VisView = require('../vis/vis-view');
var VisModel = require('../vis/vis');
var Loader = require('../core/loader');
var VizJSON = require('./vizjson');
var DEFAULT_OPTIONS = {
tiles_loader: true,
loaderControl: true,
infowindow: true, // TODO: it seems that this is no longer used
tooltip: true, // TODO: it seems that this is no longer used
logo: true,
show_empty_infowindow_fields: false,
showLimitErrors: false,
interactiveFeatures: false
};
var createVis = function (el, vizjson, options) {
if (typeof el === 'string') {
el = document.getElementById(el);
}
if (!el) {
throw new TypeError('a valid DOM element or selector must be provided');
}
if (!vizjson) {
throw new TypeError('a vizjson URL or object must be provided');
}
var isProtocolHTTPs = window && window.location.protocol && window.location.protocol === 'https:';
options = _.defaults(options || {}, DEFAULT_OPTIONS);
var visModel = new VisModel({
apiKey: options.apiKey,
authToken: options.authToken,
showEmptyInfowindowFields: options.show_empty_infowindow_fields === true,
showLimitErrors: options.showLimitErrors === true,
https: isProtocolHTTPs || options.https === true,
interactiveFeatures: options.interactiveFeatures
});
new VisView({ // eslint-disable-line
el: el,
model: visModel,
settingsModel: visModel.settings
});
if (typeof vizjson === 'string') {
var url = vizjson;
Loader.get(url, function (vizjson) {
if (vizjson) {
loadVizJSON(el, visModel, vizjson, options);
} else {
throw new Error('error fetching viz.json file');
}
});
} else {
loadVizJSON(el, visModel, vizjson, options);
}
return visModel;
};
var loadVizJSON = function (el, visModel, vizjsonData, options) {
var vizjson = new VizJSON(vizjsonData);
applyOptionsToVizJSON(vizjson, options);
var showLegends = true;
if (_.isBoolean(options.legends)) {
showLegends = options.legends;
} else if (vizjson.options && _.isBoolean(vizjson.options.legends)) {
showLegends = vizjson.options.legends;
}
var showLayerSelector = true;
if (_.isBoolean(options.layer_selector)) {
showLayerSelector = options.layer_selector;
} else if (vizjson.options && _.isBoolean(vizjson.options.layer_selector)) {
showLayerSelector = vizjson.options.layer_selector;
}
var layerSelectorEnabled = true;
if (_.isBoolean(options.layerSelectorEnabled)) {
layerSelectorEnabled = options.layerSelectorEnabled;
}
visModel.set({
title: vizjson.title,
description: vizjson.description,
https: visModel.get('https') || vizjson.https === true
});
visModel.setSettings({
showLegends: showLegends,
showLayerSelector: showLayerSelector,
layerSelectorEnabled: layerSelectorEnabled
});
visModel.load(vizjson);
if (!options.skipMapInstantiation) {
visModel.instantiateMap();
}
};
var applyOptionsToVizJSON = function (vizjson, options) {
vizjson.options = vizjson.options || {};
vizjson.options.scrollwheel = _.isBoolean(options.scrollwheel) ? options.scrollwheel : vizjson.options.scrollwheel;
if (!options.tiles_loader || !options.loaderControl) {
vizjson.removeLoaderOverlay();
}
if (options.searchControl === true) {
vizjson.addSearchOverlay();
} else if (options.searchControl === false) {
vizjson.removeSearchOverlay();
}
if ((options.title && vizjson.title) || (options.description && vizjson.description)) {
vizjson.addHeaderOverlay(options.title, options.description, options.shareable);
}
if (options.zoomControl !== undefined && !options.zoomControl) {
vizjson.removeZoomOverlay();
}
if (options.logo === false) {
vizjson.removeLogoOverlay();
}
if (_.has(options, 'vector')) {
vizjson.setVector(options.vector);
}
// if bounds are present zoom and center will not taken into account
var zoom = parseInt(options.zoom, 10);
if (!isNaN(zoom)) {
vizjson.setZoom(zoom);
}
// Center coordinates?
var centerLat = parseFloat(options.center_lat);
var centerLon = parseFloat(options.center_lon);
if (!isNaN(centerLat) && !isNaN(centerLon)) {
vizjson.setCenter([centerLat, centerLon]);
}
// Center object
if (options.center !== undefined) {
vizjson.setCenter(options.center);
}
// Bounds?
var swLat = parseFloat(options.sw_lat);
var swLon = parseFloat(options.sw_lon);
var neLat = parseFloat(options.ne_lat);
var neLon = parseFloat(options.ne_lon);
if (!isNaN(swLat) && !isNaN(swLon) && !isNaN(neLat) && !isNaN(neLon)) {
vizjson.setBounds([
[ swLat, swLon ],
[ neLat, neLon ]
]);
}
if (options.gmaps_base_type) {
vizjson.enforceGMapsBaseLayer(options.gmaps_base_type, options.gmaps_style);
}
};
module.exports = createVis;

17
src/api/promise.js Normal file
View File

@@ -0,0 +1,17 @@
var _ = require('underscore');
var Backbone = require('backbone');
// NOTE only for usage in non-core bundles (where Backbone is available)
function Promise () {
}
_.extend(Promise.prototype, Backbone.Events, {
done: function (fn) {
return this.bind('done', fn);
},
error: function (fn) {
return this.bind('error', fn);
}
});
module.exports = Promise;

704
src/api/sql.js Normal file
View File

@@ -0,0 +1,704 @@
var _ = require('underscore');
var $ = require('jquery');
var Mustache = require('mustache');
var Promise = require('./promise');
var NO_BOUNDS_ERROR_MESSAGE = 'No bounds';
// Variable that defines if a query should be using get method or post method
var MAX_LENGTH_GET_QUERY = 1024;
function SQL (options) {
if (window.cdb === this || window === this) {
return new SQL(options);
}
if (!options.user) {
throw new Error('user should be provided');
}
var loc = String(window.location.protocol);
loc = loc.slice(0, loc.length - 1);
if (loc === 'file') {
loc = 'https';
}
this.options = _.defaults(options, {
version: 'v2',
protocol: loc,
jsonp: !$.support.cors,
abortable: false
});
if (!this.options.sql_api_template) {
var opts = this.options;
var template = null;
if (opts && opts.completeDomain) {
template = opts.completeDomain;
} else {
var host = opts.host || 'carto.com';
var protocol = opts.protocol || 'https';
template = protocol + '://{user}.' + host;
}
this.options.sql_api_template = template;
}
}
SQL.prototype._host = function () {
var opts = this.options;
return opts.sql_api_template.replace('{user}', opts.user) + '/api/' + opts.version + '/sql';
};
/**
* var sql = new SQL('cartodb_username');
* sql.execute("select * from {{ table }} where id = {{ id }}", {
* table: 'test',
* id: '1'
* })
*/
SQL.prototype.execute = function (sql, vars, options, callback) {
var promise = new Promise();
if (!sql) {
throw new TypeError('sql should not be null');
}
// setup arguments
var args = arguments;
var fn = args[args.length - 1];
if (_.isFunction(fn)) {
callback = fn;
}
options = _.defaults(options || {}, this.options);
var params = {
type: 'get',
dataType: 'json',
crossDomain: true
};
if (this._xhr && this.options.abortable === true) {
this._xhr.abort();
}
if (options.cache !== undefined) {
params.cache = options.cache;
}
if (options.jsonp) {
delete params.crossDomain;
if (options.jsonpCallback) {
params.jsonpCallback = options.jsonpCallback;
}
params.dataType = 'jsonp';
}
// Substitute mapnik tokens
// resolution at zoom level 0
var res = '156543.03515625';
// full webmercator extent
var ext = 'ST_MakeEnvelope(-20037508.5,-20037508.5,20037508.5,20037508.5,3857)';
sql = sql.replace('!bbox!', ext)
.replace('!pixel_width!', res)
.replace('!pixel_height!', res);
// create query
var query = Mustache.render(sql, vars);
// check method: if we are going to send by get or by post
var isGetRequest = query.length < MAX_LENGTH_GET_QUERY;
// generate url depending on the http method
var reqParams = ['format', 'dp', 'api_key'];
// request params
if (options.extra_params) {
reqParams = reqParams.concat(options.extra_params);
}
params.url = this._host();
var i, r, v;
if (isGetRequest) {
var q = 'q=' + encodeURIComponent(query);
for (i in reqParams) {
r = reqParams[i];
v = options[r];
if (v != null) {
q += '&' + r + '=' + v;
}
}
params.url += '?' + q;
} else {
var objPost = {'q': query};
for (i in reqParams) {
r = reqParams[i];
v = options[r];
if (v != null) {
objPost[r] = v;
}
}
params.data = objPost;
params.type = 'post';
}
// wrap success and error functions
var success = options.success;
var error = options.error;
if (success) delete options.success;
if (error) delete error.success;
params.error = function (resp) {
var res = resp.responseText || resp.response;
var errors = res && JSON.parse(res);
promise.trigger('error', errors && errors.error, resp);
if (error) error(resp);
if (callback) callback(resp);
};
params.success = function (resp, status, xhr) {
// manage rewest
if (status === undefined) {
status = resp.status;
xhr = resp;
resp = JSON.parse(resp.response);
}
// Timeout explanation. CartoDB.js ticket #336
// From St.Ov.: "what setTimeout does is add a new event to the browser event queue
// and the rendering engine is already in that queue (not entirely true, but close enough)
// so it gets executed before the setTimeout event."
setTimeout(function () {
promise.trigger('done', resp, status, xhr);
if (success) success(resp, status, xhr);
if (callback) callback(null, resp);
}, 0);
};
params.complete = function () {
this._xhr = null;
}.bind(this);
// call ajax
delete options.jsonp;
this._xhr = $.ajax(_.extend(params, options));
return promise;
};
SQL.prototype.getBounds = function (sql, vars, options, callback) {
var promise = new Promise();
var args = arguments;
var fn = args[args.length - 1];
if (_.isFunction(fn)) {
callback = fn;
}
var s = 'SELECT ST_XMin(ST_Extent(the_geom)) as minx,' +
' ST_YMin(ST_Extent(the_geom)) as miny,' +
' ST_XMax(ST_Extent(the_geom)) as maxx,' +
' ST_YMax(ST_Extent(the_geom)) as maxy' +
' from ({{{ sql }}}) as subq';
sql = Mustache.render(sql, vars);
this.execute(s, { sql: sql }, options)
.done(function (result) {
if (result.rows && result.rows.length > 0 && result.rows[0].maxx != null) {
var c = result.rows[0];
var minlat = -85.0511;
var maxlat = 85.0511;
var minlon = -179;
var maxlon = 179;
var clamp = function (x, min, max) {
return x < min ? min : x > max ? max : x;
};
var lon0 = clamp(c.maxx, minlon, maxlon);
var lon1 = clamp(c.minx, minlon, maxlon);
var lat0 = clamp(c.maxy, minlat, maxlat);
var lat1 = clamp(c.miny, minlat, maxlat);
var bounds = [[lat0, lon0], [lat1, lon1]];
promise.trigger('done', bounds);
callback && callback(null, bounds);
} else {
var err = [NO_BOUNDS_ERROR_MESSAGE];
promise.trigger('error', err);
callback && callback(err);
}
})
.error(function (err) {
promise.trigger('error', err);
callback && callback(err);
});
return promise;
};
/**
* var people_under_10 = sql
* .table('test')
* .columns(['age', 'column2'])
* .filter('age < 10')
* .limit(15)
* .order_by('age')
*
* people_under_10(function(results) {
* })
*/
SQL.prototype.table = function (name) {
var _name = name;
var _filters;
var _columns = [];
var _limit;
var _order;
var _orderDir;
var _sql = this;
function _table () {
_table.fetch.apply(_table, arguments);
}
_table.fetch = function (vars) {
vars = vars || {};
var args = arguments;
var fn = args[args.length - 1];
if (_.isFunction(fn) && args.length === 1) {
vars = {};
}
_sql.execute(_table.sql(), vars, fn);
};
_table.sql = function () {
var s = 'select';
if (_columns.length) {
s += ' ' + _columns.join(',') + ' ';
} else {
s += ' * ';
}
s += 'from ' + _name;
if (_filters) {
s += ' where ' + _filters;
}
if (_limit) {
s += ' limit ' + _limit;
}
if (_order) {
s += ' order by ' + _order;
}
if (_orderDir) {
s += ' ' + _orderDir;
}
return s;
};
_table.filter = function (f) {
_filters = f;
return _table;
};
_table.order_by = function (o) {
_order = o;
return _table;
};
_table.asc = function () {
_orderDir = 'asc';
return _table;
};
_table.desc = function () {
_orderDir = 'desc';
return _table;
};
_table.columns = function (c) {
_columns = c;
return _table;
};
_table.limit = function (l) {
_limit = l;
return _table;
};
return _table;
};
/*
* sql.filter(sql.f().distance('< 10km')
*/
/* SQL.geoFilter = function() {
var _sql;
function f() {}
f.distance = function(qty) {
qty.replace('km', '*1000')
_sql += 'st_distance(the_geom) ' + qty
}
f.or = function() {
}
f.and = function() {
}
return f;
}
*/
function arrayAgg (s) {
return JSON.parse(s.replace(/^{/, '[').replace(/}$/, ']'));
}
SQL.prototype.describeString = function (sql, column, callback) {
var s = [
'WITH t as (',
' SELECT count(*) as total,',
' count(DISTINCT {{column}}) as ndist',
' FROM ({{sql}}) _wrap',
' ), a as (',
' SELECT ',
' count(*) cnt, ',
' {{column}}',
' FROM ',
' ({{sql}}) _wrap ',
' GROUP BY ',
' {{column}} ',
' ORDER BY ',
' cnt DESC',
' ), b As (',
' SELECT',
' row_number() OVER (ORDER BY cnt DESC) rn,',
' cnt',
' FROM a',
' ), c As (',
' SELECT ',
' sum(cnt) OVER (ORDER BY rn ASC) / t.total cumperc,',
' rn,',
' cnt ',
' FROM b, t',
' LIMIT 10',
' ),',
'stats as (',
'select count(distinct({{column}})) as uniq, ',
' count(*) as cnt, ',
' sum(case when COALESCE(NULLIF({{column}},\'\')) is null then 1 else 0 end)::numeric as null_count, ',
' sum(case when COALESCE(NULLIF({{column}},\'\')) is null then 1 else 0 end)::numeric / count(*)::numeric as null_ratio, ',
// ' CDB_DistinctMeasure(array_agg({{column}}::text)) as cat_weight ',
' (SELECT max(cumperc) weight FROM c) As skew ',
'from ({{sql}}) __wrap',
'),',
'hist as (',
'select array_agg(row(d, c)) array_agg from (select distinct({{column}}) d, count(*) as c from ({{sql}}) __wrap, stats group by 1 limit 100) _a',
')',
'select * from stats, hist'
];
var query = Mustache.render(s.join('\n'), {
column: column,
sql: sql
});
var normalizeName = function (str) {
var normalizedStr = str.replace(/^"(.+(?="$))?"$/, '$1'); // removes surrounding quotes
return normalizedStr.replace(/""/g, '"'); // removes duplicated quotes
};
this.execute(query, function (err, data) {
if (err) {
callback(err);
return;
}
var row = data.rows[0];
var weight = 0;
var histogram = [];
try {
var s = arrayAgg(row.array_agg);
histogram = _(s).map(function (row) {
var r = row.match(/\((.*),(\d+)/);
var name = normalizeName(r[1]);
return [name, +r[2]];
});
weight = row.skew * (1 - row.null_ratio) * (1 - row.uniq / row.cnt) * (row.uniq > 1 ? 1 : 0);
} catch (e) {
}
callback(null, {
type: 'string',
hist: histogram,
distinct: row.uniq,
count: row.cnt,
null_count: row.null_count,
null_ratio: row.null_ratio,
skew: row.skew,
weight: weight
});
});
};
SQL.prototype.describeDate = function (sql, column, callback) {
var s = [
'with minimum as (',
'SELECT min({{column}}) as start_time FROM ({{sql}}) _wrap), ',
'maximum as (SELECT max({{column}}) as end_time FROM ({{sql}}) _wrap), ',
'null_ratio as (SELECT sum(case when {{column}} is null then 1 else 0 end)::numeric / count(*)::numeric as null_ratio FROM ({{sql}}) _wrap), ',
'moments as (SELECT count(DISTINCT {{column}}) as moments FROM ({{sql}}) _wrap)',
'SELECT * FROM minimum, maximum, moments, null_ratio'
];
var query = Mustache.render(s.join('\n'), {
column: column,
sql: sql
});
this.execute(query, function (err, data) {
if (err) {
callback(err);
return;
}
var row = data.rows[0];
var e = new Date(row.end_time);
var s = new Date(row.start_time);
var steps = Math.min(row.moments, 1024);
callback(null, {
type: 'date',
start_time: s,
end_time: e,
range: e - s,
steps: steps,
null_ratio: row.null_ratio
});
});
};
SQL.prototype.describeBoolean = function (sql, column, callback) {
var s = [
'with stats as (',
'select count(distinct({{column}})) as uniq,',
'count(*) as cnt',
'from ({{sql}}) _wrap ',
'),',
'null_ratio as (',
'SELECT sum(case when {{column}} is null then 1 else 0 end)::numeric / count(*)::numeric as null_ratio FROM ({{sql}}) _wrap), ',
'true_ratio as (',
'SELECT sum(case when {{column}} is true then 1 else 0 end)::numeric / count(*)::numeric as true_ratio FROM ({{sql}}) _wrap) ',
'SELECT * FROM true_ratio, null_ratio, stats'
];
var query = Mustache.render(s.join('\n'), {
column: column,
sql: sql
});
this.execute(query, function (err, data) {
if (err) {
callback(err);
return;
}
var row = data.rows[0];
callback(null, {
type: 'boolean',
null_ratio: row.null_ratio,
true_ratio: row.true_ratio,
distinct: row.uniq,
count: row.cnt
});
});
};
SQL.prototype.describeGeom = function (sql, column, callback) {
var s = [
'with geotype as (',
'select st_geometrytype({{column}}) as geometry_type from ({{sql}}) _w where {{column}} is not null limit 1',
')',
'select * from geotype'
];
var query = Mustache.render(s.join('\n'), {
column: column,
sql: sql
});
function simplifyType (g) {
return {
'st_multipolygon': 'polygon',
'st_polygon': 'polygon',
'st_multilinestring': 'line',
'st_linestring': 'line',
'st_multipoint': 'point',
'st_point': 'point'
}[g.toLowerCase()];
}
this.execute(query, function (err, data) {
if (err) {
callback(err);
return;
}
var row = data.rows[0];
callback(null, {
type: 'geom',
// lon,lat -> lat, lon
geometry_type: row.geometry_type,
simplified_geometry_type: simplifyType(row.geometry_type)
});
});
};
SQL.prototype.columns = function (sql, options, callback) {
var args = arguments;
var fn = args[args.length - 1];
if (_.isFunction(fn)) {
callback = fn;
}
var s = 'select * from (' + sql + ') __wrap limit 0';
var exclude = ['cartodb_id', 'latitude', 'longitude', 'created_at', 'updated_at', 'lat', 'lon', 'the_geom_webmercator'];
this.execute(s, function (err, data) {
if (err) {
callback(err);
return;
}
var t = {};
for (var i in data.fields) {
if (exclude.indexOf(i) === -1) {
t[i] = data.fields[i].type;
}
}
callback(null, t);
});
};
SQL.prototype.describeFloat = function (sql, column, callback) {
var s = [
'with stats as (',
'select min({{column}}) as min,',
'max({{column}}) as max,',
'avg({{column}}) as avg,',
'count(DISTINCT {{column}}) as cnt,',
'count(distinct({{column}})) as uniq,',
'count(*) as cnt,',
'sum(case when {{column}} is null then 1 else 0 end)::numeric / count(*)::numeric as null_ratio,',
'stddev_pop({{column}}) / count({{column}}) as stddev,',
'CASE WHEN abs(avg({{column}})) > 1e-7 THEN stddev({{column}}) / abs(avg({{column}})) ELSE 1e12 END as stddevmean,',
'CDB_DistType(array_agg("{{column}}"::numeric)) as dist_type ',
'from ({{sql}}) _wrap ',
'),',
'params as (select min(a) as min, (max(a) - min(a)) / 7 as diff from ( select {{column}} as a from ({{sql}}) _table_sql where {{column}} is not null ) as foo ),',
'histogram as (',
'select array_agg(row(bucket, range, freq)) as hist from (',
'select CASE WHEN uniq > 1 then width_bucket({{column}}, min-0.01*abs(min), max+0.01*abs(max), 100) ELSE 1 END as bucket,',
'numrange(min({{column}})::numeric, max({{column}})::numeric) as range,',
'count(*) as freq',
'from ({{sql}}) _w, stats',
'group by 1',
'order by 1',
') __wrap',
'),',
'hist as (',
'select array_agg(row(d, c)) cat_hist from (select distinct({{column}}) d, count(*) as c from ({{sql}}) __wrap, stats group by 1 limit 100) _a',
'),',
'buckets as (',
'select CDB_QuantileBins(array_agg(distinct({{column}}::numeric)), 7) as quantiles, ',
' (select array_agg(x::numeric) FROM (SELECT (min + n * diff)::numeric as x FROM generate_series(1,7) n, params) p) as equalint,',
// ' CDB_EqualIntervalBins(array_agg({{column}}::numeric), 7) as equalint, ',
' CDB_JenksBins(array_agg(distinct({{column}}::numeric)), 7) as jenks, ',
' CDB_HeadsTailsBins(array_agg(distinct({{column}}::numeric)), 7) as headtails ',
'from ({{sql}}) _table_sql where {{column}} is not null',
')',
'select * from histogram, stats, buckets, hist'
];
var query = Mustache.render(s.join('\n'), {
column: column,
sql: sql
});
this.execute(query, function (err, data) {
if (err) {
callback(err);
return;
}
var row = data.rows[0];
var s = arrayAgg(row.hist);
var h = arrayAgg(row.cat_hist);
callback(null, {
type: 'number',
cat_hist:
_(h).map(function (row) {
var r = row.match(/\((.*),(\d+)/);
return [+r[1], +r[2]];
}),
hist: _(s).map(function (row) {
if (row.indexOf('empty') > -1) return;
var els = row.split('"');
return { index: els[0].replace(/\D/g, ''),
range: els[1].split(',').map(function (d) { return d.replace(/\D/g, ''); }),
freq: els[2].replace(/\D/g, '') };
}),
stddev: row.stddev,
null_ratio: row.null_ratio,
count: row.cnt,
distinct: row.uniq,
// lstddev: row.lstddev,
avg: row.avg,
max: row.max,
min: row.min,
stddevmean: row.stddevmean,
weight: (row.uniq > 1 ? 1 : 0) * (1 - row.null_ratio) * (row.stddev < -1 ? 1 : (row.stddev < 1 ? 0.5 : (row.stddev < 3 ? 0.25 : 0.1))),
quantiles: row.quantiles,
equalint: row.equalint,
jenks: row.jenks,
headtails: row.headtails,
dist_type: row.dist_type
});
});
};
// describe a column
SQL.prototype.describe = function (sql, column, options) {
var self = this;
var args = arguments;
var fn = args[args.length - 1];
if (_.isFunction(fn)) {
var _callback = fn;
}
var callback = function (err, data) {
if (err) {
_callback(err);
return;
}
data.column = column;
_callback(null, data);
};
var s = 'select * from (' + sql + ') __wrap limit 0';
this.execute(s, function (err, data) {
if (err) {
callback(err);
return;
}
var type = (options && options.type) ? options.type : data.fields[column].type;
if (!type) {
callback(new Error('column does not exist'));
} else if (type === 'string') {
self.describeString(sql, column, callback);
} else if (type === 'number') {
self.describeFloat(sql, column, callback);
} else if (type === 'geometry') {
self.describeGeom(sql, column, callback);
} else if (type === 'date') {
self.describeDate(sql, column, callback);
} else if (type === 'boolean') {
self.describeBoolean(sql, column, callback);
} else {
callback(new Error('column type is not supported'));
}
});
};
module.exports = SQL;

47
src/api/v4/README.md Normal file
View File

@@ -0,0 +1,47 @@
# V4 API
This folder contains the source files used in the `v4 public api`.
This api build using wrappers over the internal objects, those wrappers have an easy-to-use public methods,
the reference to the internal objects can be obtained with the `$getInternalModel` method.
The `$` before a method name is a naming convention. Those methods shall not be exposed in the public API
but can be used from different files. (Public only for developers).
## Api structure
All the api methods and objects are exposed through the public `carto` object.
- `carto.client` : The main object used in a CARTO.js app
- `carto.source`: Namespace for the sources.
- `Dataset`: Get all the data from a table
- `SQL`: Get the data from a custom SQL query
- `carto.style`: Namespace for the styles
- `CartoCSS`: Constructor to build layer styles.
- `carto.layer` : Namespace for the layers
- `Layer`: Constructor to build a Layer object
- `carto.dataview` : Namespace for the dataviews
- `Formula`: Constructor to build a Formula dataview
- `Category`: Constructor to build a Category dataview
- `Histogram`: Constructor to build a Histogram dataview
- `Time Series`: Constructor to build a Time Series dataview
- `carto.filter` : Namespace for the filters
- `BoundingBox`: Constructor to build a BoundingBox filter
- `BoundingBoxLeaflet`: Constructor to build a BoundingBoxLeaflet filter
- `carto.operation` : Enum with the operations available.
- `carto.dataview.status` : Enum with the dataview statuses available.
## Usage
### Common.js
```javascript
const carto = require('cartojs');
```
### Loading CARTO.js from a CDN
```javascript
window.carto; // All the api is available here
```

521
src/api/v4/client.js Normal file
View File

@@ -0,0 +1,521 @@
var _ = require('underscore');
var Backbone = require('backbone');
var CartoError = require('./error-handling/carto-error');
var Engine = require('../../engine');
var Events = require('./events');
var LayerBase = require('./layer/base');
var Layers = require('./layers');
var VERSION = require('../../../package.json').version;
var CartoValidationError = require('./error-handling/carto-validation-error');
var utils = require('../../core/util');
function getValidationError (code) {
return new CartoValidationError('client', code);
}
const DEFAULT_SERVER_URL = 'https://{username}.carto.com';
/**
* This is the entry point for a CARTO.js application.
*
* A CARTO client allows managing layers and dataviews. Some operations like addding a layer or a dataview are asynchronous.
* The client takes care of the communication between CARTO.js and the server for you.
*
* To create a new client you need a CARTO account, where you will be able to get
* your API key and username.
*
* If you want to learn more about authorization and authentication, please read the authorization fundamentals section of our Developer Center.
*
* @param {object} settings
* @param {string} settings.apiKey - API key used to authenticate against CARTO
* @param {string} settings.username - Name of the user
* @param {string} [settings.serverUrl='https://{username}.carto.com'] - URL of the windshaft server. Only needed in custom installations. Pattern: `http(s)://{username}.your.carto.instance` or `http(s)://your.carto.instance/user/{username}` (only for On-Premises environments).
*
* @example
* var client = new carto.Client({
* apiKey: 'YOUR_API_KEY_HERE',
* username: 'YOUR_USERNAME_HERE'
* });
*
* var client = new carto.Client({
* apiKey: 'YOUR_API_KEY_HERE',
* username: 'YOUR_USERNAME_HERE',
* serverUrl: 'http://{username}.your.carto.instance'
* });
*
* @constructor
* @memberof carto
* @api
*
* @fires error
* @fires success
*/
function Client (settings) {
settings.serverUrl = (settings.serverUrl || DEFAULT_SERVER_URL).replace(/{username}/, settings.username || '');
_checkSettings(settings);
this._layers = new Layers();
this._dataviews = [];
this._engine = new Engine({
apiKey: settings.apiKey,
username: settings.username,
serverUrl: settings.serverUrl,
client: 'js-' + VERSION
});
this._bindEngine(this._engine);
}
_.extend(Client.prototype, Backbone.Events);
/**
* Add a layer to the client.
* If the layer id already exists in the client this method will throw an error.
*
* @param {carto.layer.Base} - The layer to be added
*
* @fires error
* @fires success
*
* @example
* // Add a layer to the client
* client.addLayer(layer)
* .then(() => {
* console.log('Layer added');
* })
* .catch(cartoError => {
* console.error(cartoError.message);
* });
*
* @returns {Promise} - A promise that will be fulfilled when the layer is added
* @api
*/
Client.prototype.addLayer = function (layer) {
return this.addLayers([layer]);
};
/**
* Add multiple layers to the client at once.
*
* @param {carto.layer.Base[]} - An array with the layers to be added. Note that ([A, B]) displays B as the top layer.
*
* @fires error
* @fires success
*
* @example
* // Add multiple layers ad once layer to the client
* client.addLayers([layer0, layer1])
* .then(() => {
* console.log('Layers added');
* })
* .catch(cartoError => {
* console.error(cartoError.message);
* });
*
* @returns {Promise} A promise that will be fulfilled when the layers are added
* @api
*/
Client.prototype.addLayers = function (layers) {
layers.forEach(this._addLayer, this);
return this._reload();
};
/**
* Remove a layer from the client.
*
* @example
* // Remove a layer from the client
* client.removeLayer(layer)
* .then(() => {
* console.log('Layer removed');
* })
* .catch(cartoError => {
* console.error(cartoError.message);
* });
*
* @param {carto.layer.Base} - The layer to be removed
*
* @fires error
* @fires success
*
* @returns {Promise} A promise that will be fulfilled when the layer is removed
* @api
*/
Client.prototype.removeLayer = function (layer) {
return this.removeLayers([layer]);
};
/**
* Remove multiple layers from the client.
*
* @example
* // Remove multiple layers from the client
* client.removeLayers([layer1, layer2])
* .then(() => {
* console.log('Layers removed');
* })
* .catch(cartoError => {
* console.error(cartoError.message);
* });
*
*
* @param {carto.layer.Base[]} - An array with the layers to be removed
*
* @fires error
* @fires success
*
* @returns {Promise} A promise that will be fulfilled when the layers are removed
* @api
*/
Client.prototype.removeLayers = function (layers) {
var layersToRemove = layers.slice(0);
layersToRemove.forEach(this._removeLayer, this);
return this._reload();
};
/**
* Move layer order.
*
* @example
* // Move layer order
* client.moveLayer(layer1, 0)
* .then(() => {
* console.log('Layer moved');
* })
* .catch(cartoError => {
* console.error(cartoError.message);
* });
*
*
* @param {carto.layer.Base} - The layer to be moved
* @param {number} toIndex - Final index for the layer
*
* @fires error
* @fires success
*
* @returns {Promise} A promise that will be fulfilled when the layer is moved
* @api
*/
Client.prototype.moveLayer = function (layer, toIndex) {
var fromIndex = this._layers.indexOf(layer);
this._moveLayer(layer, toIndex);
if (fromIndex === toIndex) {
return Promise.resolve();
} else {
return this._reload();
}
};
/**
* Get all the {@link carto.layer.Base|layers} from the client.
*
* @example
* // Get all layers from the client
* const layers = client.getLayers();
*
* @example
* // Hide all layers from the client
* client.getLayers().forEach(layer => layer.hide());
*
* @returns {carto.layer.Base[]} An array with all the Layers from the client
* @api
*/
Client.prototype.getLayers = function () {
return this._layers.toArray();
};
/**
* Add a dataview to the client.
*
* @example
* // Add a dataview to the client
* client.addDataview(dataview)
* .then(() => {
* console.log('Dataview added');
* })
* .catch(cartoError => {
* console.error(cartoError.message);
* }):
*
* @param {carto.dataview.Base} - The dataview to be added
*
* @fires error
* @fires success
*
* @returns {Promise} - A promise that will be fulfilled when the dataview is added
* @api
*/
Client.prototype.addDataview = function (dataview) {
return this.addDataviews([dataview]);
};
/**
* Add multipe dataviews to the client.
*
* @example
* // Add several dataviews to the client
* client.addDataview([dataview0, dataview1])
* .then(() => {
* console.log('Dataviews added');
* })
* .catch(cartoError => {
* console.error(cartoError.message);
* }):
*
* @param {carto.dataview.Base[]} - An array with the dataviews to be added
*
* @fires error
* @fires success
*
* @returns {Promise} A promise that will be fulfilled when the dataviews are added
* @api
*/
Client.prototype.addDataviews = function (dataviews) {
dataviews.forEach(this._addDataview, this);
return this._reload();
};
/**
* Remove a dataview from the client.
*
* @example
* // Remove a dataview from the client
* client.removeDataview(dataview)
* .then(() => {
* console.log('Dataviews removed');
* })
* .catch(cartoError => {
* console.error(cartoError.message);
* }):
*
* @param {carto.dataview.Base} - The dataview array to be removed
*
* @fires error
* @fires success
*
* @returns {Promise} A promise that will be fulfilled when the dataview is removed
* @api
*/
Client.prototype.removeDataview = function (dataview) {
var dataviewIndex = this._dataviews.indexOf(dataview);
if (dataviewIndex === -1) {
return Promise.resolve();
}
this._dataviews.splice(dataviewIndex, 1);
this._engine.removeDataview(dataview.$getInternalModel());
dataview.disable();
return this._reload();
};
/**
* Get all the dataviews from the client.
*
* @example
* // Get all the dataviews from the client
* const dataviews = client.getDataviews();
*
* @returns {carto.dataview.Base[]} An array with all the dataviews from the client
* @api
*/
Client.prototype.getDataviews = function () {
return this._dataviews;
};
/**
* Return a {@link http://leafletjs.com/reference-1.3.1.html#tilelayer|leaflet layer} that groups all the layers that have been
* added to this client.
*
* @example
* // Get the leafletlayer from the client
* const cartoLeafletLayer = client.getLeafletLayer();
*
* @example
* // Add the leafletLayer to a leafletMap
* client.getLeafletLayer().addTo(map);
*
* @param {object} options - {@link https://leafletjs.com/reference-1.3.0.html#tilelayer-minzoom|L.TileLayer} options.
*
* @returns A {@link http://leafletjs.com/reference-1.3.1.html#tilelayer|L.TileLayer} layer that groups all the layers.
*
* @api
*/
Client.prototype.getLeafletLayer = function (options) {
// Check if Leaflet is loaded
utils.isLeafletLoaded();
if (!this._leafletLayer) {
var LeafletLayer = require('./native/leaflet-layer');
this._leafletLayer = new LeafletLayer(this._layers, this._engine, options);
}
return this._leafletLayer;
};
/**
* Return a {@link https://developers.google.com/maps/documentation/javascript/maptypes|google.maps.MapType} that groups all the layers that have been
* added to this client.
*
* @example
* // Get googlemaps MapType from client
* const gmapsMapType = client.getGoogleMapsMapType();
*
* @example
* // Add googlemaps MapType to a google map
* googleMap.overlayMapTypes.push(client.getGoogleMapsMapType(googleMap));
*
* @param {google.maps.Map} - The native Google Maps map where the CARTO layers will be displayed.
*
* @return {google.maps.MapType} A Google Maps mapType that groups all the layers:
* {@link https://developers.google.com/maps/documentation/javascript/maptypes|google.maps.MapType}
* @api
*/
Client.prototype.getGoogleMapsMapType = function (map) {
// Check if Google Maps is loaded
utils.isGoogleMapsLoaded();
if (!this._gmapsMapType) {
var GoogleMapsMapType = require('./native/google-maps-map-type');
this._gmapsMapType = new GoogleMapsMapType(this._layers, this._engine, map);
}
return this._gmapsMapType;
};
/**
* Call engine.reload wrapping the native cartojs errors
* into public CartoErrors.
*/
Client.prototype._reload = function () {
return this._engine.reload()
.then(function () {
return Promise.resolve();
})
.catch(function (error) {
return Promise.reject(new CartoError(error));
});
};
/**
* Helper used to link a layer and an engine.
* @private
*/
Client.prototype._addLayer = function (layer, engine) {
_checkLayer(layer);
this._checkDuplicatedLayerId(layer);
this._layers.add(layer);
layer.$setClient(this);
layer.$setEngine(this._engine);
this._engine.addLayer(layer.$getInternalModel());
};
/**
* Helper used to remove a layer from the client.
* @private
*/
Client.prototype._removeLayer = function (layer) {
_checkLayer(layer);
this._layers.remove(layer);
this._engine.removeLayer(layer.$getInternalModel());
};
/**
* Helper used to remove a layer from the client.
* @private
*/
Client.prototype._moveLayer = function (layer, toIndex) {
_checkLayer(layer);
_checkLayerIndex(toIndex, this._layers.size());
this._layers.move(layer, toIndex);
this._engine.moveLayer(layer.$getInternalModel(), toIndex);
};
/**
* Helper used to link a dataview and an engine
* @private
*/
Client.prototype._addDataview = function (dataview, engine) {
this._dataviews.push(dataview);
dataview.$setEngine(this._engine);
this._engine.addDataview(dataview.$getInternalModel());
};
/**
* Client exposes Event.SUCCESS and RELOAD_ERROR to the api users,
* those events are wrappers using _engine internaly.
*/
Client.prototype._bindEngine = function (engine) {
engine.on(Engine.Events.RELOAD_SUCCESS, function () {
this.trigger(Events.SUCCESS);
}.bind(this));
engine.on(Engine.Events.RELOAD_ERROR, function (err) {
this.trigger(Events.ERROR, new CartoError(err, { layers: this._layers }));
}.bind(this));
engine.on(Engine.Events.LAYER_ERROR, function (err) {
this.trigger(Events.ERROR, new CartoError(err));
}.bind(this));
};
/**
* Check if some layer in the client has the same id.
* @param {carto.layer.Base} layer
*/
Client.prototype._checkDuplicatedLayerId = function (layer) {
if (this._layers.findById(layer.getId())) {
throw getValidationError('duplicatedLayerId');
}
};
/**
* Utility function to reduce duplicated code.
*/
function _checkLayer (layer) {
if (!(layer instanceof LayerBase)) {
throw getValidationError('badLayerType');
}
}
function _checkLayerIndex (index, size) {
if (!_.isNumber(index)) {
throw getValidationError('indexNumber');
}
if (index < 0 || index >= size) {
throw getValidationError('indexOutOfRange');
}
}
function _checkSettings (settings) {
_checkApiKey(settings.apiKey);
_checkUsername(settings.username);
if (settings.serverUrl) {
_checkServerUrl(settings.serverUrl, settings.username);
}
}
function _checkApiKey (apiKey) {
if (!apiKey) {
throw getValidationError('apiKeyRequired');
}
if (!_.isString(apiKey)) {
throw getValidationError('apiKeyString');
}
}
function _checkUsername (username) {
if (!username) {
throw getValidationError('usernameRequired');
}
if (!_.isString(username)) {
throw getValidationError('usernameString');
}
}
function _checkServerUrl (serverUrl, username) {
var urlregex = /^(?:http(s)?:\/\/)?[\w.-]+(?:\.[\w\.-]+)+[\w\-\._~:/?#[\]@!\$&'\(\)\*\+,;=.]+$/;
if (!serverUrl.match(urlregex)) {
throw getValidationError('nonValidServerURL');
}
if (serverUrl.indexOf(username) < 0) {
throw getValidationError('serverURLDoesntMatchUsername');
}
}
module.exports = Client;

106
src/api/v4/constants.js Normal file
View File

@@ -0,0 +1,106 @@
var _ = require('underscore');
/**
* Constants module for dataviews
*/
/**
* Enum for operation values.
*
* @enum {string} carto.operation
* @readonly
* @memberof carto
* @api
*/
var operation = {
/** Number of elements */
COUNT: 'count',
/** Sum */
SUM: 'sum',
/** Average */
AVG: 'avg',
/** Maximum */
MAX: 'max',
/** Minimum */
MIN: 'min'
};
function isValidOperation (op) {
return _.contains(operation, op);
}
/**
* Enum for dataview status values.
*
* @enum {string} carto.dataview.status
* @readonly
* @memberof carto.dataview
* @api
*/
var status = {
/** Not fetched with the server */
NOT_LOADED: 'notLoaded',
/** Fetching with the server */
LOADING: 'loading',
/** Fetch completed */
LOADED: 'loaded',
/** Error in fetch */
ERROR: 'error'
};
/**
* Enum for dataview time aggregations.
*
* @enum {string} carto.dataview.timeAggregation
* @readonly
* @memberof carto.dataview
* @api
*/
var timeAggregation = {
/** Auto */
AUTO: 'auto',
/** Millennium */
MILLENNIUM: 'millennium',
/** Century */
CENTURY: 'century',
/** Decade */
DECADE: 'decade',
/** Year */
YEAR: 'year',
/** Quarter */
QUARTER: 'quarter',
/** Month */
MONTH: 'month',
/** Week */
WEEK: 'week',
/** Day */
DAY: 'day',
/** Hour */
HOUR: 'hour',
/** Minute */
MINUTE: 'minute'
};
function isValidTimeAggregation (agg) {
return _.contains(timeAggregation, agg);
}
/**
* ATTRIBUTION constant
*
* &copy; <a href="http://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors, &copy; <a href="https://carto.com/attribution">CARTO</a>
*
* @type {string}
* @constant
* @memberof carto
* @api
*/
var ATTRIBUTION = '&copy; <a href="http://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors, &copy; <a href="https://carto.com/attribution">CARTO</a>';
module.exports = {
operation: operation,
status: status,
timeAggregation: timeAggregation,
isValidOperation: isValidOperation,
isValidTimeAggregation: isValidTimeAggregation,
ATTRIBUTION: ATTRIBUTION
};

490
src/api/v4/dataview/base.js Normal file
View File

@@ -0,0 +1,490 @@
var _ = require('underscore');
var Backbone = require('backbone');
var status = require('../constants').status;
var SourceBase = require('../source/base');
var FilterBase = require('../filter/base');
var SQLFilterBase = require('../filter/base-sql');
var SpatialFilterTypes = require('../filter/spatial-filter-types');
var CartoError = require('../error-handling/carto-error');
var CartoValidationError = require('../error-handling/carto-validation-error');
/**
* Base class for dataview objects.
*
* Dataviews are a way to extract data from a CARTO account in predefined ways
* (eg: a list of categories, the result of a formula operation, etc.).
*
* **This object should not be used directly**
*
* The data used in a dataviews cames from a {@link carto.source.Base|source} that might change
* due to different reasons (eg: SQL query changed).
*
* When dataview data changes the dataview will trigger events to notify subscribers when new data is available.
*
* @example
* // Keep your widget data sync. Remember each dataview has his own data format.
* dataview.on('dataChanged', newData => {
* renderWidget(newData);
* })
*
* @constructor
* @abstract
* @memberof carto.dataview
* @fires dataChanged
* @fires columnChanged
* @fires statusChanged
* @fires error
* @api
*/
function Base () { }
_.extend(Base.prototype, Backbone.Events);
/**
* Return the current dataview status.
*
* @return {carto.dataview.status} Current dataview status
* @api
*/
Base.prototype.getStatus = function () {
return this._status;
};
/**
* Return true is the current status is loading.
*
* @return {boolean}
* @api
*/
Base.prototype.isLoading = function () {
return this._status === status.LOADING;
};
/**
* Return true is the current status is loaded.
*
* @return {boolean}
* @api
*/
Base.prototype.isLoaded = function () {
return this._status === status.LOADED;
};
/**
* Return true is the current status is error.
*
* @return {boolean}
* @api
*/
Base.prototype.hasError = function () {
return this._status === status.ERROR;
};
/**
* Enable the dataview. When enabled, a dataview fetches new data
* when the map changes (changing map configuration or changing map
* bounding box).
*
* @return {carto.dataview.Base} this
* @api
*/
Base.prototype.enable = function () {
return this._setEnabled(true);
};
/**
* Disable the dataview. This stops the dataview from fetching new
* data when there is a map change (like changing map configuration or changing map
* bounding box).
*
* @return {carto.dataview.Base} this
* @api
*/
Base.prototype.disable = function () {
return this._setEnabled(false);
};
/**
* Return true if the dataview is enabled.
*
* @return {boolean}
* @api
*/
Base.prototype.isEnabled = function () {
return this._enabled;
};
/**
* Return the current source where the dataview gets the data from.
*
* @return {carto.source.Base} Current source object
* @api
*/
Base.prototype.getSource = function () {
return this._source;
};
/**
* Set the dataview column.
*
* @param {string} column
* @fires columnChanged
* @return {carto.dataview.Base} this
* @api
*/
Base.prototype.setColumn = function (column) {
this._checkColumn(column);
this._column = column;
if (this._internalModel) {
this._internalModel.set('column', this._column);
}
return this;
};
/**
* Return the current dataview column where the dataview is applied.
*
* @return {string} Current dataview column
* @api
*/
Base.prototype.getColumn = function () {
return this._column;
};
/**
* Add a {@link carto.filter.Base|filter}.
*
* @param {carto.filter.Base} filter
* @return {carto.dataview.Base} this
* @api
*/
Base.prototype.addFilter = function (filter) {
this._checkFilter(filter);
this._addSpatialFilter(filter);
return this;
};
/**
* Remove a {@link carto.filter.Base|filter}.
*
* @param {carto.filter.Base} filter
* @return {carto.dataview.Base} this
* @api
*/
Base.prototype.removeFilter = function (filter) {
this._checkFilter(filter);
this._removeSpatialFilter(filter);
return this;
};
/**
* Check if a {@link carto.filter.Base|filter} exists in the dataview.
*
* @param {carto.filter.Base} filter
* @return {carto.dataview.Base} this
* @api
*/
Base.prototype.hasFilter = function (filter) {
this._checkFilter(filter);
var hasBBoxFilter = (filter === this._boundingBoxFilter) &&
(this._internalModel && this._internalModel.get('sync_on_bbox_change'));
var hasCircleFilter = (filter === this._circleFilter) &&
(this._internalModel && this._internalModel.get('sync_on_circle_change'));
var hasPolygonFilter = (filter === this._polygonFilter) &&
(this._internalModel && this._internalModel.get('sync_on_polygon_change'));
return hasBBoxFilter || hasCircleFilter || hasPolygonFilter;
};
Base.prototype.getData = function () {
throw new Error('getData must be implemented by the particular dataview.');
};
// Protected methods
Base.prototype.DEFAULTS = {};
/**
* Initialize dataview.
*
* @param {carto.source.Base} source - The source where the dataview will fetch the data
* @param {string} column - The column name to get the data
* @param {object} options - It depends on the instance
*/
Base.prototype._initialize = function (source, column, options) {
options = _.defaults(options || {}, this.DEFAULTS);
this._checkSource(source);
this._checkColumn(column);
this._checkOptions(options);
this._source = source;
this._column = column;
this._options = options;
this._status = status.NOT_LOADED;
this._enabled = true;
this._boundingBoxFilter = null;
};
Base.prototype._checkSource = function (source) {
if (!(source instanceof SourceBase)) {
throw this._getValidationError('sourceRequired');
}
};
Base.prototype._checkColumn = function (column) {
if (_.isUndefined(column)) {
throw this._getValidationError('columnRequired');
}
if (!_.isString(column)) {
throw this._getValidationError('columnString');
}
if (_.isEmpty(column)) {
throw this._getValidationError('emptyColumn');
}
};
Base.prototype._checkOptions = function (options) {
throw new Error('_checkOptions must be implemented by the particular dataview.');
};
Base.prototype._checkFilter = function (filter) {
if (!(filter instanceof FilterBase) || filter instanceof SQLFilterBase) {
throw this._getValidationError('filterRequired');
}
};
Base.prototype._createInternalModel = function (engine) {
throw new Error('_createInternalModel must be implemented by the particular dataview.');
};
Base.prototype._setEnabled = function (enabled) {
this._enabled = enabled;
if (this._internalModel) {
this._internalModel.set('enabled', enabled);
}
return this;
};
Base.prototype._listenToInternalModelSharedEvents = function () {
if (this._internalModel) {
this.listenTo(this._internalModel, 'change:data', this._onDataChanged);
this.listenTo(this._internalModel, 'change:column', this._onColumnChanged);
this.listenTo(this._internalModel, 'loading', this._onStatusLoading);
this.listenTo(this._internalModel, 'loaded', this._onStatusLoaded);
this.listenTo(this._internalModel, 'statusError', this._onStatusError);
}
};
Base.prototype._onDataChanged = function () {
this.trigger('dataChanged', this.getData());
};
Base.prototype._onColumnChanged = function () {
if (this._internalModel) {
this._column = this._internalModel.get('column');
}
this.trigger('columnChanged', this._column);
};
Base.prototype._onStatusLoading = function () {
this._status = status.LOADING;
this.trigger('statusChanged', this._status);
};
Base.prototype._onStatusLoaded = function () {
this._status = status.LOADED;
this.trigger('statusChanged', this._status);
};
Base.prototype._onStatusError = function (model, error) {
this._status = status.ERROR;
this.trigger('statusChanged', this._status, error);
this._triggerError(this, error);
};
Base.prototype._changeProperty = function (key, value, internalKey) {
var prevValue = this['_' + key];
this['_' + key] = value;
if (prevValue === value) {
return;
}
this._triggerChange(key, value);
if (this._internalModel) {
this._internalModel.set(internalKey || key, value);
}
};
Base.prototype._changeProperties = function (properties) {
_.each(properties, (value, key) => {
const prevValue = this[`_${key}`];
if (prevValue !== value) {
this[`_${key}`] = value;
this._triggerChange(key, value);
}
});
if (this._internalModel) {
this._internalModel.set(properties);
}
};
Base.prototype._triggerChange = function (key, value) {
this.trigger(key + 'Changed', value);
};
/**
* Fire a CartoError event from a internalDataviewError.
*/
Base.prototype._triggerError = function (model, internalDataviewError) {
this.trigger('error', new CartoError(internalDataviewError));
};
Base.prototype._addSpatialFilter = function (spatialFilter) {
switch (spatialFilter.type) {
case SpatialFilterTypes.BBOX:
this._addBoundingBoxFilter(spatialFilter);
break;
case SpatialFilterTypes.CIRCLE:
this._addCircleFilter(spatialFilter);
break;
case SpatialFilterTypes.POLYGON:
this._addPolygonFilter(spatialFilter);
break;
default:
throw new Error('The filter is not a valid spatial filter.');
}
};
Base.prototype._removeSpatialFilter = function (spatialFilter) {
switch (spatialFilter.type) {
case SpatialFilterTypes.BBOX:
if (spatialFilter === this._boundingBoxFilter) {
this._removeBoundingBoxFilter();
}
break;
case SpatialFilterTypes.CIRCLE:
if (spatialFilter === this._circleFilter) {
this._removeCircleFilter();
}
break;
case SpatialFilterTypes.POLYGON:
if (spatialFilter === this._polygonFilter) {
this._removePolygonFilter();
}
break;
default:
throw new Error('The filter is not a valid spatial filter.');
}
};
Base.prototype._addBoundingBoxFilter = function (bboxFilter) {
if (bboxFilter === this._boundingBoxFilter) {
return;
}
this._boundingBoxFilter = bboxFilter;
if (this._internalModel) {
this._internalModel.addBBoxFilter(this._boundingBoxFilter.$getInternalModel());
this._internalModel.set('sync_on_bbox_change', true);
}
};
Base.prototype._removeBoundingBoxFilter = function () {
this._boundingBoxFilter = null;
if (this._internalModel) {
this._internalModel.removeBBoxFilter();
this._internalModel.set('sync_on_bbox_change', false);
}
};
Base.prototype._addCircleFilter = function (circleFilter) {
if (circleFilter === this._circleFilter) {
return;
}
this._circleFilter = circleFilter;
if (this._internalModel) {
this._internalModel.addCircleFilter(this._circleFilter.$getInternalModel());
this._internalModel.set('sync_on_circle_change', true);
}
};
Base.prototype._removeCircleFilter = function () {
this._circleFilter = null;
if (this._internalModel) {
this._internalModel.removeCircleFilter();
this._internalModel.set('sync_on_circle_change', false);
}
};
Base.prototype._addPolygonFilter = function (polygonFilter) {
if (polygonFilter === this._polygonFilter) {
return;
}
this._polygonFilter = polygonFilter;
if (this._internalModel) {
this._internalModel.addPolygonFilter(this._polygonFilter.$getInternalModel());
this._internalModel.set('sync_on_polygon_change', true);
}
};
Base.prototype._removePolygonFilter = function () {
this._polygonFilter = null;
if (this._internalModel) {
this._internalModel.removePolygonFilter();
this._internalModel.set('sync_on_polygon_change', false);
}
};
Base.prototype._getValidationError = function (code) {
return new CartoValidationError('dataview', code);
};
// Internal public methods
Base.prototype.$setEngine = function (engine) {
this._source.$setEngine(engine);
if (!this._internalModel) {
this._createInternalModel(engine);
this._listenToInternalModelSharedEvents();
}
};
Base.prototype.$getInternalModel = function () {
return this._internalModel;
};
module.exports = Base;
/**
* Fired when the column name has changed. Handler gets a parameter with the new column name.
*
* @event columnChanged
* @type {string}
* @api
*/
/**
* Fired when the status has changed. Handler gets a parameter with the new status.
*
* Contains a single argument with the new status.
*
* @event statusChanged
* @type {carto.dataview.status}
* @api
*/
/**
* Fired when the data has changed. Handler gets an object with specific data for the type
* of dataview that triggered the event.
*
* @event dataChanged
* @type {carto.dataview.CategoryData|carto.dataview.FormulaData|carto.dataview.HistogramData|carto.dataview.TimeSeriesData}
* @api
*/

View File

@@ -0,0 +1,268 @@
var _ = require('underscore');
var Base = require('../base');
var constants = require('../../constants');
var CategoryDataviewModel = require('../../../../dataviews/category-dataview-model');
var CategoryFilter = require('../../../../windshaft/filters/category');
var parseCategoryData = require('./parse-data.js');
/**
*
* A category dataview is used to aggregate data performing a operation.
*
* This is similar to a group by SQL operation, for example:
*
* ```
* SELECT country, AVG(population) GROUP BY country
* ```
* The following code is the CARTO.js equivalent:
*
* ```javascript
* var categoryDataview = new carto.dataview.Category(citiesSource, 'country', {
* operation: carto.operation.AVG, // Compute the average
* operationColumn: 'population' // The name of the column where the operation will be applied.
* });
* ```
*
* Like every other dataview, this is an async object and you must wait for the data to be available.
*
* The data format for the category-dataview is described in {@link carto.dataview.CategoryData}
*
* @param {carto.source.Base} source - The source where the dataview will fetch the data
* @param {string} column - The name of the column used to create categories
* @param {object} [options]
* @param {number} [options.limit=6] - The maximum number of categories in the response
* @param {carto.operation} [options.operation] - The operation to apply to the data
* @param {string} [options.operationColumn] - The column where the operation will be applied
*
* @fires dataChanged
* @fires columnChanged
* @fires statusChanged
* @fires error
*
* @fires limitChanged
* @fires operationChanged
* @fires operationColumnChanged
*
* @constructor
* @extends carto.dataview.Base
* @memberof carto.dataview
* @api
* @example
* // From a cities dataset with name, country and population show the average city population per country:
* var column = 'country'; // Aggregate the data by country.
* var categoryDataview = new carto.dataview.Category(citiesSource, column, {
* operation: carto.operation.AVG, // Compute the average
* operationColumn: 'population' // The name of the column where the operation will be applied.
* });
* @example
* // Listen for data updates
* categoryDataview.on('dataChanged', newData => {
* console.log(newData); // CategoryData object
* });
* @example
* // You can listen to multiple events emmited by the category-dataview.
* categoryDataview.on('statusChanged', (newData, error) => { });
* categoryDataview.on('error', cartoError => { });
*
* // Listen to specific category-dataview events.
* categoryDataview.on('columnChanged', newData => {
* console.log(newData); // 'population'
* });
* categoryDataview.on('limitChanged', newData => {
* console.log(newData); // 11
* });
* categoryDataview.on('operationChanged', newData => { });
* categoryDataview.on('operationColumnChanged', newData => { });
*/
function Category (source, column, options) {
this.DEFAULTS.operationColumn = column;
this._initialize(source, column, options);
this._limit = this._options.limit;
this._operation = this._options.operation;
this._operationColumn = this._options.operationColumn;
}
Category.prototype = Object.create(Base.prototype);
/**
* Set the categories limit.
*
* @param {number} limit
* @fires limitChanged
* @return {carto.dataview.Category} this
* @api
*/
Category.prototype.setLimit = function (limit) {
this._checkLimit(limit);
this._changeProperty('limit', limit, 'categories');
return this;
};
/**
* Return the current categories limit.
*
* @return {number} Current dataview limit
* @api
*/
Category.prototype.getLimit = function () {
return this._limit;
};
/**
* Set the dataview operation.
*
* @param {carto.operation} operation
* @fires operationChanged
* @return {carto.dataview.Category} this
* @api
*/
Category.prototype.setOperation = function (operation) {
this._checkOperation(operation);
this._changeProperty('operation', operation, 'aggregation');
return this;
};
/**
* Return the current dataview operation.
*
* @return {carto.operation} Current dataview operation
* @api
*/
Category.prototype.getOperation = function () {
return this._operation;
};
/**
* Set the dataview operationColumn.
*
* @param {string} operationColumn
* @fires operationColumnChanged
* @return {carto.dataview.Category} this
* @api
*/
Category.prototype.setOperationColumn = function (operationColumn) {
this._checkOperationColumn(operationColumn);
this._changeProperty('operationColumn', operationColumn, 'aggregation_column');
return this;
};
/**
* Return the current dataview operationColumn.
*
* @return {string} Current dataview operationColumn
* @api
*/
Category.prototype.getOperationColumn = function () {
return this._operationColumn;
};
/**
* Return the resulting data.
*
* @return {carto.dataview.CategoryData}
* @api
*/
Category.prototype.getData = function () {
if (this._internalModel) {
return parseCategoryData(
this._internalModel.get('data'),
this._internalModel.get('count'),
this._internalModel.get('max'),
this._internalModel.get('min'),
this._internalModel.get('nulls'),
this._operation
);
}
return null;
};
Category.prototype.DEFAULTS = {
limit: 6,
operation: constants.operation.COUNT
};
Category.prototype._checkOptions = function (options) {
if (_.isUndefined(options)) {
throw this._getValidationError('categoryOptionsRequired');
}
this._checkLimit(options.limit);
this._checkOperation(options.operation);
this._checkOperationColumn(options.operationColumn);
};
Category.prototype._checkLimit = function (limit) {
if (_.isUndefined(limit)) {
throw this._getValidationError('categoryLimitRequired');
}
if (!_.isNumber(limit)) {
throw this._getValidationError('categoryLimitNumber');
}
if (limit <= 0) {
throw this._getValidationError('categoryLimitPositive');
}
};
Category.prototype._checkOperation = function (operation) {
if (_.isUndefined(operation) || !constants.isValidOperation(operation)) {
throw this._getValidationError('categoryInvalidOperation');
}
};
Category.prototype._checkOperationColumn = function (operationColumn) {
if (_.isUndefined(operationColumn)) {
throw this._getValidationError('categoryOperationRequired');
}
if (!_.isString(operationColumn)) {
throw this._getValidationError('categoryOperationString');
}
if (_.isEmpty(operationColumn)) {
throw this._getValidationError('categoryOperationEmpty');
}
};
Category.prototype._createInternalModel = function (engine) {
this._internalModel = new CategoryDataviewModel({
source: this._source.$getInternalModel(),
column: this._column,
aggregation: this._operation,
aggregation_column: this._operationColumn,
categories: this._limit,
sync_on_bbox_change: !!this._boundingBoxFilter,
sync_on_circle_change: !!this._circleFilter,
sync_on_polygon_change: !!this._polygonFilter,
enabled: this._enabled
}, {
engine: engine,
filter: new CategoryFilter(),
bboxFilter: this._boundingBoxFilter && this._boundingBoxFilter.$getInternalModel(),
circleFilter: this._circleFilter && this._circleFilter.$getInternalModel(),
polygonFilter: this._polygonFilter && this._polygonFilter.$getInternalModel()
});
};
module.exports = Category;
/**
* Fired when limit has changed. Handler gets a parameter with the new limit.
*
* @event limitChanged
* @type {number}
* @api
*/
/**
* Fired when operation has changed. Handler gets a parameter with the new limit.
*
* @event operationChanged
* @type {string}
* @api
*/
/**
* Fired when operationColumn has changed. Handler gets a parameter with the new operationColumn.
*
* @event operationColumnChanged
* @type {string}
* @api
*/

View File

@@ -0,0 +1,60 @@
var _ = require('underscore');
/**
* Transform the data obtained from an internal category dataview into a
* public object.
*
* @param {object[]} data
* @param {number} count
* @param {number} max
* @param {number} min
* @param {number} nulls
* @param {string} operation
*
* @return {carto.dataview.CategoryData} - The parsed and formatted data for the given parameters
*/
function parseCategoryData (data, count, max, min, nulls, operation) {
if (!data) {
return null;
}
/**
* @typedef {object} carto.dataview.CategoryData
* @property {number} count - The total number of categories
* @property {number} max - Maximum category value
* @property {number} min - Minimum category value
* @property {number} nulls - Number of null categories
* @property {string} operation - Operation used
* @property {carto.dataview.CategoryItem[]} categories
* @api
*/
return {
count: count,
max: max,
min: min,
nulls: nulls,
operation: operation,
categories: _createCategories(data)
};
}
/**
* Transform the histogram raw data into {@link carto.dataview.CategoryItem}
*/
function _createCategories (data) {
return _.map(data, function (item) {
/**
* @typedef {object} carto.dataview.CategoryItem
* @property {boolean} group - Category is a group
* @property {string} name - Category name
* @property {number} value - Category value
* @api
*/
return {
group: item.agg,
name: item.name,
value: item.value
};
});
}
module.exports = parseCategoryData;

View File

@@ -0,0 +1,134 @@
var _ = require('underscore');
var Base = require('../base');
var constants = require('../../constants');
var FormulaDataviewModel = require('../../../../dataviews/formula-dataview-model');
var parseFormulaData = require('./parse-data.js');
/**
* A formula is a simple numeric {@link carto.operation|operation} applied to the column of a {@link carto.source.Base|data source} (dataset or sql query).
*
* Like all dataviews, it is an async object so you must wait for the data to be available.
*
* @param {carto.source.Base} source - The source where the dataview will fetch the data from
* @param {string} column - The operation will be performed using this column
* @param {object} [options]
* @param {carto.operation} [options.operation] - The operation to apply to the data
*
* @fires dataChanged
* @fires columnChanged
* @fires statusChanged
* @fires error
*
* @fires operationChanged
*
* @constructor
* @extends carto.dataview.Base
* @memberof carto.dataview
* @api
* @example
* // Given a cities dataset get the most populated city
* var formulaDataview = new carto.dataview.Formula(citiesSource, 'population', {
* operation: carto.operation.MAX,
* });
* @example
* // You can listen to multiple events emitted by a formula dataview.
* // Data and status are fired by all dataviews.
* formulaDataview.on('dataChanged', newData => { });
* formulaDataview.on('statusChanged', (newData, error) => { });
* formulaDataview.on('error', cartoError => { });
*
* // Listen to specific formula-dataview events
* formulaDataview.on('columnChanged', newData => { });
* formulaDataview.on('operationChanged', newData => { });
*/
function Formula (source, column, options) {
this._initialize(source, column, options);
this._operation = this._options.operation;
}
Formula.prototype = Object.create(Base.prototype);
/**
* Set the dataview operation.
*
* @param {carto.operation} operation
* @fires operationChanged
* @return {carto.dataview.Formula} this
* @api
*/
Formula.prototype.setOperation = function (operation) {
this._checkOperation(operation);
this._changeProperty('operation', operation);
return this;
};
/**
* Return the current dataview operation.
*
* @return {carto.operation} Current dataview operation
* @api
*/
Formula.prototype.getOperation = function () {
return this._operation;
};
/**
* Return the resulting data.
*
* @return {carto.dataview.FormulaData}
* @api
*/
Formula.prototype.getData = function () {
if (this._internalModel) {
return parseFormulaData(
this._internalModel.get('nulls'),
this._operation,
this._internalModel.get('data')
);
}
return null;
};
Formula.prototype.DEFAULTS = {
operation: constants.operation.COUNT
};
Formula.prototype._checkOptions = function (options) {
if (_.isUndefined(options)) {
throw this._getValidationError('formulaOptionsRequired');
}
this._checkOperation(options.operation);
};
Formula.prototype._checkOperation = function (operation) {
if (_.isUndefined(operation) || !constants.isValidOperation(operation)) {
throw this._getValidationError('formulaInvalidOperation');
}
};
Formula.prototype._createInternalModel = function (engine) {
this._internalModel = new FormulaDataviewModel({
source: this._source.$getInternalModel(),
column: this._column,
operation: this._operation,
sync_on_bbox_change: !!this._boundingBoxFilter,
sync_on_circle_change: !!this._circleFilter,
sync_on_polygon_change: !!this._polygonFilter,
enabled: this._enabled
}, {
engine: engine,
bboxFilter: this._boundingBoxFilter && this._boundingBoxFilter.$getInternalModel(),
circleFilter: this._circleFilter && this._circleFilter.$getInternalModel(),
polygonFilter: this._polygonFilter && this._polygonFilter.$getInternalModel()
});
};
module.exports = Formula;
/**
* Fired when the operation has changed. Handler gets a parameter with the new operation.
*
* @event operationChanged
* @type {carto.operation}
* @api
*/

View File

@@ -0,0 +1,29 @@
/**
* Transform the data obtained from an internal formula dataview into a
* public object.
*
* @param {number} nulls
* @param {string} operation
* @param {number} result
*
* @return {carto.dataview.FormulaData} - The parsed and formatted data for the given parameters
*/
function parseFormulaData (nulls, operation, result) {
/**
* @description
* Object containing formula data
*
* @typedef {object} carto.dataview.FormulaData
* @property {number} nulls - Number of null values in the column
* @property {string} operation - Operation used
* @property {number} result - Result of the operation
* @api
*/
return {
nulls: nulls,
operation: operation,
result: result
};
}
module.exports = parseFormulaData;

View File

@@ -0,0 +1,228 @@
var _ = require('underscore');
var Base = require('../base');
var HistogramDataviewModel = require('../../../../dataviews/histogram-dataview-model');
var parseHistogramData = require('./parse-data.js');
/**
* A histogram is used to represent the distribution of numerical data.
*
* See {@link https://en.wikipedia.org/wiki/Histogram}.
*
* @param {carto.source.Base} source - The source where the dataview will fetch the data
* @param {string} column - The column name to get the data
* @param {object} [options]
* @param {number} [options.bins=10] - Number of bins to aggregate the data range into
* @param {number} [options.start] - Lower limit of the data range, if not present, the lower limit of the actual data will be used. Start and end values must be used together.
* @param {number} [options.end] - Upper limit of the data range, if not present, the upper limit of the actual data will be used. Start and end values must be used together.
*
* @fires dataChanged
* @fires columnChanged
* @fires statusChanged
* @fires error
*
* @fires binsChanged
*
* @constructor
* @extends carto.dataview.Base
* @memberof carto.dataview
* @api
* @example
* // Create a cities population histogram.
* var histogram = new carto.dataview.Histogram(citiesSource, 'population');
* // Set up a callback to render the histogram data every time new data is obtained.
* histogram.on('dataChanged', renderData);
* // Add the histogram to the client
* client.addDataview(histogram);
* @example
* // Create a cities population histogram with only 4 bins
* var histogram = new carto.dataview.Histogram(citiesSource, 'population', {bins: 4});
* // Add a bounding box filter, so the data will change when the map is moved.
* var bboxFilter = new carto.filter.BoundingBoxLeaflet(map);
* // Set up a callback to render the histogram data every time new data is obtained.
* histogram.on('dataChanged', histogramData => {
* console.log(histogramData);
* });
* // Add the histogram to the client
* client.addDataview(histogram);
* @example
* // Create a cities population histogram with a range
* var histogram = new carto.dataview.Histogram(citiesSource, 'population', { start: 100000, end: 5000000 });
* // Set up a callback to render the histogram data every time new data is obtained.
* histogram.on('dataChanged', histogramData => {
* console.log(histogramData);
* });
* // Add the histogram to the client
* client.addDataview(histogram);
* @example
* // The histogram is an async object so it can be on different states: LOADING, ERROR...
* // Listen to state events
* histogram.on('statusChanged', (newStatus, error) => { });
* // Listen to histogram errors
* histogram.on('error', error => { });
*/
function Histogram (source, column, options) {
this._initialize(source, column, options);
this._bins = this._options.bins;
this._start = this._options.start;
this._end = this._options.end;
}
Histogram.prototype = Object.create(Base.prototype);
Histogram.prototype.DEFAULTS = {
bins: 10
};
/**
* Return the resulting data.
*
* @return {carto.dataview.HistogramData}
* @api
*/
Histogram.prototype.getData = function () {
if (this._internalModel) {
return parseHistogramData(
this._internalModel.get('data'),
this._internalModel.get('nulls'),
this._internalModel.get('totalAmount')
);
}
return null;
};
Histogram.prototype.setColumn = function (column) {
Base.prototype.setColumn.apply(this, arguments);
this._start = null;
this._end = null;
};
/**
* Set the number of bins.
*
* @param {number} bins
* @fires binsChanged
* @return {carto.dataview.Histogram} this
* @api
*/
Histogram.prototype.setBins = function (bins) {
this._validateBins(bins);
this._changeProperty('bins', bins);
return this;
};
/**
* Return the current number of bins.
*
* @return {number} Current number of bins
* @api
*/
Histogram.prototype.getBins = function () {
return this._bins;
};
/**
* Set the lower and upper limit of the bins range
*
* @param {number} start
* @param {number} end
* @return {carto.dataview.Histogram} this
* @api
*/
Histogram.prototype.setStartEnd = function (start, end) {
this._validateStartEnd(start, end);
this._changeProperties({ start, end });
return this;
};
/**
* Return the lower limit of the bins' range
*
* @return {number} Current value of start
* @api
*/
Histogram.prototype.getStart = function () {
return this._start || this._internalModel.get('start');
};
/**
* Return the upper limit of the bins' range
*
* @return {number} Current value of end
* @api
*/
Histogram.prototype.getEnd = function () {
return this._end || this._internalModel.get('end');
};
/**
* Return the distribution type of the current data according to [Galtungs AJUS System]{@link https://en.wikipedia.org/wiki/Multimodal_distribution#Galtung.27s_classification}
*
* @return {string} Distribution type of current data
* @api
*/
Histogram.prototype.getDistributionType = function () {
if (this._internalModel) {
var data = this._internalModel.getData();
return this._internalModel.getDistributionType(data);
}
return null;
};
Histogram.prototype._validateBins = function (bins) {
if (!_.isFinite(bins) || bins < 1 || Math.floor(bins) !== bins) {
throw this._getValidationError('histogramInvalidBins');
}
};
Histogram.prototype._validateStartEnd = function (start, end) {
const values = [start, end];
if (_.every(values, _.isUndefined)) return;
const bothAreNumbers = _.every(values, number => _.isNumber(number) && !_.isNaN(number));
const bothAreNull = _.every(values, _.isNull);
if (!bothAreNumbers && !bothAreNull) {
throw this._getValidationError('histogramInvalidStartEnd');
}
};
Histogram.prototype._checkOptions = function (options) {
if (_.isUndefined(options)) {
throw this._getValidationError('histogramOptionsRequired');
}
this._validateBins(options.bins);
this._validateStartEnd(options.start, options.end);
};
Histogram.prototype._createInternalModel = function (engine) {
this._internalModel = new HistogramDataviewModel({
source: this._source.$getInternalModel(),
column: this._column,
bins: this._bins,
start: this._start,
end: this._end,
sync_on_bbox_change: !!this._boundingBoxFilter,
sync_on_circle_change: !!this._circleFilter,
sync_on_polygon_change: !!this._polygonFilter,
enabled: this._enabled,
column_type: 'number'
}, {
engine: engine,
bboxFilter: this._boundingBoxFilter && this._boundingBoxFilter.$getInternalModel(),
circleFilter: this._circleFilter && this._circleFilter.$getInternalModel(),
polygonFilter: this._polygonFilter && this._polygonFilter.$getInternalModel()
});
};
module.exports = Histogram;
/**
* Fired when bins have changed. Handler gets a parameter with the new bins.
*
* @event binsChanged
* @type {number}
* @api
*/

View File

@@ -0,0 +1,91 @@
var _ = require('underscore');
/**
* Transform the data obtained from an internal histogram dataview into a
* public object.
*
* @param {object[]} data - The raw histogram data
* @param {number} nulls - Number of data with a null
* @param {number} totalAmount - Total number of data in the histogram
*
* @return {carto.dataview.HistogramData} - The parsed and formatted data for the given parameters
*/
function parseHistogramData (data, nulls, totalAmount) {
if (!data) {
return null;
}
var compactData = _.compact(data);
var maxBin = _.max(compactData, function (bin) { return bin.freq || 0; });
var maxFreq = _.isFinite(maxBin.freq) && maxBin.freq !== 0
? maxBin.freq
: null;
/**
* @description
* Object containing histogram data.
*
* @typedef {object} carto.dataview.HistogramData
* @property {number} nulls - The number of items with null value
* @property {number} totalAmount - The number of elements returned
* @property {carto.dataview.BinItem[]} bins - Array containing the {@link carto.dataview.BinItem|data bins} for the histogram
* @property {string} type - String with value: **histogram**
* @api
*/
return {
bins: _createBins(compactData, maxFreq),
nulls: nulls || 0,
totalAmount: totalAmount
};
}
/**
* Transform the histogram raw data into {@link carto.dataview.BinItem}
*/
function _createBins (data, maxFreq) {
return data.map(function (bin) {
/**
* @example
*
* // We created an histogram containing airBnb prices per night
* const histogramDataview = new carto.dataview.Histogram(airbnbDataset, 'price', { bins: 7 });
* // Listen to dataChanged events
* histogramDataview.on('dataChanged', data => {
* // The first bin contains prices from 0 to 20€ per night, there are 3 rentals in this bin with a cost of 10 15 and 20€.
* const bin = console.log(data.bins[0]);
* // This is the bin index in the bins array
* bin.index; // 0
* // The first bin contains rentals from 0 to 20€ per night
* bin.start; // 0
* // The first bin contains rentals from 0 to 20€ per night
* bin.end; // 20
* // The lower rental in the bin is 10€ per night
* bin.min; // 10
* // The maximun rental in the bin is 20€ per night
* bin.max; // 20
* // The average price in this bin is 15€ per night
* bin.avg; // 15
* // The bin contains 3 prices
* bin.freq; // 3
* // Those 3 prices represent the 20% of the dataset.
* bin.normalized; // 0.2
* });
*
*
*
*
* @typedef {object} carto.dataview.BinItem
* @property {number} index - Number indicating the bin order
* @property {number} start - The lower limit of the bin
* @property {number} end - The higher limit of the bin
* @property {number} min - The minimal value appearing in the bin. Only appears if freq > 0
* @property {number} max - The minimal value appearing in the bin. Only appears if freq > 0
* @property {number} avg - The average value of the elements for this bin. Only appears if freq > 0
* @property {number} freq - Number of elements in the bin
* @property {number} normalized - Normalized frequency with respect to the whole data
* @api
*/
return _.extend(bin, { normalized: _.isFinite(bin.freq) && maxFreq > 0 ? bin.freq / maxFreq : 0 });
});
}
module.exports = parseHistogramData;

View File

@@ -0,0 +1,19 @@
var Category = require('./category');
var Formula = require('./formula');
var Histogram = require('./histogram');
var TimeSeries = require('./time-series');
var status = require('../constants').status;
var timeAggregation = require('../constants').timeAggregation;
/**
* @namespace carto.dataview
* @api
*/
module.exports = {
Category: Category,
Formula: Formula,
Histogram: Histogram,
TimeSeries: TimeSeries,
status: status,
timeAggregation: timeAggregation
};

View File

@@ -0,0 +1,230 @@
var _ = require('underscore');
var Base = require('../base');
var HistogramDataviewModel = require('../../../../dataviews/histogram-dataview-model');
var parseTimeSeriesData = require('./parse-data');
var timeAggregation = require('../../constants').timeAggregation;
var isValidTimeAggregation = require('../../constants').isValidTimeAggregation;
/**
* A dataview to represent an histogram of temporal data allowing to specify the granularity of the {@link carto.dataview.timeAggregation|temporal bins.}
*
* @param {carto.source.Base} source - The source where the dataview will fetch the data
* @param {string} column - The column name to get the data
* @param {object} [options]
* @param {carto.dataview.timeAggregation} [options.aggregation=auto] - Granularity of time aggregation
* @param {number} [options.offset] - Number of hours to offset the aggregation from UTC
* @param {boolean} [options.useLocalTimezone] - Indicates whether to use the local user timezone, or not
*
* @fires dataChanged
* @fires columnChanged
* @fires statusChanged
* @fires error
*
* @fires binsChanged
* @fires aggregationChanged
* @fires offsetChanged
* @fires localTimezoneChanged
*
* @constructor
* @extends carto.dataview.Base
* @memberof carto.dataview
* @api
* @example
* // We have a tweets dataset and we want to show a "per hour histogram" with the data.
* var timeSeries = new carto.dataview.TimeSeries(source0, 'last_review', {
* offset: 0,
* aggregation: 'hour'
* });
* @example
* // You can listen to multiple events emmited by the time-series-dataview.
* // Data and status are fired by all dataviews.
* timeSeries.on('dataChanged', newData => { });
* timeSeries.on('statusChanged', (newData, error) => { });
* timeSeries.on('error', cartoError => { });
*/
function TimeSeries (source, column, options) {
this._initialize(source, column, options);
this._aggregation = this._options.aggregation;
this._offset = _hoursToSeconds(this._options.offset);
this._localTimezone = this._options.useLocalTimezone;
}
TimeSeries.prototype = Object.create(Base.prototype);
TimeSeries.prototype.DEFAULTS = {
aggregation: timeAggregation.AUTO,
offset: 0,
useLocalTimezone: false
};
/**
* Return the resulting data.
*
* @return {carto.dataview.TimeSeriesData}
* @api
*/
TimeSeries.prototype.getData = function () {
if (this._internalModel) {
return parseTimeSeriesData(
this._internalModel.get('data'),
this._internalModel.get('nulls'),
this._internalModel.get('totalAmount'),
this._internalModel.getCurrentOffset()
);
}
return null;
};
/**
* Set time aggregation.
*
* @param {carto.dataview.timeAggregation} aggregation
* @fires aggregationChanged
* @return {carto.dataview.TimeSeries} this
* @api
*/
TimeSeries.prototype.setAggregation = function (aggregation) {
this._validateAggregation(aggregation);
this._changeProperty('aggregation', aggregation);
return this;
};
/**
* Return the current time aggregation.
*
* @return {carto.dataview.timeAggregation} Current time aggregation
* @api
*/
TimeSeries.prototype.getAggregation = function () {
return this._aggregation;
};
/**
* Set time offset in hours.
*
* @param {number} offset
* @fires offsetChanged
* @return {carto.dataview.TimeSeries} this
* @api
*/
TimeSeries.prototype.setOffset = function (offset) {
this._validateOffset(offset);
this._changeProperty('offset', _hoursToSeconds(offset));
return this;
};
/**
* Return the current time offset in hours.
*
* @return {number} Current time offset
* @api
*/
TimeSeries.prototype.getOffset = function () {
return _secondsToHours(this._offset);
};
/**
* Set the local timezone flag. If enabled, the time offset is overriden by the user's local timezone.
*
* @param {boolean} localTimezone
* @fires localTimezoneChanged
* @return {carto.dataview.TimeSeries} this
* @api
*/
TimeSeries.prototype.useLocalTimezone = function (enable) {
this._validateLocalTimezone(enable);
this._changeProperty('localTimezone', enable);
return this;
};
/**
* Return the current local timezone flag.
*
* @return {boolean} Current local timezone flag
* @api
*/
TimeSeries.prototype.isUsingLocalTimezone = function () {
return this._localTimezone;
};
TimeSeries.prototype._checkOptions = function (options) {
if (_.isUndefined(options)) {
throw this._getValidationError('timeSeriesOptionsRequired');
}
this._validateAggregation(options.aggregation);
this._validateOffset(options.offset);
this._validateLocalTimezone(options.useLocalTimezone);
};
TimeSeries.prototype._validateAggregation = function (aggregation) {
if (!isValidTimeAggregation(aggregation)) {
throw this._getValidationError('timeSeriesInvalidAggregation');
}
};
TimeSeries.prototype._validateOffset = function (offset) {
if (!_.isFinite(offset) || Math.floor(offset) !== offset || offset < -12 || offset > 14) {
throw this._getValidationError('timeSeriesInvalidOffset');
}
};
TimeSeries.prototype._validateLocalTimezone = function (localTimezone) {
if (!_.isBoolean(localTimezone)) {
throw this._getValidationError('timeSeriesInvalidUselocaltimezone');
}
};
TimeSeries.prototype._createInternalModel = function (engine) {
this._internalModel = new HistogramDataviewModel({
source: this._source.$getInternalModel(),
column: this._column,
aggregation: this._aggregation,
offset: this._offset,
localTimezone: this._localTimezone,
sync_on_bbox_change: !!this._boundingBoxFilter,
sync_on_circle_change: !!this._circleFilter,
sync_on_polygon_change: !!this._polygonFilter,
enabled: this._enabled,
column_type: 'date'
}, {
engine: engine,
bboxFilter: this._boundingBoxFilter && this._boundingBoxFilter.$getInternalModel(),
circleFilter: this._circleFilter && this._circleFilter.$getInternalModel(),
polygonFilter: this._polygonFilter && this._polygonFilter.$getInternalModel()
});
};
// Utility functions
function _hoursToSeconds (hours) {
return hours * 3600;
}
function _secondsToHours (seconds) {
return seconds / 3600;
}
module.exports = TimeSeries;
/**
* Fired when aggregation has changed. Handler gets a parameter with the new aggregation.
*
* @event aggregationChanged
* @type {string}
* @api
*/
/**
* Fired when localTimezone has changed. Handler gets a parameter with the new timezone.
*
* @event localTimezoneChanged
* @type {boolean}
* @api
*/
/**
* Fired when offset has changed. Handler gets a parameter with the new offset.
*
* @event offsetChanged
* @type {string}
* @api
*/

View File

@@ -0,0 +1,73 @@
var _ = require('underscore');
function secondsToHours (seconds) {
return seconds / 3600;
}
/**
* Transform the data obtained from an internal timeseries dataview into a public object.
*
* @param {object[]} data - The raw time series data
* @param {number} nulls - Number of data with a null
* @param {number} totalAmount - Total number of data in the histogram
*
* @return {TimeSeriesData} - The parsed and formatted data for the given parameters
*/
function parseTimeSeriesData (data, nulls, totalAmount, offset) {
if (!data) {
return null;
}
var compactData = _.compact(data);
var maxBin = _.max(compactData, function (bin) { return bin.freq || 0; });
var maxFreq = _.isFinite(maxBin.freq) && maxBin.freq !== 0
? maxBin.freq
: null;
/**
* @description
* Object containing time series data.
*
* @typedef {object} carto.dataview.TimeSeriesData
* @property {number} nulls - The number of items with null value
* @property {number} totalAmount - The number of elements returned
* @property {number} offset - The time offset in hours. Needed to format UTC timestamps into the proper timezone format
* @property {carto.dataview.TimeSeriesBinItem[]} bins - Array containing the {@link carto.dataview.TimeSeriesBinItem|data bins} for the time series
* @api
*/
return {
bins: _createBins(compactData, maxFreq),
nulls: nulls || 0,
offset: secondsToHours(offset),
totalAmount: totalAmount
};
}
/**
* Transform the time series raw data into {@link carto.dataview.TimeSeriesBinItem}.
*/
function _createBins (data, maxFreq) {
return data.map(function (bin) {
/**
* @typedef {object} carto.dataview.TimeSeriesBinItem
* @property {number} index - Number indicating the bin order
* @property {number} start - Starting UTC timestamp of the bin
* @property {number} end - End UTC timestamp of the bin
* @property {number} min - Minimum UTC timestamp present in the bin. Only appears if freq > 0
* @property {number} max - Maximum UTC timestamp present in the bin. Only appears if freq > 0
* @property {number} freq - Numbers of elements present in the bin
* @property {number} normalized - Normalized frequency with respect to the whole dataset
* @api
*/
return {
index: bin.bin,
start: bin.start,
end: bin.end,
min: bin.min,
max: bin.max,
freq: bin.freq,
normalized: _.isFinite(bin.freq) && maxFreq > 0 ? bin.freq / maxFreq : 0
};
});
}
module.exports = parseTimeSeriesData;

View File

@@ -0,0 +1,92 @@
var _ = require('underscore');
var ERROR_LIST = require('./error-list');
/**
* Returns two parameters to enrich a CartoError.
* - friendlyMessage: A easy to understand error description.
* - errorCode: Am unique error code
*
* @param {CartoError} cartoError
*
* @returns {object} - An object containing a friendly message and a errorCode
*/
function getExtraFields (cartoError) {
var errorlist = _getErrorList(cartoError);
var listedError = _getListedError(cartoError, errorlist);
return {
friendlyMessage: listedError.friendlyMessage,
errorCode: listedError.errorCode
};
}
/**
*
* @param {CartoError} cartoError
*/
function _getErrorList (cartoError) {
return ERROR_LIST[cartoError.origin] && ERROR_LIST[cartoError.origin][cartoError.type];
}
/**
* Get the listed error from a cartoError, if no listedError is found return a generic
* unknown error.
* @param {CartoError} cartoError
*/
function _getListedError (cartoError, errorList) {
var errorListkeys = _.keys(errorList);
var key;
for (var i = 0; i < errorListkeys.length; i++) {
key = errorListkeys[i];
if (!(errorList[key].messageRegex instanceof RegExp)) {
throw new Error('MessageRegex on ' + key + ' is not a RegExp.');
}
if (errorList[key].messageRegex.test(cartoError.message)) {
return {
friendlyMessage: _replaceRegex(cartoError, errorList[key]),
errorCode: _buildErrorCode(cartoError, key)
};
}
}
// When cartoError not found return generic values
return {
friendlyMessage: cartoError.message || '',
errorCode: _buildErrorCode(cartoError, 'unknown-error')
};
}
/**
* Replace $0 and $1 with the proper paramter in the listedError regex to build a friendly message
*/
function _replaceRegex (cartoError, listedError) {
if (!listedError.friendlyMessage) {
return cartoError.message;
}
var match = cartoError.message && cartoError.message.match(listedError.messageRegex);
if (match && match.length > 1) {
var replaced = listedError.friendlyMessage.replace('$0', match[1]);
if (match.length > 2) {
replaced = replaced.replace('$1', match[2]);
}
return replaced;
}
return listedError.friendlyMessage;
}
/**
* Generate an unique string that represents a cartoError
* @param {cartoError} cartoError
* @param {string} key
*/
function _buildErrorCode (cartoError, key) {
var fragments = [];
fragments.push(cartoError && cartoError.origin);
fragments.push(cartoError && cartoError.type);
fragments.push(key);
fragments = _.compact(fragments);
return fragments.join(':');
}
module.exports = { getExtraFields: getExtraFields };

View File

@@ -0,0 +1,137 @@
var errorExtender = require('./carto-error-extender');
var errorTracker = require('./error-tracker');
var UNEXPECTED_ERROR = 'unexpected error';
var GENERIC_ORIGIN = 'generic';
/**
* Build a cartoError from a generic error.
* @constructor
*
* @return {CartoError} A well formed object representing the error.
*/
function CartoError (error, opts) {
opts = opts || {};
var cartoError = Object.create(Error.prototype);
cartoError.message = (error && error.message) || UNEXPECTED_ERROR;
cartoError.origin = (error && error.origin) || GENERIC_ORIGIN;
cartoError.type = (error && error.type) || '';
if (_isWindshaftError(error)) {
cartoError = _transformWindshaftError(error, opts.layers, opts.analysis);
}
if (_isAjaxError(error)) {
cartoError = _transformAjaxError(error);
}
// Add extra fields
var extraFields = errorExtender.getExtraFields(cartoError);
cartoError.message = extraFields.friendlyMessage;
cartoError.errorCode = extraFields.errorCode;
// Final properties
cartoError.name = 'CartoError';
cartoError.stack = (new Error()).stack;
cartoError.originalError = error;
errorTracker.track(cartoError);
return cartoError;
}
// Windshaft should have been parsed already
function _isWindshaftError (error) {
return error && error.origin === 'windshaft';
}
function _isAjaxError (error) {
return error && error.responseText;
}
function _transformWindshaftError (error, layers, analysis) {
var cartoError = Object.create(Error.prototype);
cartoError.message = error.message;
cartoError.origin = error.origin;
cartoError.type = error.type;
if (error.type === 'layer' && layers) {
cartoError.layer = layers.findById(error.layerId);
}
if (error.type === 'analysis') {
if (analysis) {
cartoError.source = analysis;
cartoError.sourceId = analysis.getId && analysis.getId();
}
if (error.analysisId) {
cartoError.sourceId = error.analysisId;
}
}
return cartoError;
}
function _transformAjaxError (error) {
var cartoError = Object.create(Error.prototype);
cartoError.message = _handleAjaxResponse(error);
cartoError.origin = 'ajax';
cartoError.type = error.statusText;
return cartoError;
}
function _handleAjaxResponse (error) {
var errorMessage = '';
try {
var parsedError = JSON.parse(error.responseText);
errorMessage = parsedError.errors[0];
} catch (exc) {
// Swallow parse error
}
return errorMessage || UNEXPECTED_ERROR;
}
module.exports = CartoError;
/**
* Represents an error in the carto library.
*
* Some actions like adding a layer to a map are asynchronous and require a server round trip.
* If some error happens during this communnication with the server, an error with a `CartoError` object
* will be fired.
*
* CartoErrors can be obtained by listening to the client 'error' `client.on('error', callback);`,
* through any async action or by listening to 'error' events on particular objects (eg: dataviews).
*
* Promises are also rejected with a CartoError.
* @example
* // Listen when a layer has been added or there has been an error.
* client.addLayer(layerWithErrors)
* .then(()=> console.log('Layer added succesfully'))
* .catch(cartoError => console.error(cartoError.message))
* @example
* // Events also will be registered here when the map changes.
* client.on('success', function () {
* console.log('Client reloaded');
* });
*
* client.on('error', function (clientError) {
* console.error(clientError.message);
* });
* @example
* // Listen when there is an error in a dataview
* dataview.on('error', function (error) {
* console.error(error.message);
* });
*
* @typedef {object} CartoError
* @property {string} message - A short error description
* @property {string} name - The name of the error "CartoError"
* @property {string} origin - Where the error was originated: 'windshaft' | 'ajax' | 'validation'
* @property {object} originalError - An object containing the internal/original error
* @property {object} stack - Error stack trace
* @property {string} type - Error type
* @property {string} sourceId - Available if the error is related to a source object. Indicates the ID of the source that has a problem.
* @api
*/

View File

@@ -0,0 +1,17 @@
var CartoError = require('./carto-error');
/**
* Utility to build a cartoError related to validation errors.
* @constructor
*
* @return {CartoError} A well formed object representing the error.
*/
function CartoValidationError (type, message, opts) {
return new CartoError({
origin: 'validation',
type: type,
message: message
}, opts);
}
module.exports = CartoValidationError;

View File

@@ -0,0 +1,3 @@
module.exports = {
};

View File

@@ -0,0 +1,9 @@
var windshaft = require('./windshaft-errors');
var ajax = require('./ajax-errors');
var validation = require('./validation-errors');
module.exports = {
ajax: ajax,
windshaft: windshaft,
validation: validation
};

View File

@@ -0,0 +1,272 @@
module.exports = {
layer: {
'non-valid-source': {
messageRegex: /nonValidSource/,
friendlyMessage: 'The given object is not a valid source. See "carto.source.Base".'
},
'non-valid-style': {
messageRegex: /nonValidStyle/,
friendlyMessage: 'The given object is not a valid style. See "carto.style.Base".'
},
'non-valid-columns': {
messageRegex: /nonValidColumns/,
friendlyMessage: 'The given object is not a valid array of string columns.'
},
'source-with-different-client': {
messageRegex: /differentSourceClient/,
friendlyMessage: "A layer can't have a source which belongs to a different client."
},
'style-with-different-client': {
messageRegex: /differentStyleClient/,
friendlyMessage: "A layer can't have a style which belongs to a different client."
},
'wrong-interactivity-columns': {
messageRegex: /wrongInteractivityColumns\[(.+)\]#(.+)$/,
friendlyMessage: 'Columns [$0] set on `$1` do not match the columns set in aggregation options.'
}
},
source: {
'query-required': {
messageRegex: /requiredQuery/,
friendlyMessage: 'SQL Source must have a SQL query.'
},
'query-string': {
messageRegex: /requiredString/,
friendlyMessage: 'SQL Query must be a string.'
},
'no-dataset-name': {
messageRegex: /noDatasetName/,
friendlyMessage: 'Table name is required.'
},
'dataset-string': {
messageRegex: /requiredDatasetString$/,
friendlyMessage: 'Table name must be a string.'
},
'dataset-required': {
messageRegex: /requiredDataset$/,
friendlyMessage: 'Table name must be not empty.'
}
},
style: {
'required-css': {
messageRegex: /requiredCSS$/,
friendlyMessage: 'CartoCSS is required.'
},
'css-string': {
messageRegex: /requiredCSSString$/,
friendlyMessage: 'CartoCSS must be a string.'
}
},
client: {
'bad-layer-type': {
messageRegex: /badLayerType/,
friendlyMessage: 'The given object is not a layer.'
},
'index-number': {
messageRegex: /indexNumber/,
friendlyMessage: 'index property must be a number.'
},
'index-out-of-range': {
messageRegex: /indexOutOfRange/,
friendlyMessage: 'index is out of range.'
},
'api-key-required': {
messageRegex: /apiKeyRequired/,
friendlyMessage: 'apiKey property is required.'
},
'api-key-string': {
messageRegex: /apiKeyString/,
friendlyMessage: 'apiKey property must be a string.'
},
'username-required': {
messageRegex: /usernameRequired/,
friendlyMessage: 'username property is required.'
},
'username-string': {
messageRegex: /usernameString/,
friendlyMessage: 'username property must be a string.'
},
'non-valid-server-url': {
messageRegex: /nonValidServerURL/,
friendlyMessage: 'serverUrl is not a valid URL.'
},
'non-matching-server-url': {
messageRegex: /serverURLDoesntMatchUsername/,
friendlyMessage: "serverUrl doesn't match the username."
},
'duplicated-layer-id': {
messageRegex: /duplicatedLayerId/,
friendlyMessage: 'A layer with the same ID already exists in the client.'
}
},
dataview: {
'source-required': {
messageRegex: /sourceRequired/,
friendlyMessage: 'Source property is required.'
},
'column-required': {
messageRegex: /columnRequired/,
friendlyMessage: 'Column property is required.'
},
'column-string': {
messageRegex: /columnString/,
friendlyMessage: 'Column property must be a string.'
},
'empty-column': {
messageRegex: /emptyColumn/,
friendlyMessage: 'Column property must be not empty.'
},
'filter-required': {
messageRegex: /filterRequired/,
friendlyMessage: 'Filter property is required.'
},
'time-series-options-required': {
messageRegex: /timeSeriesOptionsRequired/,
friendlyMessage: 'Options object to create a time series dataview is required.'
},
'time-series-invalid-aggregation': {
messageRegex: /timeSeriesInvalidAggregation/,
friendlyMessage: 'Time aggregation must be a valid value. Use carto.dataview.timeAggregation.'
},
'time-series-invalid-offset': {
messageRegex: /timeSeriesInvalidOffset/,
friendlyMessage: 'Offset must an integer value between -12 and 14.'
},
'time-series-invalid-uselocaltimezone': {
messageRegex: /timeSeriesInvalidUselocaltimezone/,
friendlyMessage: 'useLocalTimezone must be a boolean value.'
},
'histogram-options-required': {
messageRegex: /histogramOptionsRequired/,
friendlyMessage: 'Options object to create a histogram dataview is required.'
},
'histogram-invalid-bins': {
messageRegex: /histogramInvalidBins/,
friendlyMessage: 'Bins must be a positive integer value.'
},
'histogram-invalid-start-end': {
messageRegex: /histogramInvalidStartEnd/,
friendlyMessage: 'Both start and end values must be a number or null.'
},
'formula-options-required': {
messageRegex: /formulaOptionsRequired/,
friendlyMessage: 'Formula dataview options are not defined.'
},
'formula-invalid-operation': {
messageRegex: /formulaInvalidOperation/,
friendlyMessage: 'Operation for formula dataview is not valid. Use carto.operation'
},
'category-options-required': {
messageRegex: /categoryOptionsRequired/,
friendlyMessage: 'Category dataview options are not defined.'
},
'category-limit-required': {
messageRegex: /categoryLimitRequired/,
friendlyMessage: 'Limit for category dataview is required.'
},
'category-limit-number': {
messageRegex: /categoryLimitNumber/,
friendlyMessage: 'Limit for category dataview must be a number.'
},
'category-limit-positive': {
messageRegex: /categoryLimitPositive/,
friendlyMessage: 'Limit for category dataview must be greater than 0.'
},
'category-invalid-operation': {
messageRegex: /categoryInvalidOperation/,
friendlyMessage: 'Operation for category dataview is not valid. Use carto.operation'
},
'category-operation-required': {
messageRegex: /categoryOperationRequired/,
friendlyMessage: 'Operation column for category dataview is required.'
},
'category-operation-string': {
messageRegex: /categoryOperationString/,
friendlyMessage: 'Operation column for category dataview must be a string.'
},
'category-operation-empty': {
messageRegex: /categoryOperationEmpty/,
friendlyMessage: 'Operation column for category dataview must be not empty.'
}
},
filter: {
'invalid-bounds-object': {
messageRegex: /invalidBoundsObject/,
friendlyMessage: 'Bounds object is not valid. Use a carto.filter.Bounds object'
},
'invalid-circle-object': {
messageRegex: /invalidCircleObject/,
friendlyMessage: 'Circle object is not valid. Use a carto.filter.CircleData object'
},
'invalid-polygon-object': {
messageRegex: /invalidPolygonObject/,
friendlyMessage: 'Polygon object is not valid. Use a carto.filter.PolygonData object'
},
'column-required': {
messageRegex: /columnRequired/,
friendlyMessage: 'Column property is required.'
},
'column-string': {
messageRegex: /columnString/,
friendlyMessage: 'Column property must be a string.'
},
'empty-column': {
messageRegex: /emptyColumn/,
friendlyMessage: 'Column property must be not empty.'
},
'invalid-filter': {
messageRegex: /invalidFilter(.+)/,
friendlyMessage: "'$0' is not a valid filter. Please check documentation."
},
'invalid-option': {
messageRegex: /invalidOption(.+)/,
friendlyMessage: "'$0' is not a valid option for this filter."
},
'wrong-filter-type': {
messageRegex: /wrongFilterType/,
friendlyMessage: 'Filters need to extend from carto.filter.SQLBase. Please use carto.filter.Category or carto.filter.Range.'
},
'invalid-parameter-type': {
messageRegex: /invalidParameterType(.+)/,
friendlyMessage: "Invalid parameter type for '$0'. Please check filters documentation."
}
},
aggregation: {
'threshold-required': {
messageRegex: /thresholdRequired/,
friendlyMessage: 'Aggregation threshold is required.'
},
'invalid-threshold': {
messageRegex: /invalidThreshold/,
friendlyMessage: 'Aggregation threshold must be an integer value greater than 0.'
},
'resolution-required': {
messageRegex: /resolutionRequired/,
friendlyMessage: 'Aggregation resolution is required.'
},
'invalid-resolution': {
messageRegex: /invalidResolution/,
friendlyMessage: 'Aggregation resolution must be 0.5, 1 or powers of 2 up to 256 (2, 4, 8, 16, 32, 64, 128, 256).'
},
'invalid-placement': {
messageRegex: /invalidPlacement/,
friendlyMessage: 'Aggregation placement is not valid. Must be one of these values: `point-sample`, `point-grid`, `centroid`'
},
'column-function-required': {
messageRegex: /columnFunctionRequired(.+)$/,
friendlyMessage: "Aggregation function for column '$0' is required."
},
'invalid-column-function': {
messageRegex: /invalidColumnFunction(.+)$/,
friendlyMessage: "Aggregation function for column '$0' is not valid. Use carto.aggregation.function"
},
'column-aggregated-column-required': {
messageRegex: /columnAggregatedColumnRequired(.+)$/,
friendlyMessage: "Column to be aggregated to '$0' is required."
},
'invalid-column-aggregated-column': {
messageRegex: /invalidColumnAggregatedColumn(.+)$/,
friendlyMessage: "Column to be aggregated to '$0' must be a string."
}
}
};

View File

@@ -0,0 +1,72 @@
module.exports = {
analysis: {
'sql-syntax-error': {
messageRegex: /^syntax error/
},
'invalid-dataset': {
messageRegex: /relation (.+) does not exist/,
friendlyMessage: 'Invalid dataset name used. Dataset $0 does not exist.'
},
'column-does-not-exist': {
messageRegex: /column (.+) does not exist/,
friendlyMessage: 'Invalid column name. Column $0 does not exist.'
},
'analysis-requires-authentication': {
messageRegex: /^Analysis requires authentication with API key/
}
},
generic: {
},
limit: {
'over-platform-limits': {
messageRegex: /^You are over platform's limits/
},
'generic-limit-error': {
messageRegex: /.*/,
friendlyMessage: 'The server is taking too long to respond, due to poor conectivity or a temporary error with our servers. Please try again soon.'
}
},
tile: {
'generic-tile-error': {
messageRegex: /.*/,
friendlyMessage: 'Some tiles might not be rendering correctly.'
}
},
layer: {
'column-does-not-exist': {
messageRegex: /column (.+) does not exist/,
friendlyMessage: 'Invalid column name. Column $0 does not exist.'
},
'unrecognized-rule': {
messageRegex: /Unrecognized rule: (.+)/,
friendlyMessage: 'Unrecognized rule "$0"'
},
'generic-layer-error': {
messageRegex: /.*/
}
},
dataview: {
'formula-does-not-support-operation': {
messageRegex: /Formula does not support (.+) operation/
},
'column-does-not-exist': {
messageRegex: /column (.+) does not exist/
},
'permission-denied': {
messageRegex: /permission denied for (.+)/
},
'wrong-type-column-used-in-time-series': {
messageRegex: /function date_part\(unknown, (.+)\) does not exist/,
friendlyMessage: 'Your time series column type is $0. Please use a date type.'
},
'invalid-aggregation-value': {
messageRegex: /Invalid aggregation value. Valid ones: auto, minute, hour, day, week, month, quarter, year, decade, century, millennium/
}
},
auth: {
'forbidden': {
messageRegex: /^Forbidden$/,
friendlyMessage: 'Forbidden. API key does not grant access.'
}
}
};

View File

@@ -0,0 +1,15 @@
/**
* Use {@link http://docs.trackjs.com/tracker/top-level-api} for error logging.
*/
function track (error) {
if (window.trackJs) {
try {
var message = error ? error.message + ' - code: ' + error.errorCode : JSON.stringify(error);
window.trackJs.track(new Error(message));
} catch (exc) {
// Swallow
}
}
}
module.exports = {track: track};

19
src/api/v4/events.js Normal file
View File

@@ -0,0 +1,19 @@
/**
* Fired when something went wrong on the server side.
*
* @event error
* @type {CartoError}
* @api
*/
/**
* Fired when a request to the server completed successfully.
*
* @event success
* @api
*/
module.exports = {
SUCCESS: 'success',
ERROR: 'error'
};

34
src/api/v4/filter/and.js Normal file
View File

@@ -0,0 +1,34 @@
const FiltersCollection = require('./filters-collection');
/**
* When including this filter into a {@link carto.source.SQL} or a {@link carto.source.Dataset}, the rows will be filtered by the conditions included within filters.
*
* This filter will group as many filters as you want and it will add them to the query returning the rows that match ALL the filters to render the visualization.
*
* You can add or remove filters by invoking `.addFilter()` and `.removeFilter()`.
*
* @example
* // Create a filter by room type, showing only private rooms
* const roomTypeFilter = new carto.filter.Category('room_type', { eq: 'Private room' });
* // Create a filter by price, showing only listings lower than or equal to 50€
* const priceFilter = new carto.filter.Range('price', { lte: 50 });
*
* // Combine the filters with an AND condition, returning rows that match both filters
* const filterByRoomTypeAndPrice = new carto.filter.AND([ roomTypeFilter, priceFilter ]);
*
* // Add filters to the existing source
* source.addFilter(filterByRoomTypeAndPrice);
*
* @class AND
* @extends carto.filter.FiltersCollection
* @memberof carto.filter
* @api
*/
class AND extends FiltersCollection {
constructor (filters) {
super(filters);
this.JOIN_OPERATOR = 'AND';
}
}
module.exports = AND;

View File

@@ -0,0 +1,195 @@
const _ = require('underscore');
const Base = require('./base');
const getObjectValue = require('../../../../src/util/get-object-value');
const ALLOWED_OPTIONS = ['includeNull'];
const DEFAULT_JOIN_OPERATOR = 'AND';
/**
* SQL Filter
*
* A SQL filter is the base for all the SQL filters such as the Category Filter or the Range filter
*
* @param {string} column - The filtering will be performed against this column
* @param {object} [options={}]
* @param {boolean} [options.includeNull] - Include null rows when returning data
*
* @class SQLBase
* @extends carto.filter.Base
* @memberof carto.filter
*/
class SQLBase extends Base {
constructor (column, options = {}) {
super();
this._checkColumn(column);
this._checkOptions(options);
this._column = column;
this._filters = {};
this._options = options;
}
/**
* Set any of the filter conditions, overwriting the previous one.
* @param {string} filterType - The filter type that you want to set
* @param {string} filterValue - The value of the filter
*/
set (filterType, filterValue) {
if (!filterType || !filterValue || !_.isString(filterType)) {
return;
}
const newFilter = { [filterType]: filterValue };
this._checkFilters(newFilter);
this._filters[filterType] = filterValue;
this.trigger('change:filters', newFilter);
}
/**
* Set the filter conditions, overriding all the previous ones.
* @param {object} filters - The object containing all the new filters to apply.
*/
setFilters (filters) {
if (!filters || !_.isObject(filters)) {
return;
}
this._checkFilters(filters);
this._filters = filters;
this.trigger('change:filters', filters);
}
/**
* Remove all conditions from current filter
*/
resetFilters () {
this.setFilters({});
}
$getSQL () {
const filters = Object.keys(this._filters);
let sql = filters
.map(filterType => this._interpolateFilter(filterType, this._filters[filterType]))
.filter(filter => Boolean(filter))
.join(` ${DEFAULT_JOIN_OPERATOR} `);
if (this._options.includeNull) {
this._includeNullInQuery(sql);
}
if (filters.length > 1) {
return `(${sql})`;
}
return sql;
}
_checkColumn (column) {
if (_.isUndefined(column)) {
throw this._getValidationError('columnRequired');
}
if (!_.isString(column)) {
throw this._getValidationError('columnString');
}
if (_.isEmpty(column)) {
throw this._getValidationError('emptyColumn');
}
}
_checkFilters (filters) {
Object.keys(filters).forEach(filter => {
const isFilterValid = _.contains(this.ALLOWED_FILTERS, filter);
if (!isFilterValid) {
throw this._getValidationError(`invalidFilter${filter}`);
}
const parameters = this.PARAMETER_SPECIFICATION[filter].parameters;
const haveCorrectType = parameters.every(
parameter => {
const parameterValue = getObjectValue(filters, parameter.name);
return parameter.allowedTypes.some(type => parameterIsOfType(type, parameterValue));
}
);
if (!haveCorrectType) {
throw this._getValidationError(`invalidParameterType${filter}`);
}
});
}
_checkOptions (options) {
Object.keys(options).forEach(option => {
const isOptionValid = _.contains(ALLOWED_OPTIONS, option);
if (!isOptionValid) {
throw this._getValidationError(`invalidOption${option}`);
}
});
}
_convertValueToSQLString (filterValue) {
if (_.isDate(filterValue)) {
return `'${filterValue.toISOString()}'`;
}
if (_.isArray(filterValue)) {
return filterValue
.map(value => this._convertValueToSQLString(value))
.join(',');
}
if (_.isObject(filterValue)) {
Object
.keys(filterValue)
.forEach(key => {
if (key === 'query') {
return;
}
filterValue[key] = this._convertValueToSQLString(filterValue[key]);
});
return filterValue;
}
if (_.isNumber(filterValue)) {
return filterValue;
}
return `'${normalizeString(filterValue.toString())}'`;
}
_interpolateFilter (filterType, filterValues) {
const sqlString = _.template(this.SQL_TEMPLATES[filterType]);
const value = this._convertValueToSQLString(filterValues);
return sqlString({ column: this._column, value });
}
_includeNullInQuery (sql) {
const filters = Object.keys(this._filters);
if (filters.length > 1) {
sql = `(${sql})`;
}
return `(${sql} OR ${this._column} IS NULL)`;
}
}
const parameterIsOfType = function (parameterType, parameterValue) {
return _[`is${parameterType}`](parameterValue);
};
const normalizeString = function (value) {
return value.replace(/\n/g, '\\n').replace(/\"/g, '\\"').replace(/'/g, "''");
};
module.exports = SQLBase;

45
src/api/v4/filter/base.js Normal file
View File

@@ -0,0 +1,45 @@
var _ = require('underscore');
var Backbone = require('backbone');
var CartoValidationError = require('../error-handling/carto-validation-error');
/**
* Base filter object
*
* @constructor
* @abstract
* @memberof carto.filter
* @api
*/
function Base () {}
_.extend(Base.prototype, Backbone.Events);
Base.prototype._getValidationError = function (code) {
return new CartoValidationError('filter', code);
};
module.exports = Base;
/**
* Fired when bounds have changed. Handler gets a parameter with the new bounds.
*
* @event boundsChanged
* @type {carto.filter.Bounds}
* @api
*/
/**
* Fired when circle filter has changed. Handler gets a parameter with the new circle.
*
* @event circleChanged
* @type {carto.filter.CircleData}
* @api
*/
/**
* Fired when polygon filter has changed. Handler gets a parameter with the new polygon.
*
* @event polygonChanged
* @type {carto.filter.PolygonData}
* @api
*/

View File

@@ -0,0 +1,72 @@
/* global google */
var Base = require('./base');
var GoogleMapsBoundingBoxAdapter = require('../../../geo/adapters/gmaps-bounding-box-adapter');
var BoundingBoxFilterModel = require('../../../windshaft/filters/bounding-box');
var utils = require('../../../core/util');
var SpatialFilterTypes = require('./spatial-filter-types');
/**
* Bounding box filter for Google Maps maps.
*
* When this filter is included into a dataview only the data inside the {@link https://developers.google.com/maps/documentation/javascript/3.exp/reference#Map|googleMap}
* bounds will be taken into account.
*
* @param {google.maps.map} map - The google map to track the bounds
*
* @fires boundsChanged
*
* @constructor
* @extends carto.filter.Base
* @memberof carto.filter
* @api
*
* @example
* // Create a bonding box attached to a google map.
* const bboxFilter = new carto.filter.BoundingBoxGoogleMaps(googleMap);
* // Add the filter to a dataview. Generating new data when the map bounds are changed.
* dataview.addFilter(bboxFilter);
*/
function BoundingBoxGoogleMaps (map) {
if (!_isGoogleMap(map)) {
throw new Error('Bounding box requires a Google Maps map but got: ' + map);
}
this.type = SpatialFilterTypes.BBOX;
// Adapt the Google Maps map to offer unique:
// - getBounds() function
// - 'boundsChanged' event
var mapAdapter = new GoogleMapsBoundingBoxAdapter(map);
// Use the adapter for the internal BoundingBoxFilter model
this._internalModel = new BoundingBoxFilterModel(mapAdapter);
this.listenTo(this._internalModel, 'boundsChanged', this._onBoundsChanged);
}
BoundingBoxGoogleMaps.prototype = Object.create(Base.prototype);
/**
* Return the current bounds.
*
* @return {carto.filter.Bounds} Current bounds
* @api
*/
BoundingBoxGoogleMaps.prototype.getBounds = function () {
return this._internalModel.getBounds();
};
BoundingBoxGoogleMaps.prototype._onBoundsChanged = function (bounds) {
this.trigger('boundsChanged', bounds);
};
BoundingBoxGoogleMaps.prototype.$getInternalModel = function () {
return this._internalModel;
};
// Helper to check if an element is a leafletmap object
function _isGoogleMap (element) {
// Check if Google Maps is loaded
utils.isGoogleMapsLoaded();
return element instanceof google.maps.Map;
}
module.exports = BoundingBoxGoogleMaps;

View File

@@ -0,0 +1,71 @@
/* global L */
var Base = require('./base');
var LeafletBoundingBoxAdapter = require('../../../geo/adapters/leaflet-bounding-box-adapter');
var BoundingBoxFilterModel = require('../../../windshaft/filters/bounding-box');
var utils = require('../../../core/util');
var SpatialFilterTypes = require('./spatial-filter-types');
/**
* Bounding box filter for Leaflet maps.
*
* When this filter is included into a dataview only the data inside the {@link http://leafletjs.com/reference-1.3.1.html#map|leafletMap}
* bounds will be taken into account.
*
* @param {L.Map} map - The leaflet map view
*
* @fires boundsChanged
*
* @constructor
* @extends carto.filter.Base
* @memberof carto.filter
* @api
*
* @example
* // Create a bonding box attached to a leaflet map.
* const bboxFilter = new carto.filter.BoundingBoxLeaflet(leafletMap);
* // Add the filter to a dataview. Generating new data when the map bounds are changed.
* dataview.addFilter(bboxFilter);
*/
function BoundingBoxLeaflet (map) {
if (!_isLeafletMap(map)) {
throw new Error('Bounding box requires a Leaflet map but got: ' + map);
}
this.type = SpatialFilterTypes.BBOX;
// Adapt the Leaflet map to offer unique:
// - getBounds() function
// - 'boundsChanged' event
var mapAdapter = new LeafletBoundingBoxAdapter(map);
// Use the adapter for the internal BoundingBoxFilter model
this._internalModel = new BoundingBoxFilterModel(mapAdapter);
this.listenTo(this._internalModel, 'boundsChanged', this._onBoundsChanged);
}
BoundingBoxLeaflet.prototype = Object.create(Base.prototype);
/**
* Return the current bounds.
*
* @return {carto.filter.Bounds} Current bounds
* @api
*/
BoundingBoxLeaflet.prototype.getBounds = function () {
return this._internalModel.getBounds();
};
BoundingBoxLeaflet.prototype._onBoundsChanged = function (bounds) {
this.trigger('boundsChanged', bounds);
};
BoundingBoxLeaflet.prototype.$getInternalModel = function () {
return this._internalModel;
};
// Helper to check if an element is a Leaflet map object
function _isLeafletMap (element) {
// Check if Leaflet is loaded
utils.isLeafletLoaded();
return element instanceof L.Map;
}
module.exports = BoundingBoxLeaflet;

View File

@@ -0,0 +1,93 @@
var _ = require('underscore');
var Base = require('./base');
var BoundingBoxFilterModel = require('../../../windshaft/filters/bounding-box');
var CartoValidationError = require('../error-handling/carto-validation-error');
var SpatialFilterTypes = require('./spatial-filter-types');
/**
* Generic bounding box filter.
*
* When this filter is included into a dataview only the data inside a custom bounding box will be taken into account.
*
* You can manually set the bounds via the `.setBounds()` method.
*
* This filter could be useful if you want give the users to ability to select a portion of the map and update the dataviews accordingly.
*
*
* @constructor
* @fires boundsChanged
* @extends carto.filter.Base
* @memberof carto.filter
* @api
*
*/
function BoundingBox () {
this._internalModel = new BoundingBoxFilterModel();
this.type = SpatialFilterTypes.BBOX;
}
BoundingBox.prototype = Object.create(Base.prototype);
/**
* Set the bounds.
*
* @param {carto.filter.Bounds} bounds
* @fires boundsChanged
* @return {carto.filter.BoundingBox} this
* @api
*/
BoundingBox.prototype.setBounds = function (bounds) {
this._checkBounds(bounds);
this._internalModel.setBounds(bounds);
this.trigger('boundsChanged', bounds);
return this;
};
/**
* Reset the bounds.
*
* @fires boundsChanged
* @return {carto.filter.BoundingBox} this
* @api
*/
BoundingBox.prototype.resetBounds = function () {
return this.setBounds({ west: 0, south: 0, east: 0, north: 0 });
};
/**
* Return the current bounds.
*
* @return {carto.filter.Bounds} Current bounds
* @api
*/
BoundingBox.prototype.getBounds = function () {
/**
* @typedef {object} carto.filter.Bounds
* @property {number} west - West coordinate
* @property {number} south - South coordinate
* @property {number} east - East coordinate
* @property {number} north - North coordinate
* @api
*/
return this._internalModel.getBounds();
};
BoundingBox.prototype._checkBounds = function (bounds) {
if (_.isUndefined(bounds) ||
_.isUndefined(bounds.west) ||
_.isUndefined(bounds.south) ||
_.isUndefined(bounds.east) ||
_.isUndefined(bounds.north) ||
!_.isNumber(bounds.west) ||
!_.isNumber(bounds.south) ||
!_.isNumber(bounds.east) ||
!_.isNumber(bounds.north)) {
throw new CartoValidationError('filter', 'invalidBoundsObject');
}
};
BoundingBox.prototype.$getInternalModel = function () {
return this._internalModel;
};
module.exports = BoundingBox;

View File

@@ -0,0 +1,107 @@
const SQLBase = require('./base-sql');
const CATEGORY_COMPARISON_OPERATORS = {
in: { parameters: [{ name: 'in', allowedTypes: ['Array', 'String', 'Object'] }] },
notIn: { parameters: [{ name: 'notIn', allowedTypes: ['Array', 'String', 'Object'] }] },
eq: { parameters: [{ name: 'eq', allowedTypes: ['String', 'Number', 'Date', 'Object'] }] },
notEq: { parameters: [{ name: 'notEq', allowedTypes: ['String', 'Number', 'Date', 'Object'] }] },
like: { parameters: [{ name: 'like', allowedTypes: ['String'] }] },
similarTo: { parameters: [{ name: 'similarTo', allowedTypes: ['String'] }] }
};
const ALLOWED_FILTERS = Object.freeze(Object.keys(CATEGORY_COMPARISON_OPERATORS));
/**
* When including this filter into a {@link carto.source.SQL} or a {@link carto.source.Dataset}, the rows will be filtered by the conditions included within the filter.
*
* You can filter columns with `in`, `notIn`, `eq`, `notEq`, `like`, `similarTo` filters, and update the conditions with `.set()` or `.setFilters()` method. It will refresh the visualization automatically when any filter is added or modified.
*
* This filter won't include null values within returned rows by default but you can include them by setting `includeNull` option.
*
* @param {string} column - The column which the filter will be performed against
* @param {object} filters - The filters you want to apply to the table rows
* @param {(string[]|object)} filters.in - Return rows whose column value is included within the provided values
* @param {string} filters.in.query - Return rows whose column value is included within query results
* @param {(string[]|object)} filters.notIn - Return rows whose column value is included within the provided values
* @param {string} filters.notIn.query - Return rows whose column value is not included within query results
* @param {(string|number|Date|object)} filters.eq - Return rows whose column value is equal to the provided value
* @param {string} filters.eq.query - Return rows whose column value is equal to the value returned by query
* @param {(string|number|Date|object)} filters.notEq - Return rows whose column value is not equal to the provided value
* @param {string} filters.notEq.query - Return rows whose column value is not equal to the value returned by query
* @param {string} filters.like - Return rows whose column value is like the provided value
* @param {string} filters.similarTo - Return rows whose column value is similar to the provided values
* @param {object} [options]
* @param {boolean} [options.includeNull] - Include null rows when returning data
*
* @example
* // Create a filter by room type, showing only private rooms
* const roomTypeFilter = new carto.filter.Category('room_type', { eq: 'Private Room' });
* airbnbDataset.addFilter(roomTypeFilter);
*
* @example
* // Create a filter by room type, showing only private rooms and entire apartments
* const roomTypeFilter = new carto.filter.Category('room_type', { in: ['Private Room', 'Entire home/apt'] });
* airbnbDataset.addFilter(roomTypeFilter);
*
* @example
* // Create a filter by room type, showing results included in subquery
* const roomTypeFilter = new carto.filter.Category('room_type', { in: { query: 'SELECT distinct(type) FROM rooms' } });
* airbnbDataset.addFilter(roomTypeFilter);
*
* @class Category
* @extends carto.filter.Base
* @memberof carto.filter
* @api
*/
class Category extends SQLBase {
constructor (column, filters = {}, options) {
super(column, options);
this.SQL_TEMPLATES = this._getSQLTemplates();
this.ALLOWED_FILTERS = ALLOWED_FILTERS;
this.PARAMETER_SPECIFICATION = CATEGORY_COMPARISON_OPERATORS;
this._checkFilters(filters);
this._filters = filters;
}
_getSQLTemplates () {
return {
in: '<% if (value) { %><%= column %> IN (<%= value.query || value %>)<% } else { %>true = false<% } %>',
notIn: '<% if (value) { %><%= column %> NOT IN (<%= value.query || value %>)<% } %>',
eq: '<%= column %> = <%= value.query ? "(" + value.query + ")" : value %>',
notEq: '<%= column %> != <%= value.query ? "(" + value.query + ")" : value %>',
like: '<%= column %> LIKE <%= value %>',
similarTo: '<%= column %> SIMILAR TO <%= value %>'
};
}
/**
* Set any of the filter conditions, overwriting the previous one.
* @param {string} filterType - The filter type that you want to set. `in`, `notIn`, `eq`, `notEq`, `like`, `similarTo`.
* @param {string} filterValue - The value of the filter. Check types in {@link carto.filter.Category}
*
* @memberof Category
* @method set
* @api
*/
/**
* Set filter conditions, overriding all the previous ones.
* @param {object} filters - Object containing all the new filters to apply. Check filter options in {@link carto.filter.Category}.
*
* @memberof Category
* @method setFilters
* @api
*/
/**
* Remove all conditions from current filter
*
* @memberof Category
* @method resetFilters
* @api
*/
}
module.exports = Category;

View File

@@ -0,0 +1,90 @@
var _ = require('underscore');
var Base = require('./base');
var CircleFilterModel = require('../../../windshaft/filters/circle');
var CartoValidationError = require('../error-handling/carto-validation-error');
var SpatialFilterTypes = require('./spatial-filter-types');
/**
* Generic circle filter.
*
* When this filter is included into a dataview only the data inside a custom circle will be taken into account.
*
* You can manually set the circle properties with the `setCircle()`.
*
* This filter could be useful if you want give the users the ability to select a buffer around a point of interest in the map and update the dataviews accordingly.
*
*
* @constructor
* @fires circleChanged
* @extends carto.filter.Base
* @memberof carto.filter
* @api
*
*/
function Circle () {
this._internalModel = new CircleFilterModel();
this.type = SpatialFilterTypes.CIRCLE;
}
Circle.prototype = Object.create(Base.prototype);
/**
* Set the circle.
*
* @param {carto.filter.CircleData} circle
* @fires circleChanged
* @return {carto.filter.Circle} this
* @api
*/
Circle.prototype.setCircle = function (circle) {
this._checkCircle(circle);
this._internalModel.setCircle(circle);
this.trigger('circleChanged', circle);
return this;
};
/**
* Reset the circle.
*
* @fires circleChanged
* @return {carto.filter.Circle} this
* @api
*/
Circle.prototype.resetCircle = function () {
return this.setCircle({ lat: 0, lng: 0, radius: 0 });
};
/**
* Return the current circle data
*
* @return {carto.filter.CircleData} Current circle data
* @api
*/
Circle.prototype.getCircle = function () {
/**
* @typedef {object} carto.filter.CircleData
* @property {number} lat - Center Latitude WGS84
* @property {number} lng - Center Longitude WGS84
* @property {number} radius - Radius in meters
* @api
*/
return this._internalModel.getCircle();
};
Circle.prototype._checkCircle = function (circle) {
if (_.isUndefined(circle) ||
_.isUndefined(circle.lat) ||
_.isUndefined(circle.lng) ||
_.isUndefined(circle.radius) ||
!_.isNumber(circle.lat) ||
!_.isNumber(circle.lng) ||
!_.isNumber(circle.radius)) {
throw new CartoValidationError('filter', 'invalidCircleObject');
}
};
Circle.prototype.$getInternalModel = function () {
return this._internalModel;
};
module.exports = Circle;

View File

@@ -0,0 +1,112 @@
const _ = require('underscore');
const Base = require('./base');
const SQLBase = require('./base-sql');
const DEFAULT_JOIN_OPERATOR = 'AND';
/**
* Base class for AND and OR filters.
*
* Filters Collection is a way to group a set of filters in order to create composed filters, allowing the user to change the operator that joins the filters.
*
* **This object should not be used directly.**
*
* @class FiltersCollection
* @abstract
* @extends carto.filter.Base
* @memberof carto.filter
* @api
*/
class FiltersCollection extends Base {
constructor (filters) {
super();
this._initialize(filters);
}
_initialize (filters) {
this._filters = [];
if (filters && filters.length) {
filters.map(filter => this.addFilter(filter));
}
}
/**
* Add a new filter to collection
*
* @param {(carto.filter.Range|carto.filter.Category|carto.filter.AND|carto.filter.OR)} filter
* @memberof FiltersCollection
* @api
*/
addFilter (filter) {
if (!(filter instanceof SQLBase) && !(filter instanceof FiltersCollection)) {
throw this._getValidationError('wrongFilterType');
}
if (_.contains(this._filters, filter)) return;
this.listenTo(filter, 'change:filters', filters => this._triggerFilterChange(filters));
this._filters.push(filter);
this._triggerFilterChange();
}
/**
* Remove an existing filter from collection
*
* @param {(carto.filter.Range|carto.filter.Category|carto.filter.AND|carto.filter.OR)} filter
* @returns {(carto.filter.Range|carto.filter.Category|carto.filter.AND|carto.filter.OR)} The removed element
* @memberof FiltersCollection
* @api
*/
removeFilter (filter) {
const filterIndex = _.indexOf(this._filters, filter);
if (filterIndex === -1) return;
const removedElement = this._filters.splice(filterIndex, 1)[0];
removedElement.off('change:filters', null, this);
this._triggerFilterChange();
return removedElement;
}
/**
* Get the number of added filters
*
* @returns {number} Number of contained filters
* @memberof FiltersCollection
* @api
*/
count () {
return this._filters.length;
}
/**
* Get added filters
*
* @returns {Array} Added filters
* @memberof FiltersCollection
* @api
*/
getFilters () {
return this._filters;
}
$getSQL () {
const sqlFilters = this._filters.map(filter => filter.$getSQL())
.filter(sqlString => Boolean(sqlString));
const joinedFilters = sqlFilters.join(` ${this.JOIN_OPERATOR || DEFAULT_JOIN_OPERATOR} `);
if (sqlFilters.length > 1) {
return `(${joinedFilters})`;
}
return joinedFilters;
}
_triggerFilterChange (filters) {
this.trigger('change:filters', filters);
}
}
module.exports = FiltersCollection;

View File

@@ -0,0 +1,25 @@
const BoundingBox = require('./bounding-box');
const BoundingBoxLeaflet = require('./bounding-box-leaflet');
const BoundingBoxGoogleMaps = require('./bounding-box-gmaps');
const Circle = require('./circle');
const Polygon = require('./polygon');
const Category = require('./category');
const Range = require('./range');
const AND = require('./and');
const OR = require('./or');
/**
* @namespace carto.filter
* @api
*/
module.exports = {
BoundingBox,
BoundingBoxLeaflet,
BoundingBoxGoogleMaps,
Circle,
Polygon,
Category,
Range,
AND,
OR
};

38
src/api/v4/filter/or.js Normal file
View File

@@ -0,0 +1,38 @@
const FiltersCollection = require('./filters-collection');
/**
* When including this filter into a {@link carto.source.SQL} or a {@link carto.source.Dataset}, the rows will be filtered by the conditions included within filters.
*
* This filter will group as many filters as you want and it will add them to the query returning the rows that match ANY of the filters to render the visualization.
*
* You can add or remove filters by invoking `.addFilter()` and `.removeFilter()`.
*
* @example
* // Create a filter by room type, showing only private rooms
* const roomTypeFilter = new carto.filter.Category('room_type', { eq: 'Private room' });
* // Create a filter by price, showing only listings lower than or equal to 50€
* const priceFilter = new carto.filter.Range('price', { lte: 50 });
*
* // Combine the filters with an OR operator, returning rows that match one or the other filter
* const filterByRoomTypeOrPrice = new carto.filter.OR([ roomTypeFilter, priceFilter ]);
*
* // Add filters to the existing source
* source.addFilter(filterByRoomTypeOrPrice);
*
* @class OR
* @extends carto.filter.FiltersCollection
* @memberof carto.filter
* @api
*/
class OR extends FiltersCollection {
/**
* Create a OR group filter
* @param {Array} filters - The filters to apply in the query
*/
constructor (filters) {
super(filters);
this.JOIN_OPERATOR = 'OR';
}
}
module.exports = OR;

View File

@@ -0,0 +1,91 @@
var _ = require('underscore');
var Base = require('./base');
var PolygonFilterModel = require('../../../windshaft/filters/polygon');
var CartoValidationError = require('../error-handling/carto-validation-error');
var SpatialFilterTypes = require('./spatial-filter-types');
/**
* Generic polygon filter.
*
* When this filter is included into a dataview only the data inside a custom polygon will be taken into account.
*
* You can manually set the polygon with the `setPolygon()`.
*
* This filter could be useful if you want give the users the ability to select a custom area in the map and update the dataviews accordingly.
*
*
* @constructor
* @fires polygonChanged
* @extends carto.filter.Base
* @memberof carto.filter
* @api
*
*/
function Polygon () {
this._internalModel = new PolygonFilterModel();
this.type = SpatialFilterTypes.POLYGON;
}
Polygon.prototype = Object.create(Base.prototype);
/**
* Set the polygon.
*
* @param {carto.filter.PolygonData} polygon
* @fires polygonChanged
* @return {carto.filter.Polygon} this
* @api
*/
Polygon.prototype.setPolygon = function (polygon) {
this._checkPolygon(polygon);
this._internalModel.setPolygon(polygon);
this.trigger('polygonChanged', polygon);
return this;
};
/**
* Reset the polygon.
*
* @fires polygonChanged
* @return {carto.filter.Polygon} this
* @api
*/
Polygon.prototype.resetPolygon = function () {
return this.setPolygon({
type: 'Polygon',
coordinates: []
});
};
/**
* Return the current polygon data
*
* @return {carto.filter.PolygonData} Current polygon data, expressed as a GeoJSON geometry fragment
* @api
*/
Polygon.prototype.getPolygon = function () {
/**
* @typedef {object} carto.filter.PolygonData
* @property {string} type - Geometry type, Just 'Polygon' is valid
* @property {Array.<number[]>} coordinates - Array of coordinates [lng, lat] as defined in GeoJSON geometries
* @api
*/
return this._internalModel.getPolygon();
};
Polygon.prototype._checkPolygon = function (polygon) {
if (_.isUndefined(polygon) ||
_.isUndefined(polygon.type) ||
_.isUndefined(polygon.coordinates) ||
!_.isString(polygon.type) ||
!_.isArray(polygon.coordinates) ||
polygon.type !== 'Polygon') {
throw new CartoValidationError('filter', 'invalidPolygonObject');
}
};
Polygon.prototype.$getInternalModel = function () {
return this._internalModel;
};
module.exports = Polygon;

140
src/api/v4/filter/range.js Normal file
View File

@@ -0,0 +1,140 @@
const SQLBase = require('./base-sql');
const RANGE_COMPARISON_OPERATORS = {
lt: { parameters: [{ name: 'lt', allowedTypes: ['Number', 'Date', 'Object'] }] },
lte: { parameters: [{ name: 'lte', allowedTypes: ['Number', 'Date', 'Object'] }] },
gt: { parameters: [{ name: 'gt', allowedTypes: ['Number', 'Date', 'Object'] }] },
gte: { parameters: [{ name: 'gte', allowedTypes: ['Number', 'Date', 'Object'] }] },
between: {
parameters: [
{ name: 'between.min', allowedTypes: ['Number', 'Date'] },
{ name: 'between.max', allowedTypes: ['Number', 'Date'] }
]
},
notBetween: {
parameters: [
{ name: 'notBetween.min', allowedTypes: ['Number', 'Date'] },
{ name: 'notBetween.max', allowedTypes: ['Number', 'Date'] }
]
},
betweenSymmetric: {
parameters: [
{ name: 'betweenSymmetric.min', allowedTypes: ['Number', 'Date'] },
{ name: 'betweenSymmetric.max', allowedTypes: ['Number', 'Date'] }
]
},
notBetweenSymmetric: {
parameters: [
{ name: 'notBetweenSymmetric.min', allowedTypes: ['Number', 'Date'] },
{ name: 'notBetweenSymmetric.max', allowedTypes: ['Number', 'Date'] }
]
}
};
const ALLOWED_FILTERS = Object.freeze(Object.keys(RANGE_COMPARISON_OPERATORS));
/**
* When including this filter into a {@link carto.source.SQL} or a {@link carto.source.Dataset}, the rows will be filtered by the conditions included within the filter.
*
* You can filter columns with `in`, `notIn`, `eq`, `notEq`, `like`, `similarTo` filters, and update the conditions with `.set()` or `.setFilters()` method. It will refresh the visualization automatically when any filter is added or modified.
*
* This filter won't include null values within returned rows by default but you can include them by setting `includeNull` option.
*
* @param {string} column - The column to filter rows
* @param {object} filters - The filters you want to apply to the column
* @param {(number|Date|object)} filters.lt - Return rows whose column value is less than the provided value
* @param {string} filters.lt.query - Return rows whose column value is less than the value returned by query
* @param {(number|Date|object)} filters.lte - Return rows whose column value is less than or equal to the provided value
* @param {string} filters.lte.query - Return rows whose column value is less than or equal to the value returned by query
* @param {(number|Date|object)} filters.gt - Return rows whose column value is greater than the provided value
* @param {string} filters.gt.query - Return rows whose column value is greater than the value returned by query
* @param {(number|Date|object)} filters.gte - Return rows whose column value is greater than or equal to the provided value
* @param {string} filters.gte.query - Return rows whose column value is greater than or equal to the value returned by query
* @param {(number|Date)} filters.between - Return rows whose column value is between the provided values
* @param {(number|Date)} filters.between.min - Lower value of the comparison range
* @param {(number|Date)} filters.between.max - Upper value of the comparison range
* @param {(number|Date)} filters.notBetween - Return rows whose column value is not between the provided values
* @param {(number|Date)} filters.notBetween.min - Lower value of the comparison range
* @param {(number|Date)} filters.notBetween.max - Upper value of the comparison range
* @param {(number|Date)} filters.betweenSymmetric - Return rows whose column value is between the provided values after sorting them
* @param {(number|Date)} filters.betweenSymmetric.min - Lower value of the comparison range
* @param {(number|Date)} filters.betweenSymmetric.max - Upper value of the comparison range
* @param {(number|Date)} filters.notBetweenSymmetric - Return rows whose column value is not between the provided values after sorting them
* @param {(number|Date)} filters.notBetweenSymmetric.min - Lower value of the comparison range
* @param {(number|Date)} filters.notBetweenSymmetric.max - Upper value of the comparison range
* @param {object} [options]
* @param {boolean} [options.includeNull] - Include null rows when returning data
*
* @example
* // Create a filter by price, showing only listings lower than or equal to 50€, and higher than 100€
* const priceFilter = new carto.filter.Range('price', { lte: 50, gt: 100 });
*
* // Add filter to the existing source
* airbnbDataset.addFilter(priceFilter);
*
* @example
* // Create a filter by price, showing only listings greater than or equal to the average price
* const priceFilter = new carto.filter.Range('price', { gte: { query: 'SELECT avg(price) FROM listings' } });
*
* // Add filter to the existing source
* airbnbDataset.addFilter(priceFilter);
*
* @class Range
* @extends carto.filter.Base
* @memberof carto.filter
* @api
*/
class Range extends SQLBase {
constructor (column, filters = {}, options) {
super(column, options);
this.SQL_TEMPLATES = this._getSQLTemplates();
this.ALLOWED_FILTERS = ALLOWED_FILTERS;
this.PARAMETER_SPECIFICATION = RANGE_COMPARISON_OPERATORS;
this._checkFilters(filters);
this._filters = filters;
}
_getSQLTemplates () {
return {
lt: '<%= column %> < <%= value.query ? "(" + value.query + ")" : value %>',
lte: '<%= column %> <= <%= value.query ? "(" + value.query + ")" : value %>',
gt: '<%= column %> > <%= value.query ? "(" + value.query + ")" : value %>',
gte: '<%= column %> >= <%= value.query ? "(" + value.query + ")" : value %>',
between: '<%= column %> BETWEEN <%= value.min %> AND <%= value.max %>',
notBetween: '<%= column %> NOT BETWEEN <%= value.min %> AND <%= value.max %>',
betweenSymmetric: '<%= column %> BETWEEN SYMMETRIC <%= value.min %> AND <%= value.max %>',
notBetweenSymmetric: '<%= column %> NOT BETWEEN SYMMETRIC <%= value.min %> AND <%= value.max %>'
};
}
/**
* Set any of the filter conditions, overwriting the previous one.
* @param {string} filterType - The filter type that you want to set. `lt`, `lte`, `gt`, `gte`, `between`, `notBetween`, `betweenSymmetric`, `notBetweenSymmetric`.
* @param {string} filterValue - The value of the filter. Check types in {@link carto.filter.Range}
*
* @memberof Range
* @method set
* @api
*/
/**
* Set filter conditions, overriding all the previous ones.
* @param {object} filters - Object containing all the new filters to apply. Check filter options in {@link carto.filter.Range}.
*
* @memberof Range
* @method setFilters
* @api
*/
/**
* Remove all conditions from current filter
*
* @memberof Range
* @method resetFilters
* @api
*/
}
module.exports = Range;

View File

@@ -0,0 +1,10 @@
/**
* Types of spatial filters
*/
var types = {
BBOX: 'bbox',
CIRCLE: 'circle',
POLYGON: 'polygon'
};
module.exports = types;

50
src/api/v4/index.js Normal file
View File

@@ -0,0 +1,50 @@
/**
* @api
* @namespace carto
*
* @description
* # CARTO.js
* All the library features are exposed through the `carto` namespace.
*
*
* - **Client** : The api client.
* - **source** : Source description
* - **style** : Style description
* - **layer** : Layer description
* - **dataview** : Dataview description
* - **filter** : Filter description
* - **events** : The events exposed.
* - **operation** : The operations exposed.
*/
// Add polyfill for `fetch`
require('whatwg-fetch');
// Add polyfill for `Promise`
var Promise = require('promise-polyfill');
if (!window.Promise) {
window.Promise = Promise;
}
var Client = require('./client');
var source = require('./source');
var style = require('./style');
var layer = require('./layer');
var dataview = require('./dataview');
var filter = require('./filter');
var events = require('./events');
var constants = require('./constants');
var carto = {
version: require('../../../package.json').version,
ATTRIBUTION: constants.ATTRIBUTION,
Client: Client,
source: source,
style: style,
layer: layer,
dataview: dataview,
filter: filter,
events: events,
operation: constants.operation
};
module.exports = carto;

View File

@@ -0,0 +1,178 @@
var _ = require('underscore');
var CartoValidationError = require('../error-handling/carto-validation-error');
/**
* List of possible aggregation operations.
* See {@link https://carto.com/developers/maps-api/tile-aggregation#columns } for more info.
* @enum {string} carto.layer.Aggregation.operation
* @memberof carto.layer.Aggregation
* @api
*/
var OPERATIONS = {
/** The new point will contain the average value of the or the aggregated ones */
AVG: 'avg',
/** The new point will contain the sum of the aggregated values */
SUM: 'sum',
/** The new point will contain the minimal value existing the aggregated features */
MIN: 'min',
/** The new point will contain the maximun value existing the aggregated features */
MAX: 'max',
/** The new point will contain the mode of the aggregated values */
MODE: 'mode'
};
/**
* List of possible aggregation feature placements.
* See {@link https://carto.com/developers/maps-api/tile-aggregation#placement } for more info.
* @enum {string} carto.layer.Aggregation.placement
* @memberof carto.layer.Aggregation
* @api
*/
var PLACEMENTS = {
/** The new point will be placed at a random sample of the aggregated points */
SAMPLE: 'point-sample',
/** The new point will be placed at the center of the aggregation grid cells */
GRID: 'point-grid',
/** The new point will be placed at averaged coordinated of the grouped points */
CENTROID: 'centroid'
};
var VALID_RESOLUTIONS = [0.5, 1, 2, 4, 8, 16, 32, 64, 128, 256];
/**
* An aggregation can be passed to a {@link carto.layer.Layer} to reduce the number of visible points
* increasing the performance.
*
* See {@link https://carto.com/developers/maps-api/guides/tile-aggregation/} for more info.
*
* @param {object} opts
* @param {number} opts.threshold - The minimum number of rows in the dataset for aggregation to be applied
* @param {number} opts.resolution - The cell-size of the spatial aggregation grid [more info]{@link https://carto.com/developers/maps-api/tile-aggregation#resolution}
* @param {string} opts.placement - The kind of [aggregated geometry]{@link https://carto.com/developers/maps-api/tile-aggregation#placement} generated
* @param {object} opts.columns - The new columns are computed by a applying an aggregate function to all the points in each group
* @param {string} opts.columns.aggregatedFunction - The Function used to aggregate the points: avg (average), sum, min (minimum), max (maximum) and mode (the most frequent value in the group)
* @param {string} opts.columns.aggregatedColumn - The name of the original column to be aggregated.
*
* @example
* // Create a layer with aggregated data.
* const aggregationOptions = {
* // CARTO applies aggregation if your dataset has more than threshold rows. In this case, more than 1 row.
* threshold: 1,
* // Defines the cell-size of the aggregation grid. In this case, 1x1 pixel.
* resolution: 1,
* // Where the new point will be placed. In this case, at the center of the grid.
* placement: carto.layer.Aggregation.placement.GRID,
* // Here we define the aggregated columns that we want to obtain.
* columns: {
* // Each property key is the name of the new generated column
* avg_population: {
* // The aggregated column will contain the average of the original data.
* aggregateFunction: carto.layer.Aggregation.operation.AVG,
* // The column to be aggregated
* aggregatedColumn: 'population'
* }, {
* min_population: {
* aggregateFunction: carto.layer.Aggregation.operation.MIN,
* aggregatedColumn: 'population'
* }
* };
* const aggregation = new Aggregation(options);
* const layer = new carto.layer.Layer(source, style, { aggregation: aggregation });
*
* @constructor
* @api
* @memberof carto.layer
*/
function Aggregation (opts) {
if (!_.isFinite(opts.threshold)) {
throw _getValidationError('thresholdRequired');
}
if (!_.isFinite(opts.threshold) || opts.threshold < 1 || Math.floor(opts.threshold) !== opts.threshold) {
throw _getValidationError('invalidThreshold');
}
if (!_.isFinite(opts.resolution)) {
throw _getValidationError('resolutionRequired');
}
if (!_.contains(VALID_RESOLUTIONS, opts.resolution)) {
throw _getValidationError('invalidResolution');
}
_checkValidPlacement(opts.placement);
var columns = _checkAndTransformColumns(opts.columns);
var aggregation = {
threshold: opts.threshold,
resolution: opts.resolution,
placement: opts.placement,
columns: columns
};
return _.pick(aggregation, _.identity); // Remove empty values
}
Aggregation.operation = OPERATIONS;
Aggregation.placement = PLACEMENTS;
function _checkColumns (columns) {
Object.keys(columns).forEach(function (key) {
_checkColumn(columns, key);
});
}
function _checkColumn (columns, key) {
if (!columns[key].aggregatedColumn) {
throw _getValidationError('columnAggregatedColumnRequired' + key);
}
if (!_.isString(columns[key].aggregatedColumn)) {
throw _getValidationError('invalidColumnAggregatedColumn' + key);
}
if (!columns[key].aggregateFunction) {
throw _getValidationError('columnFunctionRequired' + key);
}
if (!_.contains(_.values(OPERATIONS), columns[key].aggregateFunction)) {
throw _getValidationError('invalidColumnFunction' + key);
}
}
function _getValidationError (code) {
return new CartoValidationError('aggregation', code);
}
// Windshaft uses snake_case for column parameters
function _checkAndTransformColumns (columns) {
var returnValue = null;
if (columns) {
_checkColumns(columns);
returnValue = {};
Object.keys(columns).forEach(function (key) {
returnValue[key] = _columnToSnakeCase(columns[key]);
});
}
return returnValue;
}
// Windshaft uses snake_case for column parameters
function _columnToSnakeCase (column) {
return {
aggregate_function: column.aggregateFunction,
aggregated_column: column.aggregatedColumn
};
}
function _checkValidPlacement (placement) {
if (placement && !_.contains(_.values(PLACEMENTS), placement)) {
throw _getValidationError('invalidPlacement');
}
}
module.exports = Aggregation;

51
src/api/v4/layer/base.js Normal file
View File

@@ -0,0 +1,51 @@
var _ = require('underscore');
var Backbone = require('backbone');
/**
* Base layer object.
*
* This object should not be used directly! use {@link carto.layer.Layer} instead.
*
* @constructor
* @abstract
* @fires error
* @memberof carto.layer
* @api
*/
function Base (source, layer, options) {
options = options || {};
this._id = options.id || Base.$generateId();
}
_.extend(Base.prototype, Backbone.Events);
/**
* Get the unique autogenerated id.
*
* @return {string} Unique autogenerated id
*
*/
Base.prototype.getId = function () {
return this._id;
};
/**
* The instance id will be autogenerated by incrementing this variable.
*/
Base.$nextId = 0;
/**
* Static funciton used internally to autogenerate source ids.
*/
Base.$generateId = function () {
return 'L' + ++Base.$nextId;
};
/**
* Return the real CARTO.js model used by the layer.
*/
Base.prototype.$getInternalModel = function () {
return this._internalModel;
};
module.exports = Base;

View File

@@ -0,0 +1,27 @@
/**
* Events fired by a layer
*
* @enum {string}
* @readonly
* @memberof carto.layer
*/
var events = {
/**
* A feature has been clicked, fired every time the user clicks on a feature.
*/
FEATURE_CLICKED: 'featureClicked',
/**
* The mouse is over a feature, fired every time the user moves over a feature.
*/
FEATURE_OVER: 'featureOver',
/**
* The mouse exits a feature, fired every time the user moves out of a feature.
*/
FEATURE_OUT: 'featureOut',
/**
* There has been an error related to tiles, fired every time the features are not rendered due to an error.
*/
TILE_ERROR: 'featureError'
};
module.exports = events;

18
src/api/v4/layer/index.js Normal file
View File

@@ -0,0 +1,18 @@
var Layer = require('./layer');
var EventTypes = require('./event-types');
var Aggregation = require('./aggregation');
/**
* @namespace carto.layer
* @api
*/
module.exports = {
Aggregation: Aggregation,
Layer: Layer,
events: EventTypes
};
/**
* @namespace carto.layer.metadata
* @api
*/

576
src/api/v4/layer/layer.js Normal file
View File

@@ -0,0 +1,576 @@
var _ = require('underscore');
var Base = require('./base');
var CartoDBLayer = require('../../../geo/map/cartodb-layer');
var SourceBase = require('../source/base');
var StyleBase = require('../style/base');
var CartoError = require('../error-handling/carto-error');
var CartoValidationError = require('../error-handling/carto-validation-error');
var EVENTS = require('../events');
var metadataParser = require('./metadata/parser');
/**
* Represents a layer Object.
*
* A layer is the primary way to visualize geospatial data.
*
* To create a layer a {@link carto.source.Base|source} and {@link carto.style.Base|styles}
* are required:
*
* - The {@link carto.source.Base|source} is used to know **what** data will be displayed in the Layer.
* - The {@link carto.style.Base|style} is used to know **how** to draw the data in the Layer.
*
* A layer alone won't do too much. In order to get data from the CARTO server you must add the Layer to a {@link carto.Client|client}.
*
* ```
* // Create a layer. Remember this won't do anything unless the layer is added to a client.
* const layer = new carto.layer.Layer(source, style);
*```
*
* @param {carto.source.Base} source - The source where the layer will fetch the data
* @param {carto.style.CartoCSS} style - A CartoCSS object with the layer styling
* @param {object} [options]
* @param {Array<string>} [options.featureClickColumns=[]] - Columns that will be available for `featureClick` events
* @param {boolean} [options.visible=true] - A boolean value indicating the layer's visibility
* @param {Array<string>} [options.featureOverColumns=[]] - Columns that will be available for `featureOver` events
* @param {carto.layer.Aggregation} [options.aggregation={}] - Specify {@link carto.layer.Aggregation|aggregation } options
* @param {string} [options.id] - An unique identifier for the layer
* @fires metadataChanged
* @fires featureClicked
* @fires featureOut
* @fires featureOver
* @fires error
* @example
* const citiesSource = new carto.source.SQL('SELECT * FROM cities');
* const citiesStyle = new carto.style.CartoCSS(`
* #layer {
* marker-fill: #FABADA;
* marker-width: 10;
* }
* `);
* // Create a layer with no options
* new carto.layer.Layer(citiesSource, citiesStyle);
* @example
* const citiesSource = new carto.source.SQL('SELECT * FROM cities');
* const citiesStyle = new carto.style.CartoCSS(`
* #layer {
* marker-fill: #FABADA;
* marker-width: 10;
* }
* `);
* // Create a layer indicating what columns will be included in the featureOver event.
* new carto.layer.Layer(citiesSource, citiesStyle, {
* featureOverColumns: [ 'name' ]
* });
* @example
* const citiesSource = new carto.source.SQL('SELECT * FROM cities');
* const citiesStyle = new carto.style.CartoCSS(`
* #layer {
* marker-fill: #FABADA;
* marker-width: 10;
* }
* `);
* // Create a hidden layer
* new carto.layer.Layer(citiesSource, citiesStyle, { visible: false });
* @example
* // Listen to the event thrown when the mouse is over a feature
* layer.on('featureOver', featureEvent => {
* console.log(`Mouse over city with name: ${featureEvent.data.name}`);
* });
* @constructor
* @extends carto.layer.Base
* @memberof carto.layer
* @api
*/
function Layer (source, style, options = {}) {
Base.apply(this, arguments);
_checkSource(source);
_checkStyle(style);
this._client = undefined;
this._engine = undefined;
this._internalModel = undefined;
this._source = source;
this._style = style;
this._visible = _.isBoolean(options.visible) ? options.visible : true;
this._featureClickColumns = options.featureClickColumns || [];
this._featureOverColumns = options.featureOverColumns || [];
this._minzoom = options.minzoom || 0;
this._maxzoom = options.maxzoom || undefined;
this._aggregation = options.aggregation || {};
_validateAggregationColumnsAndInteractivity(this._aggregation.columns, this._featureClickColumns, this._featureOverColumns);
}
Layer.prototype = Object.create(Base.prototype);
/**
* Set a new style for this layer.
*
* @param {carto.style.CartoCSS} style - New style
* @fires styleChanged
* @fires error
* @return {Promise} A promise that will be fulfilled when the style is applied to the layer or rejected with a
* {@link CartoError} if something goes bad
*/
Layer.prototype.setStyle = function (style, opts) {
var prevStyle = this._style;
_checkStyle(style);
opts = opts || {};
if (prevStyle === style) {
return Promise.resolve();
}
if (!this._internalModel) {
this._style = style;
this.trigger('styleChanged', this);
return Promise.resolve();
}
// If style has an engine and is different from the layer`s engine throw an error
if (style.$getEngine() && style.$getEngine() !== this._internalModel._engine) {
throw new CartoValidationError('layer', 'differentStyleClient');
}
// If style has no engine, set the layer engine in the style.
if (!style.$getEngine()) {
style.$setEngine(this._engine);
}
this._internalModel.set('cartocss', style.getContent(), { silent: true });
return this._engine.reload()
.then(function () {
this._style = style;
this.trigger('styleChanged', this);
}.bind(this))
.catch(_rejectAndTriggerError.bind(this));
};
/**
* Get the current style for this layer.
*
* @return {carto.style.CartoCSS} Current style
* @api
*/
Layer.prototype.getStyle = function () {
return this._style;
};
/**
* Set a new source for this layer.
*
* A source and a layer must belong to the same client so you can't
* add a source belonging to a different client.
*
* @param {carto.source.Base} source - New source
* @fires sourceChanged
* @fires error
* @return {Promise} A promise that will be fulfilled when the style is applied to the layer or rejected with a
* {@link CartoError} if something goes bad
*/
Layer.prototype.setSource = function (source) {
var prevSource = this._source;
_checkSource(source);
if (prevSource === source) {
return Promise.resolve();
}
// If layer is not instantiated just store the new status
if (!this._internalModel) {
this._source = source;
this.trigger('sourceChanged', this);
return Promise.resolve();
}
// If layer has been instantiated
// If the source already has an engine and is different from the layer's engine throw an error.
if (source.$getEngine() && source.$getEngine() !== this._internalModel._engine) {
throw new CartoValidationError('layer', 'differentSourceClient');
}
// If source has no engine use the layer engine.
if (!source.$getEngine()) {
source.$setEngine(this._engine);
}
// Update the internalModel and return a promise
this._internalModel.set('source', source.$getInternalModel(), { silent: true });
return this._engine.reload()
.then(function () {
this._source = source;
this.trigger('sourceChanged', this);
}.bind(this))
.catch(_rejectAndTriggerError.bind(this));
};
/**
* Get the current source for this layer.
*
* @return {carto.source.Base} Current source
* @api
*/
Layer.prototype.getSource = function () {
return this._source;
};
/**
* Set new columns for featureClick events.
*
* @param {Array<string>} columns - An array containing column names
* @fires error
* @return {Promise}
* @api
*/
Layer.prototype.setFeatureClickColumns = function (columns) {
var prevColumns = this._featureClickColumns;
_checkColumns(columns);
if (_areColumnsTheSame(columns, prevColumns)) {
return Promise.resolve();
}
// If layer is not instantiated just store the new status
if (!this._internalModel) {
this._featureClickColumns = columns;
return Promise.resolve();
}
// Update the internalModel and return a promise
this._internalModel.infowindow.fields.set(_getInteractivityFields(columns).fields, { silent: true });
return this._engine.reload()
.then(function () {
this._featureClickColumns = columns;
}.bind(this))
.catch(_rejectAndTriggerError.bind(this));
};
/**
* Get the columns available in featureClicked events.
*
* @return {Array<string>} Column names available in featureClicked events
* @api
*/
Layer.prototype.getFeatureClickColumns = function () {
return this._featureClickColumns;
};
/**
* Set new columns for featureOver events.
*
* @param {Array<string>} columns - An array containing column names
* @fires error
* @return {Promise}
* @api
*/
Layer.prototype.setFeatureOverColumns = function (columns) {
var prevColumns = this._featureOverColumns;
_checkColumns(columns);
if (_areColumnsTheSame(columns, prevColumns)) {
return Promise.resolve();
}
// If layer is not instantiated just store the new status
if (!this._internalModel) {
this._featureOverColumns = columns;
return Promise.resolve();
}
// Update the internalModel and return a promise
this._internalModel.tooltip.fields.set(_getInteractivityFields(columns).fields, { silent: true });
return this._engine.reload()
.then(function () {
this._featureOverColumns = columns;
}.bind(this))
.catch(_rejectAndTriggerError.bind(this));
};
/**
* Get the columns available in featureOver events.
*
* @return {Array<string>} Column names available in featureOver events
* @api
*/
Layer.prototype.getFeatureOverColumns = function () {
return this._featureOverColumns;
};
/**
* Hides the layer.
*
* @fires visibilityChanged
* @return {carto.layer.Layer} this
* @api
*/
Layer.prototype.hide = function () {
var prevStatus = this._visible;
this._visible = false;
if (this._internalModel) {
this._internalModel.set('visible', false);
}
if (prevStatus) {
this.trigger('visibilityChanged', false);
}
return this;
};
/**
* Shows the layer.
*
* @fires visibilityChanged
* @return {carto.layer.Layer} this
* @api
*/
Layer.prototype.show = function () {
var prevStatus = this._visible;
this._visible = true;
if (this._internalModel) {
this._internalModel.set('visible', true);
}
if (!prevStatus) {
this.trigger('visibilityChanged', false);
}
return this;
};
/**
* Change the layer's visibility.
*
* @fires visibilityChanged
* @return {carto.layer.Layer} this
*/
Layer.prototype.toggle = function () {
return this.isVisible() ? this.hide() : this.show();
};
/**
* Return true if the layer is visible and false when not visible.
*
* @return {boolean} - A boolean value indicating the layer's visibility
* @api
*/
Layer.prototype.isVisible = function () {
return this._visible;
};
/**
* Return `true` if the layer is not visible and `false` when visible.
*
* @return {boolean} - A boolean value indicating the layer's visibility
* @api
*/
Layer.prototype.isHidden = function () {
return !this.isVisible();
};
/**
* Return true if the layer has interactivity.
*
* @return {boolean} - A boolean value indicating the layer's interactivity
* @api
*/
Layer.prototype.isInteractive = function () {
return this.getFeatureClickColumns().length > 0 || this.getFeatureOverColumns().length > 0;
};
/**
* Set the layer's order.
*
* @param {number} index - new order index for the layer.
*
* @return {Promise}
* @api
*/
Layer.prototype.setOrder = function (index) {
if (!this._client) {
return Promise.resolve();
}
return this._client.moveLayer(this, index);
};
/**
* Move the layer to the back.
*
* @return {Promise}
* @api
*/
Layer.prototype.bringToBack = function () {
return this.setOrder(0);
};
/**
* Move the layer to the front.
*
* @return {Promise}
* @api
*/
Layer.prototype.bringToFront = function () {
return this.setOrder(this._client._layers.size() - 1);
};
// Private functions.
Layer.prototype._createInternalModel = function (engine) {
var internalModel = new CartoDBLayer({
id: this._id,
source: this._source.$getInternalModel(),
cartocss: this._style.getContent(),
visible: this._visible,
infowindow: _getInteractivityFields(this._featureClickColumns),
tooltip: _getInteractivityFields(this._featureOverColumns),
minzoom: this._minzoom,
maxzoom: this._maxzoom
}, {
engine: engine,
aggregation: this._aggregation
});
internalModel.on('change:meta', function (layer, data) {
var rules = data.cartocss_meta.rules;
var styleMetadataList = metadataParser.getMetadataFromRules(rules);
/**
* Event fired by {@link carto.layer.Layer} when the style contains any TurboCarto ramp.
*
* @typedef {object} carto.layer.MetadataEvent
* @property {carto.layer.metadata.Base[]} styles - List of style metadata objects
* @api
*/
var metadata = { styles: styleMetadataList };
this.trigger('metadataChanged', metadata);
}, this);
internalModel.on('change:error', function (model, value) {
if (value && _isStyleError(value)) {
this._style.$setError(new CartoError(value));
} else if (value) {
this.trigger(EVENTS.ERROR, new CartoError(value));
}
}, this);
return internalModel;
};
// Internal functions.
Layer.prototype.$setClient = function (client) {
// Exit if the client is already set or
// it has a different engine than the layer
if (this._client || (this._engine && client._engine !== this._engine)) {
return;
}
this._client = client;
};
Layer.prototype.$setEngine = function (engine) {
if (this._engine) {
return;
}
this._engine = engine;
this._source.$setEngine(engine);
this._style.$setEngine(engine);
if (!this._internalModel) {
this._internalModel = this._createInternalModel(engine);
this._style.on('$changed', function (style) {
this._internalModel.set('cartocss', style.getContent(), { silent: true });
}, this);
}
};
// Scope functions
/**
* Transform the columns array into the format expected by the CartoDBLayer.
*/
function _getInteractivityFields (columns) {
var fields = columns.map(function (column, index) {
return {
name: column,
title: true,
position: index
};
});
return {
fields: fields
};
}
function _checkStyle (style) {
if (!(style instanceof StyleBase)) {
throw new CartoValidationError('layer', 'nonValidStyle');
}
}
function _checkSource (source) {
if (!(source instanceof SourceBase)) {
throw new CartoValidationError('layer', 'nonValidSource');
}
}
function _checkColumns (columns) {
if (_.any(columns, function (item) { return !_.isString(item); })) {
throw new CartoValidationError('layer', 'nonValidColumns');
}
}
/**
* Return true when a windshaft error is because a styling error.
*/
function _isStyleError (windshaftError) {
return windshaftError.message && windshaftError.message.indexOf('style') >= 0;
}
function _rejectAndTriggerError (err) {
var error = new CartoError(err);
this.trigger(EVENTS.ERROR, error);
return Promise.reject(error);
}
function _areColumnsTheSame (newColumns, oldColumns) {
return newColumns.length === oldColumns.length && _.isEmpty(_.difference(newColumns, oldColumns));
}
/**
* When there are aggregated columns and interactivity columns they must agree
*/
function _validateAggregationColumnsAndInteractivity (aggregationColumns, clickColumns, overColumns) {
var aggColumns = (aggregationColumns && Object.keys(aggregationColumns)) || [];
_validateColumnsConcordance(aggColumns, clickColumns, 'featureClick');
_validateColumnsConcordance(aggColumns, overColumns, 'featureOver');
}
function _validateColumnsConcordance (aggColumns, interactivityColumns, interactivity) {
if (interactivityColumns.length > 0 && aggColumns.length > 0) {
var notInAggregation = _.filter(interactivityColumns, function (clickColumn) {
return !_.contains(aggColumns, clickColumn);
});
if (notInAggregation.length > 0) {
throw new CartoValidationError('layer', 'wrongInteractivityColumns[' + notInAggregation.join(', ') + ']#' + interactivity);
}
}
}
/**
* @typedef {object} LatLng
* @property {number} lat - Latitude
* @property {number} lng - Longitude
* @api
*/
/**
* Fired when the source has changed. Handler gets a parameter with the new source.
*
* @event sourceChanged
* @type {carto.layer.Layer}
* @api
*/
/**
* Fired when the style has changed. Handler gets a parameter with the new style.
*
* @event styleChanged
* @type {carto.layer.Layer}
* @api
*/
/**
* Fired when style metadata has changed.
*
* @event metadataChanged
* @type {carto.layer.MetadataEvent}
* @api
*/
module.exports = Layer;

View File

@@ -0,0 +1,56 @@
/**
* Base metadata object
*
* @constructor
* @abstract
* @memberof carto.layer.metadata
* @api
*/
function Base (type, rule) {
this._type = type || '';
this._column = rule.getColumn();
this._mapping = rule.getMapping();
this._property = rule.getProperty();
}
/**
* Return the type of the metadata
*
* @return {string}
* @api
*/
Base.prototype.getType = function () {
return this._type;
};
/**
* Return the column of the metadata
*
* @return {string}
* @api
*/
Base.prototype.getColumn = function () {
return this._column;
};
/**
* Return the property of the metadata
*
* @return {string}
* @api
*/
Base.prototype.getMapping = function () {
return this._mapping;
};
/**
* Return the property of the metadata
*
* @return {string}
* @api
*/
Base.prototype.getProperty = function () {
return this._property;
};
module.exports = Base;

View File

@@ -0,0 +1,95 @@
var Base = require('./base');
/**
* Metadata type buckets
*
* Adding a Turbocarto ramp (with ranges) in the style generates a response
* from the server with the resulting information, after computing the ramp.
* This information is wrapped in a metadata object of type 'buckets', that
* contains a list of buckets with the range (min, max) and the value. And
* also the total min, max range and the average of the total values.
*
* For example, the following ramp will generate a metadata of type 'buckets'
* with numeric values (the size) in its buckets:
*
* marker-width: ramp([scalerank], range(5, 20), quantiles(5));
*
* In another example, this ramp will generate a metadata of type 'buckets'
* with string values (the color) in its buckets:
*
* marker-fill: ramp([scalerank], (#FFC6C4, #EE919B, #CC607D), quantiles);
*
* @param {object} rule - Rule with the cartocss metadata
* @constructor
* @hideconstructor
* @extends carto.layer.metadata.Base
* @memberof carto.layer.metadata
* @api
*/
function Buckets (rule) {
var rangeBuckets = rule.getBucketsWithRangeFilter();
/**
* @typedef {object} carto.layer.metadata.Bucket
* @property {number} min - The minimum range value
* @property {number} max - The maximum range value
* @property {number|string} value - The value of the bucket
* @api
*/
this._buckets = rangeBuckets.map(function (bucket) {
return {
min: bucket.filter.start,
max: bucket.filter.end,
value: bucket.value
};
});
this._avg = rule.getFilterAvg();
this._min = rangeBuckets.length > 0 ? rangeBuckets[0].filter.start : undefined;
this._max = rangeBuckets.length > 0 ? rangeBuckets[rangeBuckets.length - 1].filter.end : undefined;
Base.call(this, 'buckets', rule);
}
Buckets.prototype = Object.create(Base.prototype);
/**
* Return the buckets
*
* @return {carto.layer.metadata.Bucket[]}
* @api
*/
Buckets.prototype.getBuckets = function () {
return this._buckets;
};
/**
* Return the average of the column
*
* @return {number}
* @api
*/
Buckets.prototype.getAverage = function () {
return this._avg;
};
/**
* Return the minimum value in the ranges
*
* @return {number}
* @api
*/
Buckets.prototype.getMin = function () {
return this._min;
};
/**
* Return the maximum value in the ranges
*
* @return {number}
* @api
*/
Buckets.prototype.getMax = function () {
return this._max;
};
module.exports = Buckets;

View File

@@ -0,0 +1,68 @@
var Base = require('./base');
/**
* Metadata type categories
*
* Adding a Turbocarto ramp (with categories) in the style generates a response
* from the server with the resulting information after computing the ramp.
* This information is wrapped in a metadata object of type 'categories', that
* contains a list of categories with the name of the category and the value. And
* also the default value if it has been defined in the ramp.
*
* For example, the following ramp will generate a metadata of type 'categories'
* with string values (the color) in its categories. The #CCCCCC is the default
* value in this case:
*
* marker-fill: ramp([scalerank], (#F54690, #D16996, #CCCCCC), (1, 2), "=", category);
*
* @param {object} rule - Rule with the cartocss metadata
* @constructor
* @hideconstructor
* @extends carto.layer.metadata.Base
* @memberof carto.layer.metadata
* @api
*/
function Categories (rule) {
var categoryBuckets = rule.getBucketsWithCategoryFilter();
var defaultBuckets = rule.getBucketsWithDefaultFilter();
/**
* @typedef {object} carto.layer.metadata.Category
* @property {number|string} name - The name of the category
* @property {string} value - The value of the category
* @api
*/
this._categories = categoryBuckets.map(function (bucket) {
return {
name: bucket.filter.name,
value: bucket.value
};
});
this._defaultValue = defaultBuckets.length > 0 ? defaultBuckets[0].value : undefined;
Base.call(this, 'categories', rule);
}
Categories.prototype = Object.create(Base.prototype);
/**
* Return the buckets
*
* @return {carto.layer.metadata.Category[]}
* @api
*/
Categories.prototype.getCategories = function () {
return this._categories;
};
/**
* Return the default value
*
* @return {string}
* @api
*/
Categories.prototype.getDefaultValue = function () {
return this._defaultValue;
};
module.exports = Categories;

View File

@@ -0,0 +1,37 @@
var BucketsMetadata = require('./buckets');
var CategoriesMetadata = require('./categories');
var Rule = require('../../../../windshaft-integration/legends/rule.js');
/**
* Generates a list of Metadata objects from the original cartocss_meta rules
*
* @param {Rules} rulesData
* @return {metadata.Base[]}
*/
function getMetadataFromRules (rulesData) {
var metadata = [];
rulesData.forEach(function (ruleData) {
var rule = new Rule(ruleData);
if (_isBucketsMetadata(rule)) {
metadata.push(new BucketsMetadata(rule));
} else if (_isCategoriesMetadata(rule)) {
metadata.push(new CategoriesMetadata(rule));
}
});
return metadata;
}
function _isBucketsMetadata (rule) {
return rule.getBucketsWithRangeFilter().length > 0;
}
function _isCategoriesMetadata (rule) {
return rule.getBucketsWithCategoryFilter().length > 0;
}
module.exports = {
getMetadataFromRules: getMetadataFromRules
};

45
src/api/v4/layers.js Normal file
View File

@@ -0,0 +1,45 @@
var _ = require('underscore');
function Layers (layers) {
this._layers = layers || [];
}
Layers.prototype.add = function (layer) {
this._layers.push(layer);
return layer;
};
Layers.prototype.remove = function (layer) {
return this._layers.splice(this._layers.indexOf(layer), 1);
};
Layers.prototype.size = function () {
return this._layers.length;
};
Layers.prototype.indexOf = function (layer) {
return this._layers.indexOf(layer);
};
Layers.prototype.contains = function (layer) {
return this._layers.indexOf(layer) >= 0;
};
Layers.prototype.findById = function (layerId) {
return _.find(this._layers, function (layer) {
return layer.getId() === layerId;
}, this);
};
Layers.prototype.toArray = function () {
return this._layers;
};
Layers.prototype.move = function (layer, toIndex) {
var fromIndex = this._layers.indexOf(layer);
if (fromIndex >= 0 && fromIndex !== toIndex) {
this._layers.splice(toIndex, 0, this._layers.splice(fromIndex, 1)[0]);
}
};
module.exports = Layers;

View File

@@ -0,0 +1,70 @@
/* global google */
var _ = require('underscore');
var Layer = require('../layer');
var triggerLayerFeatureEvent = require('./trigger-layer-feature-event');
var GMapsCartoDBLayerGroupView = require('../../../geo/gmaps/gmaps-cartodb-layer-group-view');
var CartoError = require('../error-handling/carto-error');
/**
* This object is a custom Google Maps MapType to enable feature interactivity
* using an internal GMapsCartoDBLayerGroupView instance.
*
* NOTE: It also contains the feature events handlers. That's why it requires the carto layers array.
*/
function GoogleMapsMapType (layers, engine, map) {
this._layers = layers;
this._engine = engine;
this._map = map;
this._hoveredLayers = [];
this.tileSize = new google.maps.Size(256, 256);
this._internalView = new GMapsCartoDBLayerGroupView(this._engine._cartoLayerGroup, {
nativeMap: map
});
this._id = this._internalView._id;
this._internalView.on('featureClick', this._onFeatureClick, this);
this._internalView.on('featureOver', this._onFeatureOver, this);
this._internalView.on('featureOut', this._onFeatureOut, this);
this._internalView.on('featureError', this._onFeatureError, this);
}
GoogleMapsMapType.prototype.getTile = function (coord, zoom, ownerDocument) {
return this._internalView.getTile(coord, zoom, ownerDocument);
};
GoogleMapsMapType.prototype._onFeatureClick = function (internalEvent) {
var layer = this._layers.findById(internalEvent.layer.id);
triggerLayerFeatureEvent(Layer.events.FEATURE_CLICKED, internalEvent, layer);
};
GoogleMapsMapType.prototype._onFeatureOver = function (internalEvent) {
var layer = this._layers.findById(internalEvent.layer.id);
if (layer.isInteractive()) {
this._hoveredLayers[internalEvent.layerIndex] = true;
this._map.setOptions({ draggableCursor: 'pointer' });
}
triggerLayerFeatureEvent(Layer.events.FEATURE_OVER, internalEvent, layer);
};
GoogleMapsMapType.prototype._onFeatureOut = function (internalEvent) {
var layer = this._layers.findById(internalEvent.layer.id);
this._hoveredLayers[internalEvent.layerIndex] = false;
if (_.any(this._hoveredLayers)) {
this._map.setOptions({ draggableCursor: 'pointer' });
} else {
this._map.setOptions({ draggableCursor: 'auto' });
}
triggerLayerFeatureEvent(Layer.events.FEATURE_OUT, internalEvent, layer);
};
GoogleMapsMapType.prototype._onFeatureError = function (error) {
var cartoError = new CartoError(error);
_.each(this._layers.toArray(), function (layer) {
if (layer.isInteractive()) {
layer.trigger(Layer.events.TILE_ERROR, cartoError);
}
});
};
module.exports = GoogleMapsMapType;

View File

@@ -0,0 +1,107 @@
/* global L */
var _ = require('underscore');
var Layer = require('../layer');
var constants = require('../constants');
var triggerLayerFeatureEvent = require('./trigger-layer-feature-event');
var LeafletCartoLayerGroupView = require('../../../geo/leaflet/leaflet-cartodb-layer-group-view');
var CartoError = require('../error-handling/carto-error');
/**
* This object is a custom Leaflet layer to enable feature interactivity
* using an internal LeafletCartoLayerGroupView instance.
*
* There are two overwritten functions:
* - addTo: when the layer is added to a map it also creates a LeafletCartoLayerGroupView
* object called `_internalView` in order to enable the feature events
* - removeFrom: when the layer is removed from a map it also removes the feature events
* listeners, triggers a 'remove' event and removes the `_internalView`
*
* NOTE: It also contains the feature events handlers. That's why it requires the carto layers array.
*/
var LeafletLayer = L.TileLayer.extend({
options: {
opacity: 0.99,
maxZoom: 30,
attribution: constants.ATTRIBUTION
},
initialize: function (layers, engine, options) {
_.extend(this.options, options);
this._layers = layers;
this._engine = engine;
this._internalView = null;
this._hoveredLayers = [];
},
addTo: function (map) {
if (!this._internalView) {
this._internalView = new LeafletCartoLayerGroupView(this._engine._cartoLayerGroup, {
nativeMap: map,
nativeLayer: this
});
this._internalView.on('featureClick', this._onFeatureClick, this);
this._internalView.on('featureOver', this._onFeatureOver, this);
this._internalView.on('featureOut', this._onFeatureOut, this);
this._internalView.on('featureError', this._onFeatureError, this);
}
return L.TileLayer.prototype.addTo.call(this, map);
},
removeFrom: function (map) {
if (this._internalView) {
this._internalView.off('featureClick');
this._internalView.off('featureOver');
this._internalView.off('featureOut');
this._internalView.off('featureError');
this._internalView.notifyRemove();
}
this._internalView = null;
return L.TileLayer.prototype.removeFrom.call(this, map);
},
setUrl: undefined,
_setUrl: function (url, noDraw) {
return L.TileLayer.prototype.setUrl.call(this, url, noDraw);
},
_onFeatureClick: function (internalEvent) {
var layer = this._layers.findById(internalEvent.layer.id);
triggerLayerFeatureEvent(Layer.events.FEATURE_CLICKED, internalEvent, layer);
},
_onFeatureOver: function (internalEvent) {
var layer = this._layers.findById(internalEvent.layer.id);
if (layer.isInteractive()) {
this._hoveredLayers[internalEvent.layerIndex] = true;
this._map.getContainer().style.cursor = 'pointer';
}
triggerLayerFeatureEvent(Layer.events.FEATURE_OVER, internalEvent, layer);
},
_onFeatureOut: function (internalEvent) {
var layer = this._layers.findById(internalEvent.layer.id);
this._hoveredLayers[internalEvent.layerIndex] = false;
if (_.any(this._hoveredLayers)) {
this._map.getContainer().style.cursor = 'pointer';
} else {
this._map.getContainer().style.cursor = 'auto';
}
triggerLayerFeatureEvent(Layer.events.FEATURE_OUT, internalEvent, layer);
},
_onFeatureError: function (error) {
var cartoError = new CartoError(error);
_.each(this._layers.toArray(), function (layer) {
if (layer.isInteractive()) {
layer.trigger(Layer.events.TILE_ERROR, cartoError);
}
});
}
});
module.exports = LeafletLayer;

View File

@@ -0,0 +1,57 @@
module.exports = function (eventName, internalEvent, layer) {
if (layer) {
var event = {
data: undefined,
latLng: undefined
};
if (internalEvent.feature) {
event.data = internalEvent.feature;
}
if (internalEvent.latlng) {
event.latLng = {
lat: internalEvent.latlng[0],
lng: internalEvent.latlng[1]
};
}
if (internalEvent.position) {
event.position = {
x: internalEvent.position.x,
y: internalEvent.position.y
};
}
/**
* Event object for feature events triggered by {@link carto.layer.Layer}.
*
* @typedef {object} carto.layer.FeatureEvent
* @property {LatLng} latLng - Object with coordinates where interaction took place
* @property {object} data - Object with feature data (one attribute for each specified column)
* @api
*/
/**
* Fired when user clicks on a feature.
*
* @event featureClicked
* @type {carto.layer.FeatureEvent}
* @api
*/
/**
* Fired when user moves the mouse over a feature.
*
* @event featureOver
* @type {carto.layer.FeatureEvent}
* @api
*/
/**
* Fired when user moves the mouse out of a feature.
*
* @event featureOut
* @type {carto.layer.FeatureEvent}
* @api
*/
layer.trigger(eventName, event);
}
};

137
src/api/v4/source/base.js Normal file
View File

@@ -0,0 +1,137 @@
const _ = require('underscore');
const Backbone = require('backbone');
const CartoError = require('../error-handling/carto-error');
const FiltersCollection = require('../filter/filters-collection');
const EVENTS = require('../events');
/**
* Base data source object.
*
* The methods listed in the {@link carto.source.Base|source.Base} object are available in all source objects.
*
* Use a source to reference the data used in a {@link carto.dataview.Base|dataview} or a {@link carto.layer.Base|layer}.
*
* {@link carto.source.Base} should not be used directly use {@link carto.source.Dataset} or {@link carto.source.SQL} instead.
*
* @constructor
* @fires error
* @abstract
* @memberof carto.source
* @api
*/
function Base () {
this._id = Base.$generateId();
this._hasFiltersApplied = false;
this._appliedFilters = new FiltersCollection();
}
_.extend(Base.prototype, Backbone.Events);
/**
* The instance id will be autogenerated by incrementing this variable.
*/
Base.$nextId = 0;
/**
* Static funciton used internally to autogenerate source ids.
*/
Base.$generateId = function () {
return 'S' + ++Base.$nextId;
};
/**
* Return a unique autogenerated id.
*
* @return {string} Unique autogenerated id
*/
Base.prototype.getId = function () {
return this._id;
};
Base.prototype._createInternalModel = function (engine) {
throw new Error('_createInternalModel must be implemented by the particular source');
};
/**
* Fire a CartoError event from a internalError
*/
Base.prototype._triggerError = function (model, internalError) {
this.trigger(EVENTS.ERROR, new CartoError(internalError, { analysis: this }));
};
Base.prototype.$setEngine = function (engine) {
if (!this._internalModel) {
this._internalModel = this._createInternalModel(engine);
this._internalModel.on('change:error', this._triggerError, this);
}
};
/**
* Return the engine form the source internal model
*/
Base.prototype.$getEngine = function (engine) {
if (this._internalModel) {
return this._internalModel._engine;
}
};
/**
* Return the real CARTO.js model used by the source.
*/
Base.prototype.$getInternalModel = function () {
return this._internalModel;
};
/**
* Get added filters
*
* @returns {Array} Added filters
* @api
*/
Base.prototype.getFilters = function () {
return this._appliedFilters.getFilters();
};
/**
* Add new filter to the source
*
* @param {(carto.filter.Range|carto.filter.Category|carto.filter.AND|carto.filter.OR)} filter
* @api
*/
Base.prototype.addFilter = function (filter) {
this._hasFiltersApplied = true;
this._appliedFilters.addFilter(filter);
};
/**
* Add new filters to the source
*
* @param {Array<carto.filter.Range|carto.filter.Category|carto.filter.AND|carto.filter.OR>} filters
* @api
*/
Base.prototype.addFilters = function (filters) {
filters.forEach(filter => this.addFilter(filter));
};
/**
* Remove an existing filter from source
*
* @param {(carto.filter.Range|carto.filter.Category|carto.filter.AND|carto.filter.OR)} filter
* @api
*/
Base.prototype.removeFilter = function (filter) {
this._appliedFilters.removeFilter(filter);
this._hasFiltersApplied = Boolean(this._appliedFilters.count());
};
/**
* Remove existing filters from source
*
* @param {Array<carto.filter.Range|carto.filter.Category|carto.filter.AND|carto.filter.OR>} filters
* @api
*/
Base.prototype.removeFilters = function (filters) {
filters.forEach(filter => this.removeFilter(filter));
};
module.exports = Base;

View File

@@ -0,0 +1,128 @@
var _ = require('underscore');
var Base = require('./base');
var AnalysisModel = require('../../../analysis/analysis-model');
var CamshaftReference = require('../../../analysis/camshaft-reference');
var CartoValidationError = require('../error-handling/carto-validation-error');
var CartoError = require('../error-handling/carto-error');
/**
* A Dataset that can be used as the data source for layers and dataviews.
*
* @param {string} tableName The name of an existing table
* @example
* new carto.source.Dataset('european_cities');
* @constructor
* @fires error
* @extends carto.source.Base
* @memberof carto.source
* @api
*/
function Dataset (tableName) {
_checkTableName(tableName);
this._tableName = tableName;
Base.apply(this, arguments);
this._appliedFilters.on('change:filters', () => this._updateInternalModelQuery(this._getQueryToApply()));
}
Dataset.prototype = Object.create(Base.prototype);
/**
* Update the table name. This method is asyncronous and returns a promise which is resolved when the style
* is changed succesfully. It also fires a 'tableNameChanged' event.
*
* @param {string} tableName The name of an existing table
* @fires TableNameChanged
* @returns {Promise} - A promise that will be fulfilled when the reload cycle is completed
* @api
*/
Dataset.prototype.setTableName = function (tableName) {
_checkTableName(tableName);
this._tableName = tableName;
if (!this._internalModel) {
this._triggerTableNameChanged(this, tableName);
return Promise.resolve();
}
return this._updateInternalModelQuery(this._getQueryToApply());
};
/**
* Return the table name being used in this Dataset object.
*
* @return {string} The table name being used in this Dataset object
* @api
*/
Dataset.prototype.getTableName = function () {
return this._tableName;
};
/**
* Creates a new internal model with the given engine and attributes initialized in the constructor.
*
* @param {Engine} engine - The engine object to be assigned to the internalModel
*/
Dataset.prototype._createInternalModel = function (engine) {
var internalModel = new AnalysisModel({
id: this.getId(),
type: 'source',
query: this._getQueryToApply()
}, {
camshaftReference: CamshaftReference,
engine: engine
});
return internalModel;
};
Dataset.prototype._updateInternalModelQuery = function (query) {
if (!this._internalModel) return;
this._internalModel.set('query', query, { silent: true });
return this._internalModel._engine.reload()
.then(() => this._triggerTableNameChanged(this, this._tableName))
.catch(windshaftError => Promise.reject(new CartoError(windshaftError)));
};
Dataset.prototype._getQueryToApply = function () {
const whereClause = this._appliedFilters.$getSQL();
const datasetQuery = `SELECT * from ${this._tableName}`;
if (_.isEmpty(whereClause)) {
return datasetQuery;
}
return `SELECT * FROM (${datasetQuery}) as datasetQuery WHERE ${whereClause}`;
};
Dataset.prototype.addFilter = function (filter) {
Base.prototype.addFilter.apply(this, arguments);
this._updateInternalModelQuery(this._getQueryToApply());
};
Dataset.prototype.removeFilter = function (filters) {
Base.prototype.removeFilter.apply(this, arguments);
this._updateInternalModelQuery(this._getQueryToApply());
};
Dataset.prototype._triggerTableNameChanged = function (model, value) {
this.trigger('tableNameChanged', value);
};
function _checkTableName (tableName) {
if (_.isUndefined(tableName)) {
throw new CartoValidationError('source', 'noDatasetName');
}
if (!_.isString(tableName)) {
throw new CartoValidationError('source', 'requiredDatasetString');
}
if (_.isEmpty(tableName)) {
throw new CartoValidationError('source', 'requiredDataset');
}
}
module.exports = Dataset;

View File

@@ -0,0 +1,11 @@
var Dataset = require('./dataset');
var SQL = require('./sql');
/**
* @namespace carto.source
* @api
*/
module.exports = {
Dataset: Dataset,
SQL: SQL
};

137
src/api/v4/source/sql.js Normal file
View File

@@ -0,0 +1,137 @@
var _ = require('underscore');
var Base = require('./base');
var AnalysisModel = require('../../../analysis/analysis-model');
var CamshaftReference = require('../../../analysis/camshaft-reference');
var CartoValidationError = require('../error-handling/carto-validation-error');
var CartoError = require('../error-handling/carto-error');
/**
* A SQL Query that can be used as the data source for layers and dataviews.
*
* @param {string} query A SQL query containing a SELECT statement
* @fires error
* @example
* new carto.source.SQL('SELECT * FROM european_cities');
* @constructor
* @extends carto.source.Base
* @memberof carto.source
* @fires queryChanged
* @api
*/
function SQL (query) {
_checkQuery(query);
this._query = query;
Base.apply(this, arguments);
this._appliedFilters.on('change:filters', () => this._updateInternalModelQuery(this._getQueryToApply()));
}
SQL.prototype = Object.create(Base.prototype);
/**
* Update the query. This method is asyncronous and returns a promise which is resolved when the style
* is changed succesfully. It also fires a 'queryChanged' event.
*
* @param {string} query - The sql query that will be the source of the data
* @fires queryChanged
* @returns {Promise} - A promise that will be fulfilled when the reload cycle is completed
* @api
*/
SQL.prototype.setQuery = function (query) {
_checkQuery(query);
this._query = query;
const sqlString = this._getQueryToApply();
if (!this._internalModel) {
this._triggerQueryChanged(this, sqlString);
return Promise.resolve();
}
return this._updateInternalModelQuery(sqlString);
};
/**
* Get the query being used in this SQL source.
*
* @return {string} The query being used in this SQL object
* @api
*/
SQL.prototype.getQuery = function () {
return this._query;
};
/**
* Creates a new internal model with the given engine and attributes initialized in the constructor.
*
* @param {Engine} engine - The engine object to be assigned to the internalModel
*/
SQL.prototype._createInternalModel = function (engine) {
var internalModel = new AnalysisModel({
id: this.getId(),
type: 'source',
query: this._getQueryToApply()
}, {
camshaftReference: CamshaftReference,
engine: engine
});
internalModel.on('change:query', this._triggerQueryChanged, this);
return internalModel;
};
SQL.prototype._updateInternalModelQuery = function (query) {
if (!this._internalModel) return;
this._internalModel.set('query', query, { silent: true });
return this._internalModel._engine.reload()
.then(() => this._triggerQueryChanged(this, query))
.catch(windshaftError => Promise.reject(new CartoError(windshaftError)));
};
SQL.prototype._getQueryToApply = function () {
const whereClause = this._appliedFilters.$getSQL();
if (!this._hasFiltersApplied || _.isEmpty(whereClause)) {
return this._query;
}
return `SELECT * FROM (${this._query}) as originalQuery WHERE ${whereClause}`;
};
SQL.prototype.addFilter = function (filter) {
Base.prototype.addFilter.apply(this, arguments);
this._updateInternalModelQuery(this._getQueryToApply());
};
SQL.prototype.removeFilter = function (filters) {
Base.prototype.removeFilter.apply(this, arguments);
this._updateInternalModelQuery(this._getQueryToApply());
};
SQL.prototype._triggerQueryChanged = function (model, value) {
this.trigger('queryChanged', value);
};
function _checkQuery (query) {
if (!query) {
throw new CartoValidationError('source', 'requiredQuery');
}
if (!_.isString(query)) {
throw new CartoValidationError('source', 'requiredString');
}
}
module.exports = SQL;
/**
* Fired when the query has changed. Handler gets a parameter with the new query.
*
* @event queryChanged
* @type {string}
* @api
*/

33
src/api/v4/style/base.js Normal file
View File

@@ -0,0 +1,33 @@
var _ = require('underscore');
var Backbone = require('backbone');
/**
* Base style object.
*
* @fires error
* @constructor
* @abstract
* @memberof carto.style
* @api
*/
function Base () {}
_.extend(Base.prototype, Backbone.Events);
Base.prototype.$setError = function (cartoError) {
this._error = cartoError;
this.trigger('error', cartoError);
};
Base.prototype.$setEngine = function (newEngine) {
if (this._engine && this._engine !== newEngine) {
throw new Error('CartoCSS engine cannot be changed');
}
this._engine = newEngine;
};
Base.prototype.$getEngine = function () {
return this._engine;
};
module.exports = Base;

View File

@@ -0,0 +1,96 @@
var _ = require('underscore');
var Base = require('./base');
var CartoValidationError = require('../error-handling/carto-validation-error');
var CartoError = require('../error-handling/carto-error');
// Event constants
var CONTENT_CHANGED = 'contentChanged';
/**
* A CartoCSS/TurboCarto style that can be applied to a {@link carto.layer.Layer}.
* @param {string} content - A CartoCSS string
* @example
* var style = new carto.style.CartoCSS(`
* #layer {
* marker-fill: #FABADA;
* marker-width: 10;
* }
* `);
* @constructor
* @extends carto.style.Base
* @memberof carto.style
* @api
*/
function CartoCSS (content) {
_checkContent(content);
this._content = content;
}
CartoCSS.prototype = Object.create(Base.prototype);
/**
* Get the current CartoCSS/TurboCarto style as a string.
*
* @return {string} - The TurboCarto style for this CartoCSS object
* @api
*/
CartoCSS.prototype.getContent = function () {
return this._content;
};
/**
* Set the CartoCSS/Turbocarto as a string.
*
* @param {string} newContent - A string containing the new cartocss/turbocarto style
* @return {Promise<string>} A promise that will be resolved once the cartocss/turbocarto is updated
* @example
* // Get the cartoCSS from an exiting layer
* let cartoCSS = layer.getStyle();
* // Update the cartoCSS content, remember this method is asynchronous!
* cartoCSS.setContent(`
* #layer {
* marker-fill: blue;
* }`)
* .then(() => {
* console.log('cartoCSS was updated');
* })
* .catch(() => {
* console.error('Error updating the cartoCSS for the layer');
* });
* @api
*/
CartoCSS.prototype.setContent = function (newContent) {
_checkContent(newContent);
this._content = newContent;
// Notify layers that the style has been changed so they can update their internalModels.
this.trigger('$changed', this);
if (!this._engine) {
return _onContentChanged.call(this, newContent);
}
return this._engine.reload()
.then(function () {
return _onContentChanged.call(this, newContent);
}.bind(this))
.catch(function (windshaftError) {
return Promise.reject(new CartoError(windshaftError));
});
};
// Once the reload cycle is completed trigger a contentChanged event.
function _onContentChanged (newContent) {
this.trigger(CONTENT_CHANGED, this._content);
return Promise.resolve(this._content);
}
function _checkContent (content) {
if (!content) {
throw new CartoValidationError('style', 'requiredCSS');
}
if (!_.isString(content)) {
throw new CartoValidationError('style', 'requiredCSSString');
}
}
module.exports = CartoCSS;

View File

@@ -0,0 +1,9 @@
var CartoCSS = require('./cartocss');
/**
* @namespace carto.style
* @api
*/
module.exports = {
CartoCSS: CartoCSS
};

133
src/api/vizjson.js Normal file
View File

@@ -0,0 +1,133 @@
var _ = require('underscore');
var log = require('cdb.log');
var C = require('../constants');
var VizJSON = function (vizjson) {
_.each(Object.keys(vizjson), function (property) {
this[property] = vizjson[property];
}, this);
this.overlays = this.overlays || [];
this.layers = this.layers || [];
this._addAttributionOverlay();
};
VizJSON.prototype.isNamedMap = function () {
return !!this.datasource.template_name;
};
VizJSON.prototype.hasZoomOverlay = function () {
return this.hasOverlay(C.OVERLAY_TYPES.ZOOM);
};
VizJSON.prototype.hasOverlay = function (overlayType) {
return _.isObject(this.getOverlayByType(overlayType));
};
VizJSON.prototype.getOverlayByType = function (overlayType) {
return _.find(this.overlays, function (overlay) {
return overlay.type === overlayType;
});
};
VizJSON.prototype.addHeaderOverlay = function (showTitle, showDescription, isShareable) {
if (!this.hasOverlay(C.OVERLAY_TYPES.HEADER)) {
this.overlays.unshift({
type: C.OVERLAY_TYPES.HEADER,
order: 1,
shareable: isShareable,
url: this.url,
options: {
extra: {
title: this.title,
description: this.description,
show_title: showTitle,
show_description: showDescription
}
}
});
}
};
VizJSON.prototype.addSearchOverlay = function () {
if (!this.hasOverlay(C.OVERLAY_TYPES.SEARCH)) {
this.overlays.push({
type: C.OVERLAY_TYPES.SEARCH,
order: 3
});
}
};
VizJSON.prototype.removeOverlay = function (overlayType) {
for (var i = 0; i < this.overlays.length; ++i) {
if (this.overlays[i].type === overlayType) {
this.overlays.splice(i, 1);
return;
}
}
};
VizJSON.prototype.removeLoaderOverlay = function () {
this.removeOverlay(C.OVERLAY_TYPES.LOADER);
};
VizJSON.prototype.removeZoomOverlay = function () {
this.removeOverlay(C.OVERLAY_TYPES.ZOOM);
};
VizJSON.prototype.removeSearchOverlay = function () {
this.removeOverlay(C.OVERLAY_TYPES.SEARCH);
};
VizJSON.prototype.removeLogoOverlay = function (overlayType) {
this.removeOverlay(C.OVERLAY_TYPES.LOGO);
};
VizJSON.prototype._addAttributionOverlay = function () {
this.overlays.push({
type: C.OVERLAY_TYPES.ATTRIBUTION
});
};
VizJSON.prototype.enforceGMapsBaseLayer = function (gmapsBaseType, gmapsStyle) {
var isGmapsBaseTypeValid = _.contains(C.GMAPS_BASE_LAYER_TYPES, gmapsBaseType);
if (this.map_provider === C.MAP_PROVIDER_TYPES.LEAFLET && isGmapsBaseTypeValid) {
if (this.layers) {
this.layers[0].options.type = 'GMapsBase';
this.layers[0].options.baseType = gmapsBaseType;
this.layers[0].options.name = gmapsBaseType;
if (gmapsStyle) {
this.layers[0].options.style = typeof gmapsStyle === 'string' ? JSON.parse(gmapsStyle) : gmapsStyle;
}
this.map_provider = C.MAP_PROVIDER_TYPES.GMAPS;
this.layers[0].options.attribution = ''; // GMaps has its own attribution
} else {
log.error('No base map loaded. Using Leaflet.');
}
} else {
log.error('GMaps baseType "' + gmapsBaseType + ' is not supported. Using leaflet.');
}
};
VizJSON.prototype.setZoom = function (zoom) {
this.zoom = zoom;
this.bounds = null;
};
VizJSON.prototype.setCenter = function (center) {
this.center = center;
this.bounds = null;
};
VizJSON.prototype.setBounds = function (bounds) {
this.bounds = bounds;
};
VizJSON.prototype.setVector = function (vector) {
this.vector = vector;
};
module.exports = VizJSON;

27
src/cartodb.js Normal file
View File

@@ -0,0 +1,27 @@
window.L = require('leaflet');
require('mousewheel'); // registers itself to $.event; TODO what's this required for? still relevant for supported browsers?
require('mwheelIntent'); // registers itself to $.event; TODO what's this required for? still relevant for supported browsers?
var cdb = require('cdb');
if (window) {
window.cartodb = window.cdb = cdb;
}
cdb.core = {};
cdb.core.sanitize = require('./core/sanitize');
cdb.core.Template = require('./core/template');
cdb.core.Model = require('./core/model');
cdb.core.View = require('./core/view');
cdb.SQL = require('./api/sql');
cdb.createVis = require('./api/create-vis');
// log carto.js version
var logger = require('cdb.log');
logger.log('carto.js ' + cdb.VERSION);
cdb.helpers.GeoJSONHelper = require('./geo/geometry-models/geojson-helper');
module.exports = cdb;

9
src/cdb.config.js Normal file
View File

@@ -0,0 +1,9 @@
var Config = require('./core/config');
var config = new Config();
config.set({
cartodb_attributions: '© <a href="https://carto.com/attributions" target="_blank">CARTO</a>',
cartodb_logo_link: 'http://www.carto.com'
});
module.exports = config;

10
src/cdb.js Normal file
View File

@@ -0,0 +1,10 @@
// Creates cdb object, mutated in the entry file cartodb.js
// Used to avoid circular dependencies
var cdb = {};
cdb.VERSION = require('../package.json').version;
cdb.DEBUG = false;
cdb.helpers = {};
module.exports = cdb;

19
src/cdb.log.js Normal file
View File

@@ -0,0 +1,19 @@
var cdb = require('cdb');
module.exports = {
error: function () {
console.error.apply(console, arguments);
},
log: function () {
console.log.apply(console, arguments);
},
info: function () {
console.log.apply(console, arguments);
},
debug: function () {
if (cdb.DEBUG) console.log.apply(console, arguments);
}
};

3
src/cdb.templates.js Normal file
View File

@@ -0,0 +1,3 @@
var TemplateList = require('./core/template-list');
module.exports = new TemplateList();

28
src/constants.js Normal file
View File

@@ -0,0 +1,28 @@
module.exports = {
OVERLAY_TYPES: {
ATTRIBUTION: 'attribution',
HEADER: 'header',
LIMITS: 'limits',
TILES: 'tiles',
LOADER: 'loader',
LOGO: 'logo',
SEARCH: 'search',
ZOOM: 'zoom'
},
MAP_PROVIDER_TYPES: {
GMAPS: 'googlemaps',
LEAFLET: 'leaflet'
},
GMAPS_BASE_LAYER_TYPES: ['roadmap', 'gray_roadmap', 'dark_roadmap', 'hybrid', 'satellite', 'terrain'],
WINDSHAFT_ERRORS: {
ANALYSIS: 'analysis',
LAYER: 'layer',
LIMIT: 'limit',
TILE: 'tile', // Generic error for tiles
GENERIC: 'generic',
UNKNOWN: 'unknown'
}
};

16
src/core/config.js Normal file
View File

@@ -0,0 +1,16 @@
var Backbone = require('backbone');
/**
* global configuration
*/
var Config = Backbone.Model.extend({
VERSION: 4,
initialize: function () {},
// error track
REPORT_ERROR_URL: '/api/v0/error',
ERROR_TRACK_ENABLED: false
});
module.exports = Config;

62
src/core/loader.js Normal file
View File

@@ -0,0 +1,62 @@
var Loader = {
queue: [],
current: undefined,
_script: null,
head: null,
loadScript: function (src) {
var script = document.createElement('script');
script.type = 'text/javascript';
script.src = src;
script.async = true;
if (!Loader.head) {
Loader.head = document.getElementsByTagName('head')[0];
}
// defer the loading because IE9 loads in the same frame the script
// so Loader._script is null
setTimeout(function () {
Loader.head.appendChild(script);
}, 0);
return script;
},
get: function (url, callback) {
if (!Loader._script) {
Loader.current = callback;
Loader._script = Loader.loadScript(url + (~url.indexOf('?') ? '&' : '?') + 'callback=vizjson');
} else {
Loader.queue.push([url, callback]);
}
},
getPath: function (file) {
var scripts = document.getElementsByTagName('script');
var cartodbJsRe = /\/?cartodb[\-\._]?([\w\-\._]*)\.js\??/;
for (var i = 0; i < scripts.length; i++) {
var src = scripts[i].src;
var matches = src.match(cartodbJsRe);
if (matches) {
var bits = src.split('/');
delete bits[bits.length - 1];
return bits.join('/') + file;
}
}
return null;
}
};
// Required for jsonp callback, see Loader.get()
window.vizjson = function (data) {
Loader.current && Loader.current(data);
// remove script
Loader.head.removeChild(Loader._script);
Loader._script = null;
// next element
var a = Loader.queue.shift();
if (a) {
Loader.get(a[0], a[1]);
}
};
module.exports = Loader;

81
src/core/model.js Normal file
View File

@@ -0,0 +1,81 @@
var $ = require('jquery');
var _ = require('underscore');
var Backbone = require('backbone');
/**
* Base Model for all CartoDB model.
* DO NOT USE Backbone.Model directly
*/
var Model = Backbone.Model.extend({
initialize: function (options) {
_.bindAll(this, 'fetch', 'save', 'retrigger');
return Backbone.Model.prototype.initialize.call(this, options);
},
/**
* We are redefining fetch to be able to trigger an event when the ajax call ends, no matter if there's
* a change in the data or not. Why don't backbone does this by default? ahh, my friend, who knows.
* @method fetch
* @param args {Object}
*/
fetch: function (args) {
var self = this;
// var date = new Date();
this.trigger('loadModelStarted');
$.when(Backbone.Model.prototype.fetch.call(this, args)).done(function (ev) {
self.trigger('loadModelCompleted', ev, self);
// var dateComplete = new Date()
// console.log('completed in '+(dateComplete - date));
}).fail(function (ev) {
self.trigger('loadModelFailed', ev, self);
});
},
/**
* Changes the attribute used as Id
* @method setIdAttribute
* @param attr {String}
*/
setIdAttribute: function (attr) {
this.idAttribute = attr;
},
/**
* Listen for an event on another object and triggers on itself, with the same name or a new one
* @method retrigger
* @param ev {String} event who triggers the action
* @param obj {Object} object where the event happens
* @param obj {Object} [optional] name of the retriggered event
* @todo [xabel]: This method is repeated here and in the base view definition. There's should be a way to make it unique
*/
retrigger: function (ev, obj, retrigEvent) {
if (!retrigEvent) {
retrigEvent = ev;
}
var self = this;
obj.bind && obj.bind(ev, function () {
self.trigger(retrigEvent);
}, self);
},
/**
* We need to override backbone save method to be able to introduce new kind of triggers that
* for some reason are not present in the original library. Because you know, it would be nice
* to be able to differenciate "a model has been updated" of "a model is being saved".
* TODO: remove jquery from here
* @param {object} opt1
* @param {object} opt2
* @return {$.Deferred}
*/
save: function (opt1, opt2) {
var self = this;
if (!opt2 || !opt2.silent) this.trigger('saving');
var promise = Backbone.Model.prototype.save.apply(this, arguments);
$.when(promise).done(function () {
if (!opt2 || !opt2.silent) self.trigger('saved');
}).fail(function () {
if (!opt2 || !opt2.silent) self.trigger('errorSaving');
});
return promise;
}
});
module.exports = Model;

160
src/core/profiler.js Normal file
View File

@@ -0,0 +1,160 @@
/*
# metrics profiler
## timing
```
var timer = Profiler.metric('resource:load')
time.start();
...
time.end();
```
## counters
```
var counter = Profiler.metric('requests')
counter.inc(); // 1
counter.inc(10); // 11
counter.dec() // 10
counter.dec(10) // 0
```
## Calls per second
```
var fps = Profiler.metric('fps')
function render() {
fps.mark();
}
```
*/
var MAX_HISTORY = 1024;
function Profiler () {}
Profiler.metrics = {};
Profiler._backend = null;
Profiler.get = function (name) {
return Profiler.metrics[name] || {
max: 0,
min: Number.MAX_VALUE,
avg: 0,
total: 0,
count: 0,
last: 0,
history: typeof (Float32Array) !== 'undefined' ? new Float32Array(MAX_HISTORY) : []
};
};
Profiler.backend = function (_) {
Profiler._backend = _;
};
Profiler.new_value = function (name, value, type, defer) {
type = type || 'i';
var t = Profiler.metrics[name] = Profiler.get(name);
t.max = Math.max(t.max, value);
t.min = Math.min(t.min, value);
t.total += value;
++t.count;
t.avg = t.total / t.count;
t.history[t.count % MAX_HISTORY] = value;
if (!defer) {
Profiler._backend && Profiler._backend([type, name, value]);
} else {
var n = performance.now();
// don't allow to send stats quick
if (n - t.last > 1000) {
Profiler._backend && Profiler._backend([type, name, t.avg]);
t.last = n;
}
}
};
Profiler.print_stats = function () {
for (var k in Profiler.metrics) {
var t = Profiler.metrics[k];
console.log(' === ' + k + ' === ');
console.log(' max: ' + t.max);
console.log(' min: ' + t.min);
console.log(' avg: ' + t.avg);
console.log(' count: ' + t.count);
console.log(' total: ' + t.total);
}
};
function Metric (name) {
this.t0 = null;
this.name = name;
this.count = 0;
}
Metric.prototype = {
//
// start a time measurement
//
start: function () {
this.t0 = performance.now();
return this;
},
// elapsed time since start was called
_elapsed: function () {
return performance.now() - this.t0;
},
//
// finish a time measurement and register it
// ``start`` should be called first, if not this
// function does not take effect
//
end: function (defer) {
if (this.t0 !== null) {
Profiler.new_value(this.name, this._elapsed(), 't', defer);
this.t0 = null;
}
},
//
// increments the value
// qty: how many, default = 1
//
inc: function (qty) {
qty = qty === undefined ? 1 : qty;
Profiler.new_value(this.name, qty, 'i');
},
//
// decrements the value
// qty: how many, default = 1
//
dec: function (qty) {
qty = qty === undefined ? 1 : qty;
Profiler.new_value(this.name, qty, 'd');
},
//
// measures how many times per second this function is called
//
mark: function () {
++this.count;
if (this.t0 === null) {
this.start();
return;
}
var elapsed = this._elapsed();
if (elapsed > 1) {
Profiler.new_value(this.name, this.count);
this.count = 0;
this.start();
}
}
};
Profiler.metric = function (name) {
return new Metric(name);
};
module.exports = Profiler;

24
src/core/sanitize.js Normal file
View File

@@ -0,0 +1,24 @@
var htmlCssSanitizer = require('html-css-sanitizer');
/**
* Sanitize inputHtml of unsafe HTML tags & attributes
* @param {String} inputHtml
* @param {Function} optionalSanitizer By default undefined, for which the default sanitizer will be used.
* Pass a function (that takes inputHtml) to sanitize yourself, or false/null to skip sanitize call.
*/
htmlCssSanitizer.html = function (inputHtml, optionalSanitizer) {
if (!inputHtml) return;
if (optionalSanitizer === undefined) {
return htmlCssSanitizer.sanitize(inputHtml, function (url) {
// Return all URLs for <a href=""> (javascript: and data: URLs are removed prior to this fn is called)
return url;
});
} else if (typeof optionalSanitizer === 'function') {
return optionalSanitizer(inputHtml);
} else { // alt sanitization set to false/null/other, treat as if caller takes responsibility to sanitize output
return inputHtml;
}
};
module.exports = htmlCssSanitizer;

28
src/core/template-list.js Normal file
View File

@@ -0,0 +1,28 @@
var _ = require('underscore');
var Backbone = require('backbone');
var log = require('cdb.log');
var Template = require('./template');
var TemplateList = Backbone.Collection.extend({
model: Template,
getTemplate: function (templateName) {
if (this.namespace) {
templateName = this.namespace + templateName;
}
var t = this.find(function (t) {
return t.get('name') === templateName;
});
if (t) {
return _.bind(t.render, t);
}
log.error(templateName + ' not found');
return null;
}
});
module.exports = TemplateList;

92
src/core/template.js Normal file
View File

@@ -0,0 +1,92 @@
var _ = require('underscore');
var Backbone = require('backbone');
var Mustache = require('mustache');
var log = require('cdb.log');
/**
* template system
* usage:
var tmpl = new Template({
template: "hi, my name is {{ name }}",
type: 'mustache' // undescore by default
});
console.log(tmpl.render({name: 'rambo'})));
// prints "hi, my name is rambo"
you could pass the compiled tempalte directly:
var tmpl = new Template({
compiled: function() { return 'my compiled template'; }
});
*/
var Template = Backbone.Model.extend({
initialize: function () {
this.bind('change', this._invalidate);
this._invalidate();
},
url: function () {
return this.get('template_url');
},
parse: function (data) {
return {
'template': data
};
},
_invalidate: function () {
this.compiled = null;
if (this.get('template_url')) {
this.fetch();
}
},
compile: function () {
var tmplType = this.get('type') || 'underscore';
var fn = Template.compilers[tmplType];
if (fn) {
return fn(this.get('template'));
} else {
log.error("can't get rendered for " + tmplType);
}
return null;
},
/**
* renders the template with specified vars
*/
render: function (vars) {
var c = this.compiled = this.compiled || this.get('compiled') || this.compile();
var rendered = c(vars);
return rendered;
},
asFunction: function () {
return _.bind(this.render, this);
}
}, {
compilers: {
'underscore': _.template,
'mustache': typeof (Mustache) === 'undefined'
? null
// Replacement for Mustache.compile, which was removed in version 0.8.0
: function compile (template) {
Mustache.parse(template);
return function (view, partials) {
return Mustache.render(template, view, partials);
};
}
},
compile: function (tmpl, type) {
var t = new Template({
template: tmpl,
type: type || 'underscore'
});
return _.bind(t.render, t);
}
});
module.exports = Template;

195
src/core/util.js Normal file
View File

@@ -0,0 +1,195 @@
var _ = require('underscore');
var util = {};
util.isCORSSupported = function () {
return 'withCredentials' in new XMLHttpRequest();
};
util.array2hex = function (byteArr) {
var encoded = [];
for (var i = 0; i < byteArr.length; ++i) {
encoded.push(String.fromCharCode(byteArr[i] + 128));
}
return util.btoa(encoded.join(''));
};
util.btoa = function (data) {
if (typeof window['btoa'] === 'function') {
return util.encodeBase64Native(data);
}
return util.encodeBase64(data);
};
util.encodeBase64Native = function (input) {
return btoa(input);
};
// ie7 btoa,
// from http://phpjs.org/functions/base64_encode/
util.encodeBase64 = function (data) {
var b64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=';
var o1, o2, o3, h1, h2, h3, h4, bits;
var i = 0;
var ac = 0;
var enc = '';
var tmpArr = [];
if (!data) {
return data;
}
do {
// pack three octets into four hexets
o1 = data.charCodeAt(i++);
o2 = data.charCodeAt(i++);
o3 = data.charCodeAt(i++);
bits = (o1 << 16) | (o2 << 8) | o3;
h1 = (bits >> 18) & 0x3f;
h2 = (bits >> 12) & 0x3f;
h3 = (bits >> 6) & 0x3f;
h4 = bits & 0x3f;
// use hexets to index into b64, and append result to encoded string
tmpArr[ac++] =
b64.charAt(h1) + b64.charAt(h2) + b64.charAt(h3) + b64.charAt(h4);
} while (i < data.length);
enc = tmpArr.join('');
var r = data.length % 3;
return (r ? enc.slice(0, r - 3) : enc) + '==='.slice(r || 3);
};
util.uniqueCallbackName = function (str) {
util._callback_c = util._callback_c || 0;
++util._callback_c;
return util.crc32(str) + '_' + util._callback_c;
};
util.crc32 = function (str) {
var crcTable = util._crcTable || (util._crcTable = util._makeCRCTable());
var crc = 0 ^ -1;
for (var i = 0, l = str.length; i < l; ++i) {
crc = (crc >>> 8) ^ crcTable[(crc ^ str.charCodeAt(i)) & 0xff];
}
return (crc ^ -1) >>> 0;
};
util._makeCRCTable = function () {
var c;
var crcTable = [];
for (var n = 0; n < 256; ++n) {
c = n;
for (var k = 0; k < 8; ++k) {
c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
}
crcTable[n] = c;
}
return crcTable;
};
util._inferBrowser = function (ua) {
var browser = {};
ua =
ua || (typeof window !== 'undefined' && window.navigator.userAgent) || '';
function detectIE () {
var msie = ua.indexOf('MSIE ');
var trident = ua.indexOf('Trident/');
if (msie > -1 || trident > -1) return true;
return false;
}
function getIEVersion () {
if (!document.compatMode) return 5;
if (!window.XMLHttpRequest) return 6;
if (!document.querySelector) return 7;
if (!document.addEventListener) return 8;
if (!window.atob) return 9;
if (document.all) return 10;
else return 11;
}
if (detectIE()) {
browser.ie = { version: getIEVersion() };
} else if (ua.indexOf('Edge/') > -1) browser.edge = ua;
else if (ua.indexOf('Chrome') > -1) browser.chrome = ua;
else if (ua.indexOf('Firefox') > -1) browser.firefox = ua;
else if (ua.indexOf('Opera') > -1) browser.opera = ua;
else if (ua.indexOf('Safari') > -1) browser.safari = ua;
return browser;
};
util.browser = util._inferBrowser();
util.isMobileDevice = function () {
return /Android|webOS|iPhone|iPod|BlackBerry|IEMobile|Opera Mini/i.test(
navigator.userAgent
);
};
util.supportsTouch = function () {
return 'ontouchstart' in window || navigator.msMaxTouchPoints;
};
var webGLSupportedAndEnabled = null;
util.isWebGLSupported = function () {
if (webGLSupportedAndEnabled === null) {
var canvas = document.createElement('canvas');
webGLSupportedAndEnabled =
!!window.WebGLRenderingContext &&
!!(canvas.getContext('webgl') || canvas.getContext('experimental-webgl'));
}
return webGLSupportedAndEnabled;
};
/**
* Returns true if the string ends with provided suffix
*/
util.endsWith = function (str, suffix) {
return str.indexOf(suffix, str.length - suffix.length) !== -1;
};
util.checkRequiredOpts = function (actualOpts, requiredOpts, from) {
_.each(requiredOpts, function (item) {
if (_.isUndefined(actualOpts[item])) {
throw new Error(
item + ' is required' + (from ? ' to initialize ' + from : '')
);
}
});
};
/**
* Checks that the correct Leaflet version is loaded
*/
util.isLeafletLoaded = function () {
if (!window.L) {
throw new Error('Leaflet is required');
}
if (window.L.version < '1.0.0') {
throw new Error('Leaflet +1.0 is required');
}
};
/**
* Checks that the correct Google Maps version is loaded
*/
util.isGoogleMapsLoaded = function () {
if (!window.google) {
throw new Error('Google Maps is required');
}
if (!window.google.maps) {
throw new Error('Google Maps is required');
}
if (window.google.maps.version < '3.31.0') {
throw new Error('Google Maps version should be >= 3.31');
}
};
module.exports = util;

173
src/core/view.js Normal file
View File

@@ -0,0 +1,173 @@
var _ = require('underscore');
var Backbone = require('backbone');
var Profiler = require('cdb.core.Profiler');
var templates = require('cdb.templates');
/**
* Base View for all CartoDB views.
* DO NOT USE Backbone.View directly
*/
var View = Backbone.View.extend({
classLabel: 'cdb.core.View',
constructor: function (options) {
this.options = _.defaults(options, this.options);
this._models = [];
this._subviews = {};
Backbone.View.call(this, options);
View.viewCount++;
View.views[this.cid] = this;
this._created_at = new Date();
Profiler.new_value('total_views', View.viewCount);
},
add_related_model: function (m) {
if (!m) throw Error('added non valid model');
this._models.push(m);
},
addView: function (v) {
this._subviews[v.cid] = v;
v._parent = this;
},
removeView: function (v) {
delete this._subviews[v.cid];
},
clearSubViews: function () {
_(this._subviews).each(function (v) {
v.clean();
});
this._subviews = {};
},
/**
* this method clean removes the view
* and clean and events associated. Call it when
* the view is not going to be used anymore
*/
clean: function () {
var self = this;
this.trigger('clean');
this.clearSubViews();
// remove from parent
if (this._parent) {
this._parent.removeView(this);
this._parent = null;
}
this.remove();
this.unbind();
this.stopListening();
// remove this model binding
if (this.model && this.model.unbind) this.model.unbind(null, null, this);
// remove model binding
_(this._models).each(function (m) {
m.unbind(null, null, self);
});
this._models = [];
View.viewCount--;
delete View.views[this.cid];
return this;
},
/**
* utility methods
*/
getTemplate: function (tmpl) {
if (this.options.template) {
return _.template(this.options.template);
}
return templates.getTemplate(tmpl);
},
show: function () {
this.$el.show();
},
hide: function () {
this.$el.hide();
},
/**
* Listen for an event on another object and triggers on itself, with the same name or a new one
* @method retrigger
* @param ev {String} event who triggers the action
* @param obj {Object} object where the event happens
* @param obj {Object} [optional] name of the retriggered event;
*/
retrigger: function (ev, obj, retrigEvent) {
if (!retrigEvent) {
retrigEvent = ev;
}
var self = this;
obj.bind && obj.bind(ev, function () {
self.trigger(retrigEvent);
}, self);
// add it as related model//object
this.add_related_model(obj);
},
/**
* Captures an event and prevents the default behaviour and stops it from bubbling
* @method killEvent
* @param event {Event}
*/
killEvent: function (ev) {
if (ev && ev.preventDefault) {
ev.preventDefault();
}
if (ev && ev.stopPropagation) {
ev.stopPropagation();
}
},
/**
* Remove all the tipsy tooltips from the document
* @method cleanTooltips
*/
cleanTooltips: function () {
this.$('.tipsy').remove();
}
}, {
viewCount: 0,
views: {},
/**
* when a view with events is inherit and you want to add more events
* this helper can be used:
* var MyView = new core.View({
* events: View.extendEvents({
* 'click': 'fn'
* })
* });
*/
extendEvents: function (newEvents) {
return function () {
return _.extend(newEvents, this.constructor.__super__.events);
};
},
/**
* search for views in a view and check if they are added as subviews
*/
runChecker: function () {
_.each(View.views, function (view) {
_.each(view, function (prop, k) {
if (k !== '_parent' &&
view.hasOwnProperty(k) &&
prop instanceof View &&
view._subviews[prop.cid] === undefined) {
console.log('=========');
console.log('untracked view: ');
console.log(prop.el);
console.log('parent');
console.log(view.el);
console.log(' ');
}
});
});
}
});
module.exports = View;

View File

@@ -0,0 +1,320 @@
var _ = require('underscore');
var DataviewModelBase = require('./dataview-model-base');
var SearchModel = require('./category-dataview/search-model');
var CategoryModelRange = require('./category-dataview/category-model-range');
var CategoriesCollection = require('./category-dataview/categories-collection');
/**
* Category dataview model
*
* - It has several internal models/collections
* - search model: it manages category search results.
* - filter model: it knows which items are accepted or rejected.
*/
module.exports = DataviewModelBase.extend({
defaults: _.extend(
{
type: 'category',
filterEnabled: false,
categories: 6,
allCategoryNames: [] // all (new + previously accepted), updated on data fetch (see parse)
},
DataviewModelBase.prototype.defaults
),
_getDataviewSpecificURLParams: function () {
var params = [
'own_filter=' + (this.get('filterEnabled') ? 1 : 0),
'categories=' + this.get('categories')
];
return params;
},
initialize: function (attrs, opts) {
DataviewModelBase.prototype.initialize.call(this, attrs, opts);
// Internal model for calculating total amount of values in the category
this._rangeModel = new CategoryModelRange({
apiKey: this._engine.getApiKey(),
authToken: this._engine.getAuthToken()
});
this._data = new CategoriesCollection(null, {
aggregationModel: this
});
this._searchModel = new SearchModel({
apiKey: this._engine.getApiKey(),
authToken: this._engine.getAuthToken()
}, {
aggregationModel: this
});
this.on('change:column change:aggregation change:aggregation_column', this._reloadAndForceFetch, this);
this.on('change:categories', this.refresh, this);
this.bind('change:url', function () {
this._searchModel.set({
url: this.get('url')
});
}, this);
this.once('change:url', function () {
this._rangeModel.setUrl(this.get('url'));
}, this);
this._rangeModel.bind('change:totalCount change:categoriesCount', function () {
this.set({
totalCount: this._rangeModel.get('totalCount'),
categoriesCount: this._rangeModel.get('categoriesCount')
});
}, this);
this._bindSearchModelEvents();
if (attrs && attrs.acceptedCategories) {
this.filter.accept(attrs.acceptedCategories);
}
},
_onMapBoundsChanged: function () {
DataviewModelBase.prototype._onMapBoundsChanged.apply(this, arguments);
this._searchModel.fetchIfSearchIsApplied();
},
_onCircleChanged: function () {
DataviewModelBase.prototype._onCircleChanged.apply(this, arguments);
this._searchModel.fetchIfSearchIsApplied();
},
_onPolygonChanged: function () {
DataviewModelBase.prototype._onPolygonChanged.apply(this, arguments);
this._searchModel.fetchIfSearchIsApplied();
},
_bindSearchModelEvents: function () {
this.listenTo(this._searchModel, 'loading', function () {
this.trigger('loading', this);
}, this);
this.listenTo(this._searchModel, 'loaded', function () {
this.trigger('loaded', this);
}, this);
this.listenTo(this._searchModel, 'error', function (model, response) {
if (!response || (response && response.statusText !== 'abort')) {
this.trigger('error', model, response);
}
}, this);
this.listenTo(this._searchModel, 'change:data', this._onSearchDataChange, this);
},
_onSearchDataChange: function () {
this.getSearchResult().each(function (m) {
var selected = this.filter.isAccepted(m.get('name'));
m.set('selected', selected);
}, this);
this.trigger('change:searchData', this);
},
_shouldFetchOnBoundingBoxChange: function () {
return DataviewModelBase.prototype._shouldFetchOnBoundingBoxChange.call(this) && !this.isSearchApplied();
},
_shouldFetchOnCircleChange: function () {
return DataviewModelBase.prototype._shouldFetchOnCircleChange.call(this) && !this.isSearchApplied();
},
_shouldFetchOnPolygonChange: function () {
return DataviewModelBase.prototype._shouldFetchOnPolygonChange.call(this) && !this.isSearchApplied();
},
enableFilter: function () {
this.set('filterEnabled', true);
},
disableFilter: function () {
this.set('filterEnabled', false);
},
// Search model helper methods //
getSearchQuery: function () {
return this._searchModel.getSearchQuery();
},
setSearchQuery: function (q) {
this._searchModel.set('q', q);
},
isSearchValid: function () {
return this._searchModel.isValid();
},
getSearchResult: function () {
return this._searchModel.getData();
},
getSearchCount: function () {
return this._searchModel.getCount();
},
applySearch: function () {
this._searchModel.fetch();
},
isSearchApplied: function () {
return this._searchModel.isSearchApplied();
},
cleanSearch: function () {
this._searchModel.resetData();
},
setupSearch: function () {
if (!this.isSearchApplied()) {
this._searchModel.setData(
this._data.toJSON()
);
}
},
getData: function () {
return this._data;
},
getSize: function () {
return this._data.size();
},
getCount: function () {
return this.get('categoriesCount');
},
isOtherAvailable: function () {
return this._data.isOtherAvailable();
},
numberOfAcceptedCategories: function () {
var acceptedCategories = this.filter.acceptedCategories;
var numberOfRejectedCategories = this.numberOfRejectedCategories();
var data = this.getData();
var totalCategories = data.size();
var numberOfAcceptedCategories = data.reduce(
function (memo, cat) {
var isCategoryInData = acceptedCategories.where({ name: cat.get('name') }).length > 0;
return memo + (isCategoryInData ? 1 : 0);
},
0
);
if (!numberOfRejectedCategories) {
return numberOfAcceptedCategories;
} else {
return totalCategories - numberOfRejectedCategories;
}
},
numberOfRejectedCategories: function () {
var rejectedCategories = this.filter.rejectedCategories;
var data = this.getData();
return data.reduce(
function (memo, cat) {
var isCategoryInData = rejectedCategories.where({ name: cat.get('name') }).length > 0;
return memo + (isCategoryInData ? 1 : 0);
},
0
);
},
refresh: function () {
if (this.isSearchApplied()) {
this._searchModel.fetch();
} else {
this.fetch();
}
},
parse: function (d) {
var newData = [];
var _tmpArray = {};
var allNewCategories = d.categories;
var allNewCategoryNames = [];
var acceptedCategoryNames = [];
_.each(allNewCategories, function (datum) {
var category = datum.category;
allNewCategoryNames.push(category);
var isRejected = this.filter.isRejected(category);
_tmpArray[category] = true;
newData.push({
selected: !isRejected,
name: category,
agg: datum.agg,
value: datum.value
});
}, this);
// Only accepted categories should appear when filterEnabled is true
if (this.get('filterEnabled')) {
// Add accepted items that are not present in the categories data
this.filter.acceptedCategories.each(function (mdl) {
var category = mdl.get('name');
acceptedCategoryNames.push(category);
if (!_tmpArray[category]) {
newData.push({
selected: true,
name: category,
agg: false,
value: 0
});
}
}, this);
}
this._data.reset(newData);
return {
allCategoryNames: _
.chain(allNewCategoryNames)
.union(acceptedCategoryNames)
.unique()
.value(),
data: newData,
nulls: d.nulls,
min: d.min,
max: d.max,
count: d.count
};
},
// Backbone toJson function override
// This function is used to serialize the server request
toJSON: function () {
return {
type: 'aggregation',
source: { id: this.getSourceId() },
options: {
column: this.get('column'),
aggregation: this.get('aggregation'),
// TODO server-side is using camelCased attr name, update once fixed
aggregationColumn: this.get('aggregation_column')
}
};
}
},
// Class props
{
ATTRS_NAMES: DataviewModelBase.ATTRS_NAMES.concat([
'column',
'aggregation',
'aggregation_column',
'acceptedCategories',
'categories'
])
}
);

View File

@@ -0,0 +1,50 @@
var Backbone = require('backbone');
var _ = require('underscore');
var CategoryItemModel = require('./category-item-model');
var COUNT_AGGREGATION_TYPE = 'count';
/**
* Data categories collection
*
* - It basically sorts by (value, selected and "Other").
*/
module.exports = Backbone.Collection.extend({
model: CategoryItemModel,
initialize: function (models, options) {
this.aggregationModel = options.aggregationModel;
this.aggregation = options.aggregationModel.get('aggregation');
},
reset: function (models, options) {
if (this.aggregationModel.get('aggregation') !== COUNT_AGGREGATION_TYPE) {
models = _.filter(models, function (category) {
var isModel = category instanceof Backbone.Model;
var value = isModel ? category.get('value') : category.value;
return value != null;
});
}
Backbone.Collection.prototype.reset.call(this, models, options);
},
comparator: function (a, b) {
if (a.get('name') === 'Other') {
return 1;
} else if (b.get('name') === 'Other') {
return -1;
} else if (a.get('value') === b.get('value')) {
return (a.get('selected') < b.get('selected')) ? 1 : -1;
} else {
return (a.get('value') < b.get('value')) ? 1 : -1;
}
},
isOtherAvailable: function () {
return this.where({
agg: true,
name: 'Other'
}).length > 0;
}
});

View File

@@ -0,0 +1,14 @@
var Model = require('../../core/model');
/**
* Model for a category
*/
module.exports = Model.extend({
defaults: {
name: '',
agg: false,
value: 0
}
});

View File

@@ -0,0 +1,60 @@
var _ = require('underscore');
var Model = require('../../core/model');
/**
* This model is used for getting the total amount of values
* from the category.
*
*/
module.exports = Model.extend({
defaults: {
url: '',
totalCount: 0,
categoriesCount: 0
},
url: function () {
var url = this.get('url');
var queryOptions = [];
if (this.get('apiKey')) {
url += '?api_key=' + this.get('apiKey');
} else if (this.get('authToken')) {
var authToken = this.get('authToken');
if (authToken instanceof Array) {
_.each(authToken, function (token) {
queryOptions.push('auth_token[]=' + token);
});
} else {
queryOptions.push('auth_token=' + authToken);
}
url += '?' + queryOptions.join('&');
}
return url;
},
initialize: function () {
this.bind('change:url', function () {
this.fetch();
}, this);
},
setUrl: function (url) {
this.set('url', url);
},
parse: function (d) {
// Calculating the total amount of all categories with the sum of all
// values from this model included the aggregated (Other)
return {
categoriesCount: d.categoriesCount,
totalCount: _.reduce(
_.pluck(d.categories, 'value'),
function (memo, value) {
return memo + value;
},
0
)
};
}
});

View File

@@ -0,0 +1,127 @@
var _ = require('underscore');
var Model = require('../../core/model');
var BackboneAbortSync = require('../../util/backbone-abort-sync');
var CategoriesCollection = require('./categories-collection');
/**
* Category search model
*/
module.exports = Model.extend({
defaults: {
q: '',
data: [],
url: ''
},
url: function () {
var url = this.get('url') + '/search?q=' + encodeURIComponent(this.get('q'));
if (this.get('apiKey')) {
url += '&api_key=' + this.get('apiKey');
} else if (this.get('authToken')) {
var authToken = this.get('authToken');
if (authToken instanceof Array) {
_.each(authToken, function (token) {
url += '&auth_token[]=' + token;
});
} else {
url += '&auth_token=' + authToken;
}
}
return url;
},
initialize: function (attrs, opts) {
this._data = new CategoriesCollection(null, {
aggregationModel: opts.aggregationModel
});
this.sync = BackboneAbortSync.bind(this);
},
fetchIfSearchIsApplied: function () {
if (this.isSearchApplied()) {
this.fetch();
}
},
setData: function (data) {
var categories = this._parseData(data);
this._data.reset(categories);
this.set('data', categories);
},
getData: function () {
return this._data;
},
getSize: function () {
return this._data.size();
},
getCount: function () {
return this.getSize();
},
isValid: function () {
var str = this.get('q');
return !!(str || '');
},
resetData: function () {
this.setData([]);
this.set('q', '');
},
getSearchQuery: function () {
return this.get('q');
},
isSearchApplied: function () {
return this.isValid() && this.getSize() > 0;
},
_parseData: function (categories) {
var newData = [];
_.each(categories, function (d) {
if (!d.agg) {
newData.push({
selected: false,
name: (d.category || d.name).toString(),
agg: d.agg,
value: d.value
});
}
}, this);
return newData;
},
parse: function (r) {
var categories = this._parseData(r.categories);
this._data.reset(categories);
return {
data: categories
};
},
fetch: function (opts) {
opts = opts || {};
this.trigger('loading', this);
if (opts.success) {
var successCallback = opts && opts.success;
}
return Model.prototype.fetch.call(this, _.extend(opts, {
success: function () {
successCallback && successCallback(arguments);
this.trigger('loaded', this);
}.bind(this),
error: function (mdl, err) {
if (!err || (err && err.statusText !== 'abort')) {
this.trigger('error', mdl, err);
}
}.bind(this)
}));
}
});

View File

@@ -0,0 +1,572 @@
var _ = require('underscore');
var Model = require('../core/model');
var BackboneAbortSync = require('../util/backbone-abort-sync');
var AnalysisModel = require('../analysis/analysis-model');
var util = require('../core/util');
var parseWindshaftErrors = require('../windshaft/error-parser');
var UNFETCHED_STATUS = 'unfetched';
var FETCHING_STATUS = 'fetching';
var FETCHED_STATUS = 'fetched';
var FETCH_ERROR_STATUS = 'error';
var REQUEST_GET_MAX_URL_LENGTH = 2083; // IE11
var REQUIRED_OPTS = [
'engine'
];
/**
* Default dataview model
*/
module.exports = Model.extend({
defaults: {
url: '',
data: [],
sync_on_bbox_change: true,
sync_on_circle_change: true,
sync_on_polygon_change: true,
enabled: true,
status: UNFETCHED_STATUS
},
url: function () {
var params = _.union(
[ this._getSpatialFilterParam() ],
this._getDataviewSpecificURLParams()
);
this._addAuthTo(params);
var urlWithParams = this.get('url') + '?' + params.join('&');
if (urlWithParams.length > REQUEST_GET_MAX_URL_LENGTH) {
throw new Error(
'URL length is longer than allowed (' + REQUEST_GET_MAX_URL_LENGTH + ' chars). ' +
'Check your filters (eg. if using a Polygon filter, reduce the number of vertices).'
);
}
return urlWithParams;
},
_addAuthTo: function (params) {
if (this._engine.getApiKey()) {
params.push('api_key=' + this._engine.getApiKey());
} else if (this._engine.getAuthToken()) {
var authToken = this._engine.getAuthToken();
if (authToken instanceof Array) {
_.each(authToken, function (token) {
params.push('auth_token[]=' + token);
});
} else {
params.push('auth_token=' + authToken);
}
}
},
_getSpatialFilterParam: function () {
if (this._bboxFilter) {
return this._getBoundingBoxFilterParam();
}
if (this._circleFilter) {
return this._getCircleFilterParam();
}
if (this._polygonFilter) {
return this._getPolygonFilterParam();
}
},
_getBoundingBoxFilterParam: function () {
var result = '';
this._checkBBoxFilter();
if (this.syncsOnBoundingBoxChanges()) {
result = 'bbox=' + this._bboxFilter.serialize();
}
return result;
},
_getCircleFilterParam: function () {
var result = '';
this._checkCircleFilter();
if (this.syncsOnCircleChanges()) {
result = 'circle=' + this._circleFilter.serialize();
}
return result;
},
_getPolygonFilterParam: function () {
var result = '';
this._checkPolygonFilter();
if (this.syncsOnPolygonChanges()) {
result = 'polygon=' + this._polygonFilter.serialize();
}
return result;
},
/**
* Subclasses might override this method to define extra params that will be appended
* to the dataview's URL.
* @return {Array} An array of strings in the form of "key=value".
*/
_getDataviewSpecificURLParams: function () {
return [];
},
initialize: function (attrs, opts) {
attrs = attrs || {};
opts = opts || {};
util.checkRequiredOpts(opts, REQUIRED_OPTS, 'DataviewModelBase');
this._hasBinds = false;
this._engine = opts.engine;
if (!attrs.source) throw new Error('source is a required attr');
this._checkSourceAttribute(this.getSource());
this.getSource().markAsSourceOf(this);
if (!attrs.id) {
this.set('id', this.defaults.type + '-' + this.cid);
}
this.sync = BackboneAbortSync.bind(this);
// filter is optional, so have to guard before using it
this.filter = opts.filter;
if (this.filter) {
this.filter.set('dataviewId', this.id);
}
this._addSpatialFilterFrom(opts);
this._initBinds();
},
_addSpatialFilterFrom (opts) {
if (opts.bboxFilter) {
this.addBBoxFilter(opts.bboxFilter);
}
if (opts.circleFilter) {
this.addCircleFilter(opts.circleFilter);
}
if (opts.polygonFilter) {
this.addPolygonFilter(opts.polygonFilter);
}
},
_initBinds: function () {
this.listenToOnce(this, 'change:url', function () {
this._checkBBoxFilter();
if (this.syncsOnBoundingBoxChanges() && !this._bboxFilter.areBoundsAvailable()) {
// wait until map gets bounds from view
this.listenTo(this._bboxFilter, 'boundsChanged', this._fetch);
} else {
this._fetch();
}
});
if (this.filter) {
this.listenTo(this.filter, 'change', this._onFilterChanged);
}
this.getSource().on('change:status', this._onAnalysisStatusChange, this);
},
_onChangeBinds: function () {
this.on('change:sync_on_bbox_change', function () {
this.refresh();
}, this);
this.on('change:url', function (model, value, opts) {
this._newDataAvailable = true;
if (this._shouldFetchOnURLChange(opts && _.pick(opts, ['forceFetch', 'sourceId']))) {
this.refresh();
}
}, this);
this.on('change:enabled', function (mdl, isEnabled) {
if (isEnabled && this._newDataAvailable) {
this.refresh();
this._newDataAvailable = false;
}
}, this);
},
_onMapBoundsChanged: function () {
if (this._shouldFetchOnBoundingBoxChange()) {
// If the widget is the first one created it changes the map bounds
// and cancels the first ._fetch request so we have to call ._fetch here
// instead of .refresh to set the binds if they're not set up yet
this._fetch();
}
if (this.syncsOnBoundingBoxChanges()) {
this._newDataAvailable = true;
}
},
_onCircleChanged: function () {
if (this._shouldFetchOnCircleChange()) {
this._fetch();
}
if (this.syncsOnCircleChanges()) {
this._newDataAvailable = true;
}
},
_onPolygonChanged: function () {
if (this._shouldFetchOnPolygonChange()) {
this._fetch();
}
if (this.syncsOnPolygonChanges()) {
this._newDataAvailable = true;
}
},
_fetch: function () {
this.fetch({
success: function () {
if (!this._hasBinds) {
this._hasBinds = true;
this._onChangeBinds();
}
}.bind(this)
});
},
_onAnalysisStatusChange: function (analysis, status) {
if (analysis.isLoading()) {
this._triggerLoading();
} else if (analysis.isFailed()) {
this._triggerStatusError(analysis.get('error'));
}
// loaded will be triggered through the default behavior, so not necessary to react on that status here
},
_triggerLoading: function () {
this.trigger('loading', this);
},
_triggerStatusError: function (error) {
this.trigger('statusError', this, error); // Backbone already emits an event `error` in failed requests. Avoiding name collision.
},
/**
* @protected
*/
_onFilterChanged: function (filter) {
this._reload({
sourceId: this.getSourceId()
});
},
_reloadAndForceFetch: function () {
this._reload({
sourceId: this.getSourceId(),
forceFetch: true
});
},
_reload: function (opts) {
opts = opts || {};
this._engine.reload(opts);
},
_shouldFetchOnURLChange: function (options) {
options = options || {};
var sourceId = options.sourceId;
var forceFetch = options.forceFetch;
if (forceFetch) {
return true;
}
return this.isEnabled() &&
this._sourceAffectsMyOwnSource(sourceId);
},
_sourceAffectsMyOwnSource: function (sourceId) {
if (!sourceId) {
return true;
}
var sourceAnalysis = this.getSource();
return sourceAnalysis && sourceAnalysis.findAnalysisById(sourceId);
},
_shouldFetchOnBoundingBoxChange: function () {
return this.isEnabled() &&
this.syncsOnBoundingBoxChanges();
},
_shouldFetchOnCircleChange: function () {
return this.isEnabled() &&
this.syncsOnCircleChanges();
},
_shouldFetchOnPolygonChange: function () {
return this.isEnabled() &&
this.syncsOnPolygonChanges();
},
refresh: function () {
this.fetch();
},
addBBoxFilter: function (bboxFilter) {
if (!bboxFilter) {
return;
}
this._stopListeningBBoxChanges();
this._bboxFilter = bboxFilter;
this._listenToBBoxChanges();
},
removeBBoxFilter: function () {
this._stopListeningBBoxChanges();
this._bboxFilter = null;
},
addCircleFilter: function (circleFilter) {
if (!circleFilter) {
return;
}
this._stopListeningCircleChanges();
this._circleFilter = circleFilter;
this._listenToCircleChanges();
},
removeCircleFilter: function () {
this._stopListeningCircleChanges();
this._circleFilter = null;
},
addPolygonFilter: function (polygonFilter) {
if (!polygonFilter) {
return;
}
this._stopListeningPolygonChanges();
this._polygonFilter = polygonFilter;
this._listenToPolygonChanges();
},
removePolygonFilter: function () {
this._stopListeningPolygonChanges();
this._polygonFilter = null;
},
update: function (attrs) {
if (_.has(attrs, 'source')) {
throw new Error('Source of dataviews cannot be updated');
}
attrs = _.pick(attrs, this.constructor.ATTRS_NAMES);
this.set(attrs);
},
getData: function () {
return this.get('data');
},
getPreviousData: function () {
return this.previous('data');
},
fetch: function (opts) {
opts = opts || {};
this.set('status', FETCHING_STATUS);
this._triggerLoading();
if (opts.success) {
var successCallback = opts && opts.success;
}
return Model.prototype.fetch.call(this, _.extend(opts, {
success: function () {
this.set('status', FETCHED_STATUS);
successCallback && successCallback(arguments);
this.trigger('loaded', this);
}.bind(this),
error: function (_model, response) {
if (!response || (response && response.statusText !== 'abort')) {
this.set('status', FETCH_ERROR_STATUS);
if (this._errorWithBigPolygonFilter(_model)) {
response.statusText = 'error in the dataview request. ' +
'Check the Polygon filter size (reduce the number of vertices and retry).';
}
var error = this._parseError(response);
this._triggerStatusError(error);
}
}.bind(this)
}));
},
_errorWithBigPolygonFilter: function (model) {
var usingPolygonFilter = model && model.attributes && model.attributes.sync_on_polygon_change;
if (usingPolygonFilter && this.url().length > REQUEST_GET_MAX_URL_LENGTH) {
return true;
}
return false;
},
toJSON: function () {
throw new Error('toJSON should be defined for each dataview');
},
getSourceType: function () {
return this.getSource().get('type');
},
getSourceId: function () {
var source = this.getSource();
return source && source.id;
},
getSource: function () {
return this.get('source');
},
isSourceType: function () {
return this.getSourceType() === 'source';
},
isFiltered: function () {
var isFiltered = false;
if (this.filter) {
isFiltered = !this.filter.isEmpty();
}
return isFiltered;
},
remove: function () {
this._removeExistingAnalysisBindings();
this.getSource().unmarkAsSourceOf(this);
this.trigger('destroy', this);
this.stopListening();
},
_removeExistingAnalysisBindings: function () {
this.getSource().off('change:status', this._onAnalysisStatusChange, this);
},
isFetched: function () {
return this.get('status') === FETCHED_STATUS;
},
isUnavailable: function () {
return this.get('status') === FETCH_ERROR_STATUS;
},
isEnabled: function () {
return this.get('enabled');
},
setUnavailable: function () {
return this.set('status', FETCH_ERROR_STATUS);
},
syncsOnBoundingBoxChanges: function () {
return this.get('sync_on_bbox_change');
},
syncsOnCircleChanges: function () {
return this.get('sync_on_circle_change');
},
syncsOnPolygonChanges: function () {
return this.get('sync_on_polygon_change');
},
_checkSourceAttribute: function (source) {
if (!(source instanceof AnalysisModel)) {
throw new Error('Source must be an instance of AnalysisModel');
}
},
_checkBBoxFilter: function () {
if (this.syncsOnBoundingBoxChanges() && !this._bboxFilter) {
throw new Error('Cannot sync on bounding box changes. There is no bounding box filter.');
}
},
_checkCircleFilter: function () {
if (this.syncsOnCircleChanges() && !this._circleFilter) {
throw new Error('Cannot sync on circle filter changes. There is no circle filter.');
}
},
_checkPolygonFilter: function () {
if (this.syncsOnPolygonChanges() && !this._polygonFilter) {
throw new Error('Cannot sync on polygon filter changes. There is no polygon filter.');
}
},
_listenToBBoxChanges: function () {
if (this._bboxFilter) {
this.listenTo(this._bboxFilter, 'boundsChanged', this._onMapBoundsChanged);
}
},
_stopListeningBBoxChanges: function () {
if (this._bboxFilter) {
this.stopListening(this._bboxFilter, 'boundsChanged');
}
},
_listenToCircleChanges: function () {
if (this._circleFilter) {
this.listenTo(this._circleFilter, 'circleChanged', this._onCircleChanged);
}
},
_stopListeningCircleChanges: function () {
if (this._circleFilter) {
this.stopListening(this._circleFilter, 'circleChanged');
}
},
_listenToPolygonChanges: function () {
if (this._polygonFilter) {
this.listenTo(this._polygonFilter, 'polygonChanged', this._onPolygonChanged);
}
},
_stopListeningPolygonChanges: function () {
if (this._polygonFilter) {
this.stopListening(this._polygonFilter, 'polygonChanged');
}
},
_parseError: function (response) {
var error = {};
var errors = parseWindshaftErrors(response, 'dataview');
if (errors.length > 0) {
error = errors[0];
}
return error;
}
},
// Class props
{
ATTRS_NAMES: [
'id',
'sync_on_bbox_change',
'sync_on_circle_change',
'sync_on_polygon_change',
'enabled',
'source'
]
});

View File

@@ -0,0 +1,24 @@
var _ = require('underscore');
var Backbone = require('backbone');
var DataviewsCollection = Backbone.Collection.extend({
isAnyDataviewFiltered: function () {
return this.any(function (dataviewModel) {
var filter = dataviewModel.filter;
return (filter && !filter.isEmpty());
});
},
getFilters: function () {
return this.reduce(function (filters, dataviewModel) {
var filter = dataviewModel.filter;
if (filter && !filter.isEmpty()) {
filters['dataviews'] = filters['dataviews'] || {};
_.extend(filters['dataviews'], filter.toJSON());
}
return filters;
}, {});
}
});
module.exports = DataviewsCollection;

View File

@@ -0,0 +1,96 @@
var _ = require('underscore');
var Model = require('../core/model');
var util = require('../core/util');
var CategoryFilter = require('../windshaft/filters/category');
var RangeFilter = require('../windshaft/filters/range');
var CategoryDataviewModel = require('./category-dataview-model');
var FormulaDataviewModel = require('./formula-dataview-model');
var HistogramDataviewModel = require('./histogram-dataview-model');
var BBoxFilter = require('../windshaft/filters/bounding-box');
var MapModelBoundingBoxAdapter = require('../geo/adapters/map-model-bounding-box-adapter');
var REQUIRED_OPTS = [
'map',
'engine',
'dataviewsCollection'
];
/**
* Factory to create dataviews.
* Takes care of adding and wiring up lifeceycle to other related objects (e.g. dataviews collection, layers etc.)
*/
module.exports = Model.extend({
initialize: function (attrs, opts) {
util.checkRequiredOpts(opts, REQUIRED_OPTS, 'DataviewsFactory');
this._engine = opts.engine;
this._dataviewsCollection = opts.dataviewsCollection;
var mapAdapter = new MapModelBoundingBoxAdapter(opts.map);
this._bboxFilter = new BBoxFilter(mapAdapter);
},
createCategoryModel: function (attrs) {
_checkProperties(attrs, ['source', 'column']);
attrs = this._generateAttrsForDataview(attrs, CategoryDataviewModel.ATTRS_NAMES);
attrs.aggregation = attrs.aggregation || 'count';
attrs.aggregation_column = attrs.aggregation_column || attrs.column;
var categoryFilter = new CategoryFilter();
return this._newModel(
new CategoryDataviewModel(attrs, {
engine: this._engine,
filter: categoryFilter,
bboxFilter: this._bboxFilter
})
);
},
createFormulaModel: function (attrs) {
_checkProperties(attrs, ['source', 'column', 'operation']);
attrs = this._generateAttrsForDataview(attrs, FormulaDataviewModel.ATTRS_NAMES);
var dataview = this._newModel(
new FormulaDataviewModel(attrs, {
engine: this._engine,
bboxFilter: this._bboxFilter
})
);
return dataview;
},
createHistogramModel: function (attrs) {
_checkProperties(attrs, ['source', 'column']);
attrs = this._generateAttrsForDataview(attrs, HistogramDataviewModel.ATTRS_NAMES);
var rangeFilter = new RangeFilter();
return this._newModel(
new HistogramDataviewModel(attrs, {
engine: this._engine,
filter: rangeFilter,
bboxFilter: this._bboxFilter
})
);
},
_generateAttrsForDataview: function (attrs, whitelistedAttrs) {
return _.pick(attrs, whitelistedAttrs);
},
_newModel: function (m) {
this._dataviewsCollection.add(m);
return m;
}
});
function _checkProperties (obj, propertiesArray) {
_.each(propertiesArray, function (prop) {
if (obj[prop] === undefined) {
throw new Error(prop + ' is required');
}
});
}

View File

@@ -0,0 +1,44 @@
var _ = require('underscore');
var DataviewModelBase = require('./dataview-model-base');
module.exports = DataviewModelBase.extend({
defaults: _.extend(
{
type: 'formula',
data: ''
},
DataviewModelBase.prototype.defaults
),
initialize: function () {
DataviewModelBase.prototype.initialize.apply(this, arguments);
this.on('change:column change:operation', this._reloadAndForceFetch, this);
},
parse: function (r) {
return {
data: r.result,
nulls: r.nulls
};
},
toJSON: function () {
return {
type: 'formula',
source: { id: this.getSourceId() },
options: {
column: this.get('column'),
operation: this.get('operation')
}
};
}
},
// Class props
{
ATTRS_NAMES: DataviewModelBase.ATTRS_NAMES.concat([
'column',
'operation'
])
}
);

View File

@@ -0,0 +1,130 @@
var _ = require('underscore');
var AGGREGATION_DATA = {
second: { unit: 'second', factor: 1 },
minute: { unit: 'minute', factor: 1 },
hour: { unit: 'hour', factor: 1 },
day: { unit: 'day', factor: 1 },
week: { unit: 'day', factor: 7 },
month: { unit: 'month', factor: 1 },
quarter: { unit: 'month', factor: 3 },
year: { unit: 'month', factor: 12 },
decade: { unit: 'month', factor: 120 },
century: { unit: 'month', factor: 1200 },
millennium: { unit: 'month', factor: 12000 }
};
var helper = {};
function trimBuckets (buckets, filledBuckets, totalBuckets) {
var index = null;
var keepGoing = true;
for (var i = filledBuckets.length - 1; i >= 0 && keepGoing && (_.isFinite(totalBuckets) ? i >= totalBuckets : true); i--) {
if (filledBuckets[i]) {
keepGoing = false;
} else {
index = i;
}
}
return index !== null
? buckets.slice(0, index)
: buckets;
}
helper.fillTimestampBuckets = function (buckets, start, aggregation, numberOfBins, from, totalBuckets) {
var filledBuckets = []; // To catch empty buckets
var definedBucket = false;
for (var i = 0; i < numberOfBins; i++) {
definedBucket = buckets[i] !== undefined;
filledBuckets.push(definedBucket);
var bucketStart = this.add(start, i, aggregation);
var nextBucketStart = this.add(start, i + 1, aggregation);
buckets[i] = _.extend({
bin: i,
start: bucketStart,
end: nextBucketStart - 1,
next: nextBucketStart,
freq: 0
}, buckets[i]);
delete buckets[i].timestamp;
}
return from === 'totals'
? buckets
: trimBuckets(buckets, filledBuckets, totalBuckets);
};
helper.fillNumericBuckets = function (buckets, start, width, numberOfBins) {
for (var i = 0; i < numberOfBins; i++) {
var bucketStart = start + (i * width);
var commonBucketEnd = start + ((i + 1) * width);
var isLastBucket = (i + 1) === numberOfBins;
var bucketEnd = (isLastBucket && buckets[i]) ? buckets[i].max : commonBucketEnd;
var filledBucket = _.extend({}, {
bin: i,
start: bucketStart,
end: bucketEnd,
freq: 0
}, buckets[i]);
buckets[i] = filledBucket;
}
};
helper.hasChangedSomeOf = function (list, changed) {
return _.some(_.keys(changed), function (key) {
return _.contains(list, key);
});
};
/**
* Add a `number` of aggregations to the provided timestamp
*
* @param {number} timestamp - Starting timestamp
* @param {number} number - Number of aggregations to add
* @param {object} aggregation
* @param {string} aggregation.unit - unit of the aggregation
* @param {number} aggregation.factor - number of aggretagion units
*/
helper.add = function (timestamp, number, aggregation) {
if (!AGGREGATION_DATA.hasOwnProperty(aggregation)) {
throw Error('aggregation "' + aggregation + '" is not defined');
}
var date = new Date(timestamp * 1000);
var unit = AGGREGATION_DATA[aggregation].unit;
var factor = AGGREGATION_DATA[aggregation].factor;
var value = number * factor;
switch (unit) {
case 'second':
return date.setUTCSeconds(date.getUTCSeconds() + value) / 1000;
case 'minute':
return date.setUTCMinutes(date.getUTCMinutes() + value) / 1000;
case 'hour':
return date.setUTCHours(date.getUTCHours() + value) / 1000;
case 'day':
return date.setUTCDate(date.getUTCDate() + value) / 1000;
case 'month':
var n = date.getUTCDate();
date.setUTCDate(1);
date.setUTCMonth(date.getUTCMonth() + value);
date.setUTCDate(Math.min(n, _getDaysInMonth(date.getUTCFullYear(), date.getUTCMonth())));
return date.getTime() / 1000;
default:
return 0;
}
};
/* Internal functions */
function _getDaysInMonth (year, month) {
return [31, (_isLeapYear(year) ? 29 : 28), 31, 30, 31, 30, 31, 31, 30, 31, 30, 31][month];
}
function _isLeapYear (year) {
return ((year % 4 === 0) && (year % 100 !== 0)) || (year % 400 === 0);
}
module.exports = helper;

View File

@@ -0,0 +1,444 @@
var _ = require('underscore');
var Backbone = require('backbone');
var d3 = require('d3-array');
var DataviewModelBase = require('./dataview-model-base');
var HistogramDataModel = require('./histogram-dataview/histogram-data-model');
var helper = require('./helpers/histogram-helper');
var dateUtils = require('../util/date-utils');
module.exports = DataviewModelBase.extend({
defaults: _.extend(
{
type: 'histogram',
totalAmount: 0,
filteredAmount: 0,
hasNulls: false,
localTimezone: false
},
DataviewModelBase.prototype.defaults
),
_getDataviewSpecificURLParams: function () {
var params = [];
if (_.isNumber(this.get('own_filter'))) {
params.push('own_filter=' + this.get('own_filter'));
if (this.get('column_type') === 'number' && this.get('bins')) {
params.push('bins=' + this.get('bins'));
}
} else {
var offset = this.getCurrentOffset();
if (this.get('column_type') === 'number' && this.get('bins')) {
params.push('bins=' + this.get('bins'));
} else if (this.get('column_type') === 'date') {
params.push('aggregation=' + (this.get('aggregation') || 'auto'));
if (_.isFinite(offset)) {
params.push('offset=' + offset);
}
}
// Start - End
var start = this.get('start');
var end = this.get('end');
if (_.isFinite(start) && _.isFinite(end)) {
params.push('start=' + start);
params.push('end=' + end);
}
}
return params;
},
initialize: function (attrs, opts) {
this._localOffset = dateUtils.getLocalOffset();
// Internal model for calculating all the data in the histogram (without filters)
this._totals = new HistogramDataModel({
bins: this.get('bins'),
aggregation: this.get('aggregation'),
offset: this.get('offset'),
column_type: this.get('column_type'),
apiKey: opts && opts.engine && opts.engine.getApiKey(),
authToken: opts && opts.engine && opts.engine.getAuthToken(),
localTimezone: this.get('localTimezone'),
localOffset: this._localOffset,
start: this.get('start'),
end: this.get('end')
});
DataviewModelBase.prototype.initialize.apply(this, arguments);
this._data = new Backbone.Collection(this.get('data'));
if (attrs && (attrs.min || attrs.max)) {
this.filter && this.filter.setRange(this.get('min'), this.get('max'));
}
},
_initBinds: function () {
DataviewModelBase.prototype._initBinds.apply(this);
this._updateURLBinding();
// When original data gets fetched
this._totals.bind('loadModelCompleted', this._onTotalsDataFetched, this);
this._totals.once('loadModelCompleted', this._updateBindings, this);
this._totals.bind('error', this.setUnavailable, this);
this._totals.bind('error', this._onTotalsError, this);
this.on('change:column', this._onColumnChanged, this);
this.on('change:localTimezone', this._onLocalTimezoneChanged, this);
this.on('change', this._onFieldsChanged, this);
this.on('change:column_type', this._onColumnTypeChanged, this);
},
_onLocalTimezoneChanged: function () {
this._totals.set('localTimezone', this.get('localTimezone'));
},
_updateURLBinding: function () {
this.off('change:url');
this.on('change:url', this._onUrlChanged, this);
},
_updateBindings: function () {
this._onChangeBinds();
this._updateURLBinding();
},
enableFilter: function () {
this.set('own_filter', 1);
},
disableFilter: function () {
this.unset('own_filter');
},
getData: function () {
return this._data.toJSON();
},
getUnfilteredData: function () {
return this._totals.get('data');
},
getUnfilteredDataModel: function () {
return this._totals;
},
getSize: function () {
return this._data.size();
},
getColumnType: function () {
return this.get('column_type');
},
hasNulls: function () {
return this.get('hasNulls');
},
parse: function (data) {
var aggregation = data.aggregation || (this._totals && this._totals.get('aggregation'));
var numberOfBins = _.isFinite(data.bins_count)
? data.bins_count
: this.get('bins');
var width = data.bin_width;
var start = this.get('column_type') === 'date' ? data.timestamp_start : data.bins_start;
var parsedData = {
data: [],
filteredAmount: 0,
nulls: 0,
totalAmount: 0
};
if (this.has('error')) {
return parsedData;
}
parsedData.data = new Array(numberOfBins);
_.each(data.bins, function (bin) {
parsedData.data[bin.bin] = bin;
});
this.set({
aggregation: aggregation
}, { silent: true });
if (this.get('column_type') === 'date') {
parsedData.data = helper.fillTimestampBuckets(parsedData.data, start, aggregation, numberOfBins, 'filtered', this._totals.get('data').length);
numberOfBins = parsedData.data.length;
} else {
helper.fillNumericBuckets(parsedData.data, start, width, numberOfBins);
}
// if parse option is passed in the constructor, this._data is not created yet at this point
this._data && this._data.reset(parsedData.data);
// Calculate totals
parsedData.totalAmount = this._calculateTotalAmount(parsedData.data);
parsedData.filteredAmount = this._calculateFilteredAmount(this.filter, this._data);
parsedData.nulls = data.nulls;
parsedData.bins = numberOfBins;
if (data.nulls != null) {
parsedData = _.extend({}, parsedData, {
nulls: data.nulls,
hasNulls: true
});
}
return parsedData;
},
_onFilterChanged: function (filter) {
this.set('filteredAmount', this._calculateFilteredAmount(filter, this._data));
DataviewModelBase.prototype._onFilterChanged.apply(this, arguments);
},
_onColumnChanged: function () {
this._totals.set({
column_type: this.get('column_type'),
column: this.get('column'),
start: null,
end: null
});
this.set('aggregation', undefined, { silent: true });
this._reloadAndForceFetch();
},
_calculateTotalAmount: function (buckets) {
return _.reduce(buckets, function (memo, bucket) {
var add = bucket && bucket.freq
? bucket.freq
: 0;
return memo + add;
}, 0);
},
_calculateFilteredAmount: function (filter, data) {
var filteredAmount = 0;
if (filter && filter.get('min') !== void 0 && filter.get('max') !== void 0) {
var indexes = this._findBinsIndexes(data, filter.get('min'), filter.get('max'));
filteredAmount = this._sumBinsFreq(data, indexes.start, indexes.end);
}
return filteredAmount;
},
_findBinsIndexes: function (data, start, end) {
var startBin = data.findWhere({ start: Math.min(start, end) });
var endBin = data.findWhere({ end: Math.max(start, end) });
return {
start: startBin && startBin.get('bin'),
end: endBin && endBin.get('bin')
};
},
_sumBinsFreq: function (data, start, end) {
return _.reduce(data.slice(start, end + 1), function (acum, d) {
return (d.get('freq') || 0) + acum;
}, 0);
},
/*
Ported from cartodb-postgresql
https://github.com/CartoDB/cartodb-postgresql/blob/master/scripts-available/CDB_DistType.sql
*/
getDistributionType: function (data) {
var histogram = data || this.get('data');
var freqAccessor = function (a) { return a.freq; };
var osc = d3.max(histogram, freqAccessor) - d3.min(histogram, freqAccessor);
var mean = d3.mean(histogram, freqAccessor);
// When the difference between the max and the min values is less than
// 10 percent of the mean, it's a flat histogram (F)
if (osc < mean * 0.1) return 'F';
var sumFreqs = d3.sum(histogram, freqAccessor);
var freqs = histogram.map(function (bin) {
return 100 * bin.freq / sumFreqs;
});
// The ajus array represents relative growths
var ajus = freqs.map(function (freq, index) {
var next = freqs[index + 1];
if (freq > next) return -1;
if (Math.abs(freq - next) <= 0.05) return 0;
return 1;
});
ajus.pop();
var maxAjus = d3.max(ajus);
var minAjus = d3.min(ajus);
// If it never grows or shrinks, it returns flat
if (minAjus === 0 && maxAjus === 0) return 'F';
else if (maxAjus < 1) return 'L';
else if (minAjus > -1) return 'J';
else {
var uniques = _.uniq(ajus);
var A_TYPES = [[1, -1], [1, 0, -1], [1, -1, 0], [0, 1, -1]];
var U_TYPES = [[-1, 1], [-1, 0, 1], [-1, 1, 0], [0, -1, 1]];
if (A_TYPES.some(function (e) {
return _.isEqual(e, uniques);
})) return 'A';
else if (U_TYPES.some(function (e) {
return _.isEqual(e, uniques);
})) return 'U';
else return 'S';
}
},
toJSON: function (d) {
var columnType = this.get('column_type');
var offset = this.get('offset');
var options = {
column: this.get('column')
};
if (columnType === 'number' && this.get('bins')) {
options.bins = this.get('bins');
} else if (columnType === 'date') {
options.aggregation = this.get('aggregation') || 'auto';
if (_.isFinite(offset)) {
options.offset = offset;
}
}
return {
type: 'histogram',
source: { id: this.getSourceId() },
options: options
};
},
_onColumnTypeChanged: function () {
this.filter && this.filter.set('column_type', this.get('column_type'));
},
_onChangeBinds: function () {
DataviewModelBase.prototype._onChangeBinds.call(this);
},
_onUrlChanged: function () {
this._totals.set({
offset: this.get('offset'),
bins: this.get('bins')
}, { silent: true });
this._totals.setUrl(this.get('url'));
},
_onTotalsDataFetched: function (data, model) {
var start = model.get('start');
var end = model.get('end');
if (_.isFinite(start) && _.isFinite(end)) {
this.set({
start: start,
end: end
});
}
this.set({
aggregation: model.get('aggregation') || 'auto',
offset: model.get('offset') || 0,
bins: model.get('bins'),
error: model.get('error')
}, { silent: true });
var resetFilter = false;
if (this.get('column_type') === 'date' && (_.has(this.changed, 'aggregation') || _.has(this.changed, 'offset'))) {
resetFilter = true;
} else if (this.get('column_type') === 'number' && _.has(this.changed, 'bins')) {
resetFilter = true;
}
resetFilter
? this._resetFilterAndFetch()
: this.fetch();
},
_onFieldsChanged: function () {
this._setTotalsStartEnd();
if (!helper.hasChangedSomeOf(['bins', 'aggregation', 'offset'], this.changed)) {
return;
}
var aggregationChangedToUndefined = _.has(this.changed, 'aggregation') && _.isUndefined(this.changed.aggregation);
// We should avoid fetching totals when bins has changed and aggregation has
// changed to undefined. That means a change in column. If we set the bins
// we trigger a fetch while a map instantiation is ongoing. The API returns bad data in that case.
if (this.get('column_type') === 'number' && !aggregationChangedToUndefined) {
this._totals.set('bins', this.get('bins'));
}
if (this.get('column_type') === 'date') {
if (this.hasChanged('aggregation')) {
this._resetFilter();
}
this._totals.set({
offset: this.get('offset'),
aggregation: this.get('aggregation')
});
}
},
_setTotalsStartEnd: function () {
const start = this.get('start');
const end = this.get('end');
const startEndChanged = helper.hasChangedSomeOf(['start', 'end'], this.changed);
const startEndValid = _.isFinite(start) && _.isFinite(end);
const hasDifferentValues = this._totals.get('start') !== start || this._totals.get('end') !== end;
if (startEndChanged && startEndValid && hasDifferentValues) {
this._totals.set({ start, end });
this._totals.refresh();
}
},
_resetFilterAndFetch: function () {
this._resetFilter();
this.fetch();
},
_resetFilter: function () {
this.disableFilter();
this.filter && this.filter.unsetRange();
},
_onTotalsError: function (model, error) {
var parsedError = error && this._parseError(error);
if (parsedError && parsedError.message !== 'abort') {
this._triggerStatusError(parsedError);
}
},
getCurrentOffset: function () {
return this.get('localTimezone')
? this._localOffset
: this.get('offset');
}
},
// Class props
{
ATTRS_NAMES: DataviewModelBase.ATTRS_NAMES.concat([
'column',
'column_type',
'bins',
'min',
'max',
'aggregation',
'offset'
])
}
);

View File

@@ -0,0 +1,155 @@
var _ = require('underscore');
var BackboneAbortSync = require('../../util/backbone-abort-sync');
var Model = require('../../core/model');
var helper = require('../helpers/histogram-helper');
/**
* This model is used for getting the total amount of data
* from the histogram widget (without any filter).
*/
module.exports = Model.extend({
defaults: {
url: '',
data: [],
localTimezone: false,
localOffset: 0,
hasBeenFetched: false
},
url: function () {
var params = [];
var columnType = this.get('column_type');
var offset = this._getCurrentOffset();
var aggregation = this.get('aggregation') || 'auto';
params.push('no_filters=1');
if (columnType === 'number' && this.get('bins')) {
params.push('bins=' + this.get('bins'));
} else if (columnType === 'date') {
params.push('aggregation=' + aggregation);
if (_.isFinite(offset)) {
params.push('offset=' + offset);
}
}
if (this.get('apiKey')) {
params.push('api_key=' + this.get('apiKey'));
} else if (this.get('authToken')) {
var authToken = this.get('authToken');
if (authToken instanceof Array) {
_.each(authToken, function (token) {
params.push('auth_token[]=' + token);
});
} else {
params.push('auth_token=' + authToken);
}
}
// Start - End
var start = this.get('start');
var end = this.get('end');
if (_.isFinite(start) && _.isFinite(end)) {
params.push('start=' + start);
params.push('end=' + end);
}
return this.get('url') + '?' + params.join('&');
},
initialize: function () {
this.sync = BackboneAbortSync.bind(this);
this._initBinds();
},
_initBinds: function () {
this.on('change:url', function () {
this.refresh();
}, this);
this.on('change:aggregation change:offset', function () {
if (this.get('column_type') === 'date' && this.get('aggregation')) {
this.refresh();
}
}, this);
this.on('change:bins', function () {
if (this.get('column_type') === 'number') {
this.refresh();
}
}, this);
this.on('change:localTimezone', function () {
this.refresh();
}, this);
this.on('change:column', function () {
this.set('aggregation', 'auto', { silent: true });
});
this.on('sync', function () {
this.set('hasBeenFetched', true);
});
},
setUrl: function (url) {
if (!url) {
throw new Error('url not specified');
}
this.set('url', url);
},
setBins: function (bins) {
this.set('bins', bins, { silent: bins === void 0 });
},
getData: function () {
return this.get('data');
},
parse: function (data) {
var aggregation = data.aggregation || this.get('aggregation');
var numberOfBins = data.bins_count || 0;
var width = data.bin_width;
var start = this.get('column_type') === 'date' ? data.timestamp_start : data.bins_start;
var parsedData = {};
parsedData.data = new Array(numberOfBins);
if (aggregation) {
parsedData.aggregation = aggregation;
this.set('aggregation', aggregation, { silent: true });
}
_.each(data.bins, function (bin) {
parsedData.data[bin.bin] = bin;
});
if (this.get('column_type') === 'date') {
parsedData.data = helper.fillTimestampBuckets(parsedData.data, start, aggregation, numberOfBins, 'totals');
numberOfBins = parsedData.data.length;
} else {
helper.fillNumericBuckets(parsedData.data, start, width, numberOfBins);
}
if (parsedData.data.length > 0) {
parsedData.start = parsedData.data[0].start;
parsedData.end = parsedData.data[parsedData.data.length - 1].end;
}
parsedData.bins = numberOfBins;
return parsedData;
},
refresh: function () {
this.fetch();
},
_getCurrentOffset: function () {
return this.get('localTimezone')
? this.get('localOffset')
: this.get('offset');
}
});

463
src/engine.js Normal file
View File

@@ -0,0 +1,463 @@
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}
*/

View File

@@ -0,0 +1,52 @@
/* global google */
var _ = require('underscore');
var Model = require('../../core/model');
/**
* Adapt the Google Maps map to offer unique:
* - getBounds() function
* - 'boundsChanged' event
*/
module.exports = Model.extend({
initialize: function (map) {
this._isReady = false;
this._map = map;
this._debouncedTriggerBoundsChanged = _.debounce(this._triggerBoundsChanged, 200);
google.maps.event.addListener(
this._map,
'bounds_changed',
this._debouncedTriggerBoundsChanged.bind(this)
);
},
getBounds: function () {
if (this._isReady) {
var mapBounds = this._map.getBounds();
var sw = mapBounds.getSouthWest();
var ne = mapBounds.getNorthEast();
return {
west: sw.lng(),
south: sw.lat(),
east: ne.lng(),
north: ne.lat()
};
}
return {
west: 0,
south: 0,
east: 0,
north: 0
};
},
clean: function () {
google.maps.event.clearListeners(this._map, 'bounds_changed');
},
_triggerBoundsChanged: function () {
this._isReady = true;
this.trigger('boundsChanged', this.getBounds());
}
});

View File

@@ -0,0 +1,40 @@
var _ = require('underscore');
var Model = require('../../core/model');
/**
* Adapt the Leaflet map to offer unique:
* - getBounds() function
* - 'boundsChanged' event
*/
module.exports = Model.extend({
initialize: function (map) {
this._map = map;
this._debouncedTriggerBoundsChanged = _.debounce(this._triggerBoundsChanged, 200);
this._map.on(
'move zoom',
this._debouncedTriggerBoundsChanged,
this
);
},
getBounds: function () {
var mapBounds = this._map.getBounds();
var sw = mapBounds.getSouthWest();
var ne = mapBounds.getNorthEast();
return {
west: sw.lng,
south: sw.lat,
east: ne.lng,
north: ne.lat
};
},
clean: function () {
this._map.off('move zoom');
},
_triggerBoundsChanged: function () {
this.trigger('boundsChanged', this.getBounds());
}
});

View File

@@ -0,0 +1,39 @@
var _ = require('underscore');
var Model = require('../../core/model');
/**
* Adapt the mapModel to offer unique:
* - getBounds() function
* - 'boundsChanged' event
*/
module.exports = Model.extend({
initialize: function (map) {
this._map = map;
this._debouncedTriggerBoundsChanged = _.debounce(this._triggerBoundsChanged, 200);
this._map.on(
'change:view_bounds_ne change:center change:zoom',
this._debouncedTriggerBoundsChanged,
this
);
},
getBounds: function () {
var mapBounds = this._map.getViewBounds();
return {
west: mapBounds[0][1],
south: mapBounds[0][0],
east: mapBounds[1][1],
north: mapBounds[1][0]
};
},
clean: function () {
this._map.off('change:view_bounds_ne change:center change:zoom');
},
_triggerBoundsChanged: function () {
this.trigger('boundsChanged', this.getBounds());
}
});

View File

@@ -0,0 +1,96 @@
var parseWindshaftErrors = require('../windshaft/error-parser');
function CartoDBLayerGroupViewBase (layerGroupModel, opts) {
opts = opts || {};
this.interaction = [];
this.nativeMap = opts.nativeMap;
this._mapModel = opts.mapModel;
layerGroupModel.on('change:urls', this._reload, this);
layerGroupModel.onLayerVisibilityChanged(this._reload.bind(this));
this._reload();
}
CartoDBLayerGroupViewBase.prototype = {
_reload: function () {
throw new Error('_reload must be implemented');
},
_reloadInteraction: function () {
this._clearInteraction();
this.model.forEachGroupedLayer(function (layerModel, layerIndex) {
if ((layerModel.isVisible()) &&
(layerModel.isInteractive() || (this._mapModel && this._mapModel.isFeatureInteractivityEnabled()))) {
this._enableInteraction(layerIndex);
}
}, this);
},
_clearInteraction: function () {
for (var layerIndex in this.interaction) {
if (this.interaction.hasOwnProperty(layerIndex) &&
this.interaction[layerIndex]) {
this.interaction[layerIndex].remove();
this.interaction[layerIndex] = null;
}
}
},
_enableInteraction: function (layerIndexInLayerGroup) {
var self = this;
var tilejson = this._generateTileJSON(layerIndexInLayerGroup);
if (tilejson) {
var previousLayerInteraction = this.interaction[layerIndexInLayerGroup];
if (previousLayerInteraction) {
previousLayerInteraction.remove();
}
// eslint-disable-next-line
this.interaction[layerIndexInLayerGroup] = new this.interactionClass()
.map(this.nativeMap)
.tilejson(tilejson)
.on('on', function (zeraEvent) {
if (self._interactionDisabled) return;
zeraEvent.layer = layerIndexInLayerGroup;
self._manageOnEvents(self.nativeMap, zeraEvent);
})
.on('off', function (zeraEvent) {
if (self._interactionDisabled) return;
zeraEvent = zeraEvent || {};
// TODO: zera has an .on('error', () => { }) callback that should be used here
if (zeraEvent.errors != null) {
self._manageInteractivityErrors(zeraEvent);
}
zeraEvent.layer = layerIndexInLayerGroup;
self._manageOffEvents(self.nativeMap, zeraEvent);
});
}
},
_manageInteractivityErrors: function (payload) {
var errors = parseWindshaftErrors(payload);
if (errors.length > 0) {
this.trigger('featureError', errors[0]);
}
},
_generateTileJSON: function (layerIndexInLayerGroup) {
if (this.model.hasURLs()) {
return {
tilejson: '2.0.0',
scheme: 'xyz',
grids: this.model.getGridURLTemplatesWithSubdomains(layerIndexInLayerGroup),
tiles: this.model.getTileURLTemplatesWithSubdomains(),
formatter: function (options, data) { return data; }
};
}
},
error: function (e) { },
tilesOk: function () { }
};
module.exports = CartoDBLayerGroupViewBase;

View File

@@ -0,0 +1,250 @@
var _ = require('underscore');
var $ = require('jquery');
var Backbone = require('backbone');
var LayerTypes = require('./map/layer-types');
var util = require('../core/util');
var CartoDBLayerGroup = Backbone.Model.extend({
defaults: {
visible: true,
type: 'layergroup'
},
initialize: function (attributes, options) {
options = options || {};
if (!options.layersCollection) {
throw new Error('layersCollection option is required');
}
this._layersCollection = options.layersCollection;
},
addError: function (error) {
var type = error.type;
if (!type) {
throw new Error('Error must have a type property.');
}
this.trigger('error:' + type, error);
},
forEachGroupedLayer: function (iteratee, context) {
_.each(this._getGroupedLayers(), iteratee.bind(context || this));
},
_getGroupedLayers: function () {
return this._layersCollection.getCartoDBLayers();
},
_getLayers: function () {
return this._layersCollection.reject(LayerTypes.isGoogleMapsBaseLayer);
},
getIndexOfLayerInLayerGroup: function (layerModel) {
return this._getGroupedLayers().indexOf(layerModel);
},
getLayerInLayerGroupAt: function (index) {
return this._getGroupedLayers()[index];
},
getCartoLayerById: function (id) {
return this._layersCollection.get(id);
},
isEqual: function () {
return false;
},
hasURLs: function () {
return !!this.get('urls');
},
getSubdomains: function () {
return (this.get('urls') && this.get('urls').subdomains) || [];
},
getTileURLTemplatesWithSubdomains: function () {
var urlTemplate = this.getTileURLTemplate();
var subdomains = this.getSubdomains();
if (subdomains && subdomains.length) {
return _.map(subdomains, function (subdomain) {
return urlTemplate.replace('{s}', subdomain);
});
}
return [ urlTemplate ];
},
getTileURLTemplate: function (type) {
type = type || 'png';
var tileURLTemplate = (this.get('urls') && this.get('urls').tiles);
if (!tileURLTemplate) return '';
if (type === 'png') {
if (this._areAllLayersHidden()) {
return '';
}
return this._generatePNGTileURLTemplate(tileURLTemplate);
} else if (type === 'mvt') {
return this._generateMTVTileURLTemplate(tileURLTemplate);
}
},
_generatePNGTileURLTemplate: function (urlTemplate) {
var mapnikLayersIndexes = this._getIndexesOfVisibleMapnikLayers();
if (mapnikLayersIndexes) {
urlTemplate = urlTemplate
.replace('{layerIndexes}', mapnikLayersIndexes)
.replace('{format}', 'png');
return this._appendAuthParamsToURL(urlTemplate);
}
return '';
},
_generateMTVTileURLTemplate: function (urlTemplate) {
urlTemplate = urlTemplate
.replace('{layerIndexes}', 'mapnik')
.replace('{format}', 'mvt');
return this._appendAuthParamsToURL(urlTemplate);
},
_areAllLayersHidden: function () {
return _.all(this._getGroupedLayers(), function (layerModel) {
return !layerModel.isVisible();
});
},
_getIndexesOfVisibleMapnikLayers: function (url) {
var indexOfLayersInWindshaft = this.get('indexOfLayersInWindshaft');
return _.reduce(this._getGroupedLayers(), function (indexes, layerModel, layerIndex) {
if (layerModel.isVisible()) {
indexes.push(indexOfLayersInWindshaft[layerIndex]);
}
return indexes;
}, []).join(',');
},
_getIndexesOfVisibleLayers: function (url) {
return _.reduce(this._getLayers(), function (indexes, layerModel, layerIndex) {
if (layerModel.isVisible()) {
indexes.push(layerIndex);
}
return indexes;
}, []).join(',');
},
hasTileURLTemplates: function () {
return !!this.getTileURLTemplate();
},
getGridURLTemplatesWithSubdomains: function (layerIndex) {
var gridURLTemplates = (this.get('urls') && this.get('urls').grids && this.get('urls').grids[layerIndex]) || [];
if (this.get('urls') && this.get('urls').subdomains) {
var subdomains = this.get('urls').subdomains;
gridURLTemplates = _.map(gridURLTemplates, function (url, i) {
return url.replace('{s}', subdomains[i]);
});
}
return _.map(gridURLTemplates, this._appendAuthParamsToURL, this);
},
getAttributesBaseURL: function (layerIndex) {
return this.get('urls') && this.get('urls').attributes && this.get('urls').attributes[layerIndex];
},
getStaticImageURLTemplate: function () {
var staticImageURLTemplate = this.get('urls') && this.get('urls').image;
if (staticImageURLTemplate) {
staticImageURLTemplate = this._appendParamsToURL(staticImageURLTemplate, [ 'layer=' + this._getIndexesOfVisibleLayers() ]);
staticImageURLTemplate = this._appendAuthParamsToURL(staticImageURLTemplate);
staticImageURLTemplate = staticImageURLTemplate.replace('{s}', this.getSubdomains()[0]);
}
return staticImageURLTemplate;
},
fetchAttributes: function (layerIndex, featureID, callback) {
var attributeBaseURL = this.getAttributesBaseURL(layerIndex);
if (!attributeBaseURL) {
throw new Error('Attributes cannot be fetched until urls are set');
}
var url = this._appendAuthParamsToURL(attributeBaseURL + '/' + featureID);
$.ajax({
dataType: 'jsonp',
url: url,
jsonpCallback: '_cdbi_layer_attributes_' + util.uniqueCallbackName(this.toJSON()),
cache: true,
success: function (data) {
// loadingTime.end();
callback(data);
},
error: function (data) {
// loadingTime.end();
// cartodb.core.Profiler.metric('cartodb-js.named_map.attributes.error').inc();
callback(null);
}
});
},
_appendAuthParamsToURL: function (url) {
var params = [];
if (this.get('apiKey')) {
params.push('api_key=' + this.get('apiKey'));
} else if (this.get('authToken')) {
var authToken = this.get('authToken');
if (authToken instanceof Array) {
_.each(authToken, function (token) {
params.push('auth_token[]=' + token);
});
} else {
params.push('auth_token=' + authToken);
}
}
return this._appendParamsToURL(url, params);
},
_appendParamsToURL: function (url, params) {
if (params.length) {
var separator = '?';
if (url.indexOf('?') !== -1) {
separator = '&';
}
return url + separator + params.join('&');
}
return url;
},
onLayerVisibilityChanged: function (callback) {
this._layersCollection.on('change:visible', function (layerModel) {
if (this._isLayerGrouped(layerModel)) {
callback(layerModel);
}
}, this);
},
onLayerAdded: function (callback) {
this._layersCollection.on('add', function (layerModel) {
if (this._isLayerGrouped(layerModel)) {
callback(layerModel, this.getLayerInLayerGroupAt(layerModel));
}
}, this);
},
_isLayerGrouped: function (layerModel) {
return this._getGroupedLayers().indexOf(layerModel) >= 0;
}
});
module.exports = CartoDBLayerGroup;

View File

@@ -0,0 +1,81 @@
var ENDPOINT = 'https://api.mapbox.com/geocoding/v5/mapbox.places-permanent/{{address}}.json?access_token={{access_token}}';
var TYPES = {
country: 'country',
region: 'region',
postcode: 'postal-area',
district: 'localadmin',
place: 'venue',
locality: 'locality',
neighborhood: 'neighbourhood',
address: 'address',
poi: 'venue',
'poi.landmark': 'venue'
};
function MapboxGeocoder () { }
MapboxGeocoder.geocode = function (address, token) {
if (!address) {
throw new Error('MapboxGeocoder.geocode called with no address');
}
if (!token) {
throw new Error('MapboxGeocoder.geocode called with no access_token');
}
return fetch(ENDPOINT.replace('{{address}}', address).replace('{{access_token}}', token))
.then(function (response) {
return response.json();
})
.then(function (response) {
return _formatResponse(response);
});
};
/**
* Transform a mapbox geocoder response on a object friendly with our search widget.
* @param {object} rawMapboxResponse - The raw mapbox geocoding response, {@see https://www.mapbox.com/api-documentation/?language=JavaScript#response-object}
*/
function _formatResponse (rawMapboxResponse) {
if (!rawMapboxResponse.features.length) {
return [];
}
return [{
boundingbox: _getBoundingBox(rawMapboxResponse.features[0]),
center: _getCenter(rawMapboxResponse.features[0]),
type: _getType(rawMapboxResponse.features[0])
}];
}
/**
* Mapbox returns [lon, lat] while we use [lat, lon]
*/
function _getCenter (feature) {
return [feature.center[1], feature.center[0]];
}
/**
* Transform the feature type into a well known enum.
*/
function _getType (feature) {
if (TYPES[feature.place_type[0]]) {
return TYPES[feature.place_type[0]];
}
return 'default';
}
/**
* Transform the feature bbox into a carto.js well known format.
*/
function _getBoundingBox (feature) {
if (!feature.bbox) {
return;
}
return {
south: feature.bbox[0],
west: feature.bbox[1],
north: feature.bbox[2],
east: feature.bbox[3]
};
}
module.exports = MapboxGeocoder;

View File

@@ -0,0 +1,95 @@
var ENDPOINT = 'https://api.tomtom.com/search/2/search/{{address}}.json?key={{apiKey}}';
var TYPES = {
'Geography': 'region',
'Geography:Country': 'country',
'Geography:CountrySubdivision': 'region',
'Geography:CountrySecondarySubdivision': 'region',
'Geography:CountryTertiarySubdivision': 'region',
'Geography:Municipality': 'localadmin',
'Geography:MunicipalitySubdivision': 'locality',
'Geography:Neighbourhood': 'neighbourhood',
'Geography:PostalCodeArea': 'postal-area',
'Street': 'neighbourhood',
'Address Range': 'neighbourhood',
'Point Address': 'address',
'Cross Street': 'address',
'POI': 'venue'
};
function TomTomGeocoder () { }
TomTomGeocoder.geocode = function (address, apiKey) {
if (!address) {
throw new Error('TomTomGeocoder.geocode called with no address');
}
if (!apiKey) {
throw new Error('TomTomGeocoder.geocode called with no apiKey');
}
return fetch(ENDPOINT.replace('{{address}}', address).replace('{{apiKey}}', apiKey))
.then(function (response) {
return response.json();
})
.then(function (response) {
return _formatResponse(response);
});
};
/**
* Transform a tomtom geocoder response into an object more friendly for our search widget.
* @param {object} rawTomTomResponse - The raw tomtom geocoding response, {@see https://developer.tomtom.com/search-api/search-api-documentation-geocoding/geocode}
*/
function _formatResponse (rawTomTomResponse) {
if (!rawTomTomResponse.results.length) {
return [];
}
const bestCandidate = rawTomTomResponse.results[0];
return [{
boundingbox: _getBoundingBox(bestCandidate),
center: _getCenter(bestCandidate),
type: _getType(bestCandidate)
}];
}
/**
* TomTom returns { lon, lat } while we use [lat, lon]
*/
function _getCenter (result) {
return [result.position.lat, result.position.lon];
}
/**
* Transform the feature type into a well known enum.
*/
function _getType (result) {
let type = result.type;
if (TYPES[type]) {
if (type === 'Geography' && result.entityType) {
type = type + ':' + result.entityType;
}
return TYPES[type];
}
return 'default';
}
/**
* Transform the feature bbox into a carto.js well known format.
*/
function _getBoundingBox (result) {
if (!result.viewport) {
return;
}
const upperLeft = result.viewport.topLeftPoint;
const bottomRight = result.viewport.btmRightPoint;
return {
south: bottomRight.lat,
west: upperLeft.lon,
north: upperLeft.lat,
east: bottomRight.lon
};
}
module.exports = TomTomGeocoder;

Some files were not shown because too many files have changed in this diff Show More