From 8bac7c577dc08ed031d45b70a85c9fd464d121fd Mon Sep 17 00:00:00 2001 From: Jonathan Beezley Date: Mon, 15 May 2017 10:25:36 -0400 Subject: [PATCH 1/5] Reorganize util code to work with jsdoc correctly --- src/util/clustering.js | 480 ++++++++-------- src/util/distanceGrid.js | 249 +++++---- src/util/index.js | 1128 +++++++++++++++++++++++++++++++++++++- src/util/init.js | 1095 ------------------------------------ src/util/mockVGL.js | 2 + src/util/throttle.js | 369 ++++++------- 6 files changed, 1667 insertions(+), 1656 deletions(-) delete mode 100644 src/util/init.js diff --git a/src/util/clustering.js b/src/util/clustering.js index d58ce90c68..2b44e09050 100644 --- a/src/util/clustering.js +++ b/src/util/clustering.js @@ -1,273 +1,269 @@ /** - * @file * Using methods adapted from leaflet to cluster an array of positions * hierarchically given an array of length scales (zoom levels). */ -(function () { - 'use strict'; - - var $ = require('jquery'); - var vgl = require('vgl'); - - /** - * This class manages a group of nearby points that are clustered as a - * single object for display purposes. The class constructor is private - * and only meant to be created by the ClusterGroup object. - * - * This is a tree-like data structure. Each node in the tree is a - * cluster containing child clusters and unclustered points. - * - * @class - * @private - * - * @param {geo.util.ClusterGroup} group The source cluster group - * @param {number} zoom The zoom level of the current node - * @param {object[]} children An array of ClusterTrees or point objects - */ - function ClusterTree(group, zoom, children) { - this._group = group; - this._zoom = zoom; - this._points = []; // Unclustered points - this._clusters = []; // Child clusters - this._count = 0; // Total number of points - this._parent = null; - this._coord = null; // The cached coordinates - var that = this; - - // add the children provided in the constructor call - (children || []).forEach(function (c) { - that._add(c); - }); - } - - /** - * Add a point or cluster as a child to the current cluster. - * @param {object} pt A ClusterTree or point object - * @private - */ - ClusterTree.prototype._add = function (pt) { - var inc = 1; - - if (pt instanceof ClusterTree) { - // add a child cluster - this._clusters.push(pt); - inc = pt._count; - } else { - this._points.push(pt); - } - pt._parent = this; +var $ = require('jquery'); +var vgl = require('vgl'); - // increment the counter - this._increment(inc); - }; +/** + * This class manages a group of nearby points that are clustered as a + * single object for display purposes. The class constructor is private + * and only meant to be created by the ClusterGroup object. + * + * This is a tree-like data structure. Each node in the tree is a + * cluster containing child clusters and unclustered points. + * + * @class + * @private + * + * @param {geo.util.ClusterGroup} group The source cluster group + * @param {number} zoom The zoom level of the current node + * @param {object[]} children An array of ClusterTrees or point objects + */ +function ClusterTree(group, zoom, children) { + this._group = group; + this._zoom = zoom; + this._points = []; // Unclustered points + this._clusters = []; // Child clusters + this._count = 0; // Total number of points + this._parent = null; + this._coord = null; // The cached coordinates + var that = this; + + // add the children provided in the constructor call + (children || []).forEach(function (c) { + that._add(c); + }); +} - /** - * Increment the child counter for this and the parent. - * @param {number} inc The value to increment by - * @private - */ - ClusterTree.prototype._increment = function (inc) { - this._coord = null; - this._count += inc; - if (this._parent) { - this._parent._increment(inc); - } - }; +/** + * Add a point or cluster as a child to the current cluster. + * @param {object} pt A ClusterTree or point object + * @private + */ +ClusterTree.prototype._add = function (pt) { + var inc = 1; + + if (pt instanceof ClusterTree) { + // add a child cluster + this._clusters.push(pt); + inc = pt._count; + } else { + this._points.push(pt); + } + pt._parent = this; - /** - * Return the total number of child points contained in the cluster. - * @returns {number} Total points contained - */ - ClusterTree.prototype.count = function () { - return this._count; - }; + // increment the counter + this._increment(inc); +}; - /** - * Recursively call a function on all points contained in the cluster. - * Calls the function with `this` as the current ClusterTree object, and - * arguments to arguments the point object and the zoom level: - * func.call(this, point, zoom) - */ - ClusterTree.prototype.each = function (func) { - var i; - for (i = 0; i < this._points.length; i += 1) { - func.call(this, this._points[i], this._zoom); - } - for (i = 0; i < this._clusters.length; i += 1) { - this._clusters[i].each.call( - this._clusters[i], - func - ); - } - }; +/** + * Increment the child counter for this and the parent. + * @param {number} inc The value to increment by + * @private + */ +ClusterTree.prototype._increment = function (inc) { + this._coord = null; + this._count += inc; + if (this._parent) { + this._parent._increment(inc); + } +}; - /** - * Get the coordinates of the cluster (the mean position of all the points - * contained). This is lazily calculated and cached. - */ - ClusterTree.prototype.coords = function () { - var i, center = {x: 0, y: 0}; - if (this._coord) { - return this._coord; - } - // first add up the points at the node - for (i = 0; i < this._points.length; i += 1) { - center.x += this._points[i].x; - center.y += this._points[i].y; - } +/** + * Return the total number of child points contained in the cluster. + * @returns {number} Total points contained + */ +ClusterTree.prototype.count = function () { + return this._count; +}; - // add up the contribution from the clusters - for (i = 0; i < this._clusters.length; i += 1) { - center.x += this._clusters[i].coords().x * this._clusters[i].count(); - center.y += this._clusters[i].coords().y * this._clusters[i].count(); - } +/** + * Recursively call a function on all points contained in the cluster. + * Calls the function with `this` as the current ClusterTree object, and + * arguments to arguments the point object and the zoom level: + * func.call(this, point, zoom) + */ +ClusterTree.prototype.each = function (func) { + var i; + for (i = 0; i < this._points.length; i += 1) { + func.call(this, this._points[i], this._zoom); + } + for (i = 0; i < this._clusters.length; i += 1) { + this._clusters[i].each.call( + this._clusters[i], + func + ); + } +}; - return { - x: center.x / this.count(), - y: center.y / this.count() - }; - }; +/** + * Get the coordinates of the cluster (the mean position of all the points + * contained). This is lazily calculated and cached. + */ +ClusterTree.prototype.coords = function () { + var i, center = {x: 0, y: 0}; + if (this._coord) { + return this._coord; + } + // first add up the points at the node + for (i = 0; i < this._points.length; i += 1) { + center.x += this._points[i].x; + center.y += this._points[i].y; + } - /** - * This class manages clustering of an array of positions hierarchically. - * The algorithm and code was adapted from the Leaflet marker cluster - * plugin by David Leaver: https://github.com/Leaflet/Leaflet.markercluster - * - * @class geo.util.ClusterGroup - * @param {object} opts An options object - * @param {number} width The width of the window; used for scaling. - * @param {number} height The height of the window; used for scaling. - * @param {number} maxZoom The maximimum zoom level to calculate - * @param {number} radius Proportional to the clustering radius in pixels - */ - function C(opts, width, height) { - - var DistanceGrid = require('./distanceGrid'); - - // store the options - this._opts = $.extend({ - maxZoom: 18, - radius: 0.05 - }, opts); - this._opts.width = this._opts.width || width || 256; - this._opts.height = this._opts.height || height || 256; - - // generate the initial datastructures - this._clusters = {}; // clusters at each zoom level - this._points = {}; // unclustered points at each zoom level - - var zoom, scl; - for (zoom = this._opts.maxZoom; zoom >= 0; zoom -= 1) { - scl = this._scaleAtLevel(zoom, this._opts.width, this._opts.height); - this._clusters[zoom] = new DistanceGrid(scl); - this._points[zoom] = new DistanceGrid(scl); - } - this._topClusterLevel = new ClusterTree(this, -1); + // add up the contribution from the clusters + for (i = 0; i < this._clusters.length; i += 1) { + center.x += this._clusters[i].coords().x * this._clusters[i].count(); + center.y += this._clusters[i].coords().y * this._clusters[i].count(); } - /** - * Returns a characteristic distance scale at a particular zoom level. This - * scale is used to control the clustering radius. When the renderer supports - * it, this call should be replaced by a calculation involving the view port - * size in point coordinates at a particular zoom level. - * @private - */ - C.prototype._scaleAtLevel = function (zoom, width, height) { - return vgl.zoomToHeight(zoom, width, height) / 2 * this._opts.radius; + return { + x: center.x / this.count(), + y: center.y / this.count() }; +}; - /** - * Add a position to the cluster group. - * @protected - */ - C.prototype.addPoint = function (point) { - var zoom, closest, parent, newCluster, lastParent, z; - - // start at the maximum zoom level and search for nearby - // - // 1. existing clusters - // 2. unclustered points - // - // otherwise add the point as a new unclustered point - - for (zoom = this._opts.maxZoom; zoom >= 0; zoom -= 1) { - - // find near cluster - closest = this._clusters[zoom].getNearObject(point); - if (closest) { - // add the point to the cluster and return - closest._add(point); - return; - } +/** + * This class manages clustering of an array of positions hierarchically. + * The algorithm and code was adapted from the Leaflet marker cluster + * plugin by David Leaver: https://github.com/Leaflet/Leaflet.markercluster + * + * @class + * @alias geo.util.ClusterGroup + * @param {object} opts An options object + * @param {number} width The width of the window; used for scaling. + * @param {number} height The height of the window; used for scaling. + * @param {number} maxZoom The maximimum zoom level to calculate + * @param {number} radius Proportional to the clustering radius in pixels + */ +function C(opts, width, height) { + + var DistanceGrid = require('./distanceGrid'); + + // store the options + this._opts = $.extend({ + maxZoom: 18, + radius: 0.05 + }, opts); + this._opts.width = this._opts.width || width || 256; + this._opts.height = this._opts.height || height || 256; + + // generate the initial datastructures + this._clusters = {}; // clusters at each zoom level + this._points = {}; // unclustered points at each zoom level + + var zoom, scl; + for (zoom = this._opts.maxZoom; zoom >= 0; zoom -= 1) { + scl = this._scaleAtLevel(zoom, this._opts.width, this._opts.height); + this._clusters[zoom] = new DistanceGrid(scl); + this._points[zoom] = new DistanceGrid(scl); + } + this._topClusterLevel = new ClusterTree(this, -1); +} - // find near point - closest = this._points[zoom].getNearObject(point); - if (closest) { - parent = closest._parent; - if (parent) { - // remove the point from the parent - for (z = parent._points.length - 1; z >= 0; z -= 1) { - if (parent._points[z] === closest) { - parent._points.splice(z, 1); - parent._increment(-1); - break; - } - } - } +/** + * Returns a characteristic distance scale at a particular zoom level. This + * scale is used to control the clustering radius. When the renderer supports + * it, this call should be replaced by a calculation involving the view port + * size in point coordinates at a particular zoom level. + * @private + */ +C.prototype._scaleAtLevel = function (zoom, width, height) { + return vgl.zoomToHeight(zoom, width, height) / 2 * this._opts.radius; +}; - if (!parent) { - $.noop(); - } - // create a new cluster with these two points - newCluster = new ClusterTree(this, zoom, [closest, point]); - this._clusters[zoom].addObject(newCluster, newCluster.coords()); - - // create intermediate parent clusters that don't exist - lastParent = newCluster; - for (z = zoom - 1; z > parent._zoom; z -= 1) { - lastParent = new ClusterTree(this, z, [lastParent]); - this._clusters[z].addObject(lastParent, lastParent.coords()); - } - parent._add(lastParent); +/** + * Add a position to the cluster group. + * @protected + */ +C.prototype.addPoint = function (point) { + var zoom, closest, parent, newCluster, lastParent, z; + + // start at the maximum zoom level and search for nearby + // + // 1. existing clusters + // 2. unclustered points + // + // otherwise add the point as a new unclustered point + + for (zoom = this._opts.maxZoom; zoom >= 0; zoom -= 1) { + + // find near cluster + closest = this._clusters[zoom].getNearObject(point); + if (closest) { + // add the point to the cluster and return + closest._add(point); + return; + } - // remove closest from this zoom level and any above (replace with newCluster) - for (z = zoom; z >= 0; z -= 1) { - if (!this._points[z].removeObject(closest, closest)) { + // find near point + closest = this._points[zoom].getNearObject(point); + if (closest) { + parent = closest._parent; + if (parent) { + // remove the point from the parent + for (z = parent._points.length - 1; z >= 0; z -= 1) { + if (parent._points[z] === closest) { + parent._points.splice(z, 1); + parent._increment(-1); break; } } + } + + if (!parent) { + $.noop(); + } + // create a new cluster with these two points + newCluster = new ClusterTree(this, zoom, [closest, point]); + this._clusters[zoom].addObject(newCluster, newCluster.coords()); + + // create intermediate parent clusters that don't exist + lastParent = newCluster; + for (z = zoom - 1; z > parent._zoom; z -= 1) { + lastParent = new ClusterTree(this, z, [lastParent]); + this._clusters[z].addObject(lastParent, lastParent.coords()); + } + parent._add(lastParent); - return; + // remove closest from this zoom level and any above (replace with newCluster) + for (z = zoom; z >= 0; z -= 1) { + if (!this._points[z].removeObject(closest, closest)) { + break; + } } - // add an unclustered point - this._points[zoom].addObject(point, point); + return; } - // otherwise add to the top - this._topClusterLevel._add(point); - }; + // add an unclustered point + this._points[zoom].addObject(point, point); + } - /** - * Return the unclustered points contained at a given zoom level. - * @param {number} zoom The zoom level - * @return {object[]} The array of unclustered points - */ - C.prototype.points = function (zoom) { - zoom = Math.min(Math.max(Math.floor(zoom), 0), this._opts.maxZoom - 1); - return this._points[Math.floor(zoom)].contents(); - }; + // otherwise add to the top + this._topClusterLevel._add(point); +}; - /** - * Return the clusters contained at a given zoom level. - * @param {number} zoom The zoom level - * @return {ClusterTree[]} The array of clusters - */ - C.prototype.clusters = function (zoom) { - zoom = Math.min(Math.max(Math.floor(zoom), 0), this._opts.maxZoom - 1); - return this._clusters[Math.floor(zoom)].contents(); - }; +/** + * Return the unclustered points contained at a given zoom level. + * @param {number} zoom The zoom level + * @return {object[]} The array of unclustered points + */ +C.prototype.points = function (zoom) { + zoom = Math.min(Math.max(Math.floor(zoom), 0), this._opts.maxZoom - 1); + return this._points[Math.floor(zoom)].contents(); +}; + +/** + * Return the clusters contained at a given zoom level. + * @param {number} zoom The zoom level + * @return {ClusterTree[]} The array of clusters + */ +C.prototype.clusters = function (zoom) { + zoom = Math.min(Math.max(Math.floor(zoom), 0), this._opts.maxZoom - 1); + return this._clusters[Math.floor(zoom)].contents(); +}; - module.exports = C; -})(); +module.exports = C; diff --git a/src/util/distanceGrid.js b/src/util/distanceGrid.js index ddcc4966d8..5379210217 100644 --- a/src/util/distanceGrid.js +++ b/src/util/distanceGrid.js @@ -50,150 +50,149 @@ SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. */ /** - * @file * Code taken from https://github.com/Leaflet/Leaflet.markercluster * to support faster hierarchical clustering of features. * @copyright 2012, David Leaver */ -(function () { - "use strict"; - - var $ = require('jquery'); - var L = {}; - L.Util = { - // return unique ID of an object - stamp: function (obj) { - obj._leaflet_id = obj._leaflet_id || ++L.Util.lastId; - return obj._leaflet_id; - }, - lastId: 0 - }; - - var DistanceGrid = function (cellSize) { - this._cellSize = cellSize; - this._sqCellSize = cellSize * cellSize; - this._grid = {}; - this._objectPoint = {}; - }; - - DistanceGrid.prototype = { - - addObject: function (obj, point) { - var x = this._getCoord(point.x), - y = this._getCoord(point.y), - grid = this._grid, - row = grid[y] = grid[y] || {}, - cell = row[x] = row[x] || [], - stamp = L.Util.stamp(obj); - - point.obj = obj; - this._objectPoint[stamp] = point; - - cell.push(obj); - }, - - updateObject: function (obj, point) { - this.removeObject(obj); - this.addObject(obj, point); - }, - - //Returns true if the object was found - removeObject: function (obj, point) { - var x = this._getCoord(point.x), - y = this._getCoord(point.y), - grid = this._grid, - row = grid[y] = grid[y] || {}, - cell = row[x] = row[x] || [], - i, len; - - delete this._objectPoint[L.Util.stamp(obj)]; - - for (i = 0, len = cell.length; i < len; i++) { - if (cell[i] === obj) { - - cell.splice(i, 1); - - if (len === 1) { - delete row[x]; - } +var $ = require('jquery'); +var L = {}; +L.Util = { + // return unique ID of an object + stamp: function (obj) { + obj._leaflet_id = obj._leaflet_id || ++L.Util.lastId; + return obj._leaflet_id; + }, + lastId: 0 +}; - return true; +/** + * @class + * @alias geo.util.DistanceGrid + */ +var DistanceGrid = function (cellSize) { + this._cellSize = cellSize; + this._sqCellSize = cellSize * cellSize; + this._grid = {}; + this._objectPoint = {}; +}; + +DistanceGrid.prototype = { + + addObject: function (obj, point) { + var x = this._getCoord(point.x), + y = this._getCoord(point.y), + grid = this._grid, + row = grid[y] = grid[y] || {}, + cell = row[x] = row[x] || [], + stamp = L.Util.stamp(obj); + + point.obj = obj; + this._objectPoint[stamp] = point; + + cell.push(obj); + }, + + updateObject: function (obj, point) { + this.removeObject(obj); + this.addObject(obj, point); + }, + + //Returns true if the object was found + removeObject: function (obj, point) { + var x = this._getCoord(point.x), + y = this._getCoord(point.y), + grid = this._grid, + row = grid[y] = grid[y] || {}, + cell = row[x] = row[x] || [], + i, len; + + delete this._objectPoint[L.Util.stamp(obj)]; + + for (i = 0, len = cell.length; i < len; i++) { + if (cell[i] === obj) { + + cell.splice(i, 1); + + if (len === 1) { + delete row[x]; } + + return true; } + } - }, + }, - eachObject: function (fn, context) { - var i, j, k, len, row, cell, removed, - grid = this._grid; + eachObject: function (fn, context) { + var i, j, k, len, row, cell, removed, + grid = this._grid; - for (i in grid) { - row = grid[i]; + for (i in grid) { + row = grid[i]; - for (j in row) { - cell = row[j]; + for (j in row) { + cell = row[j]; - for (k = 0, len = cell.length; k < len; k++) { - removed = fn.call(context, cell[k]); - if (removed) { - k--; - len--; - } + for (k = 0, len = cell.length; k < len; k++) { + removed = fn.call(context, cell[k]); + if (removed) { + k--; + len--; } } } - }, - - getNearObject: function (point) { - var x = this._getCoord(point.x), - y = this._getCoord(point.y), - i, j, k, row, cell, len, obj, dist, - objectPoint = this._objectPoint, - closestDistSq = this._sqCellSize, - closest = null; - - for (i = y - 1; i <= y + 1; i++) { - row = this._grid[i]; - if (row) { - - for (j = x - 1; j <= x + 1; j++) { - cell = row[j]; - if (cell) { - - for (k = 0, len = cell.length; k < len; k++) { - obj = cell[k]; - dist = this._sqDist( - objectPoint[L.Util.stamp(obj)], - point - ); - if (dist < closestDistSq) { - closestDistSq = dist; - closest = obj; - } + } + }, + + getNearObject: function (point) { + var x = this._getCoord(point.x), + y = this._getCoord(point.y), + i, j, k, row, cell, len, obj, dist, + objectPoint = this._objectPoint, + closestDistSq = this._sqCellSize, + closest = null; + + for (i = y - 1; i <= y + 1; i++) { + row = this._grid[i]; + if (row) { + + for (j = x - 1; j <= x + 1; j++) { + cell = row[j]; + if (cell) { + + for (k = 0, len = cell.length; k < len; k++) { + obj = cell[k]; + dist = this._sqDist( + objectPoint[L.Util.stamp(obj)], + point + ); + if (dist < closestDistSq) { + closestDistSq = dist; + closest = obj; } } } } } - return closest; - }, - - /* return the point coordinates contained in the structure */ - contents: function () { - return $.map(this._objectPoint, function (val) { return val; }); - }, - - _getCoord: function (x) { - return Math.floor(x / this._cellSize); - }, - - _sqDist: function (p, p2) { - var dx = p2.x - p.x, - dy = p2.y - p.y; - return dx * dx + dy * dy; } - }; - - module.exports = DistanceGrid; -})(); + return closest; + }, + + /* return the point coordinates contained in the structure */ + contents: function () { + return $.map(this._objectPoint, function (val) { return val; }); + }, + + _getCoord: function (x) { + return Math.floor(x / this._cellSize); + }, + + _sqDist: function (p, p2) { + var dx = p2.x - p.x, + dy = p2.y - p.y; + return dx * dx + dy * dy; + } +}; + +module.exports = DistanceGrid; diff --git a/src/util/index.js b/src/util/index.js index f7206750fc..68aadcb499 100644 --- a/src/util/index.js +++ b/src/util/index.js @@ -1,12 +1,1126 @@ var $ = require('jquery'); +var throttle = require('./throttle'); +var mockVGL = require('./mockVGL'); +var DistanceGrid = require('./distanceGrid.js'); +var ClusterGroup = require('./clustering.js'); + +var chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; + +var m_timingData = {}, + m_timingKeepRecent = 200, + m_threshold = 15, + m_originalRequestAnimationFrame; + +/** + * Takes a variable number of arguments and returns the first numeric value + * it finds. + * @private + */ +function setNumeric() { + var i; + for (i = 0; i < arguments.length; i += 1) { + if (isFinite(arguments[i])) { + return arguments[i]; + } + } +} + +////////////////////////////////////////////////////////////////////////////// /** - * @module geo.util + * Contains utility classes and methods used by geojs. + * @namespace geo.util */ -var util = require('./init'); -$.extend(util, require('./throttle')); -$.extend(util, require('./mockVGL')); -util.DistanceGrid = require('./distanceGrid.js'); -util.ClusterGroup = require('./clustering.js'); +////////////////////////////////////////////////////////////////////////////// +var util = module.exports = { + DistanceGrid: DistanceGrid, + ClusterGroup: ClusterGroup, + + /** + * Returns true if the given point lies in the given polygon. + * Algorithm description: + * http://www.ecse.rpi.edu/Homepages/wrf/Research/Short_Notes/pnpoly.html + * @param {geo.screenPosition} point The test point + * @param {geo.screenPosition[]} outer The outer boundary of the polygon + * @param {geo.screenPosition[][]} [inner] Inner boundaries (holes) + * @param {Object} [range] If specified, range.min.x, range.min.y, + * range.max.x, and range.max.y specified the extents of the outer + * polygon and are used for early detection. + * @returns {boolean} true if the point is inside the polygon. + * @memberof geo.util + */ + pointInPolygon: function (point, outer, inner, range) { + var inside = false, n = outer.length, i, j; + + if (range && range.min && range.max) { + if (point.x < range.min.x || point.y < range.min.y || + point.x > range.max.x || point.y > range.max.y) { + return; + } + } + + if (n < 3) { + // we need 3 coordinates for this to make sense + return false; + } + + for (i = 0, j = n - 1; i < n; j = i, i += 1) { + if (((outer[i].y > point.y) !== (outer[j].y > point.y)) && + (point.x < (outer[j].x - outer[i].x) * + (point.y - outer[i].y) / (outer[j].y - outer[i].y) + outer[i].x)) { + inside = !inside; + } + } + + if (inner && inside) { + (inner || []).forEach(function (hole) { + inside = inside && !util.pointInPolygon(point, hole); + }); + } + + return inside; + }, + + /** + * Return a point in the basis of the triangle. If the point is located on + * a vertex of the triangle, it will be at vert0: (0, 0), vert1: (1, 0), + * vert2: (0, 1). If it is within the triangle, its coordinates will be + * 0 <= x <= 1, 0 <= y <= 1, x + y <= 1. + * + * @param {object} point: the point to convert. + * @param {object} vert0: vertex 0 of the triangle + * @param {object} vert1: vertex 1 (x direction) of the triangle + * @param {object} vert2: vertex 2 (y direction) of the triangle + * @returns {object} basisPoint: the point in the triangle basis, or + * undefined if the triangle is degenerate. + * @memberof geo.util + */ + pointTo2DTriangleBasis: function (point, vert0, vert1, vert2) { + var a = vert1.x - vert0.x, + b = vert2.x - vert0.x, + c = vert1.y - vert0.y, + d = vert2.y - vert0.y, + x = point.x - vert0.x, + y = point.y - vert0.y, + det = a * d - b * c; + if (det) { + return {x: (x * d - y * b) / det, y: (x * -c + y * a) / det}; + } + }, + + /** + * Returns true if the argument is an HTML Image element that is fully + * loaded. + * + * @param {object} img: an object that might be an HTML Image element. + * @param {boolean} [allowFailedImage]: if true, an image element that has + * a source and has failed to load is also considered 'ready' in the + * sense that it isn't expected to change to a better state. + * @returns {boolean} true if this is an image that is ready. + * @memberof geo.util + */ + isReadyImage: function (img, allowFailedImage) { + if (img instanceof Image && img.complete && img.src) { + if ((img.naturalWidth && img.naturalHeight) || allowFailedImage) { + return true; + } + } + return false; + }, + + /** + * Returns true if the argument is a function. + */ + isFunction: function (f) { + return typeof f === 'function'; + }, + + /** + * Returns the argument if it is function, otherwise returns a function + * that returns the argument. + */ + ensureFunction: function (f) { + if (util.isFunction(f)) { + return f; + } else { + return function () { return f; }; + } + }, + + /** + * Return a random string of length n || 8. + */ + randomString: function (n) { + var s, i, r; + n = n || 8; + s = ''; + for (i = 0; i < n; i += 1) { + r = Math.floor(Math.random() * chars.length); + s += chars.substring(r, r + 1); + } + return s; + }, + + /* This is a list of regex and processing functions for color conversions + * to rgb objects. Each entry contains: + * name: a name of the color conversion. + * regex: a regex that, if it matches the color string, will cause the + * process function to be invoked. + * process: a function that takes (color, match) with the original color + * string and the results of matching the regex. It outputs an rgb + * color object or the original color string if there is still a + * parsing failure. + * In general, these conversions are somewhat more forgiving than the css + * specification (see https://drafts.csswg.org/css-color/) in that + * percentages may be mixed with numbers, and that floating point values + * are accepted for all numbers. Commas are optional. As per the latest + * draft standard, rgb and rgba are aliases of each other, as are hsl and + * hsla. + * @memberof geo.util + */ + cssColorConversions: [{ + name: 'rgb', + regex: new RegExp( + '^\\s*rgba?' + + '\\(\\s*(\\d+\\.?\\d*|\\.\\d?)\\s*(%?)\\s*' + + ',?\\s*(\\d+\\.?\\d*|\\.\\d?)\\s*(%?)\\s*' + + ',?\\s*(\\d+\\.?\\d*|\\.\\d?)\\s*(%?)\\s*' + + '(,?\\s*(\\d+\\.?\\d*|\\.\\d?)\\s*(%?)\\s*)?' + + '\\)\\s*$'), + process: function (color, match) { + color = { + r: Math.min(1, Math.max(0, +match[1] / (match[2] ? 100 : 255))), + g: Math.min(1, Math.max(0, +match[3] / (match[4] ? 100 : 255))), + b: Math.min(1, Math.max(0, +match[5] / (match[6] ? 100 : 255))) + }; + if (match[7]) { + color.a = Math.min(1, Math.max(0, +match[8] / (match[9] ? 100 : 1))); + } + return color; + } + }, { + name: 'hsl', + regex: new RegExp( + '^\\s*hsla?' + + '\\(\\s*(\\d+\\.?\\d*|\\.\\d?)\\s*(deg)?\\s*' + + ',?\\s*(\\d+\\.?\\d*|\\.\\d?)\\s*%\\s*' + + ',?\\s*(\\d+\\.?\\d*|\\.\\d?)\\s*%\\s*' + + '(,?\\s*(\\d+\\.?\\d*|\\.\\d?)\\s*(%?)\\s*)?' + + '\\)\\s*$'), + process: function (color, match) { + /* Conversion from https://www.w3.org/TR/2011/REC-css3-color-20110607 + */ + var hue_to_rgb = function (m1, m2, h) { + h = h - Math.floor(h); + if (h * 6 < 1) { + return m1 + (m2 - m1) * h * 6; + } + if (h * 6 < 3) { + return m2; + } + if (h * 6 < 4) { + return m1 + (m2 - m1) * (2 / 3 - h) * 6; + } + return m1; + }; + + var h = +match[1] / 360, + s = Math.min(1, Math.max(0, +match[3] / 100)), + l = Math.min(1, Math.max(0, +match[4] / 100)), + m2 = l <= 0.5 ? l * (s + 1) : l + s - l * s, + m1 = l * 2 - m2; + color = { + r: hue_to_rgb(m1, m2, h + 1 / 3), + g: hue_to_rgb(m1, m2, h), + b: hue_to_rgb(m1, m2, h - 1 / 3) + }; + if (match[5]) { + color.a = Math.min(1, Math.max(0, +match[6] / (match[7] ? 100 : 1))); + } + return color; + } + }], + + /** + * Convert a color to a standard rgb object. Allowed inputs: + * - rgb object with optional 'a' (alpha) value. + * - css color name + * - #rrggbb, #rrggbbaa, #rgb, #rgba hexadecimal colors + * - rgb(), rgba(), hsl(), and hsla() css colors + * - transparent + * The output object always contains r, g, b on a scale of [0-1]. If an + * alpha value is specified, the output will also contain an 'a' value on a + * scale of [0-1]. Objects already in rgb format are not checked to make + * sure that all parameters are in the range of [0-1], but string inputs + * are so validated. + * + * @param {object|string} color: one of the various input formats. + * @returns {object} an rgb color object, possibly with an 'a' value. If + * the input cannot be converted to a valid color, the input value is + * returned. + * @memberof geo.util + */ + convertColor: function (color) { + if (color === undefined || (color.r !== undefined && + color.g !== undefined && color.b !== undefined)) { + return color; + } + var opacity; + if (typeof color === 'string') { + if (util.cssColors.hasOwnProperty(color)) { + color = util.cssColors[color]; + } else if (color.charAt(0) === '#') { + if (color.length === 4 || color.length === 5) { + /* interpret values of the form #rgb as #rrggbb and #rgba as + * #rrggbbaa */ + if (color.length === 5) { + opacity = parseInt(color.slice(4), 16) / 0xf; + } + color = parseInt(color.slice(1, 4), 16); + color = (color & 0xf00) * 0x1100 + (color & 0xf0) * 0x110 + (color & 0xf) * 0x11; + } else if (color.length === 7 || color.length === 9) { + if (color.length === 9) { + opacity = parseInt(color.slice(7), 16) / 0xff; + } + color = parseInt(color.slice(1, 7), 16); + } + } else if (color === 'transparent') { + opacity = color = 0; + } else if (color.indexOf('(') >= 0) { + for (var idx = 0; idx < util.cssColorConversions.length; idx += 1) { + var match = util.cssColorConversions[idx].regex.exec(color); + if (match) { + return util.cssColorConversions[idx].process(color, match); + } + } + } + } + if (isFinite(color)) { + color = { + r: ((color & 0xff0000) >> 16) / 255, + g: ((color & 0xff00) >> 8) / 255, + b: ((color & 0xff)) / 255 + }; + } + if (opacity !== undefined) { + color.a = opacity; + } + return color; + }, + + /** + * Convert a color to a six or eight digit hex value prefixed with #. + * @memberof geo.util + */ + convertColorToHex: function (color, allowAlpha) { + var rgb = util.convertColor(color), value; + if (!rgb.r && !rgb.g && !rgb.b) { + value = '#000000'; + } else { + value = '#' + ((1 << 24) + (Math.round(rgb.r * 255) << 16) + + (Math.round(rgb.g * 255) << 8) + + Math.round(rgb.b * 255)).toString(16).slice(1); + } + if (rgb.a !== undefined && allowAlpha) { + value += (256 + Math.round(rgb.a * 255)).toString(16).slice(1); + } + return value; + }, + + /** + * Normalize a coordinate object into {x: ..., y: ..., z: ... } form. + * Accepts 2-3d arrays, + * latitude -> lat -> y + * longitude -> lon -> lng -> x + * @memberof geo.util + */ + normalizeCoordinates: function (p) { + p = p || {}; + if (Array.isArray(p)) { + return { + x: p[0], + y: p[1], + z: p[2] || 0 + }; + } + return { + x: setNumeric( + p.x, + p.longitude, + p.lng, + p.lon, + 0 + ), + y: setNumeric( + p.y, + p.latitude, + p.lat, + 0 + ), + z: setNumeric( + p.z, + p.elevation, + p.elev, + p.height, + 0 + ) + }; + }, + + /** + * Radius of the earth in meters, from the equatorial radius of SRID 4326. + * @memberof geo.util + */ + radiusEarth: 6378137, + + /** + * Compare two arrays and return if their contents are equal. + * @param {array} a1 first array to compare + * @param {array} a2 second array to compare + * @returns {boolean} true if the contents of the arrays are equal. + * @memberof geo.util + */ + compareArrays: function (a1, a2) { + return (a1.length === a2.length && a1.every(function (el, idx) { + return el === a2[idx]; + })); + }, + + /** + * Create a vec3 that is always an array. This should only be used if it + * will not be used in a WebGL context. Plain arrays usually use 64-bit + * float values, whereas vec3 defaults to 32-bit floats. + * + * @returns {Array} zeroed-out vec3 compatible array. + * @memberof geo.util + */ + vec3AsArray: function () { + return [0, 0, 0]; + }, + + /** + * Create a mat4 that is always an array. This should only be used if it + * will not be used in a WebGL context. Plain arrays usually use 64-bit + * float values, whereas mat4 defaults to 32-bit floats. + * + * @returns {Array} identity mat4 compatible array. + * @memberof geo.util + */ + mat4AsArray: function () { + return [ + 1, 0, 0, 0, + 0, 1, 0, 0, + 0, 0, 1, 0, + 0, 0, 0, 1 + ]; + }, + + /** + * Get a buffer for a vgl geometry source. If a buffer already exists and + * is the correct size, return it. Otherwise, allocate a new buffer; any + * data in an old buffer is discarded. + * + * @param geom: the geometry to reference and modify. + * @param srcName: the name of the source. + * @param len: the number of elements for the array. + * @returns {Float32Array} + * @memberof geo.util + */ + getGeomBuffer: function (geom, srcName, len) { + var src = geom.sourceByName(srcName), data; + + data = src.data(); + if (data instanceof Float32Array && data.length === len) { + return data; + } + data = new Float32Array(len); + src.setData(data); + return data; + }, + + /** + * Ensure that the input and modifiers properties of all actions are + * objects, not plain strings. + * + * @param {Array} actions: an array of actions to adjust as needed. + * @memberof geo.util + */ + adjustActions: function (actions) { + var action, i; + for (i = 0; i < actions.length; i += 1) { + action = actions[i]; + if ($.type(action.input) === 'string') { + var actionEvents = {}; + actionEvents[action.input] = true; + action.input = actionEvents; + } + if (!action.modifiers) { + action.modifiers = {}; + } + if ($.type(action.modifiers) === 'string') { + var actionModifiers = {}; + actionModifiers[action.modifiers] = true; + action.modifiers = actionModifiers; + } + } + }, + + /** + * Add an action to the list of handled actions. + * + * @param {Array} actions: an array of actions to adjust as needed. + * @param {object} action: an object defining the action. This must have + * action and event properties, and may have modifiers, name, and owner. + * Use action, name, and owner to make this entry distinct if it will + * need to be removed later. + * @param {boolean} toEnd: the action is added at the beginning of the + * actions list unless toEnd is true. Earlier actions prevent later + * actions with the similar input and modifiers. + * @memberof geo.util + */ + addAction: function (actions, action, toEnd) { + if (toEnd) { + actions.push(action); + } else { + actions.unshift(action); + } + util.adjustActions(actions); + }, + + /** + * Check if an action is in the actions list. An action matches if the + * action, name, and owner match. A null or undefined value will match all + * actions. If using an action object, this is the same as passing + * (action.action, action.name, action.owner). + * + * @param {Array} actions: an array of actions to search. + * @param {object|string} action Either an action object or the name of an + * action. + * @param {string} name Optional name associated with the action. + * @param {string} owner Optional owner associated with the action. + * @return action the first matching action or null. + * @memberof geo.util + */ + hasAction: function (actions, action, name, owner) { + if (action && action.action) { + name = action.name; + owner = action.owner; + action = action.action; + } + for (var i = 0; i < actions.length; i += 1) { + if ((!action || actions[i].action === action) && + (!name || actions[i].name === name) && + (!owner || actions[i].owner === owner)) { + return actions[i]; + } + } + return null; + }, + + /** + * Remove all matching actions. Actions are matched as with hasAction. + * + * @param {Array} actions: an array of actions to adjust as needed. + * @param {object|string} action Either an action object or the name of an + * action. + * @param {string} name Optional name associated with the action. + * @param {string} owner Optional owner associated with the action. + * @return numRemoved the number of actions that were removed. + * @memberof geo.util + */ + removeAction: function (actions, action, name, owner) { + var found, removed = 0; + + do { + found = util.hasAction(actions, action, name, owner); + if (found) { + actions.splice($.inArray(found, actions), 1); + removed += 1; + } + } while (found); + return removed; + }, + + /** + * Determine if the current inputs and modifiers match a known action. + * + * @param {object} inputs: an object where each input that is currently + * active is truthy. Common inputs are left, right, middle, wheel. + * @param {object} modifiers: an object where each currently applied + * modifier is truthy. Common modifiers are shift, ctrl, alt, meta. + * @param {Array} actions: a list of actions to compare to the inputs and + * modifiers. The first action that matches will be returned. + * @returns {object} action A matching action or undefined. + * @memberof geo.util + */ + actionMatch: function (inputs, modifiers, actions) { + var matched; + + /* actions must have already been processed by adjustActions */ + if (actions.some(function (action) { + for (var input in action.input) { + if (action.input.hasOwnProperty(input)) { + if ((action.input[input] === false && inputs[input]) || + (action.input[input] && !inputs[input])) { + return false; + } + } + } + for (var modifier in action.modifiers) { + if (action.modifiers.hasOwnProperty(modifier)) { + if ((action.modifiers[modifier] === false && modifiers[modifier]) || + (action.modifiers[modifier] && !modifiers[modifier])) { + return false; + } + } + } + matched = action; + return true; + })) { + return matched; + } + }, + + /** + * Return recommended defaults for map parameters and osm or tile layer + * paramaters where the expected intent is to use the map in pixel + * coordinates (upper left is (0, 0), lower right is (width, height). The + * returned objects can be modified or extended. For instance, + * var results = pixelCoordinateParams('#map', 10000, 9000); + * geo.map($.extend(results.map, {clampZoom: false})); + * + * @param {string} [node] DOM selector for the map container + * @param {number} width width of the whole map contents in pixels + * @param {number} height height of the whole map contents in pixels + * @param {number} tileWidth if an osm or tile layer is going to be used, + * the width of a tile. + * @param {number} tileHeight if an osm or tile layer is going to be used, + * the height of a tile. + * @memberof geo.util + */ + pixelCoordinateParams: function (node, width, height, tileWidth, tileHeight) { + var mapW, mapH, tiled; + if (node) { + node = $(node); + mapW = node.innerWidth(); + mapH = node.innerHeight(); + } + tileWidth = tileWidth || width; + tileHeight = tileHeight || height; + tiled = (tileWidth !== width || tileHeight !== height); + var minLevel = Math.min(0, Math.floor(Math.log(Math.min( + (mapW || tileWidth) / tileWidth, + (mapH || tileHeight) / tileHeight)) / Math.log(2))), + maxLevel = Math.ceil(Math.log(Math.max( + width / tileWidth, + height / tileHeight)) / Math.log(2)); + var mapParams = { + node: node, + ingcs: '+proj=longlat +axis=esu', + gcs: '+proj=longlat +axis=enu', + maxBounds: {left: 0, top: 0, right: width, bottom: height}, + unitsPerPixel: Math.pow(2, maxLevel), + center: {x: width / 2, y: height / 2}, + min: minLevel, + max: maxLevel, + zoom: minLevel, + clampBoundsX: true, + clampBoundsY: true, + clampZoom: true + }; + var layerParams = { + maxLevel: maxLevel, + wrapX: false, + wrapY: false, + tileOffset: function () { + return {x: 0, y: 0}; + }, + attribution: '', + tileWidth: tileWidth, + tileHeight: tileHeight, + tileRounding: Math.ceil, + tilesAtZoom: tiled ? function (level) { + var scale = Math.pow(2, maxLevel - level); + return { + x: Math.ceil(width / tileWidth / scale), + y: Math.ceil(height / tileHeight / scale) + }; + } : undefined, + tilesMaxBounds: tiled ? function (level) { + var scale = Math.pow(2, maxLevel - level); + return { + x: Math.floor(width / scale), + y: Math.floor(height / scale) + }; + } : undefined + }; + return {map: mapParams, layer: layerParams}; + }, + + /** + * Escape any character in a string that has a code point >= 127. + * + * @param {string} text: the string to escape. + * @returns {string}: the escaped string. + * @memberof geo.util + */ + escapeUnicodeHTML: function (text) { + return text.replace(/./g, function (k) { + var code = k.charCodeAt(); + if (code < 127) { + return k; + } + return '&#' + code.toString(10) + ';'; + }); + }, + + /** + * Check svg image and html img tags. If the source is set, load images + * explicitly and convert them to local data:image references. + * + * @param {selector} elem: a jquery selector that may contain images. + * @returns {array}: a list of deferred objects that resolve when images + * are dereferences. + * @memberof geo.util + */ + dereferenceElements: function (elem) { + var deferList = []; + + $('img,image', elem).each(function () { + var src = $(this); + var key = src.is('image') ? 'href' : 'src'; + if (src.attr(key)) { + var img = new Image(); + if (src.attr(key).substr(0, 4) === 'http' || src[0].crossOrigin) { + img.crossOrigin = src[0].crossOrigin || 'anonymous'; + } + var defer = $.Deferred(); + img.onload = function () { + var cvs = document.createElement('canvas'); + cvs.width = img.naturalWidth; + cvs.height = img.naturalHeight; + cvs.getContext('2d').drawImage(img, 0, 0); + src.attr(key, cvs.toDataURL('image/png')); + if (src.attr(key).substr(0, 10) !== 'data:image') { + src.remove(); + } + defer.resolve(); + }; + img.onerror = function () { + src.remove(); + defer.resolve(); + }; + img.src = src.attr(key); + deferList.push(defer); + } + }); + return deferList; + }, + + /** + * Convert an html element to an image. This attempts to localize any + * images within the element. If there are other external references, the + * image may not work due to security considerations. + * + * @param {object} elem: either a jquery selector or an html element. This + * may contain multiple elements. The direct parent and grandparent + * of the element are used for class information. + * @param {number} parents: number of layers up to travel to get class + * information. + * @returns {deferred}: a jquery deferred object which receives an HTML + * Image element when resolved. + * @memberof geo.util + */ + htmlToImage: function (elem, parents) { + var defer = $.Deferred(), container; + + var parent = $(elem); + elem = $(elem).clone(); + while (parents && parents > 0) { + parent = parent.parent(); + if (parent.is('div')) { + /* Create a containing div with the parent's class and id (so css + * will be used), but override size and background. */ + container = $('
').attr({ + 'class': parent.attr('class'), + id: parent.attr('id') + }).css({ + width: '100%', + height: '100%', + background: 'none', + margin: 0 + }); + container.append(elem); + elem = container; + } + parents -= 1; + } + // canvas elements won't render properly here. + $('canvas', elem).remove(); + container = $('
'); + container.css({ + width: parent.width() + 'px', + height: parent.height() + 'px' + }); + container.append($('')); + var body = $(''); + container.append(body); + /* We must specify the new body as having no background, or we'll clobber + * other layers. */ + body.css({ + width: parent.width() + 'px', + height: parent.height() + 'px', + background: 'none', + margin: 0 + }); + body.append(elem); + var deferList = util.dereferenceElements(elem); + /* Get styles and links in order, as order matters in css */ + $('style,link[rel="stylesheet"]').each(function () { + var styleElem; + if ($(this).is('style')) { + styleElem = $(this).clone(); + } else { + var fetch = $.Deferred(); + styleElem = $('