/*
* Copyright 2003-2006, 2009, 2017, United States Government, as represented by the Administrator of the
* National Aeronautics and Space Administration. All rights reserved.
*
* The NASAWorldWind/WebWorldWind platform is licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
define([
'../../error/ArgumentError',
'./HeatMapColoredTile',
'./HeatMapIntervalType',
'./HeatMapTile',
'../../util/ImageSource',
'../../geom/Location',
'../../util/Logger',
'../../geom/MeasuredLocation',
'../TiledImageLayer',
'../../geom/Sector',
'../../util/WWUtil'
], function (ArgumentError,
HeatMapColoredTile,
HeatMapIntervalType,
HeatMapTile,
ImageSource,
Location,
Logger,
MeasuredLocation,
TiledImageLayer,
Sector,
WWUtil) {
"use strict";
/**
* Constructs a HeatMap Layer.
*
* The default implementation uses gradient circles to display measured locations. The measure of the locations
* define the colors of the gradient.
*
* @alias HeatMapLayer
* @constructor
* @augments TiledImageLayer
* @classdesc A HeatMap layer for visualising an array of measured locations.
* @param {String} displayName This layer's display name.
* @param {MeasuredLocation[]} measuredLocations An array of locations with measures to visualise.
* @param {Number} numLevels Optional. If provided it specifies the amount of levels that will be generated for
* this layer.
*/
var HeatMapLayer = function (displayName, measuredLocations, numLevels) {
this.tileWidth = 256;
this.tileHeight = 256;
TiledImageLayer.call(this, new Sector(-90, 90, -180, 180), new Location(45, 45), numLevels || 18, 'image/png', 'HeatMap' + WWUtil.guid(), this.tileWidth, this.tileHeight);
this.displayName = displayName;
var data = {};
for (var lat = -90; lat <= 90; lat++) {
data[lat] = {};
for (var lon = -180; lon <= 180; lon++) {
data[lat][lon] = [];
}
}
var latitude, longitude;
var max = Number.MIN_VALUE;
measuredLocations.forEach(function (measured) {
latitude = Math.floor(measured.latitude);
longitude = Math.floor(measured.longitude);
data[latitude][longitude].push(measured);
if(measured.measure > max) {
max = measured.measure;
}
});
this._data = data;
this._measuredLocations = measuredLocations;
this._intervalType = HeatMapIntervalType.CONTINUOUS;
this._scale = ['blue', 'cyan', 'lime', 'yellow', 'red'];
this._radius = 12.5;
this._incrementPerIntensity = 1 / max;
this.setGradient(measuredLocations);
};
HeatMapLayer.prototype = Object.create(TiledImageLayer.prototype);
Object.defineProperties(HeatMapLayer.prototype, {
/**
* Type of interval to apply between the minimum and maximum values in the data. Default value is CONTINUOUS.
* The supported values are CONTINUOUS and QUANTILES
* @memberof HeatMapLayer.prototype
* @type {HeatMapIntervalType}
*/
intervalType: {
get: function () {
return this._intervalType;
},
set: function (intervalType) {
this._intervalType = intervalType;
this.setGradient();
}
},
/**
* Array of colors representing the scale used when generating the gradients.
* The default value is ['blue', 'cyan', 'lime', 'yellow', 'red'].
* @memberof HeatMapLayer.prototype
* @type {String[]}
*/
scale: {
get: function () {
return this._scale;
},
set: function (scale) {
this._scale = scale;
this.setGradient();
}
},
/**
* Gradient of colours used to draw the points and derived from the scale, intervalType and the data. The colors
* are stored in an object, which has as keys the percentage from which the given color should be applied.
* The default object based on the default scale and intervalType (in case of CONTINUOUS the data are irrelevant):
* {
* "0": "blue",
* "0.2": "cyan",
* "0.4": "lime",
* "o.6": "yellow",
* "0.8": "red"
* }
* @memberOf HeatMapLayer.prototype
* @type {Object}
*/
gradient: {
get: function () {
return this._gradient;
}
},
/**
* Radius of a point in pixels. The default value is 12.5.
* @memberof HeatMapLayer.prototype
* @type {Number}
*/
radius: {
get: function () {
return this._radius;
},
set: function (radius) {
this._radius = radius;
}
}
});
/**
* Returns the relevant points for the visualisation for current sector. To retrieve the relevant points, the data
* are stored in the two dimensional array representation of the geographic locations. The first index represents
* latitude from -90 to 90. The second index represents longitude from -180 to 180.
* For the sector pieces outside of the bounds of valid latitude and longitude, the array behaves as if it was
* continuous in the longitudinal space of <-180,180> and clamped to the min, max value in case of latitudinal space.
* @private
* @param data {MeasuredLocation[][]} Two dimensional array covering the whole globe in WGS84 coordinates.
* @param sector {Sector} Visible sector limiting the data to retrieve.
* @returns {MeasuredLocation[]} Array of the Measured Locations relevant for given sector.
*/
HeatMapLayer.prototype.filterGeographically = function (data, sector) {
var minLatitude = Math.floor(sector.minLatitude);
var maxLatitude = Math.floor(sector.maxLatitude);
var minLongitude = Math.floor(sector.minLongitude);
var maxLongitude = Math.floor(sector.maxLongitude);
var extraLongitudeBefore = 0, extraLongitudeAfter = 0;
// Clamp the latitude to <-90,90>
if (minLatitude <= -90) {
minLatitude = -90;
}
if (maxLatitude >= 90) {
maxLatitude = 90;
}
// Handle continuousness of the longitudinal space.
if (minLongitude <= -180) {
extraLongitudeBefore = Math.abs(minLongitude - (-180));
minLongitude = -180;
}
if (maxLongitude >= 180) {
extraLongitudeAfter = Math.abs(maxLongitude - 180);
maxLongitude = 180;
}
var result = [];
this.gatherGeographical(data, result, sector, minLatitude, maxLatitude, minLongitude, maxLongitude);
if (extraLongitudeBefore !== 0) {
var beforeSector = new Sector(minLatitude, maxLatitude, 180 - extraLongitudeBefore, 180);
this.gatherGeographical(data, result, beforeSector, minLatitude, maxLatitude, 180 - extraLongitudeBefore, 180, -360);
}
if (extraLongitudeAfter !== 0) {
var afterSector = new Sector(minLatitude, maxLatitude, -180, -180 + extraLongitudeAfter);
this.gatherGeographical(data, result, afterSector, minLatitude, maxLatitude, -180, -180 + extraLongitudeAfter, 360);
}
return result;
};
/**
* Internal method to gather the geographical data for given sector and bounding box. The data are in the two dimensional
* array structure. Therefore the method goes through the slice of the array limited by the bounding box. The
* elements in given 1 * 1 degree space are then filtered based on their exact location and the exact limitation of
* the sector.
* @private
* @param data {MeasuredLocation[][]} Two dimensional array covering the whole globe in WGS84 coordinates. The globe
* is split into the grid of 1 * 1 degrees.
* @param result {MeasuredLocation[]} Array of the Locations that are valid within given area.
* @param sector {Sector} The sector representing the detailed area relevant for given
* @param minLatitude {Number} The integer bound of the sector relevant for the filtering.
* @param maxLatitude {Number} The integer bound of the sector relevant for the filtering.
* @param minLongitude {Number} The integer bound of the sector relevant for the filtering.
* @param maxLongitude {Number} The integer bound of the sector relevant for the filtering.
* @param adaptLongitude {Number} Optional. If this is supplied, then the result contains new measured locations
* with the longitude adapted by adding this number.
*/
HeatMapLayer.prototype.gatherGeographical = function (data, result, sector, minLatitude, maxLatitude, minLongitude,
maxLongitude, adaptLongitude) {
var lat, lon;
for (lat = minLatitude; lat <= maxLatitude; lat++) {
for (lon = minLongitude; lon <= maxLongitude; lon++) {
data[lat][lon].forEach(function (element) {
if (sector.containsLocation(element.latitude, element.longitude)) {
if(!adaptLongitude) {
result.push(element);
} else {
result.push(new MeasuredLocation(element.latitude, adaptLongitude + element.longitude, element.measure));
}
}
});
}
}
};
/**
* Sets gradient based on the Scale and IntervalType. It supports CONTINUOUS and QUANTILES types of the interval.
* The CONTINUOUS simply distribute the colors equally among the percentage.
* The QUNATILES distribution takes into account the underlying data. The data are sorted based on the measures.
* The amount of colors in the scale decides how many of the intervals will be in the resulting gradient. The value
* used as the key for the resulting gradient is taken from element on the position represented by the percentage
* counted via following formula:
* gradientIndex = (data[Math.floor((1 / scale.length) * data.length)].measure) / max;
* @private
*/
HeatMapLayer.prototype.setGradient = function () {
var intervalType = this.intervalType;
var scale = this.scale;
var gradient = {};
if (intervalType === HeatMapIntervalType.CONTINUOUS) {
scale.forEach(function (color, index) {
gradient[index / scale.length] = color;
});
} else if (intervalType === HeatMapIntervalType.QUANTILES) {
var data = this._measuredLocations;
// Equal amount of pieces in each group.
data.sort(function (item1, item2) {
if (item1.measure < item2.measure) {
return -1;
} else if (item1.measure > item2.measure) {
return 1;
} else {
return 0;
}
});
var max = data[data.length - 1].measure;
if (data.length >= scale.length) {
scale.forEach(function (color, index) {
if(index === 0) {
gradient[0] = color;
} else {
gradient[
data[Math.floor(index / scale.length * data.length)].measure / max
] = color;
}
});
} else {
scale.forEach(function (color, index) {
gradient[index / scale.length] = color;
});
}
}
this._gradient = gradient;
};
/**
* The Image for given Tile is generated and drawn on internal Canvas.
* @inheritDoc
*/
HeatMapLayer.prototype.retrieveTileImage = function (dc, tile, suppressRedraw) {
if (this.currentRetrievals.indexOf(tile.imagePath) < 0) {
if (this.absentResourceList.isResourceAbsent(tile.imagePath)) {
return;
}
var imagePath = tile.imagePath,
cache = dc.gpuResourceCache,
layer = this,
radius = this.radius;
var extended = this.calculateExtendedSector(tile.sector, 2 * (radius / this.tileWidth), 2 * (radius / this.tileHeight));
var extendedWidth = Math.ceil(extended.extensionFactorWidth * this.tileWidth);
var extendedHeight = Math.ceil(extended.extensionFactorHeight * this.tileHeight);
var data = this.filterGeographically(this._data, extended.sector);
var canvas = this.createHeatMapTile(data, {
sector: extended.sector,
width: this.tileWidth + 2 * extendedWidth,
height: this.tileHeight + 2 * extendedHeight,
radius: radius,
intensityGradient: this.gradient,
incrementPerIntensity: this._incrementPerIntensity,
extendedWidth: extendedWidth,
extendedHeight: extendedHeight
}).canvas();
var result = document.createElement('canvas');
result.height = this.tileHeight;
result.width = this.tileWidth;
result.getContext('2d').putImageData(
canvas.getContext('2d').getImageData(extendedWidth, extendedHeight, this.tileWidth, this.tileHeight),
0, 0
);
var texture = layer.createTexture(dc, tile, result);
layer.removeFromCurrentRetrievals(imagePath);
if (texture) {
cache.putResource(imagePath, texture, texture.size);
layer.currentTilesInvalid = true;
layer.absentResourceList.unmarkResourceAbsent(imagePath);
if (!suppressRedraw) {
// Send an event to request a redraw.
var e = document.createEvent('Event');
e.initEvent(WorldWind.REDRAW_EVENT_TYPE, true, true);
window.dispatchEvent(e);
}
}
}
};
/**
* Calculates the new sector for which the data will be filtered and which will be drawn on the tile.
* The standard version just applies extension factor to the difference between minimum and maximum. The extension
* factor may differ between latitude and longitude.
* @protected
* @param sector {Sector} Sector to use as basis for the extension.
* @param extensionFactorWidth {Number} The factor to be applied on the width to get sector representing the right geographical area.
* @param extensionFactorHeight {Number} The factor to be applied on the height to get sector representing the right geographical area.
* @return {Object} .sector New extended sector.
* .extensionFactorHeight The factor by which the area is changed on the latitude.
* .extensionFactorWidth The factor by which the area is changed on the longitude.
*/
HeatMapLayer.prototype.calculateExtendedSector = function (sector, extensionFactorWidth, extensionFactorHeight) {
var latitudeChange = (sector.maxLatitude - sector.minLatitude) * extensionFactorHeight;
var longitudeChange = (sector.maxLongitude - sector.minLongitude) * extensionFactorWidth;
return {
sector: new Sector(
sector.minLatitude - latitudeChange,
sector.maxLatitude + latitudeChange,
sector.minLongitude - longitudeChange,
sector.maxLongitude + longitudeChange
),
extensionFactorHeight: extensionFactorHeight,
extensionFactorWidth: extensionFactorWidth
};
};
/**
* Overwrite this method if you want to use a custom implementation of tile used for displaying the data. In the
* default version the tile draws points as blurring circles coloured based on the gradient.
* @protected
* @param data {Object[]} Array of information constituting points in the map.
* @param options {Object}
* @param options.sector {Sector} Sector with the geographical information for tile representation.
* @param options.width {Number} Width of the Canvas to be created in pixels.
* @param options.height {Number} Height of the Canvas to be created in pixels.
* @param options.radius {Number} Radius of the data point in pixels.
* @param options.incrementPerIntensity {Number} The ratio representing the 1 / measure for the maximum measure.
* @param options.intensityGradient {Object} Gradient of colours used to draw the points and derived from the scale,
* intervalType and the data.
* @param options.extendedWidth {Number} Minimal width that needs to be valid in the resulting object.
* @param options.extendedHeight {Number} Minimal height that needs to be valid in the resulting object.
*
* @return {HeatMapTile} Instance of the implementation of the HeatMapTile used for this instance of the layer.
*/
HeatMapLayer.prototype.createHeatMapTile = function (data, options) {
return new HeatMapColoredTile(data, options);
};
return HeatMapLayer;
});