diff --git a/src/core_plugins/choropleth/index.js b/src/core_plugins/choropleth/index.js new file mode 100644 index 0000000000000..7647e5e4023bd --- /dev/null +++ b/src/core_plugins/choropleth/index.js @@ -0,0 +1,9 @@ +export default function (kibana) { + + return new kibana.Plugin({ + uiExports: { + visTypes: ['plugins/choropleth/choropleth_vis'] + } + }); + +} diff --git a/src/core_plugins/choropleth/package.json b/src/core_plugins/choropleth/package.json new file mode 100644 index 0000000000000..749682a5fb883 --- /dev/null +++ b/src/core_plugins/choropleth/package.json @@ -0,0 +1,4 @@ +{ + "name": "choropleth", + "version": "kibana" +} diff --git a/src/core_plugins/choropleth/public/choropleth.less b/src/core_plugins/choropleth/public/choropleth.less new file mode 100644 index 0000000000000..308f3a26c59cc --- /dev/null +++ b/src/core_plugins/choropleth/public/choropleth.less @@ -0,0 +1,7 @@ +.choropleth-vis { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; +} diff --git a/src/core_plugins/choropleth/public/choropleth_controller.html b/src/core_plugins/choropleth/public/choropleth_controller.html new file mode 100644 index 0000000000000..f28b5a85e5e4b --- /dev/null +++ b/src/core_plugins/choropleth/public/choropleth_controller.html @@ -0,0 +1,3 @@ +
+ +
diff --git a/src/core_plugins/choropleth/public/choropleth_controller.js b/src/core_plugins/choropleth/public/choropleth_controller.js new file mode 100644 index 0000000000000..11eff58b59da8 --- /dev/null +++ b/src/core_plugins/choropleth/public/choropleth_controller.js @@ -0,0 +1,141 @@ +import uiModules from 'ui/modules'; +import 'plugins/kbn_vislib_vis_types/controls/vislib_basic_options'; +import _ from 'lodash'; +import AggConfigResult from 'ui/vis/agg_config_result'; +import KibanaMap from 'ui/vis_maps/kibana_map'; +import FilterBarFilterBarClickHandlerProvider from 'ui/filter_bar/filter_bar_click_handler'; +import ChoroplethLayer from './choropleth_layer'; +import colorramps from 'ui/vislib/components/color/colormaps'; +import AggResponsePointSeriesTooltipFormatterProvider from './tooltip_formatter'; +import { ResizeCheckerProvider } from 'ui/resize_checker'; + +const module = uiModules.get('kibana/choropleth', ['kibana']); +module.controller('KbnChoroplethController', function ($scope, $element, Private, getAppState, tilemapSettings) { + + const filterBarClickHandler = Private(FilterBarFilterBarClickHandlerProvider); + const tooltipFormatter = Private(AggResponsePointSeriesTooltipFormatterProvider); + const ResizeChecker = Private(ResizeCheckerProvider); + + const resizeChecker = new ResizeChecker($element); + const containerNode = $element[0]; + + let kibanaMap = null; + resizeChecker.on('resize', () => { + if (kibanaMap) { + kibanaMap.resize(); + } + }); + let choroplethLayer = null; + + async function makeKibanaMap() { + + if (!tilemapSettings.isInitialized()) { + await tilemapSettings.loadSettings(); + } + + const minMaxZoom = tilemapSettings.getMinMaxZoom(false); + kibanaMap = new KibanaMap(containerNode, minMaxZoom); + const url = tilemapSettings.getUrl(); + const options = tilemapSettings.getTMSOptions(); + kibanaMap.setBaseLayer({ baseLayerType: 'tms', options: { url, ...options } }); + kibanaMap.addLegendControl(); + kibanaMap.addFitControl(); + kibanaMap.persistUiStateForVisualization($scope.vis); + } + + const kibanaMapReady = makeKibanaMap(); + $scope.$watch('esResponse', async function (response) { + + kibanaMapReady.then(() => { + const metricsAgg = _.first($scope.vis.aggs.bySchemaName.metric); + const termAggId = _.first(_.pluck($scope.vis.aggs.bySchemaName.segment, 'id')); + let results; + if (!response || !response.aggregations) { + results = []; + } else { + const buckets = response.aggregations[termAggId].buckets; + results = buckets.map((bucket) => { + return { + term: bucket.key, + value: getValue(metricsAgg, bucket) + }; + }); + } + + if (!$scope.vis.params.selectedJoinField) { + $scope.vis.params.selectedJoinField = $scope.vis.params.selectedLayer.fields[0]; + } + updateChoroplethLayer($scope.vis.params.selectedLayer.url); + choroplethLayer.setMetrics(results, metricsAgg); + if ($scope.vis.aggs.bySchemaName.segment && $scope.vis.aggs.bySchemaName.segment[0]) { + const fieldName = $scope.vis.aggs.bySchemaName.segment[0].params.field.name; + choroplethLayer.setTooltipFormatter(tooltipFormatter, metricsAgg, fieldName); + } else { + choroplethLayer.setTooltipFormatter(tooltipFormatter, metricsAgg, null); + } + + kibanaMap.useUiStateFromVisualization($scope.vis); + kibanaMap.resize(); + $element.trigger('renderComplete'); + }); + }); + + $scope.$watch('vis.params', (visParams) => { + kibanaMapReady.then(() => { + if (!visParams.selectedJoinField) { + visParams.selectedJoinField = visParams.selectedLayer.fields[0]; + } + + updateChoroplethLayer(visParams.selectedLayer.url); + choroplethLayer.setJoinField(visParams.selectedJoinField.name); + choroplethLayer.setColorRamp(colorramps[visParams.colorSchema]); + + kibanaMap.setShowTooltip(visParams.addTooltip); + kibanaMap.setLegendPosition(visParams.legendPosition); + kibanaMap.useUiStateFromVisualization($scope.vis); + kibanaMap.resize(); + $element.trigger('renderComplete'); + }); + }); + + function updateChoroplethLayer(url) { + + if (choroplethLayer && choroplethLayer.equalsGeoJsonUrl(url)) { + return; + } + kibanaMap.removeLayer(choroplethLayer); + + const previousMetrics = choroplethLayer ? choroplethLayer.getMetrics() : null; + const previousMetricsAgg = choroplethLayer ? choroplethLayer.getMetricsAgg() : null; + choroplethLayer = new ChoroplethLayer(url); + if (previousMetrics && previousMetricsAgg) { + choroplethLayer.setMetrics(previousMetrics, previousMetricsAgg); + } + choroplethLayer.on('select', function (event) { + const appState = getAppState(); + const clickHandler = filterBarClickHandler(appState); + const aggs = $scope.vis.aggs.getResponseAggs(); + const aggConfigResult = new AggConfigResult(aggs[0], false, event, event); + clickHandler({ point: { aggConfigResult: aggConfigResult } }); + }); + kibanaMap.addLayer(choroplethLayer); + } + +}); + + + + + +function getValue(metricsAgg, bucket) { + let size = metricsAgg.getValue(bucket); + if (typeof size !== 'number' || isNaN(size)) { + try { + size = bucket[1].values[0].value;//lift out first value (e.g. median aggregations return as array) + } catch (e) { + size = 1;//punt + } + } + return size; +} + diff --git a/src/core_plugins/choropleth/public/choropleth_layer.js b/src/core_plugins/choropleth/public/choropleth_layer.js new file mode 100644 index 0000000000000..7fc5a285d4112 --- /dev/null +++ b/src/core_plugins/choropleth/public/choropleth_layer.js @@ -0,0 +1,269 @@ +import $ from 'jquery'; +import L from 'leaflet'; +import _ from 'lodash'; +import d3 from 'd3'; +import KibanaMapLayer from 'ui/vis_maps/kibana_map_layer'; +import colorramps from 'ui/vislib/components/color/colormaps'; + +export default class ChoroplethLayer extends KibanaMapLayer { + + constructor(geojsonUrl) { + super(); + + this._metrics = null; + this._joinField = null; + this._colorRamp = colorramps['Yellow to Red']; + this._tooltipFormatter = () => ''; + + this._geojsonUrl = geojsonUrl; + this._leafletLayer = L.geoJson(null, { + onEachFeature: (feature, layer) => { + layer.on('click', () => { + this.emit('select', feature.properties[this._joinField]); + }); + let location = null; + layer.on({ + mouseover: () => { + + const tooltipContents = this._tooltipFormatter(feature); + if (!location) { + const leafletGeojon = L.geoJson(feature); + location = leafletGeojon.getBounds().getCenter(); + } + + this.emit('showTooltip', { + content: tooltipContents, + position: location + }); + }, + mouseout: () => { + this.emit('hideTooltip'); + } + }); + }, + style: emptyStyle + }); + + this._loaded = false; + $.ajax({//todo: replace with es6 fetch + dataType: 'json', + url: geojsonUrl, + success: (data) => { + this._leafletLayer.addData(data); + this._loaded = true; + this._setStyle(); + } + }).error(function () { + }); + } + + _setStyle() { + if (!this._loaded || !this._metrics || !this._joinField) { + return; + } + + + const styleFunction = makeChoroplethStyleFunction(this._metrics, this._colorRamp, this._joinField); + this._leafletLayer.setStyle(styleFunction); + + + if (this._metrics && this._metrics.length > 0) { + const { min, max } = getMinMax(this._metrics); + this._legendColors = getLegendColors(this._colorRamp); + const quantizeDomain = (min !== max) ? [min, max] : d3.scale.quantize().domain(); + this._legendQuantizer = d3.scale.quantize().domain(quantizeDomain).range(this._legendColors); + } + this.emit('styleChanged'); + } + + getMetrics() { + return this._metrics; + } + + getMetricsAgg() { + return this._metricsAgg; + } + + getUrl() { + return this._geojsonUrl; + } + + setTooltipFormatter(tooltipFormatter, metricsAgg, fieldName) { + this._tooltipFormatter = (geojsonFeature) => { + if (!this._metrics) { + return ''; + } + const match = this._metrics.find((bucket) => { + return bucket.term === geojsonFeature.properties[this._joinField]; + }); + return tooltipFormatter(metricsAgg, match, fieldName); + }; + } + + setJoinField(joinfield) { + if (joinfield === this._joinField) { + return; + } + this._joinField = joinfield; + this._setStyle(); + } + + + setMetrics(metrics, metricsAgg) { + this._metrics = metrics; + this._metricsAgg = metricsAgg; + this._valueFormatter = this._metricsAgg.fieldFormatter(); + this._setStyle(); + } + + setColorRamp(colorRamp) { + if (_.isEqual(colorRamp, this._colorRamp)) { + return; + } + this._colorRamp = colorRamp; + this._setStyle(); + } + + equalsGeoJsonUrl(geojsonUrl) { + return this._geojsonUrl === geojsonUrl; + } + + appendLegendContents(jqueryDiv) { + + + if (!this._legendColors || !this._legendQuantizer || !this._metricsAgg) { + return; + } + + const titleText = this._metricsAgg.makeLabel(); + const $title = $('
').addClass('tilemap-legend-title').text(titleText); + jqueryDiv.append($title); + + this._legendColors.forEach((color) => { + + const labelText = this._legendQuantizer + .invertExtent(color) + .map(this._valueFormatter) + .join(' – '); + + const label = $('
'); + const icon = $('').css({ + background: color, + 'border-color': makeColorDarker(color) + }); + + const text = $('').text(labelText); + label.append(icon); + label.append(text); + + jqueryDiv.append(label); + }); + + } + +} + + +function makeColorDarker(color) { + const amount = 1.3;//magic number, carry over from earlier + return d3.hcl(color).darker(amount).toString(); +} + + +function getMinMax(data) { + let min = data[0].value; + let max = data[0].value; + for (let i = 1; i < data.length; i += 1) { + min = Math.min(data[i].value, min); + max = Math.max(data[i].value, max); + } + return { min, max }; +} + + +function makeChoroplethStyleFunction(data, colorramp, joinField) { + + if (data.length === 0) { + return function () { + return emptyStyle(); + }; + } + + const { min, max } = getMinMax(data); + + + return function (geojsonFeature) { + + const match = data.find((bucket) => { + if (typeof bucket.term === 'string' && typeof geojsonFeature.properties[joinField] === 'string') { + return normalizeString(bucket.term) === normalizeString(geojsonFeature.properties[joinField]); + } else { + return bucket.term === geojsonFeature.properties[joinField]; + } + }); + + if (!match) { + return emptyStyle(); + } + + return { + fillColor: getChoroplethColor(match.value, min, max, colorramp), + weight: 2, + opacity: 1, + color: 'white', + fillOpacity: 0.7 + }; + }; +} + + +function normalizeString(string) { + return string.trim().toLowerCase(); +} + + +function getLegendColors(colorRamp) { + const colors = []; + colors[0] = getColor(colorRamp, 0); + colors[1] = getColor(colorRamp, Math.floor(colorRamp.length * 1 / 4)); + colors[2] = getColor(colorRamp, Math.floor(colorRamp.length * 2 / 4)); + colors[3] = getColor(colorRamp, Math.floor(colorRamp.length * 3 / 4)); + colors[4] = getColor(colorRamp, colorRamp.length - 1); + return colors; +} + +function getColor(colorRamp, i) { + + if (!colorRamp[i]) { + return getColor(); + } + + const color = colorRamp[i][1]; + const red = Math.floor(color[0] * 255); + const green = Math.floor(color[1] * 255); + const blue = Math.floor(color[2] * 255); + return `rgb(${red},${green},${blue})`; +} + + +function getChoroplethColor(value, min, max, colorRamp) { + if (min === max) { + return getColor(colorRamp, colorRamp.length - 1); + } + const fraction = (value - min) / (max - min); + const index = Math.round(colorRamp.length * fraction) - 1; + const i = Math.max(Math.min(colorRamp.length - 1, index), 0); + + return getColor(colorRamp, i); +} + +const emptyStyleObject = { + weight: 1, + opacity: 0.6, + color: 'rgb(200,200,200)', + fillOpacity: 0 +}; +function emptyStyle() { + return emptyStyleObject; +} + diff --git a/src/core_plugins/choropleth/public/choropleth_vis.js b/src/core_plugins/choropleth/public/choropleth_vis.js new file mode 100644 index 0000000000000..3bd796dbade4c --- /dev/null +++ b/src/core_plugins/choropleth/public/choropleth_vis.js @@ -0,0 +1,105 @@ +import 'plugins/choropleth/choropleth.less'; +import 'plugins/choropleth/choropleth_controller'; +import 'plugins/choropleth/choropleth_vis_params'; +import TemplateVisTypeTemplateVisTypeProvider from 'ui/template_vis_type/template_vis_type'; +import VisSchemasProvider from 'ui/vis/schemas'; +import choroplethTemplate from 'plugins/choropleth/choropleth_controller.html'; +import visTypes from 'ui/registry/vis_types'; +import colorramps from 'ui/vislib/components/color/colormaps'; + +visTypes.register(function ChoroplethProvider(Private, vectormapsConfig) { + + const TemplateVisType = Private(TemplateVisTypeTemplateVisTypeProvider); + const Schemas = Private(VisSchemasProvider); + + + const defaultLayers = [ + { + type: 'default', + url: '../plugins/choropleth/data/world_countries.geojson', + name: 'World Countries', + fields: [ + { + name: 'iso', + description: '2-letter abbreviation' + }, + { + name: 'name', + description: 'Country name' + } + ] + }, + { + type: 'default', + url: '../plugins/choropleth/data/state.geojson', + name: 'US States', + fields: [{ + name: 'STUSPS10', + description: '2-letter abbreviation' + }, { + name: 'NAME10', + description: 'State name' + }] + } + ]; + + const vectorLayers = vectormapsConfig.layers.concat(defaultLayers); + return new TemplateVisType({ + name: 'choropleth', + title: 'Vector Map', + implementsRenderComplete: true, + description: 'Show metrics on a thematic map. Use one of the provide base maps, or add your own. ' + + 'Darker colors represent higher values.', + icon: 'fa-globe', + template: choroplethTemplate, + params: { + defaults: { + legendPosition: 'bottomright', + addTooltip: true, + colorSchema: 'Yellow to Red', + selectedLayer: vectorLayers[0], + selectedJoinField: vectorLayers[0].fields[0] + }, + legendPositions: [{ + value: 'bottomleft', + text: 'bottom left', + }, { + value: 'bottomright', + text: 'bottom right', + }, { + value: 'topleft', + text: 'top left', + }, { + value: 'topright', + text: 'top right', + }], + colorSchemas: Object.keys(colorramps), + vectormap: vectorLayers, + editor: '' + }, + schemas: new Schemas([ + { + group: 'metrics', + name: 'metric', + title: '', + min: 1, + max: 1, + aggFilter: ['!std_dev', '!percentiles', '!percentile_ranks'], + defaults: [ + { schema: 'metric', type: 'count' } + ] + }, + { + group: 'buckets', + name: 'segment', + icon: 'fa fa-globe', + title: 'shape field', + min: 1, + max: 1, + aggFilter: ['terms'] + } + ]) + }); +}); + + diff --git a/src/core_plugins/choropleth/public/choropleth_vis_params.html b/src/core_plugins/choropleth/public/choropleth_vis_params.html new file mode 100644 index 0000000000000..a778c2343d394 --- /dev/null +++ b/src/core_plugins/choropleth/public/choropleth_vis_params.html @@ -0,0 +1,70 @@ +
+ +
+
+ Layer Settings +
+
+ +
+ +
+ +
+
+ +
+ +
+ +
+
+ +
+
+ Style Settings +
+
+ +
+ +
+ +
+
+ + +
+
+ Basic Settings +
+
+ + + + + +
diff --git a/src/core_plugins/choropleth/public/choropleth_vis_params.js b/src/core_plugins/choropleth/public/choropleth_vis_params.js new file mode 100644 index 0000000000000..25a0f02e07a2f --- /dev/null +++ b/src/core_plugins/choropleth/public/choropleth_vis_params.js @@ -0,0 +1,17 @@ +import uiModules from 'ui/modules'; +import choroplethVisParamsTemplate from 'plugins/choropleth/choropleth_vis_params.html'; + +uiModules.get('kibana/choropleth') + .directive('choroplethVisParams', function () { + return { + restrict: 'E', + template: choroplethVisParamsTemplate, + link: function ($scope) { + + $scope.onLayerChange = function () { + $scope.vis.params.selectedJoinField = $scope.vis.params.selectedLayer.fields[0]; + }; + + } + }; + }); diff --git a/src/core_plugins/choropleth/public/tooltip.html b/src/core_plugins/choropleth/public/tooltip.html new file mode 100644 index 0000000000000..b0d07cb80e4b3 --- /dev/null +++ b/src/core_plugins/choropleth/public/tooltip.html @@ -0,0 +1,8 @@ + + + + + + + +
{{detail.label}}{{detail.value}}
diff --git a/src/core_plugins/choropleth/public/tooltip_formatter.js b/src/core_plugins/choropleth/public/tooltip_formatter.js new file mode 100644 index 0000000000000..d78cea59c5f52 --- /dev/null +++ b/src/core_plugins/choropleth/public/tooltip_formatter.js @@ -0,0 +1,32 @@ +import $ from 'jquery'; +export default function TileMapTooltipFormatter($compile, $rootScope) { + + const $tooltipScope = $rootScope.$new(); + const $el = $('
').html(require('./tooltip.html')); + $compile($el)($tooltipScope); + + return function tooltipFormatter(metricAgg, metric, fieldName) { + + if (!metric) { + return ''; + } + + $tooltipScope.details = []; + if (fieldName && metric) { + $tooltipScope.details.push({ + label: fieldName, + value: metric.term + }); + } + + if (metric) { + $tooltipScope.details.push({ + label: metricAgg.makeLabel(), + value: metricAgg.fieldFormatter()(metric.value) + }); + } + + $tooltipScope.$apply(); + return $el.html(); + }; +} diff --git a/src/core_plugins/kbn_vislib_vis_types/public/editors/tile_map.html b/src/core_plugins/kbn_vislib_vis_types/public/editors/tile_map.html index 258d35fd23fb0..4820b987fe888 100644 --- a/src/core_plugins/kbn_vislib_vis_types/public/editors/tile_map.html +++ b/src/core_plugins/kbn_vislib_vis_types/public/editors/tile_map.html @@ -56,7 +56,7 @@
Joi.object({ }), profile: Joi.boolean().default(false) }).default(), - status: Joi.object({ allowAnonymous: Joi.boolean().default(false) }).default(), + vectormap: Joi.object({ + layers: Joi.array().items(Joi.object({ + url: Joi.string(), + type: Joi.string(), + name: Joi.string(), + fields: Joi.array().items(Joi.object({ + name: Joi.string(), + description: Joi.string() + })) + })) + }), tilemap: Joi.object({ manifestServiceUrl: Joi.when('$dev', { is: true, @@ -156,7 +166,7 @@ module.exports = () => Joi.object({ url: Joi.string(), options: Joi.object({ attribution: Joi.string(), - minZoom: Joi.number().min(1, 'Must not be less than 1').default(1), + minZoom: Joi.number().min(0, 'Must be 0 or higher').default(0), maxZoom: Joi.number().default(10), tileSize: Joi.number(), subdomains: Joi.array().items(Joi.string()).single(), diff --git a/src/ui/public/agg_response/geo_json/geo_json.js b/src/ui/public/agg_response/geo_json/geo_json.js index 2bb6fbafdb81f..0bd247e53c6f2 100644 --- a/src/ui/public/agg_response/geo_json/geo_json.js +++ b/src/ui/public/agg_response/geo_json/geo_json.js @@ -15,10 +15,12 @@ export default function TileMapConverterFn(Private) { const geoI = columnIndex('segment'); const metricI = columnIndex('metric'); + const centroidI = _.findIndex(table.columns, (col) => col.aggConfig.type.name === 'geo_centroid'); + const geoAgg = _.get(table.columns, [geoI, 'aggConfig']); const metricAgg = _.get(table.columns, [metricI, 'aggConfig']); - const features = rowsToFeatures(table, geoI, metricI); + const features = rowsToFeatures(table, geoI, metricI, centroidI); const values = features.map(function (feature) { return feature.properties.value; }); diff --git a/src/ui/public/agg_response/geo_json/rows_to_features.js b/src/ui/public/agg_response/geo_json/rows_to_features.js index 38e2757a50466..437ff928197c4 100644 --- a/src/ui/public/agg_response/geo_json/rows_to_features.js +++ b/src/ui/public/agg_response/geo_json/rows_to_features.js @@ -10,7 +10,8 @@ function unwrap(val) { return getAcr(val) ? val.value : val; } -function convertRowsToFeatures(table, geoI, metricI) { +function convertRowsToFeatures(table, geoI, metricI, centroidI) { + return _.transform(table.rows, function (features, row) { const geohash = unwrap(row[geoI]); if (!geohash) return; @@ -23,6 +24,16 @@ function convertRowsToFeatures(table, geoI, metricI) { location.longitude[2] ]; + //courtsey of @JacobBrandt: https://github.com/elastic/kibana/pull/9676/files#diff-c7c9f237e673ff486654f6cc6caa89f6 + let point = centerLatLng; + const centroid = unwrap(row[centroidI]); + if (centroid) { + point = [ + centroid.lat, + centroid.lon + ]; + } + // order is nw, ne, se, sw const rectangle = [ [location.latitude[0], location.longitude[0]], @@ -37,14 +48,15 @@ function convertRowsToFeatures(table, geoI, metricI) { type: 'Feature', geometry: { type: 'Point', - coordinates: centerLatLng.slice(0).reverse() + coordinates: point.slice(0).reverse() }, properties: { geohash: geohash, value: unwrap(row[metricI]), aggConfigResult: getAcr(row[metricI]), center: centerLatLng, - rectangle: rectangle + rectangle: rectangle, + centroid: centroid } }); }, []); diff --git a/src/ui/public/agg_types/agg_params.js b/src/ui/public/agg_types/agg_params.js index 251a0e9d64e5b..837f27c3a3843 100644 --- a/src/ui/public/agg_types/agg_params.js +++ b/src/ui/public/agg_types/agg_params.js @@ -8,8 +8,6 @@ import AggTypesParamTypesStringProvider from 'ui/agg_types/param_types/string'; import AggTypesParamTypesRawJsonProvider from 'ui/agg_types/param_types/raw_json'; import AggTypesParamTypesBaseProvider from 'ui/agg_types/param_types/base'; export default function AggParamsFactory(Private) { - - const paramTypeMap = { field: Private(AggTypesParamTypesFieldProvider), optioned: Private(AggTypesParamTypesOptionedProvider), diff --git a/src/ui/public/agg_types/agg_type.js b/src/ui/public/agg_types/agg_type.js index 53e3850d3dadf..ba59f24007254 100644 --- a/src/ui/public/agg_types/agg_type.js +++ b/src/ui/public/agg_types/agg_type.js @@ -110,6 +110,17 @@ export default function AggTypeFactory(Private) { this.params = new AggParams(this.params); } + /** + * Designed for multi-value metric aggs, this method can return a + * set of AggConfigs that should replace this aggConfig in requests + * + * @method getRequestAggs + * @returns {array[AggConfig]|undefined} - an array of aggConfig objects + * that should replace this one, + * or undefined + */ + this.getRequestAggs = config.getRequestAggs || _.noop; + /** * Designed for multi-value metric aggs, this method can return a * set of AggConfigs that should replace this aggConfig in result sets diff --git a/src/ui/public/agg_types/buckets/geo_hash.js b/src/ui/public/agg_types/buckets/geo_hash.js index a9a164fbb4a40..1c71fb4da20fe 100644 --- a/src/ui/public/agg_types/buckets/geo_hash.js +++ b/src/ui/public/agg_types/buckets/geo_hash.js @@ -1,10 +1,13 @@ import _ from 'lodash'; import AggTypesBucketsBucketAggTypeProvider from 'ui/agg_types/buckets/_bucket_agg_type'; +import VisAggConfigProvider from 'ui/vis/agg_config'; import precisionTemplate from 'ui/agg_types/controls/precision.html'; import { geohashColumns } from 'ui/utils/decode_geo_hash'; export default function GeoHashAggDefinition(Private, config) { const BucketAggType = Private(AggTypesBucketsBucketAggTypeProvider); + const AggConfig = Private(VisAggConfigProvider); + const defaultPrecision = 2; const maxPrecision = parseInt(config.get('visualization:tileMap:maxPrecision'), 10) || 12; /** @@ -54,6 +57,11 @@ export default function GeoHashAggDefinition(Private, config) { default: true, write: _.noop }, + { + name: 'useGeocentroid', + default: true, + write: _.noop + }, { name: 'mapZoom', write: _.noop @@ -79,6 +87,23 @@ export default function GeoHashAggDefinition(Private, config) { output.params.precision = aggConfig.params.autoPrecision ? autoPrecisionVal : getPrecision(aggConfig.params.precision); } } - ] + ], + getRequestAggs: function (agg) { + if (!agg.params.useGeocentroid) { + return agg; + } + + /** + * By default, add the geo_centroid aggregation + */ + return [agg, new AggConfig(agg.vis, { + type: 'geo_centroid', + enabled:true, + params: { + field: agg.getField() + }, + schema: 'metric' + })]; + } }); } diff --git a/src/ui/public/agg_types/controls/precision.html b/src/ui/public/agg_types/controls/precision.html index c72fef8a85b9f..adee607784631 100644 --- a/src/ui/public/agg_types/controls/precision.html +++ b/src/ui/public/agg_types/controls/precision.html @@ -18,7 +18,7 @@
-
+
+
+ +
+ diff --git a/src/ui/public/agg_types/index.js b/src/ui/public/agg_types/index.js index da5a5a6c94521..bdb8421f0d2a8 100644 --- a/src/ui/public/agg_types/index.js +++ b/src/ui/public/agg_types/index.js @@ -10,6 +10,7 @@ import AggTypesMetricsTopHitProvider from 'ui/agg_types/metrics/top_hit'; import AggTypesMetricsStdDeviationProvider from 'ui/agg_types/metrics/std_deviation'; import AggTypesMetricsCardinalityProvider from 'ui/agg_types/metrics/cardinality'; import AggTypesMetricsPercentilesProvider from 'ui/agg_types/metrics/percentiles'; +import AggTypesMetricsGeoCentroidProvider from 'ui/agg_types/metrics/geo_centroid'; import AggTypesMetricsPercentileRanksProvider from 'ui/agg_types/metrics/percentile_ranks'; import AggTypesMetricsDerivativeProvider from 'ui/agg_types/metrics/derivative'; import AggTypesMetricsCumulativeSumProvider from 'ui/agg_types/metrics/cumulative_sum'; @@ -28,6 +29,8 @@ import AggTypesMetricsBucketSumProvider from 'ui/agg_types/metrics/bucket_sum'; import AggTypesMetricsBucketAvgProvider from 'ui/agg_types/metrics/bucket_avg'; import AggTypesMetricsBucketMinProvider from 'ui/agg_types/metrics/bucket_min'; import AggTypesMetricsBucketMaxProvider from 'ui/agg_types/metrics/bucket_max'; + + export default function AggTypeService(Private) { const aggs = { @@ -51,6 +54,7 @@ export default function AggTypeService(Private) { Private(AggTypesMetricsBucketSumProvider), Private(AggTypesMetricsBucketMinProvider), Private(AggTypesMetricsBucketMaxProvider), + Private(AggTypesMetricsGeoCentroidProvider) ], buckets: [ Private(AggTypesBucketsDateHistogramProvider), @@ -61,7 +65,7 @@ export default function AggTypeService(Private) { Private(AggTypesBucketsTermsProvider), Private(AggTypesBucketsFiltersProvider), Private(AggTypesBucketsSignificantTermsProvider), - Private(AggTypesBucketsGeoHashProvider) + Private(AggTypesBucketsGeoHashProvider), ] }; diff --git a/src/ui/public/agg_types/metrics/geo_centroid.js b/src/ui/public/agg_types/metrics/geo_centroid.js new file mode 100644 index 0000000000000..8a6d5dae38803 --- /dev/null +++ b/src/ui/public/agg_types/metrics/geo_centroid.js @@ -0,0 +1,22 @@ +import AggTypesMetricsMetricAggTypeProvider from 'ui/agg_types/metrics/metric_agg_type'; + +export default function AggTypeMetricGeoCentroidProvider(Private) { + const MetricAggType = Private(AggTypesMetricsMetricAggTypeProvider); + + return new MetricAggType({ + name: 'geo_centroid', + title: 'Geo Centroid', + makeLabel: function () { + return 'Geo Centroid'; + }, + params: [ + { + name: 'field', + filterFieldTypes: 'geo_point' + } + ], + getValue: function (agg, bucket) { + return bucket[agg.id] && bucket[agg.id].location; + } + }); +} diff --git a/src/ui/public/utils/zoom_to_precision.js b/src/ui/public/utils/zoom_to_precision.js new file mode 100644 index 0000000000000..375a032d63613 --- /dev/null +++ b/src/ui/public/utils/zoom_to_precision.js @@ -0,0 +1,38 @@ +import { geohashColumns } from 'ui/utils/decode_geo_hash'; + +const maxPrecision = 12; +/** + * Map Leaflet zoom levels to geohash precision levels. + * The size of a geohash column-width on the map should be at least `minGeohashPixels` pixels wide. + */ + + + + +const zoomPrecisionMap = {}; +const minGeohashPixels = 16; + +function calculateZoomToPrecisionMap(maxZoom) { + + for (let zoom = 0; zoom <= maxZoom; zoom += 1) { + if (typeof zoomPrecisionMap[zoom] === 'number') { + continue; + } + const worldPixels = 256 * Math.pow(2, zoom); + zoomPrecisionMap[zoom] = 1; + for (let precision = 2; precision <= maxPrecision; precision += 1) { + const columns = geohashColumns(precision); + if ((worldPixels / columns) >= minGeohashPixels) { + zoomPrecisionMap[zoom] = precision; + } else { + break; + } + } + } +} + + +export default function zoomToPrecision(mapZoom, maxPrecision, maxZoom) { + calculateZoomToPrecisionMap(typeof maxZoom === 'number' ? maxZoom : 21); + return Math.min(zoomPrecisionMap[mapZoom], maxPrecision); +} diff --git a/src/ui/public/vis/agg_config.js b/src/ui/public/vis/agg_config.js index f766f61f82933..8bf158d87fdca 100644 --- a/src/ui/public/vis/agg_config.js +++ b/src/ui/public/vis/agg_config.js @@ -260,6 +260,11 @@ export default function AggConfigFactory(Private) { ); }; + AggConfig.prototype.getRequestAggs = function () { + if (!this.type) return; + return this.type.getRequestAggs(this) || [this]; + }; + AggConfig.prototype.getResponseAggs = function () { if (!this.type) return; return this.type.getResponseAggs(this) || [this]; diff --git a/src/ui/public/vis/agg_configs.js b/src/ui/public/vis/agg_configs.js index 3099a243d0feb..31d3d83899449 100644 --- a/src/ui/public/vis/agg_configs.js +++ b/src/ui/public/vis/agg_configs.js @@ -108,7 +108,6 @@ export default function AggConfigsFactory(Private) { }) .value(); } - this.getRequestAggs() .filter(function (config) { return !config.type.hasNoDsl; @@ -145,12 +144,18 @@ export default function AggConfigsFactory(Private) { }); removeParentAggs(dslTopLvl); - return dslTopLvl; }; AggConfigs.prototype.getRequestAggs = function () { - return _.sortBy(this, function (agg) { + //collect all the aggregations + const aggregations = this.reduce((requestValuesAggs, agg) => { + const aggs = agg.getRequestAggs(); + return aggs ? requestValuesAggs.concat(aggs) : requestValuesAggs; + }, []); + + //move metrics to the end + return _.sortBy(aggregations, function (agg) { return agg.schema.group === 'metrics' ? 1 : 0; }); }; diff --git a/src/ui/public/vis_maps/__tests__/tile_maps/map.js b/src/ui/public/vis_maps/__tests__/tile_maps/map.js deleted file mode 100644 index b12634fca1327..0000000000000 --- a/src/ui/public/vis_maps/__tests__/tile_maps/map.js +++ /dev/null @@ -1,215 +0,0 @@ -import expect from 'expect.js'; -import ngMock from 'ng_mock'; -import _ from 'lodash'; -import L from 'leaflet'; - -import sinon from 'auto-release-sinon'; -import geoJsonData from 'fixtures/vislib/mock_data/geohash/_geo_json'; -import $ from 'jquery'; -import VislibVisualizationsMapProvider from 'ui/vis_maps/visualizations/_map'; - -// // Data -// const dataArray = [ -// ['geojson', require('fixtures/vislib/mock_data/geohash/_geo_json')], -// ['columns', require('fixtures/vislib/mock_data/geohash/_columns')], -// ['rows', require('fixtures/vislib/mock_data/geohash/_rows')], -// ]; - -// TODO: Test the specific behavior of each these -// const mapTypes = [ -// 'Scaled Circle Markers', -// 'Shaded Circle Markers', -// 'Shaded Geohash Grid', -// 'Heatmap' -// ]; - -describe('tilemaptest - TileMap Map Tests', function () { - const $mockMapEl = $('
'); - let TileMapMap; - let tilemapSettings; - const leafletStubs = {}; - const leafletMocks = {}; - - - beforeEach(ngMock.module('kibana')); - beforeEach(ngMock.inject(function (Private, $injector) { - // mock parts of leaflet - leafletMocks.tileLayer = { on: sinon.stub() }; - leafletMocks.map = { on: sinon.stub() }; - leafletStubs.tileLayer = sinon.stub(L, 'tileLayer', _.constant(leafletMocks.tileLayer)); - leafletStubs.tileLayer.wms = sinon.stub(L.tileLayer, 'wms', _.constant(leafletMocks.tileLayer)); - - leafletStubs.map = sinon.stub(L, 'map', _.constant(leafletMocks.map)); - - TileMapMap = Private(VislibVisualizationsMapProvider); - - tilemapSettings = $injector.get('tilemapSettings'); - - })); - - async function loadTileMapSettings() { - await tilemapSettings.loadSettings(); - } - - describe('instantiation', function () { - let createStub; - - beforeEach(loadTileMapSettings); - - beforeEach(async function () { - createStub = sinon.stub(TileMapMap.prototype, '_createMap', _.noop); - new TileMapMap($mockMapEl, geoJsonData, {}); - }); - - it('should create the map', function () { - expect(createStub.callCount).to.equal(1); - }); - }); - - describe('createMap', function () { - let map; - let mapStubs; - - beforeEach(loadTileMapSettings); - - beforeEach(function () { - mapStubs = { - destroy: sinon.stub(TileMapMap.prototype, 'destroy'), - attachEvents: sinon.stub(TileMapMap.prototype, '_attachEvents'), - addMarkers: sinon.stub(TileMapMap.prototype, '_addMarkers'), - }; - map = new TileMapMap($mockMapEl, geoJsonData, {}); - }); - - it('should create leaflet objects for tileLayer and map', function () { - expect(leafletStubs.tileLayer.callCount).to.equal(1); - expect(leafletStubs.map.callCount).to.equal(1); - - const callArgs = leafletStubs.map.firstCall.args; - const mapOptions = callArgs[1]; - expect(callArgs[0]).to.be($mockMapEl.get(0)); - expect(mapOptions).to.have.property('zoom'); - expect(mapOptions).to.have.property('center'); - }); - - it('should attach events and add markers', function () { - expect(mapStubs.attachEvents.callCount).to.equal(1); - expect(mapStubs.addMarkers.callCount).to.equal(1); - }); - - it('should call destroy only if a map exists', function () { - expect(mapStubs.destroy.callCount).to.equal(0); - map._createMap(); - expect(mapStubs.destroy.callCount).to.equal(1); - }); - - it('should create a WMS layer if WMS is enabled', function () { - expect(L.tileLayer.wms.called).to.be(false); - map = new TileMapMap($mockMapEl, geoJsonData, { attr: { wms: { enabled: true } } }); - map._createMap(); - expect(L.tileLayer.wms.called).to.be(true); - }); - - it('should create layer with all options from `tilemapSettings.getOptions()`', () => { - sinon.assert.calledOnce(L.tileLayer); - - const leafletOptions = tilemapSettings.getTMSOptions(); - expect(L.tileLayer.firstCall.args[1]).to.eql(leafletOptions); - }); - }); - - describe('attachEvents', function () { - beforeEach(loadTileMapSettings); - - beforeEach(function () { - sinon.stub(TileMapMap.prototype, '_createMap', function () { - this._tileLayer = leafletMocks.tileLayer; - this.map = leafletMocks.map; - this._attachEvents(); - }); - new TileMapMap($mockMapEl, geoJsonData, {}); - }); - - it('should attach interaction events', function () { - const expectedTileEvents = ['tileload']; - const expectedMapEvents = ['draw:created', 'moveend', 'zoomend', 'unload']; - const matchedEvents = { - tiles: 0, - maps: 0, - }; - - _.times(leafletMocks.tileLayer.on.callCount, function (index) { - const ev = leafletMocks.tileLayer.on.getCall(index).args[0]; - if (_.includes(expectedTileEvents, ev)) matchedEvents.tiles++; - }); - expect(matchedEvents.tiles).to.equal(expectedTileEvents.length); - - _.times(leafletMocks.map.on.callCount, function (index) { - const ev = leafletMocks.map.on.getCall(index).args[0]; - if (_.includes(expectedMapEvents, ev)) matchedEvents.maps++; - }); - expect(matchedEvents.maps).to.equal(expectedMapEvents.length); - }); - }); - - - describe('addMarkers', function () { - let map; - let createStub; - - beforeEach(loadTileMapSettings); - - beforeEach(function () { - sinon.stub(TileMapMap.prototype, '_createMap'); - createStub = sinon.stub(TileMapMap.prototype, '_createMarkers', _.constant({ addLegend: _.noop })); - map = new TileMapMap($mockMapEl, geoJsonData, {}); - }); - it('should pass the map options to the marker', function () { - map._addMarkers(); - - const args = createStub.firstCall.args[0]; - expect(args).to.have.property('tooltipFormatter'); - expect(args).to.have.property('valueFormatter'); - expect(args).to.have.property('attr'); - }); - - it('should destroy existing markers', function () { - const destroyStub = sinon.stub(); - map._markers = { destroy: destroyStub }; - map._addMarkers(); - - expect(destroyStub.callCount).to.be(1); - }); - }); - - describe('getDataRectangles', function () { - let map; - - beforeEach(loadTileMapSettings); - - beforeEach(function () { - sinon.stub(TileMapMap.prototype, '_createMap'); - map = new TileMapMap($mockMapEl, geoJsonData, {}); - }); - - it('should return an empty array if no data', function () { - map = new TileMapMap($mockMapEl, {}, {}); - const rects = map._getDataRectangles(); - expect(rects).to.have.length(0); - }); - - it('should return an array of arrays of rectangles', function () { - const rects = map._getDataRectangles(); - _.times(5, function () { - const index = _.random(rects.length - 1); - const rect = rects[index]; - const featureRect = geoJsonData.geoJson.features[index].properties.rectangle; - expect(rect.length).to.equal(featureRect.length); - - // should swap the array - const checkIndex = _.random(rect.length - 1); - expect(rect[checkIndex]).to.eql(featureRect[checkIndex]); - }); - }); - }); -}); diff --git a/src/ui/public/vis_maps/__tests__/tile_maps/markers.js b/src/ui/public/vis_maps/__tests__/tile_maps/markers.js deleted file mode 100644 index 1422bd1a41918..0000000000000 --- a/src/ui/public/vis_maps/__tests__/tile_maps/markers.js +++ /dev/null @@ -1,362 +0,0 @@ - -import angular from 'angular'; -import expect from 'expect.js'; -import ngMock from 'ng_mock'; -import _ from 'lodash'; -import L from 'leaflet'; -import sinon from 'auto-release-sinon'; -import geoJsonData from 'fixtures/vislib/mock_data/geohash/_geo_json'; -import VislibVisualizationsMarkerTypesBaseMarkerProvider from 'ui/vis_maps/visualizations/marker_types/base_marker'; -import VislibVisualizationsMarkerTypesShadedCirclesProvider from 'ui/vis_maps/visualizations/marker_types/shaded_circles'; -import VislibVisualizationsMarkerTypesScaledCirclesProvider from 'ui/vis_maps/visualizations/marker_types/scaled_circles'; -import VislibVisualizationsMarkerTypesHeatmapProvider from 'ui/vis_maps/visualizations/marker_types/heatmap'; -// defaults to roughly the lower 48 US states -const defaultSWCoords = [13.496, -143.789]; -const defaultNECoords = [55.526, -57.919]; -const bounds = {}; - -angular.module('MarkerFactory', ['kibana']); - -function setBounds(southWest, northEast) { - bounds.southWest = L.latLng(southWest || defaultSWCoords); - bounds.northEast = L.latLng(northEast || defaultNECoords); -} - -function getBounds() { - return L.latLngBounds(bounds.southWest, bounds.northEast); -} - -const mockMap = { - addLayer: _.noop, - closePopup: _.noop, - getBounds: getBounds, - removeControl: _.noop, - removeLayer: _.noop, - getZoom: _.constant(5) -}; - -describe('tilemaptest - Marker Tests', function () { - let mapData; - let markerLayer; - - function createMarker(MarkerClass, geoJson, tooltipFormatter) { - mapData = _.assign({}, geoJsonData.geoJson, geoJson || {}); - mapData.properties.allmin = mapData.properties.min; - mapData.properties.allmax = mapData.properties.max; - - return new MarkerClass(mockMap, mapData, { - valueFormatter: geoJsonData.valueFormatter, - tooltipFormatter: tooltipFormatter || null - }); - } - - beforeEach(function () { - setBounds(); - }); - - afterEach(function () { - if (markerLayer) { - markerLayer.destroy(); - markerLayer = undefined; - } - }); - - describe('Base Methods', function () { - let MarkerClass; - - beforeEach(ngMock.module('MarkerFactory')); - beforeEach(ngMock.inject(function (Private) { - MarkerClass = Private(VislibVisualizationsMarkerTypesBaseMarkerProvider); - markerLayer = createMarker(MarkerClass); - })); - - describe('filterToMapBounds', function () { - it('should not filter any features', function () { - // set bounds to the entire world - setBounds([-87.252, -343.828], [87.252, 343.125]); - const boundFilter = markerLayer._filterToMapBounds(); - const mapFeature = mapData.features.filter(boundFilter); - - expect(mapFeature.length).to.equal(mapData.features.length); - }); - - it('should filter out data points that are outside of the map bounds', function () { - // set bounds to roughly US southwest - setBounds([31.690, -124.387], [42.324, -102.919]); - const boundFilter = markerLayer._filterToMapBounds(); - const mapFeature = mapData.features.filter(boundFilter); - - expect(mapFeature.length).to.be.lessThan(mapData.features.length); - }); - }); - - describe('legendQuantizer', function () { - it('should return a range of hex colors', function () { - const minColor = markerLayer._legendQuantizer(mapData.properties.allmin); - const maxColor = markerLayer._legendQuantizer(mapData.properties.allmax); - - expect(minColor.substring(0, 1)).to.equal('#'); - expect(minColor).to.have.length(7); - expect(maxColor.substring(0, 1)).to.equal('#'); - expect(maxColor).to.have.length(7); - expect(minColor).to.not.eql(maxColor); - }); - - it('should return a color with 1 color', function () { - const geoJson = { properties: { min: 1, max: 1 } }; - markerLayer = createMarker(MarkerClass, geoJson); - - // ensure the quantizer domain is correct - const color = markerLayer._legendQuantizer(1); - expect(color).to.not.be(undefined); - expect(color.substring(0, 1)).to.equal('#'); - - // should always get the same color back - _.times(5, function () { - const randColor = markerLayer._legendQuantizer(0); - expect(randColor).to.equal(color); - }); - }); - }); - - describe('applyShadingStyle', function () { - it('should return a style object', function () { - const style = markerLayer.applyShadingStyle(100); - expect(style).to.be.an('object'); - - const keys = _.keys(style); - const expected = ['fillColor', 'color']; - _.each(expected, function (key) { - expect(keys).to.contain(key); - }); - }); - - it('should use the legendQuantizer', function () { - const spy = sinon.spy(markerLayer, '_legendQuantizer'); - markerLayer.applyShadingStyle(100); - expect(spy.callCount).to.equal(1); - }); - }); - - describe('showTooltip', function () { - it('should use the tooltip formatter', function () { - const sample = _.sample(mapData.features); - - markerLayer = createMarker(MarkerClass, null, Function.prototype);//create marker with tooltip - markerLayer._attr.addTooltip = true; - const stub = sinon.stub(markerLayer, '_tooltipFormatter', function () { - return; - }); - markerLayer._showTooltip(sample); - expect(stub.callCount).to.equal(1); - expect(stub.firstCall.calledWith(sample)).to.be(true); - }); - }); - - describe('addLegend', function () { - let addToSpy; - let leafletControlStub; - - beforeEach(function () { - addToSpy = sinon.spy(); - leafletControlStub = sinon.stub(L, 'control', function () { - return { - addTo: addToSpy - }; - }); - }); - - it('should do nothing if there is already a legend', function () { - markerLayer._legend = { legend: 'exists' }; // anything truthy - - markerLayer.addLegend(); - expect(leafletControlStub.callCount).to.equal(0); - }); - - it('should create a leaflet control', function () { - markerLayer.addLegend(); - expect(leafletControlStub.callCount).to.equal(1); - expect(addToSpy.callCount).to.equal(1); - expect(addToSpy.firstCall.calledWith(markerLayer.map)).to.be(true); - expect(markerLayer._legend).to.have.property('onAdd'); - }); - - it('should use the value formatter', function () { - const formatterSpy = sinon.spy(markerLayer, '_valueFormatter'); - // called twice for every legend color defined - const expectedCallCount = markerLayer._legendColors.length * 2; - - markerLayer.addLegend(); - const legend = markerLayer._legend.onAdd(); - - expect(formatterSpy.callCount).to.equal(expectedCallCount); - expect(legend).to.be.a(HTMLDivElement); - }); - }); - }); - - describe('Shaded Circles', function () { - beforeEach(ngMock.module('MarkerFactory')); - beforeEach(ngMock.inject(function (Private) { - const MarkerClass = Private(VislibVisualizationsMarkerTypesShadedCirclesProvider); - markerLayer = createMarker(MarkerClass); - })); - - describe('geohashMinDistance method', function () { - it('should return a finite number', function () { - const sample = _.sample(mapData.features); - const distance = markerLayer._geohashMinDistance(sample); - - expect(distance).to.be.a('number'); - expect(_.isFinite(distance)).to.be(true); - }); - }); - }); - - describe('Scaled Circles', function () { - let zoom; - - beforeEach(ngMock.module('MarkerFactory')); - beforeEach(ngMock.inject(function (Private) { - zoom = _.random(1, 18); - sinon.stub(mockMap, 'getZoom', _.constant(zoom)); - const MarkerClass = Private(VislibVisualizationsMarkerTypesScaledCirclesProvider); - markerLayer = createMarker(MarkerClass); - })); - - describe('radiusScale method', function () { - const valueArray = [10, 20, 30, 40, 50, 60]; - const max = _.max(valueArray); - - it('should return 0 for value of 0', function () { - expect(markerLayer._radiusScale(0)).to.equal(0); - }); - - it('should return a scaled value for negative and positive numbers', function () { - const upperBound = markerLayer._radiusScale(max); - const results = []; - - function roundValue(value) { - // round number to 6 decimal places - const r = Math.pow(10, 6); - return Math.round(value * r) / r; - } - - _.each(valueArray, function (value, i) { - const ratio = Math.pow(value / max, 0.5); - const comparison = ratio * upperBound; - const radius = markerLayer._radiusScale(value); - const negRadius = markerLayer._radiusScale(value * -1); - results.push(radius); - - expect(negRadius).to.equal(radius); - expect(roundValue(radius)).to.equal(roundValue(comparison)); - - // check that the radius is getting larger - if (i > 0) { - expect(radius).to.be.above(results[i - 1]); - } - }); - }); - }); - }); - - describe('Heatmaps', function () { - beforeEach(ngMock.module('MarkerFactory')); - beforeEach(ngMock.inject(function (Private) { - const MarkerClass = Private(VislibVisualizationsMarkerTypesHeatmapProvider); - markerLayer = createMarker(MarkerClass); - })); - - describe('dataToHeatArray', function () { - let max; - - beforeEach(function () { - max = mapData.properties.allmax; - }); - - it('should return an array or values for each feature', function () { - const arr = markerLayer._dataToHeatArray(max); - expect(arr).to.be.an('array'); - expect(arr).to.have.length(mapData.features.length); - - }); - - it('should return an array item with lat, lng, metric for each feature', function () { - _.times(3, function () { - const arr = markerLayer._dataToHeatArray(max); - const index = _.random(mapData.features.length - 1); - const feature = mapData.features[index]; - const featureValue = feature.properties.value; - const featureArr = feature.geometry.coordinates.slice(0).concat(featureValue); - expect(arr[index]).to.eql(featureArr); - }); - }); - - it('should return an array item with lat, lng, normalized metric for each feature', function () { - _.times(5, function () { - markerLayer._attr.heatNormalizeData = true; - - const arr = markerLayer._dataToHeatArray(max); - const index = _.random(mapData.features.length - 1); - const feature = mapData.features[index]; - const featureValue = feature.properties.value / max; - const featureArr = feature.geometry.coordinates.slice(0).concat(featureValue); - expect(arr[index]).to.eql(featureArr); - }); - }); - }); - - describe('tooltipProximity', function () { - it('should return true if feature is close enough to event latlng', function () { - _.times(5, function () { - const feature = _.sample(mapData.features); - const point = markerLayer._getLatLng(feature); - const arr = markerLayer._tooltipProximity(point, feature); - expect(arr).to.be(true); - }); - }); - - it('should return false if feature is not close enough to event latlng', function () { - _.times(5, function () { - const feature = _.sample(mapData.features); - const point = L.latLng(90, -180); - const arr = markerLayer._tooltipProximity(point, feature); - expect(arr).to.be(false); - }); - }); - }); - - describe('nearestFeature', function () { - it('should return nearest geoJson feature object', function () { - _.times(5, function () { - const feature = _.sample(mapData.features); - const point = markerLayer._getLatLng(feature); - const nearestPoint = markerLayer._nearestFeature(point); - expect(nearestPoint).to.equal(feature); - }); - }); - }); - - describe('getLatLng', function () { - it('should return a leaflet latLng object', function () { - const feature = _.sample(mapData.features); - const latLng = markerLayer._getLatLng(feature); - const compare = L.latLng(feature.geometry.coordinates.slice(0).reverse()); - expect(latLng).to.eql(compare); - }); - - it('should memoize the result', function () { - const spy = sinon.spy(L, 'latLng'); - const feature = _.sample(mapData.features); - - markerLayer._getLatLng(feature); - expect(spy.callCount).to.be(1); - - markerLayer._getLatLng(feature); - expect(spy.callCount).to.be(1); - }); - }); - }); - -}); diff --git a/src/ui/public/vis_maps/__tests__/tile_maps/tile_map.js b/src/ui/public/vis_maps/__tests__/tile_maps/tile_map.js deleted file mode 100644 index 9b942e22f3bd1..0000000000000 --- a/src/ui/public/vis_maps/__tests__/tile_maps/tile_map.js +++ /dev/null @@ -1,137 +0,0 @@ -import expect from 'expect.js'; -import ngMock from 'ng_mock'; -import _ from 'lodash'; -import sinon from 'auto-release-sinon'; - -import geoJsonData from 'fixtures/vislib/mock_data/geohash/_geo_json'; -import MockMap from 'fixtures/tilemap_map'; -import $ from 'jquery'; -import VislibVisualizationsTileMapProvider from 'ui/vis_maps/visualizations/tile_map'; -const mockChartEl = $('
'); - -let TileMap; -let extentsStub; - -function createTileMap(handler, chartEl, chartData) { - handler = handler || { - visConfig: { - get: function () { - return ''; - } - }, - uiState: { - get: function () { - return ''; - } - } - }; - chartEl = chartEl || mockChartEl; - chartData = chartData || geoJsonData; - - return new TileMap(handler, chartEl, chartData); -} - -describe('tilemaptest - TileMap Tests', function () { - let tilemap; - - beforeEach(ngMock.module('kibana')); - beforeEach(ngMock.inject(function (Private) { - Private.stub(require('ui/vis_maps/visualizations/_map'), MockMap); - TileMap = Private(VislibVisualizationsTileMapProvider); - extentsStub = sinon.stub(TileMap.prototype, '_appendGeoExtents', _.noop); - })); - - beforeEach(function () { - tilemap = createTileMap(); - }); - - it('should inherit props from chartData', function () { - _.each(geoJsonData, function (val, prop) { - expect(tilemap).to.have.property(prop, val); - }); - }); - - it('should append geoExtents', function () { - expect(extentsStub.callCount).to.equal(1); - }); - - describe('draw', function () { - it('should return a function', function () { - expect(tilemap.draw()).to.be.a('function'); - }); - }); - - describe('appendMap', function () { - let $selection; - - beforeEach(function () { - $selection = $('
'); - expect(tilemap.maps).to.have.length(0); - tilemap._appendMap($selection); - }); - - it('should add the tilemap class', function () { - expect($selection.hasClass('tilemap')).to.equal(true); - }); - - it('should append maps and required controls', function () { - expect(tilemap.maps).to.have.length(1); - const map = tilemap.maps[0]; - expect(map.addTitle.callCount).to.equal(0); - expect(map.addFitControl.callCount).to.equal(1); - expect(map.addBoundingControl.callCount).to.equal(1); - }); - - it('should only add controls if data exists', function () { - const noData = { - geohashGridAgg: { vis: { params: {} } }, - geoJson: { - features: [], - properties: {}, - hits: 20 - } - }; - tilemap = createTileMap(null, null, noData); - - tilemap._appendMap($selection); - expect(tilemap.maps).to.have.length(1); - - const map = tilemap.maps[0]; - expect(map.addTitle.callCount).to.equal(0); - expect(map.addFitControl.callCount).to.equal(0); - expect(map.addBoundingControl.callCount).to.equal(0); - }); - - it('should append title if set in the data object', function () { - const mapTitle = 'Test Title'; - tilemap = createTileMap(null, null, _.assign({ title: mapTitle }, geoJsonData)); - tilemap._appendMap($selection); - const map = tilemap.maps[0]; - - expect(map.addTitle.callCount).to.equal(1); - expect(map.addTitle.firstCall.calledWith(mapTitle)).to.equal(true); - }); - }); - - describe('destroy', function () { - const maps = []; - const mapCount = 5; - - beforeEach(function () { - _.times(mapCount, function () { - maps.push(new MockMap()); - }); - tilemap.maps = maps; - expect(tilemap.maps).to.have.length(mapCount); - tilemap.destroy(); - }); - - it('should destroy all the maps', function () { - expect(tilemap.maps).to.have.length(0); - expect(maps).to.have.length(mapCount); - _.each(maps, function (map) { - expect(map.destroy.callCount).to.equal(1); - }); - }); - }); -}); diff --git a/src/ui/public/vis_maps/__tests__/tile_maps/tilemap_settings.js b/src/ui/public/vis_maps/__tests__/tile_maps/tilemap_settings.js deleted file mode 100644 index e5d8982aac2e5..0000000000000 --- a/src/ui/public/vis_maps/__tests__/tile_maps/tilemap_settings.js +++ /dev/null @@ -1,61 +0,0 @@ -import expect from 'expect.js'; -import ngMock from 'ng_mock'; -import url from 'url'; - -describe('tilemaptest - TileMapSettingsTests-deprecated', function () { - let tilemapSettings; - let loadSettings; - - beforeEach(ngMock.module('kibana', ($provide) => { - $provide.decorator('tilemapsConfig', () => ({ - manifestServiceUrl: 'https://proxy-tiles.elastic.co/v1/manifest', - deprecated: { - isOverridden: true, - config: { - url: 'https://tiles.elastic.co/v1/default/{z}/{x}/{y}.png?my_app_name=kibana_tests', - options: { - minZoom: 1, - maxZoom: 10, - attribution: '© [Elastic Tile Service](https://www.elastic.co/elastic_tile_service)' - } - }, - } - })); - })); - - beforeEach(ngMock.inject(function ($injector, $rootScope) { - tilemapSettings = $injector.get('tilemapSettings'); - - loadSettings = () => { - tilemapSettings.loadSettings(); - $rootScope.$digest(); - }; - })); - - describe('getting settings', function () { - beforeEach(function () { - loadSettings(); - }); - - it('should get url', function () { - - const mapUrl = tilemapSettings.getUrl(); - expect(mapUrl).to.contain('{x}'); - expect(mapUrl).to.contain('{y}'); - expect(mapUrl).to.contain('{z}'); - - const urlObject = url.parse(mapUrl, true); - expect(urlObject.hostname).to.be('tiles.elastic.co'); - expect(urlObject.query).to.have.property('my_app_name', 'kibana_tests'); - - }); - - it('should get options', function () { - const options = tilemapSettings.getTMSOptions(); - expect(options).to.have.property('minZoom'); - expect(options).to.have.property('maxZoom'); - expect(options).to.have.property('attribution'); - }); - - }); -}); diff --git a/src/ui/public/vis_maps/__tests__/tile_maps/tilemap_settings_mocked.js b/src/ui/public/vis_maps/__tests__/tile_maps/tilemap_settings_mocked.js deleted file mode 100644 index 3f34c40e9c1ed..0000000000000 --- a/src/ui/public/vis_maps/__tests__/tile_maps/tilemap_settings_mocked.js +++ /dev/null @@ -1,140 +0,0 @@ -import expect from 'expect.js'; -import ngMock from 'ng_mock'; -import url from 'url'; - -describe('tilemaptest - TileMapSettingsTests-mocked', function () { - let tilemapSettings; - let tilemapsConfig; - let loadSettings; - - beforeEach(ngMock.module('kibana', ($provide) => { - $provide.decorator('tilemapsConfig', () => ({ - manifestServiceUrl: 'http://foo.bar/manifest', - deprecated: { - isOverridden: false, - config: { - url: '', - options: { - minZoom: 1, - maxZoom: 10, - attribution: '© [Elastic Tile Service](https://www.elastic.co/elastic_tile_service)' - } - }, - } - })); - })); - - beforeEach(ngMock.inject(($injector, $httpBackend) => { - tilemapSettings = $injector.get('tilemapSettings'); - tilemapsConfig = $injector.get('tilemapsConfig'); - - loadSettings = (expectedUrl) => { - // body and headers copied from https://proxy-tiles.elastic.co/v1/manifest - const MANIFEST_BODY = `{ - "services":[ - { - "id":"road_map", - "url":"https://proxy-tiles.elastic.co/v1/default/{z}/{x}/{y}.png?elastic_tile_service_tos=agree&my_app_name=kibana", - "minZoom":0, - "maxZoom":12, - "attribution":"© [Elastic Tile Service](https://www.elastic.co/elastic-tile-service)" - } - ] - }`; - - const MANIFEST_HEADERS = { - 'access-control-allow-methods': 'GET, OPTIONS', - 'access-control-allow-origin': '*', - 'content-length': `${MANIFEST_BODY.length}`, - 'content-type': 'application/json; charset=utf-8', - date: (new Date()).toUTCString(), - server: 'tileprox/20170102101655-a02e54d', - status: '200', - }; - - $httpBackend - .expect('GET', expectedUrl ? expectedUrl : () => true) - .respond(MANIFEST_BODY, MANIFEST_HEADERS); - - tilemapSettings.loadSettings(); - - $httpBackend.flush(); - }; - })); - - afterEach(ngMock.inject($httpBackend => { - $httpBackend.verifyNoOutstandingRequest(); - $httpBackend.verifyNoOutstandingExpectation(); - })); - - describe('getting settings', function () { - beforeEach(() => { - loadSettings(); - }); - - - it('should get url', async function () { - - const mapUrl = tilemapSettings.getUrl(); - expect(mapUrl).to.contain('{x}'); - expect(mapUrl).to.contain('{y}'); - expect(mapUrl).to.contain('{z}'); - - const urlObject = url.parse(mapUrl, true); - expect(urlObject).to.have.property('hostname', 'proxy-tiles.elastic.co'); - expect(urlObject.query).to.have.property('my_app_name', 'kibana'); - expect(urlObject.query).to.have.property('elastic_tile_service_tos', 'agree'); - expect(urlObject.query).to.have.property('my_app_version'); - - }); - - it('should get options', async function () { - const options = tilemapSettings.getTMSOptions(); - expect(options).to.have.property('minZoom', 0); - expect(options).to.have.property('maxZoom', 12); - expect(options).to.have.property('attribution').contain('©'); // html entity for ©, ensures that attribution is escaped - }); - - }); - - describe('modify', function () { - function assertQuery(expected) { - const mapUrl = tilemapSettings.getUrl(); - const urlObject = url.parse(mapUrl, true); - Object.keys(expected).forEach(key => { - expect(urlObject.query).to.have.property(key, expected[key]); - }); - } - - it('accepts an object', () => { - tilemapSettings.addQueryParams({ foo: 'bar' }); - loadSettings(); - assertQuery({ foo: 'bar' }); - }); - - it('merged additions with previous values', () => { - // ensure that changes are always additive - tilemapSettings.addQueryParams({ foo: 'bar' }); - tilemapSettings.addQueryParams({ bar: 'stool' }); - loadSettings(); - assertQuery({ foo: 'bar', bar: 'stool' }); - }); - - it('overwrites conflicting previous values', () => { - // ensure that conflicts are overwritten - tilemapSettings.addQueryParams({ foo: 'bar' }); - tilemapSettings.addQueryParams({ bar: 'stool' }); - tilemapSettings.addQueryParams({ foo: 'tstool' }); - loadSettings(); - assertQuery({ foo: 'tstool', bar: 'stool' }); - }); - - it('merges query params into manifest request', () => { - tilemapSettings.addQueryParams({ foo: 'bar' }); - tilemapsConfig.manifestServiceUrl = 'http://test.com/manifest?v=1'; - loadSettings('http://test.com/manifest?v=1&my_app_version=1.2.3&foo=bar'); - }); - - }); - -}); diff --git a/src/ui/public/vis_maps/geohash_layer.js b/src/ui/public/vis_maps/geohash_layer.js new file mode 100644 index 0000000000000..288cfcef4cd4f --- /dev/null +++ b/src/ui/public/vis_maps/geohash_layer.js @@ -0,0 +1,90 @@ +import KibanaMapLayer from './kibana_map_layer'; +import _ from 'lodash'; +import Heatmap from './markers/heatmap'; +import ScaledCircles from './markers/scaled_circles'; +import ShadedCircles from './markers/shaded_circles'; +import GeohashGrid from './markers/geohash_grid'; + +export default class GeohashLayer extends KibanaMapLayer { + + constructor(featureCollection, options, zoom, kibanaMap) { + + super(); + + this._geohashGeoJson = featureCollection; + this._geohashOptions = options; + this._zoom = zoom; + this._kibanaMap = kibanaMap; + + this._createGeohashMarkers(); + } + + _createGeohashMarkers() { + const markerOptions = { + valueFormatter: this._geohashOptions.valueFormatter, + tooltipFormatter: this._geohashOptions.tooltipFormatter + }; + switch (this._geohashOptions.mapType) { + case 'Scaled Circle Markers': + this._geohashMarkers = new ScaledCircles(this._geohashGeoJson, markerOptions, this._zoom, this._kibanaMap); + break; + case 'Shaded Circle Markers': + this._geohashMarkers = new ShadedCircles(this._geohashGeoJson, markerOptions, this._zoom, this._kibanaMap); + break; + case 'Shaded Geohash Grid': + this._geohashMarkers = new GeohashGrid(this._geohashGeoJson, markerOptions, this._zoom, this._kibanaMap); + break; + case 'Heatmap': + this._geohashMarkers = new Heatmap(this._geohashGeoJson, { + radius: parseFloat(this._geohashOptions.heatmap.heatRadius), + blur: parseFloat(this._geohashOptions.heatmap.heatBlur), + maxZoom: parseFloat(this._geohashOptions.heatmap.heatMaxZoom), + minOpaxity: parseFloat(this._geohashOptions.heatmap.heatMinOpacity), + heatNormalizeData: parseFloat(this._geohashOptions.heatmap.heatNormalizeData), + tooltipFormatter: this._geohashOptions.tooltipFormatter + }, this._zoom, this._kibanaMap); + break; + default: + throw new Error(`${this._geohashOptions.mapType} mapType not recognized`); + + } + + this._geohashMarkers.on('showTooltip', (event) => this.emit('showTooltip', event)); + this._geohashMarkers.on('hideTooltip', (event) => this.emit('hideTooltip', event)); + this._leafletLayer = this._geohashMarkers.getLeafletLayer(); + } + + appendLegendContents(jqueryDiv) { + return this._geohashMarkers.appendLegendContents(jqueryDiv); + } + + movePointer(...args) { + this._geohashMarkers.movePointer(...args); + } + + updateExtent() { + //this removal is required to trigger the bounds filter again + this._kibanaMap.removeLayer(this); + this._createGeohashMarkers(); + this._kibanaMap.addLayer(this); + } + + + isReusable(options) { + + if (_.isEqual(this._geohashOptions, options)) { + return true; + } + + if (this._geohashOptions.mapType !== options.mapType) { + return false; + } else if (this._geohashOptions.mapType === 'Heatmap' && !_.isEqual(this._geohashOptions.heatmap, options)) { + return false; + } else { + return true; + } + } +} + + + diff --git a/src/ui/public/vis_maps/kibana_map.js b/src/ui/public/vis_maps/kibana_map.js new file mode 100644 index 0000000000000..dfff75ec18d4a --- /dev/null +++ b/src/ui/public/vis_maps/kibana_map.js @@ -0,0 +1,528 @@ +import { EventEmitter } from 'events'; +import L from 'leaflet'; +import $ from 'jquery'; +import _ from 'lodash'; +import zoomToPrecision from 'ui/utils/zoom_to_precision'; + +const FitControl = L.Control.extend({ + options: { + position: 'topleft' + }, + initialize: function (fitContainer, kibanaMap) { + this._fitContainer = fitContainer; + this._kibanaMap = kibanaMap; + this._leafletMap = null; + }, + onAdd: function (leafletMap) { + this._leafletMap = leafletMap; + $(this._fitContainer).html('') + .on('click', e => { + e.preventDefault(); + this._kibanaMap.fitToData(); + }); + + return this._fitContainer; + }, + onRemove: function () { + $(this._fitContainer).off('click'); + } +}); + + +const LegendControl = L.Control.extend({ + + options: { + position: 'topright' + }, + + updateContents() { + this._legendContainer.empty(); + const $div = $('
').addClass('tilemap-legend'); + this._legendContainer.append($div); + const layers = this._kibanaMap.getLayers(); + layers.forEach((layer) =>layer.appendLegendContents($div)); + }, + + + initialize: function (container, kibanaMap, position) { + this._legendContainer = container; + this._kibanaMap = kibanaMap; + this.options.position = position; + + }, + onAdd: function () { + this._layerUpdateHandle = () => this.updateContents(); + this._kibanaMap.on('layers:update', this._layerUpdateHandle); + this.updateContents(); + return this._legendContainer.get(0); + }, + onRemove: function () { + this._kibanaMap.removeListener('layers:update', this._layerUpdateHandle); + } + +}); + +/** + * Collects map functionality required for Kibana. + * Serves as simple abstraction for leaflet as well. + */ +class KibanaMap extends EventEmitter { + + constructor(containerNode, options) { + + super(); + this._containerNode = containerNode; + this._leafletBaseLayer = null; + this._baseLayerSettings = null; + this._baseLayerIsDesaturated = true; + + this._leafletDrawControl = null; + this._leafletFitControl = null; + this._leafletLegendControl = null; + this._legendPosition = 'topright'; + + this._layers = []; + this._listeners = []; + this._showTooltip = false; + + this._leafletMap = L.map(containerNode, { + minZoom: options.minZoom, + maxZoom: options.maxZoom + }); + this._leafletMap.fitWorld(); + const worldBounds = L.latLngBounds(L.latLng(-90, -180), L.latLng(90, 180)); + this._leafletMap.setMaxBounds(worldBounds); + + let previousZoom = this._leafletMap.getZoom(); + this._leafletMap.on('zoomend', () => { + if (previousZoom !== this._leafletMap.getZoom()) { + previousZoom = this._leafletMap.getZoom(); + this.emit('zoomchange'); + } + }); + this._leafletMap.on('zoomend', () => this.emit('zoomend')); + this._leafletMap.on('moveend', () => this.emit('moveend')); + this._leafletMap.on('dragend', e => this._layers.forEach(layer => layer.updateExtent('dragend', e))); + this._leafletMap.on('mousemove', e => this._layers.forEach(layer => layer.movePointer('mousemove', e))); + this._leafletMap.on('mouseout', e => this._layers.forEach(layer => layer.movePointer('mouseout', e))); + this._leafletMap.on('mousedown', e => this._layers.forEach(layer => layer.movePointer('mousedown', e))); + this._leafletMap.on('mouseup', e => this._layers.forEach(layer => layer.movePointer('mouseup', e))); + this._leafletMap.on('draw:created', event => { + const drawType = event.layerType; + if (drawType === 'rectangle') { + const bounds = event.layer.getBounds(); + + const southEast = bounds.getSouthEast(); + const northWest = bounds.getNorthWest(); + let southEastLng = southEast.lng; + if (southEastLng > 180) { + southEastLng -= 360; + } + let northWestLng = northWest.lng; + if (northWestLng < -180) { + northWestLng += 360; + } + + const southEastLat = southEast.lat; + const northWestLat = northWest.lat; + + //Bounds cannot be created unless they form a box with larger than 0 dimensions + //Invalid areas are rejected by ES. + if (southEastLat === northWestLat || southEastLng === northWestLng) { + return; + } + + this.emit('drawCreated:rectangle', { + bounds: { + bottom_right: { + lat: southEastLat, + lon: southEastLng + }, + top_left: { + lat: northWestLat, + lon: northWestLng + } + } + }); + } else if (drawType === 'polygon') { + const latLongs = event.layer.getLatLngs(); + this.emit('drawCreated:polygon', { + points: latLongs.map(leafletLatLng => { + return { + lat: leafletLatLng.lat, + lon: leafletLatLng.lng + }; + }) + }); + } + }); + + this.resize(); + + } + + setShowTooltip(showTooltip) { + this._showTooltip = showTooltip; + } + + getLayers() { + return this._layers.slice(); + } + + + addLayer(kibanaLayer) { + + + this.emit('layers:invalidate'); + + const onshowTooltip = (event) => { + + if (!this._showTooltip) { + return; + } + + if (!this._popup) { + this._popup = L.popup({ autoPan: false }); + this._popup.setLatLng(event.position); + this._popup.setContent(event.content); + this._popup.openOn(this._leafletMap); + } else { + if (!this._popup.getLatLng().equals(event.position)) { + this._popup.setLatLng(event.position); + } + if (this._popup.getContent() !== event.content) { + this._popup.setContent(event.content); + } + } + + + }; + + kibanaLayer.on('showTooltip', onshowTooltip); + this._listeners.push({ name: 'showTooltip', handle: onshowTooltip, layer: kibanaLayer }); + + const onHideTooltip = () => { + this._leafletMap.closePopup(); + this._popup = null; + }; + kibanaLayer.on('hideTooltip', onHideTooltip); + this._listeners.push({ name: 'hideTooltip', handle: onHideTooltip, layer: kibanaLayer }); + + + const onStyleChanged = () => { + if (this._leafletLegendControl) { + this._leafletLegendControl.updateContents(); + } + }; + kibanaLayer.on('styleChanged', onStyleChanged); + this._listeners.push({ name: 'styleChanged', handle: onStyleChanged, layer: kibanaLayer }); + + this._layers.push(kibanaLayer); + kibanaLayer.addToLeafletMap(this._leafletMap); + this.emit('layers:update'); + } + + + isReady() { + return this._layers.every(layer => layer.isReady()); + } + + removeLayer(layer) { + const index = this._layers.indexOf(layer); + if (index >= 0) { + this._layers.splice(index, 1); + layer.removeFromLeafletMap(this._leafletMap); + } + this._listeners.forEach(listener => { + if (listener.layer === layer) { + listener.layer.removeListener(listener.name, listener.handle); + } + }); + } + + destroy() { + if (this._leafletFitControl) { + this._leafletMap.removeControl(this._leafletFitControl); + } + if (this._leafletDrawControl) { + this._leafletMap.removeControl(this._leafletDrawControl); + } + if (this._leafletLegendControl) { + this._leafletMap.removeControl(this._leafletLegendControl); + } + this.setBaseLayer(null); + for (const layer of this._layers) { + layer.removeFromLeafletMap(this._leafletMap); + } + this._leafletMap.remove(); + this._containerNode.innerHTML = ''; + this._listeners.forEach(listener => listener.layer.removeListener(listener.name, listener.handle)); + } + + getCenter() { + const center = this._leafletMap.getCenter(); + return { lon: center.lng, lat: center.lat }; + } + + setCenter(latitude, longitude) { + const latLong = L.latLng(latitude, longitude); + if (latLong.equals && !latLong.equals(this._leafletMap.getCenter())) { + this._leafletMap.setView(latLong); + } + } + + setZoomLevel(zoomLevel) { + if (this._leafletMap.getZoom() !== zoomLevel) { + this._leafletMap.setZoom(zoomLevel); + } + } + + getZoomLevel() { + return this._leafletMap.getZoom(); + } + + getAutoPrecision() { + return zoomToPrecision(this._leafletMap.getZoom(), 12, this._leafletMap.getMaxZoom()); + } + + getBounds() { + + const bounds = this._leafletMap.getBounds(); + if (!bounds) { + return null; + } + + const southEast = bounds.getSouthEast(); + const northWest = bounds.getNorthWest(); + let southEastLng = southEast.lng; + if (southEastLng > 180) { + southEastLng -= 360; + } + let northWestLng = northWest.lng; + if (northWestLng < -180) { + northWestLng += 360; + } + + const southEastLat = southEast.lat; + const northWestLat = northWest.lat; + + //Bounds cannot be created unless they form a box with larger than 0 dimensions + //Invalid areas are rejected by ES. + if (southEastLat === northWestLat || southEastLng === northWestLng) { + return; + } + + return { + bottom_right: { + lat: southEastLat, + lon: southEastLng + }, + top_left: { + lat: northWestLat, + lon: northWestLng + } + }; + } + + + setDesaturateBaseLayer(isDesaturated) { + if (isDesaturated === this._baseLayerIsDesaturated) { + return; + } + this._baseLayerIsDesaturated = isDesaturated; + this._updateDesaturation(); + this._leafletBaseLayer.redraw(); + } + + addDrawControl() { + const drawOptions = { + draw: { + polyline: false, + marker: false, + circle: false, + polygon: false, + rectangle: { + shapeOptions: { + stroke: false, + color: '#000' + } + } + } + }; + this._leafletDrawControl = new L.Control.Draw(drawOptions); + this._leafletMap.addControl(this._leafletDrawControl); + } + + addFitControl() { + + if (this._leafletFitControl || !this._leafletMap) { + return; + } + + const fitContainer = L.DomUtil.create('div', 'leaflet-control leaflet-bar leaflet-control-fit'); + this._leafletFitControl = new FitControl(fitContainer, this); + this._leafletMap.addControl(this._leafletFitControl); + } + + addLegendControl() { + if (this._leafletLegendControl || !this._leafletMap) { + return; + } + this._updateLegend(); + } + + setLegendPosition(position) { + this._legendPosition = position; + if (this._leafletLegendControl) { + this._leafletMap.removeControl(this._leafletLegendControl); + this._updateLegend(); + } + } + + _updateLegend() { + const $wrapper = $('
').addClass('tilemap-legend-wrapper'); + this._leafletLegendControl = new LegendControl($wrapper, this, this._legendPosition); + this._leafletMap.addControl(this._leafletLegendControl); + } + + resize() { + this._leafletMap.invalidateSize(); + this._updateExtent(); + } + + + setBaseLayer(settings) { + + if (_.isEqual(settings, this._baseLayerSettings)) { + return; + } + + this._baseLayerSettings = settings; + if (settings === null) { + if (this._leafletBaseLayer && this._leafletMap) { + this._leafletMap.removeLayer(this._leafletBaseLayer); + this._leafletBaseLayer = null; + } + return; + } + + if (this._leafletBaseLayer) { + this._leafletMap.removeLayer(this._leafletBaseLayer); + this._leafletBaseLayer = null; + } + + let baseLayer; + if (settings.baseLayerType === 'wms') { + baseLayer = this._getWMSBaseLayer(settings.options); + } else if (settings.baseLayerType === 'tms') { + baseLayer = this._getTMSBaseLayer((settings.options)); + } + + baseLayer.on('tileload', () => this._updateDesaturation()); + baseLayer.on('load', () => { this.emit('baseLayer:loaded');}); + baseLayer.on('loading', () => {this.emit('baseLayer:loading');}); + + this._leafletBaseLayer = baseLayer; + this._leafletBaseLayer.addTo(this._leafletMap); + this._leafletBaseLayer.bringToBack(); + this.resize(); + + } + + isInside(bucketRectBounds) { + const mapBounds = this._leafletMap.getBounds(); + return mapBounds.intersects(bucketRectBounds); + } + + fitToData() { + + if (!this._leafletMap) { + return; + } + + let bounds = null; + this._layers.forEach(layer => { + const leafletLayer = layer.getLeafletLayer(); + const b = leafletLayer.getBounds(); + if (bounds) { + bounds.extend(b); + } else { + bounds = b; + } + }); + + if (bounds) { + this._leafletMap.fitBounds(bounds); + } + } + + _getTMSBaseLayer(options) { + return L.tileLayer(options.url, { + minZoom: options.minZoom, + maxZoom: options.maxZoom, + subdomains: options.subdomains, + attribution: options.attribution + }); + } + + _getWMSBaseLayer(options) { + return L.tileLayer.wms(options.url, { + attribution: options.attribution, + format: options.format, + layers: options.layers, + minZoom: options.minZoom, + maxZoom: options.maxZoom, + styles: options.styles, + transparent: options.transparent, + version: options.version + }); + } + + _updateExtent() { + this._layers.forEach(layer => layer.updateExtent()); + } + + _updateDesaturation() { + const tiles = $('img.leaflet-tile-loaded'); + if (this._baseLayerIsDesaturated) { + tiles.removeClass('filters-off'); + } else if (!this._baseLayerIsDesaturated) { + tiles.addClass('filters-off'); + } + } + + persistUiStateForVisualization(visualization) { + this.on('moveend', () => { + const uiState = visualization.getUiState(); + const centerFromUIState = uiState.get('mapCenter'); + const zoomFromUiState = parseInt(uiState.get('mapZoom')); + if (isNaN(zoomFromUiState) || this.getZoomLevel() !== zoomFromUiState) { + uiState.set('mapZoom', this.getZoomLevel()); + } + const centerFromMap = this.getCenter(); + if (!centerFromUIState || centerFromMap.lon !== centerFromUIState[1] || centerFromMap.lat !== centerFromUIState[0]) { + uiState.set('mapCenter', [centerFromMap.lat, centerFromMap.lon]); + } + }); + } + + useUiStateFromVisualization(visualization) { + const uiState = visualization.getUiState(); + const zoomFromUiState = parseInt(uiState.get('mapZoom')); + const centerFromUIState = uiState.get('mapCenter'); + if (!isNaN(zoomFromUiState)) { + this.setZoomLevel(zoomFromUiState); + } + if (centerFromUIState) { + this.setCenter(centerFromUIState[0], centerFromUIState[1]); + } + } + + +} + + + + +export default KibanaMap; + diff --git a/src/ui/public/vis_maps/kibana_map_layer.js b/src/ui/public/vis_maps/kibana_map_layer.js new file mode 100644 index 0000000000000..97a068ca25013 --- /dev/null +++ b/src/ui/public/vis_maps/kibana_map_layer.js @@ -0,0 +1,40 @@ +import { EventEmitter } from 'events'; + + +export default class KibanaMapLayer extends EventEmitter { + constructor() { + super(); + this._leafletLayer = null; + } + getLeafletLayer() { + return this._leafletLayer; + } + + addToLeafletMap(leafletMap) { + this._leafletLayer.addTo(leafletMap); + } + + removeFromLeafletMap(leafletMap) { + leafletMap.removeLayer(this._leafletLayer); + } + + appendLegendContents() { + } + + updateExtent() { + } + + movePointer() { + } + + getBounds() { + } +} + + + + + + + + diff --git a/src/ui/public/vis_maps/lib/data.js b/src/ui/public/vis_maps/lib/data.js deleted file mode 100644 index fea6e2363dbd8..0000000000000 --- a/src/ui/public/vis_maps/lib/data.js +++ /dev/null @@ -1,200 +0,0 @@ -import d3 from 'd3'; -import _ from 'lodash'; -export default function DataFactory() { - /** - * Provides an API for pulling values off the data - * and calculating values using the data - * - * @class Data - * @constructor - * @param data {Object} Elasticsearch query results - * @param attr {Object|*} Visualization options - */ - class Data { - constructor(data, uiState) { - this.uiState = uiState; - this.data = this.copyDataObj(data); - this._normalizeOrdered(); - } - - copyDataObj(data) { - const copyChart = data => { - const newData = {}; - Object.keys(data).forEach(key => { - if (key !== 'series') { - newData[key] = data[key]; - } else { - newData[key] = data[key].map(seri => { - return { - label: seri.label, - values: seri.values.map(val => { - const newVal = _.clone(val); - newVal.aggConfig = val.aggConfig; - newVal.aggConfigResult = val.aggConfigResult; - newVal.extraMetrics = val.extraMetrics; - return newVal; - }) - }; - }); - } - }); - return newData; - }; - - if (!data.series) { - const newData = {}; - Object.keys(data).forEach(key => { - if (!['rows', 'columns'].includes(key)) { - newData[key] = data[key]; - } - else { - newData[key] = data[key].map(chart => { - return copyChart(chart); - }); - } - }); - return newData; - } - return copyChart(data); - } - - /** - * Returns an array of the actual x and y data value objects - * from data with series keys - * - * @method chartData - * @returns {*} Array of data objects - */ - chartData() { - if (!this.data.series) { - const arr = this.data.rows ? this.data.rows : this.data.columns; - return _.toArray(arr); - } - return [this.data]; - } - - /** - * Returns an array of chart data objects - * - * @method getVisData - * @returns {*} Array of chart data objects - */ - getVisData() { - let visData; - - if (this.data.rows) { - visData = this.data.rows; - } else if (this.data.columns) { - visData = this.data.columns; - } else { - visData = [this.data]; - } - - return visData; - } - - /** - * get min and max for all cols, rows of data - * - * @method getMaxMin - * @return {Object} - */ - getGeoExtents() { - const visData = this.getVisData(); - - return _.reduce(_.pluck(visData, 'geoJson.properties'), function (minMax, props) { - return { - min: Math.min(props.min, minMax.min), - max: Math.max(props.max, minMax.max) - }; - }, { min: Infinity, max: -Infinity }); - } - - /** - * Get attributes off the data, e.g. `tooltipFormatter` or `xAxisFormatter` - * pulls the value off the first item in the array - * these values are typically the same between data objects of the same chart - * TODO: May need to verify this or refactor - * - * @method get - * @param thing {String} Data object key - * @returns {*} Data object value - */ - get(thing, def) { - const source = (this.data.rows || this.data.columns || [this.data])[0]; - return _.get(source, thing, def); - } - - /** - * Return an array of all value objects - * Pluck the data.series array from each data object - * Create an array of all the value objects from the series array - * - * @method flatten - * @returns {Array} Value objects - */ - flatten() { - return _(this.chartData()) - .pluck('series') - .flattenDeep() - .pluck('values') - .flattenDeep() - .value(); - } - - /** - * ensure that the datas ordered property has a min and max - * if the data represents an ordered date range. - * - * @return {undefined} - */ - _normalizeOrdered() { - const data = this.getVisData(); - const self = this; - - data.forEach(function (d) { - if (!d.ordered || !d.ordered.date) return; - - const missingMin = d.ordered.min == null; - const missingMax = d.ordered.max == null; - - if (missingMax || missingMin) { - const extent = d3.extent(self.xValues()); - if (missingMin) d.ordered.min = extent[0]; - if (missingMax) d.ordered.max = extent[1]; - } - }); - } - - /** - * Calculates min and max values for all map data - * series.rows is an array of arrays - * each row is an array of values - * last value in row array is bucket count - * - * @method mapDataExtents - * @param series {Array} Array of data objects - * @returns {Array} min and max values - */ - mapDataExtents(series) { - const values = _.map(series.rows, function (row) { - return row[row.length - 1]; - }); - return [_.min(values), _.max(values)]; - } - - /** - * Get the maximum number of series, considering each chart - * individually. - * - * @return {number} - the largest number of series from all charts - */ - maxNumberOfSeries() { - return this.chartData().reduce(function (max, chart) { - return Math.max(max, chart.series.length); - }, 0); - } - } - - return Data; -} diff --git a/src/ui/public/vis_maps/lib/dispatch.js b/src/ui/public/vis_maps/lib/dispatch.js deleted file mode 100644 index 68eec629892d9..0000000000000 --- a/src/ui/public/vis_maps/lib/dispatch.js +++ /dev/null @@ -1,299 +0,0 @@ -import d3 from 'd3'; -import _ from 'lodash'; -import $ from 'jquery'; -import SimpleEmitter from 'ui/utils/simple_emitter'; - -export default function DispatchClass(Private, config) { - - /** - * Handles event responses - * - * @class Dispatch - * @constructor - * @param handler {Object} Reference to Handler Class Object - */ - - class Dispatch extends SimpleEmitter { - constructor(handler) { - super(); - this.handler = handler; - this._listeners = {}; - } - - /** - * Response to click and hover events - * - * @param d {Object} Data point - * @param i {Number} Index number of data point - * @returns {{value: *, point: *, label: *, color: *, pointIndex: *, - * series: *, config: *, data: (Object|*), - * e: (d3.event|*), handler: (Object|*)}} Event response object - */ - eventResponse(d, i) { - const datum = d._input || d; - const data = d3.event.target.nearestViewportElement ? - d3.event.target.nearestViewportElement.__data__ : d3.event.target.__data__; - const label = d.label ? d.label : (d.series || 'Count'); - const isSeries = !!(data && data.series); - const isSlices = !!(data && data.slices); - const series = isSeries ? data.series : undefined; - const slices = isSlices ? data.slices : undefined; - const handler = this.handler; - const color = _.get(handler, 'data.color'); - const isPercentage = (handler && handler.visConfig.get('mode', 'normal') === 'percentage'); - - const eventData = { - value: d.y, - point: datum, - datum: datum, - label: label, - color: color ? color(label) : undefined, - pointIndex: i, - series: series, - slices: slices, - config: handler && handler.visConfig, - data: data, - e: d3.event, - handler: handler - }; - - if (isSeries) { - // Find object with the actual d value and add it to the point object - const object = _.find(series, { 'label': label }); - if (object) { - eventData.value = +object.values[i].y; - - if (isPercentage) { - // Add the formatted percentage to the point object - eventData.percent = (100 * d.y).toFixed(1) + '%'; - } - } - } - - return eventData; - } - - /** - * Returns a function that adds events and listeners to a D3 selection - * - * @method addEvent - * @param event {String} - * @param callback {Function} - * @returns {Function} - */ - addEvent(event, callback) { - return function (selection) { - selection.each(function () { - const element = d3.select(this); - - if (typeof callback === 'function') { - return element.on(event, callback); - } - }); - }; - } - - /** - * - * @method addHoverEvent - * @returns {Function} - */ - addHoverEvent() { - const self = this; - const isClickable = this.listenerCount('click') > 0; - const addEvent = this.addEvent; - const $el = this.handler.el; - if (!this.handler.highlight) { - this.handler.highlight = self.highlight; - } - - function hover(d, i) { - // Add pointer if item is clickable - if (isClickable) { - self.addMousePointer.call(this, arguments); - } - - self.handler.highlight.call(this, $el); - self.emit('hover', self.eventResponse(d, i)); - } - - return addEvent('mouseover', hover); - } - - /** - * - * @method addMouseoutEvent - * @returns {Function} - */ - addMouseoutEvent() { - const self = this; - const addEvent = this.addEvent; - const $el = this.handler.el; - if (!this.handler.unHighlight) { - this.handler.unHighlight = self.unHighlight; - } - - function mouseout() { - self.handler.unHighlight.call(this, $el); - } - - return addEvent('mouseout', mouseout); - } - - /** - * - * @method addClickEvent - * @returns {Function} - */ - addClickEvent() { - const self = this; - const addEvent = this.addEvent; - - function click(d, i) { - self.emit('click', self.eventResponse(d, i)); - } - - return addEvent('click', click); - } - - /** - * Determine if we will allow brushing - * - * @method allowBrushing - * @returns {Boolean} - */ - allowBrushing() { - const xAxis = this.handler.categoryAxes[0]; - - //Allow brushing for ordered axis - date histogram and histogram - return Boolean(xAxis.ordered); - } - - /** - * Determine if brushing is currently enabled - * - * @method isBrushable - * @returns {Boolean} - */ - isBrushable() { - return this.allowBrushing() && this.listenerCount('brush') > 0; - } - - /** - * Mouseover Behavior - * - * @method addMousePointer - * @returns {d3.Selection} - */ - addMousePointer() { - return d3.select(this).style('cursor', 'pointer'); - } - - /** - * Highlight the element that is under the cursor - * by reducing the opacity of all the elements on the graph. - * @param element {d3.Selection} - * @method highlight - */ - highlight(element) { - const label = this.getAttribute('data-label'); - if (!label) return; - - const dimming = config.get('visualization:dimmingOpacity'); - $(element).parent().find('[data-label]') - .css('opacity', 1)//Opacity 1 is needed to avoid the css application - .not((els, el) => String($(el).data('label')) === label) - .css('opacity', justifyOpacity(dimming)); - } - - /** - * Mouseout Behavior - * - * @param element {d3.Selection} - * @method unHighlight - */ - unHighlight(element) { - $('[data-label]', element.parentNode).css('opacity', 1); - } - - /** - * Adds D3 brush to SVG and returns the brush function - * - * @param xScale {Function} D3 xScale function - * @param svg {HTMLElement} Reference to SVG - * @returns {*} Returns a D3 brush function and a SVG with a brush group attached - */ - createBrush(xScale, svg) { - const self = this; - const visConfig = self.handler.visConfig; - const { width, height } = svg.node().getBBox(); - const isHorizontal = self.handler.categoryAxes[0].axisConfig.isHorizontal(); - - // Brush scale - const brush = d3.svg.brush(); - if (isHorizontal) { - brush.x(xScale); - } else { - brush.y(xScale); - } - - brush.on('brushend', function brushEnd() { - - // Assumes data is selected at the chart level - // In this case, the number of data objects should always be 1 - const data = d3.select(this).data()[0]; - const isTimeSeries = (data.ordered && data.ordered.date); - - // Allows for brushing on d3.scale.ordinal() - const selected = xScale.domain().filter(function (d) { - return (brush.extent()[0] <= xScale(d)) && (xScale(d) <= brush.extent()[1]); - }); - const range = isTimeSeries ? brush.extent() : selected; - - return self.emit('brush', { - range: range, - config: visConfig, - e: d3.event, - data: data - }); - }); - - // if `addBrushing` is true, add brush canvas - if (self.listenerCount('brush')) { - const rect = svg.insert('g', 'g') - .attr('class', 'brush') - .call(brush) - .call(function (brushG) { - // hijack the brush start event to filter out right/middle clicks - const brushHandler = brushG.on('mousedown.brush'); - if (!brushHandler) return; // touch events in use - brushG.on('mousedown.brush', function () { - if (validBrushClick(d3.event)) brushHandler.apply(this, arguments); - }); - }) - .selectAll('rect'); - - if (isHorizontal) { - rect.attr('height', height); - } else { - rect.attr('width', width); - } - - return brush; - } - } - } - - function validBrushClick(event) { - return event.button === 0; - } - - - function justifyOpacity(opacity) { - const decimalNumber = parseFloat(opacity, 10); - const fallbackOpacity = 0.5; - return (0 <= decimalNumber && decimalNumber <= 1) ? decimalNumber : fallbackOpacity; - } - - return Dispatch; -} diff --git a/src/ui/public/vis_maps/lib/layout.js b/src/ui/public/vis_maps/lib/layout.js deleted file mode 100644 index 24402f960c136..0000000000000 --- a/src/ui/public/vis_maps/lib/layout.js +++ /dev/null @@ -1,50 +0,0 @@ -import d3 from 'd3'; -import MapSplitProvider from './splits/map_split'; - -export default function LayoutFactory(Private) { - const mapSplit = Private(MapSplitProvider); - class Layout { - constructor(el, config, data) { - this.el = el; - this.config = config; - this.data = data; - } - - render() { - this.removeAll(); - this.createLayout(); - } - - createLayout() { - const wrapper = this.appendElem(this.el, 'div', 'vis-wrapper'); - wrapper.datum(this.data.data); - const colWrapper = this.appendElem(wrapper.node(), 'div', 'vis-col-wrapper'); - const chartWrapper = this.appendElem(colWrapper.node(), 'div', 'chart-wrapper'); - chartWrapper.call(mapSplit, colWrapper.node(), this.config); - } - - appendElem(el, type, className) { - if (!el || !type || !className) { - throw new Error('Function requires that an el, type, and class be provided'); - } - - if (typeof el === 'string') { - // Create a DOM reference with a d3 selection - // Need to make sure that the `el` is bound to this object - // to prevent it from being appended to another Layout - el = d3.select(this.el) - .select(el)[0][0]; - } - - return d3.select(el) - .append(type) - .attr('class', className); - } - - removeAll() { - return d3.select(this.el).selectAll('*').remove(); - } - } - - return Layout; -} diff --git a/src/ui/public/vis_maps/lib/maps_config.js b/src/ui/public/vis_maps/lib/maps_config.js deleted file mode 100644 index 3e5a036a4ca55..0000000000000 --- a/src/ui/public/vis_maps/lib/maps_config.js +++ /dev/null @@ -1,38 +0,0 @@ -/** - * Provides vislib configuration, throws error if invalid property is accessed without providing defaults - */ -import _ from 'lodash'; - -export default function MapsConfigFactory() { - - const DEFAULT_VIS_CONFIG = { - style: { - margin : { top: 10, right: 3, bottom: 5, left: 3 } - }, - alerts: {}, - categoryAxes: [], - valueAxes: [] - }; - - - class MapsConfig { - constructor(mapsConfigArgs) { - this._values = _.defaultsDeep({}, mapsConfigArgs, DEFAULT_VIS_CONFIG); - } - - get(property, defaults) { - if (_.has(this._values, property) || typeof defaults !== 'undefined') { - return _.get(this._values, property, defaults); - } else { - throw new Error(`Accessing invalid config property: ${property}`); - return defaults; - } - } - - set(property, value) { - return _.set(this._values, property, value); - } - } - - return MapsConfig; -} diff --git a/src/ui/public/vis_maps/lib/splits/map_split.js b/src/ui/public/vis_maps/lib/splits/map_split.js deleted file mode 100644 index 710c77083927a..0000000000000 --- a/src/ui/public/vis_maps/lib/splits/map_split.js +++ /dev/null @@ -1,52 +0,0 @@ -import d3 from 'd3'; -define(function () { - return function ChartSplitFactory() { - - /* - * Adds div DOM elements to the `.chart-wrapper` element based on the data layout. - * For example, if the data has rows, it returns the same number of - * `.chart` elements as row objects. - */ - return function split(selection) { - selection.each(function (data) { - const div = d3.select(this) - .attr('class', function () { - // Determine the parent class - if (data.rows) { - return 'chart-wrapper-row'; - } else if (data.columns) { - return 'chart-wrapper-column'; - } else { - return 'chart-wrapper'; - } - }); - let divClass; - - const charts = div.selectAll('charts') - .append('div') - .data(function (d) { - // Determine the child class - if (d.rows) { - divClass = 'chart-row'; - return d.rows; - } else if (d.columns) { - divClass = 'chart-column'; - return d.columns; - } else { - divClass = 'chart'; - return [d]; - } - }) - .enter() - .append('div') - .attr('class', function () { - return divClass; - }); - - if (!data.geoJson) { - charts.call(split); - } - }); - }; - }; -}); diff --git a/src/ui/public/vis_maps/lib/tilemap_settings.js b/src/ui/public/vis_maps/lib/tilemap_settings.js index 0cda8efd7444e..610c4e480f197 100644 --- a/src/ui/public/vis_maps/lib/tilemap_settings.js +++ b/src/ui/public/vis_maps/lib/tilemap_settings.js @@ -1,7 +1,6 @@ import uiModules from 'ui/modules'; import _ from 'lodash'; import marked from 'marked'; -import uiRoutes from 'ui/routes'; import { modifyUrl } from 'ui/url'; marked.setOptions({ @@ -9,16 +8,6 @@ marked.setOptions({ sanitize: true // Sanitize HTML tags }); -/** - * Reloads the setting for each route, - * This is to ensure, that if the license changed during the lifecycle of the application, - * we get an update. - * tilemapSettings itself will take care that the manifest-service is not queried when not necessary. - */ -uiRoutes.afterSetupWork(function (tilemapSettings) { - return tilemapSettings.loadSettings(); -}); - uiModules.get('kibana') .service('tilemapSettings', function ($http, tilemapsConfig, $sanitize, kbnVersion) { const attributionFromConfig = $sanitize(marked(tilemapsConfig.deprecated.config.options.attribution || '')); @@ -190,6 +179,10 @@ uiModules.get('kibana') } + isInitialized() { + return this._settingsInitialized; + } + /** * Checks if there was an error during initialization of the parameters diff --git a/src/ui/public/vis_maps/maps.js b/src/ui/public/vis_maps/maps.js deleted file mode 100644 index 5a23cf13a48d5..0000000000000 --- a/src/ui/public/vis_maps/maps.js +++ /dev/null @@ -1,123 +0,0 @@ -import _ from 'lodash'; -import $ from 'jquery'; -import d3 from 'd3'; -import MapsConfigProvider from './lib/maps_config'; -import TileMapChartProvider from './visualizations/tile_map'; -import EventsProvider from 'ui/events'; -import MapsDataProvider from './lib/data'; -import LayoutProvider from './lib/layout'; -import './styles/_tilemap.less'; - -export default function MapsFactory(Private) { - const Events = Private(EventsProvider); - const MapsConfig = Private(MapsConfigProvider); - const TileMapChart = Private(TileMapChartProvider); - const Data = Private(MapsDataProvider); - const Layout = Private(LayoutProvider); - - class Maps extends Events { - constructor($el, vis, mapsConfigArgs) { - super(arguments); - this.el = $el.get ? $el.get(0) : $el; - this.vis = vis; - this.mapsConfigArgs = mapsConfigArgs; - - // memoize so that the same function is returned every time, - // allowing us to remove/re-add the same function - this.getProxyHandler = _.memoize(function (event) { - const self = this; - return function (e) { - self.emit(event, e); - }; - }); - - this.enable = this.chartEventProxyToggle('on'); - this.disable = this.chartEventProxyToggle('off'); - } - - chartEventProxyToggle(method) { - return function (event, chart) { - const proxyHandler = this.getProxyHandler(event); - - _.each(chart ? [chart] : this.charts, function (chart) { - chart.events[method](event, proxyHandler); - }); - }; - } - - on(event, listener) { - const first = this.listenerCount(event) === 0; - const ret = Events.prototype.on.call(this, event, listener); - const added = this.listenerCount(event) > 0; - - // if this is the first listener added for the event - // enable the event in the handler - if (first && added && this.handler) this.handler.enable(event); - - return ret; - } - - off(event, listener) { - const last = this.listenerCount(event) === 1; - const ret = Events.prototype.off.call(this, event, listener); - const removed = this.listenerCount(event) === 0; - - // Once all listeners are removed, disable the events in the handler - if (last && removed && this.handler) this.handler.disable(event); - return ret; - } - - render(data, uiState) { - if (!data) { - throw new Error('No valid data!'); - } - - this.uiState = uiState; - this.data = new Data(data, this.uiState); - this.visConfig = new MapsConfig(this.mapsConfigArgs, this.data, this.uiState); - this.layout = new Layout(this.el, this.visConfig, this.data); - this.draw(); - } - - destroy() { - this.charts.forEach(chart => chart.destroy()); - d3.select(this.el).selectAll('*').remove(); - } - - draw() { - // Destroy the charts before they get removed from the DOM on the new - // layout render. - if(this.charts !== undefined) { - this.charts.forEach(chart => chart.destroy()); - } - - this.layout.render(); - const self = this; - this.charts = []; - - - let loadedCount = 0; - const chartSelection = d3.select(this.el).selectAll('.chart'); - chartSelection.each(function (chartData) { - const chart = new TileMapChart(self, this, chartData); - - self.activeEvents().forEach(function (event) { - self.enable(event, chart); - }); - - self.charts.push(chart); - chart.render(); - - chart.events.on('rendered', function () { - loadedCount++; - if (loadedCount === chartSelection.length) { - $(self.el).trigger('renderComplete'); - } - }); - }); - } - - } - - return Maps; -} diff --git a/src/ui/public/vis_maps/maps_renderbot.js b/src/ui/public/vis_maps/maps_renderbot.js index 19a3cd7921655..8c1acee917b88 100644 --- a/src/ui/public/vis_maps/maps_renderbot.js +++ b/src/ui/public/vis_maps/maps_renderbot.js @@ -1,86 +1,238 @@ +import $ from 'jquery'; import _ from 'lodash'; -import MapsProvider from 'ui/vis_maps/maps'; import VisRenderbotProvider from 'ui/vis/renderbot'; import MapsVisTypeBuildChartDataProvider from 'ui/vislib_vis_type/build_chart_data'; +import FilterBarPushFilterProvider from 'ui/filter_bar/push_filter'; +import KibanaMap from './kibana_map'; +import GeohashLayer from './geohash_layer'; +import './lib/tilemap_settings'; +import './styles/_tilemap.less'; +import { ResizeCheckerProvider } from 'ui/resize_checker'; -module.exports = function MapsRenderbotFactory(Private, $injector, tilemapSettings, Notifier) { - const AngularPromise = $injector.get('Promise'); - const Maps = Private(MapsProvider); + +module.exports = function MapsRenderbotFactory(Private, $injector, tilemapSettings, Notifier, courier, getAppState) { + + const ResizeChecker = Private(ResizeCheckerProvider); const Renderbot = Private(VisRenderbotProvider); const buildChartData = Private(MapsVisTypeBuildChartDataProvider); - const notify = new Notifier({ - location: 'Tilemap' - }); - - _.class(MapsRenderbot).inherits(Renderbot); - function MapsRenderbot(vis, $el, uiState) { - MapsRenderbot.Super.call(this, vis, $el, uiState); - this._createVis(); - } + const notify = new Notifier({ location: 'Tilemap' }); + + class MapsRenderbot extends Renderbot { + + constructor(vis, $el, uiState) { + super(vis, $el, uiState); + this._buildChartData = buildChartData.bind(this); + this._geohashLayer = null; + this._kibanaMap = null; + this._kibanaMapReady = this._makeKibanaMap($el); + + this._baseLayerDirty = true; + this._dataDirty = true; + this._paramsDirty = true; + + + this._resizeChecker = new ResizeChecker($el); + this._resizeChecker.on('resize', () => { + if (this._kibanaMap) { + this._kibanaMap.resize(); + } + }); + } + + async _makeKibanaMap($el) { + + if (!tilemapSettings.isInitialized()) { + await tilemapSettings.loadSettings(); + } + + if (tilemapSettings.getError()) { + //Still allow the visualization to be built, but show a toast that there was a problem retrieving map settings + //Even though the basemap will not display, the user will at least still see the overlay data + notify.warning(tilemapSettings.getError().message); + } + + const containerElement = $($el)[0]; + const minMaxZoom = tilemapSettings.getMinMaxZoom(false); + this._kibanaMap = new KibanaMap(containerElement, minMaxZoom); + this._kibanaMap.addDrawControl(); + this._kibanaMap.addFitControl(); + this._kibanaMap.addLegendControl(); + + this._kibanaMap.persistUiStateForVisualization(this.vis); + this._kibanaMap.useUiStateFromVisualization(this.vis); + + let previousPrecision = this._kibanaMap.getAutoPrecision(); + let precisionChange = false; + this._kibanaMap.on('zoomchange', () => { + precisionChange = (previousPrecision !== this._kibanaMap.getAutoPrecision()); + previousPrecision = this._kibanaMap.getAutoPrecision(); + }); + this._kibanaMap.on('zoomend', () => { + + const isAutoPrecision = _.get(this._chartData, 'geohashGridAgg.params.autoPrecision', true); + if (!isAutoPrecision) { + return; + } + + this._dataDirty = true; + if (precisionChange) { + courier.fetch(); + } else { + this._recreateGeohashLayer(); + this._dataDirty = false; + this._doRenderComplete(); + } + }); + + + this._kibanaMap.on('drawCreated:rectangle', event => { + addSpatialFilter(_.get(this._chartData, 'geohashGridAgg'), 'geo_bounding_box', event.bounds); + }); + this._kibanaMap.on('baseLayer:loaded', () => { + this._baseLayerDirty = false; + this._doRenderComplete(); + }); + this._kibanaMap.on('baseLayer:loading', () => { + this._baseLayerDirty = true; + }); + } - MapsRenderbot.prototype._createVis = function () { - if (tilemapSettings.getError()) { - //Still allow the visualization to be build, but show a toast that there was a problem retrieving map settings - //Even though the basemap will not display, the user will at least still see the overlay data - notify.warning(tilemapSettings.getError().message); + _recreateGeohashLayer() { + if (this._geohashLayer) { + this._kibanaMap.removeLayer(this._geohashLayer); + } + if (!this._geohashGeoJson) { + return; + } + const geohashOptions = this._getGeohashOptions(); + this._geohashLayer = new GeohashLayer(this._chartData.geoJson, geohashOptions, this._kibanaMap.getZoomLevel(), this._kibanaMap); + this._kibanaMap.addLayer(this._geohashLayer); } - if (this.mapsVis) this.destroy(); - this.mapsParams = this._getMapsParams(); - this.mapsVis = new Maps(this.$el[0], this.vis, this.mapsParams); - _.each(this.vis.listeners, (listener, event) => { - this.mapsVis.on(event, listener); - }); - if (this.mapsData) { - this.mapsVis.render(this.mapsData, this.uiState); + /** + * called on data change + * @param esResponse + */ + render(esResponse) { + this._dataDirty = true; + this._kibanaMapReady.then(() => { + this._chartData = this._buildChartData(esResponse); + this._geohashGeoJson = this._chartData.geoJson; + this._recreateGeohashLayer(); + this._kibanaMap.useUiStateFromVisualization(this.vis); + this._kibanaMap.resize(); + this._dataDirty = false; + this._doRenderComplete(); + }); } - }; - MapsRenderbot.prototype._getMapsParams = function () { - const self = this; + destroy() { + if (this._kibanaMap) { + this._kibanaMap.destroy(); + } + } - return _.assign( - {}, - self.vis.type.params.defaults, - { - type: self.vis.type.name, - // Add attribute which determines whether an index is time based or not. - hasTimeField: self.vis.indexPattern && self.vis.indexPattern.hasTimeField() - }, - self.vis.params - ); - }; + /** + * called on options change (vis.params change) + */ + updateParams() { + + this._paramsDirty = true; + this._kibanaMapReady.then(() => { + const mapParams = this._getMapsParams(); + if (mapParams.wms.enabled) { + const { minZoom, maxZoom } = tilemapSettings.getMinMaxZoom(true); + this._kibanaMap.setBaseLayer({ + baseLayerType: 'wms', + options: { + minZoom: minZoom, + maxZoom: maxZoom, + url: mapParams.wms.url, + ...mapParams.wms.options + } + }); + } else { + if (!tilemapSettings.hasError()) { + const url = tilemapSettings.getUrl(); + const options = tilemapSettings.getTMSOptions(); + this._kibanaMap.setBaseLayer({ + baseLayerType: 'tms', + options: { url, ...options } + }); + } + } + const geohashOptions = this._getGeohashOptions(); + if (!this._geohashLayer || !this._geohashLayer.isReusable(geohashOptions)) { + this._recreateGeohashLayer(); + } + + this._kibanaMap.setDesaturateBaseLayer(mapParams.isDesaturated); + this._kibanaMap.setShowTooltip(mapParams.addTooltip); + this._kibanaMap.setLegendPosition(mapParams.legendPosition); + + this._kibanaMap.useUiStateFromVisualization(this.vis); + this._kibanaMap.resize(); + this._paramsDirty = false; + this._doRenderComplete(); + }); + } - MapsRenderbot.prototype.buildChartData = buildChartData; - MapsRenderbot.prototype.render = function (esResponse) { - this.mapsData = this.buildChartData(esResponse); - return AngularPromise.delay(1).then(() => { - this.mapsVis.render(this.mapsData, this.uiState); - }); - }; + _getMapsParams() { + return _.assign( + {}, + this.vis.type.params.defaults, + { + type: this.vis.type.name, + hasTimeField: this.vis.indexPattern && this.vis.indexPattern.hasTimeField()// Add attribute which determines whether an index is time based or not. + }, + this.vis.params + ); + } - MapsRenderbot.prototype.destroy = function () { - const self = this; + _getGeohashOptions() { + const newParams = this._getMapsParams(); + return { + valueFormatter: this._chartData ? this._chartData.valueFormatter : null, + tooltipFormatter: this._chartData ? this._chartData.tooltipFormatter : null, + mapType: newParams.mapType, + heatmap: { + heatBlur: newParams.heatBlur, + heatMaxZoom: newParams.heatMaxZoom, + heatMinOpacity: newParams.heatMinOpacity, + heatNormalizeData: newParams.heatNormalizeData, + heatRadius: newParams.heatRadius + } + }; + } - const mapsVis = self.mapsVis; + _doRenderComplete() { - _.forOwn(self.vis.listeners, function (listener, event) { - mapsVis.off(event, listener); - }); + if (this._paramsDirty || this._dataDirty || this._baseLayerDirty) { + return; + } + $(this.el).trigger('renderComplete'); + } - mapsVis.destroy(); - }; + } - MapsRenderbot.prototype.updateParams = function () { - const self = this; + function addSpatialFilter(agg, filterName, filterData) { + if (!agg) { + return; + } + + const indexPatternName = agg.vis.indexPattern.id; + const field = agg.fieldName(); + const filter = {}; + filter[filterName] = {}; + filter[filterName][field] = filterData; - // get full maps params object - const newParams = self._getMapsParams(); + const putFilter = Private(FilterBarPushFilterProvider)(getAppState()); + return putFilter(filter, false, indexPatternName); + } - // if there's been a change, replace the vis - if (!_.isEqual(newParams, self.mapsParams)) self._createVis(); - }; return MapsRenderbot; }; + + diff --git a/src/ui/public/vis_maps/markers/geohash_grid.js b/src/ui/public/vis_maps/markers/geohash_grid.js new file mode 100644 index 0000000000000..753125384c5ba --- /dev/null +++ b/src/ui/public/vis_maps/markers/geohash_grid.js @@ -0,0 +1,17 @@ +import L from 'leaflet'; +import ScaledCircles from './scaled_circles'; + +export default class GeohashGrid extends ScaledCircles { + getMarkerFunction() { + return function (feature) { + const geohashRect = feature.properties.rectangle; + // get bounds from northEast[3] and southWest[1] + // corners in geohash rectangle + const corners = [ + [geohashRect[3][0], geohashRect[3][1]], + [geohashRect[1][0], geohashRect[1][1]] + ]; + return L.rectangle(corners); + }; + } +} diff --git a/src/ui/public/vis_maps/markers/heatmap.js b/src/ui/public/vis_maps/markers/heatmap.js new file mode 100644 index 0000000000000..769899ff35096 --- /dev/null +++ b/src/ui/public/vis_maps/markers/heatmap.js @@ -0,0 +1,197 @@ +import L from 'leaflet'; +import _ from 'lodash'; +import d3 from 'd3'; +import { EventEmitter } from 'events'; + +/** + * Map overlay: canvas layer with leaflet.heat plugin + * + * @param map {Leaflet Object} + * @param geoJson {geoJson Object} + * @param params {Object} + */ +export default class Heatmap extends EventEmitter { + + constructor(featureCollection, options, zoom) { + + super(); + this._geojsonFeatureCollection = featureCollection; + const max = _.get(featureCollection, 'properties.max'); + const points = dataToHeatArray(max, options.heatNormalizeData, featureCollection); + this._leafletLayer = L.heatLayer(points, options); + this._tooltipFormatter = options.tooltipFormatter; + this._zoom = zoom; + this._disableTooltips = false; + this._getLatLng = _.memoize(function (feature) { + return L.latLng( + feature.geometry.coordinates[1], + feature.geometry.coordinates[0] + ); + }, function (feature) { + // turn coords into a string for the memoize cache + return [feature.geometry.coordinates[1], feature.geometry.coordinates[0]].join(','); + }); + + this._currentFeature = null; + this._addTooltips(); + } + + getBounds() { + return this._leafletLayer.getBounds(); + } + + getLeafletLayer() { + return this._leafletLayer; + } + + appendLegendContents() { + } + + + movePointer(type, event) { + if (type === 'mousemove') { + this._deboundsMoveMoveLocation(event); + } else if (type === 'mouseout') { + this.emit('hideTooltip'); + } else if (type === 'mousedown') { + this._disableTooltips = true; + this.emit('hideTooltip'); + } else if (type === 'mouseup') { + this._disableTooltips = false; + } + } + + + _addTooltips() { + + const mouseMoveLocation = (e) => { + + + + if (!this._geojsonFeatureCollection.features.length || this._disableTooltips) { + this.emit('hideTooltip'); + return; + } + + const feature = this._nearestFeature(e.latlng); + if (this._tooltipProximity(e.latlng, feature)) { + const content = this._tooltipFormatter(feature); + if (!content) { + return; + } + this.emit('showTooltip', { + content: content, + position: e.latlng + }); + } else { this.emit('hideTooltip'); + } + }; + + this._deboundsMoveMoveLocation = _.debounce(mouseMoveLocation.bind(this), 15, { + 'leading': true, + 'trailing': false + }); + } + + /** + * Finds nearest feature in mapData to event latlng + * + * @method _nearestFeature + * @param latLng {Leaflet latLng} + * @return nearestPoint {Leaflet latLng} + */ + _nearestFeature(latLng) { + const self = this; + let nearest; + + if (latLng.lng < -180 || latLng.lng > 180) { + return; + } + + _.reduce(this._geojsonFeatureCollection.features, function (distance, feature) { + const featureLatLng = self._getLatLng(feature); + const dist = latLng.distanceTo(featureLatLng); + + if (dist < distance) { + nearest = feature; + return dist; + } + + return distance; + }, Infinity); + + return nearest; + } + + /** + * display tooltip if feature is close enough to event latlng + * + * @method _tooltipProximity + * @param latlng {Leaflet latLng Object} + * @param feature {geoJson Object} + * @return {Boolean} + */ + _tooltipProximity(latlng, feature) { + if (!feature) return; + + let showTip = false; + const featureLatLng = this._getLatLng(feature); + + // zoomScale takes map zoom and returns proximity value for tooltip display + // domain (input values) is map zoom (min 1 and max 18) + // range (output values) is distance in meters + // used to compare proximity of event latlng to feature latlng + const zoomScale = d3.scale.linear() + .domain([1, 4, 7, 10, 13, 16, 18]) + .range([1000000, 300000, 100000, 15000, 2000, 150, 50]); + + const proximity = zoomScale(this._zoom); + const distance = latlng.distanceTo(featureLatLng); + + // maxLngDif is max difference in longitudes + // to prevent feature tooltip from appearing 360° + // away from event latlng + const maxLngDif = 40; + const lngDif = Math.abs(latlng.lng - featureLatLng.lng); + + if (distance < proximity && lngDif < maxLngDif) { + showTip = true; + } + + d3.scale.pow().exponent(0.2) + .domain([1, 18]) + .range([1500000, 50]); + return showTip; + } + +} + + + +/** + * returns data for data for heat map intensity + * if heatNormalizeData attribute is checked/true + • normalizes data for heat map intensity + * + * @method _dataToHeatArray + * @param max {Number} + * @return {Array} + */ +function dataToHeatArray(max, heatNormalizeData, featureCollection) { + + return featureCollection.features.map((feature) => { + const lat = feature.geometry.coordinates[1]; + const lng = feature.geometry.coordinates[0]; + let heatIntensity; + if (!heatNormalizeData) { + // show bucket value on heatmap + heatIntensity = feature.properties.value; + } else { + // show bucket value normalized to max value + heatIntensity = feature.properties.value / max; + } + + return [lat, lng, heatIntensity]; + }); +} + diff --git a/src/ui/public/vis_maps/markers/scaled_circles.js b/src/ui/public/vis_maps/markers/scaled_circles.js new file mode 100644 index 0000000000000..cc1c2504128de --- /dev/null +++ b/src/ui/public/vis_maps/markers/scaled_circles.js @@ -0,0 +1,231 @@ +import L from 'leaflet'; +import _ from 'lodash'; +import d3 from 'd3'; +import $ from 'jquery'; +import { EventEmitter } from 'events'; + +export default class ScaledCircles extends EventEmitter { + + constructor(featureCollection, options, targetZoom, kibanaMap) { + super(); + this._geohashGeoJson = featureCollection; + this._zoom = targetZoom; + + this._valueFormatter = options.valueFormatter; + this._tooltipFormatter = options.tooltipFormatter; + this._map = options.map; + + this._legendColors = null; + this._legendQuantizer = null; + + this._popups = []; + this._leafletLayer = L.geoJson(null, { + pointToLayer: this.getMarkerFunction(), + style: this.getStyleFunction(), + onEachFeature: (feature, layer) => { + this._bindPopup(feature, layer); + }, + filter: (feature) => { + const bucketRectBounds = _.get(feature, 'properties.rectangle'); + return kibanaMap.isInside(bucketRectBounds); + } + }); + this._leafletLayer.addData(this._geohashGeoJson); + } + + getLeafletLayer() { + return this._leafletLayer; + } + + + getStyleFunction() { + const min = _.get(this._geohashGeoJson, 'properties.min', 0); + const max = _.get(this._geohashGeoJson, 'properties.max', 1); + + const quantizeDomain = (min !== max) ? [min, max] : d3.scale.quantize().domain(); + this._legendColors = makeCircleMarkerLegendColors(min, max); + this._legendQuantizer = d3.scale.quantize().domain(quantizeDomain).range(this._legendColors); + + return makeStyleFunction(min, max, this._legendColors, quantizeDomain); + } + + + movePointer() { + } + + getLabel() { + if (this._popups.length) { + return this._popups[0].feature.properties.aggConfigResult.aggConfig.makeLabel(); + } + return ''; + } + + + appendLegendContents(jqueryDiv) { + + if (!this._legendColors || !this._legendQuantizer) { + return; + } + + const titleText = this.getLabel(); + const $title = $('
').addClass('tilemap-legend-title').text(titleText); + jqueryDiv.append($title); + + this._legendColors.forEach((color) => { + const labelText = this._legendQuantizer + .invertExtent(color) + .map(this._valueFormatter) + .join(' – '); + + const label = $('
'); + const icon = $('').css({ + background: color, + 'border-color': makeColorDarker(color) + }); + + const text = $('').text(labelText); + label.append(icon); + label.append(text); + + jqueryDiv.append(label); + }); + + } + + + /** + * Binds popup and events to each feature on map + * + * @method bindPopup + * @param feature {Object} + * @param layer {Object} + * return {undefined} + */ + _bindPopup(feature, layer) { + const popup = layer.on({ + mouseover: (e) => { + const layer = e.target; + // bring layer to front if not older browser + if (!L.Browser.ie && !L.Browser.opera) { + layer.bringToFront(); + } + this._showTooltip(feature); + }, + mouseout: () => { + this.emit('hideTooltip'); + } + }); + + this._popups.push(popup); + } + + /** + * Checks if event latlng is within bounds of mapData + * features and shows tooltip for that feature + * + * @method _showTooltip + * @param feature {LeafletFeature} + * @param latLng? {Leaflet latLng} + * @return undefined + */ + _showTooltip(feature, latLng) { + + const lat = _.get(feature, 'geometry.coordinates.1'); + const lng = _.get(feature, 'geometry.coordinates.0'); + latLng = latLng || L.latLng(lat, lng); + + const content = this._tooltipFormatter(feature); + if (!content) { + return; + } + + this.emit('showTooltip', { + content: content, + position: latLng + }); + } + + getMarkerFunction() { + const scaleFactor = 0.6; + return (feature, latlng) => { + const value = feature.properties.value; + const scaledRadius = this._radiusScale(value) * scaleFactor; + return L.circleMarker(latlng).setRadius(scaledRadius); + }; + } + + /** + * radiusScale returns a number for scaled circle markers + * for relative sizing of markers + * + * @method _radiusScale + * @param value {Number} + * @return {Number} + */ + _radiusScale(value) { + + //magic numbers + const precisionBiasBase = 5; + const precisionBiasNumerator = 200; + + const precision = _.max(this._geohashGeoJson.features.map((feature) => { + return String(feature.properties.geohash).length; + })); + + const pct = Math.abs(value) / Math.abs(this._geohashGeoJson.properties.max); + const zoomRadius = 0.5 * Math.pow(2, this._zoom); + const precisionScale = precisionBiasNumerator / Math.pow(precisionBiasBase, precision); + + // square root value percentage + return Math.pow(pct, 0.5) * zoomRadius * precisionScale; + } + + getBounds() { + return this._leafletLayer.getBounds(); + } + +} + + +/** + * d3 quantize scale returns a hex color, used for marker fill color + * + * @method quantizeLegendColors + * return {undefined} + */ +function makeCircleMarkerLegendColors(min, max) { + const reds1 = ['#ff6128']; + const reds3 = ['#fecc5c', '#fd8d3c', '#e31a1c']; + const reds5 = ['#fed976', '#feb24c', '#fd8d3c', '#f03b20', '#bd0026']; + const bottomCutoff = 2; + const middleCutoff = 24; + let legendColors; + if (max - min <= bottomCutoff) { + legendColors = reds1; + } else if (max - min <= middleCutoff) { + legendColors = reds3; + } else { + legendColors = reds5; + } + return legendColors; +} + +function makeColorDarker(color) { + const amount = 1.3;//magic number, carry over from earlier + return d3.hcl(color).darker(amount).toString(); +} + +function makeStyleFunction(min, max, legendColors, quantizeDomain) { + const legendQuantizer = d3.scale.quantize().domain(quantizeDomain).range(legendColors); + return (feature) => { + const value = _.get(feature, 'properties.value'); + const color = legendQuantizer(value); + return { + fillColor: color, + color: makeColorDarker(color), + weight: 1.5, + opacity: 1, + fillOpacity: 0.75 + }; + }; +} diff --git a/src/ui/public/vis_maps/markers/shaded_circles.js b/src/ui/public/vis_maps/markers/shaded_circles.js new file mode 100644 index 0000000000000..8c67f4fcdf396 --- /dev/null +++ b/src/ui/public/vis_maps/markers/shaded_circles.js @@ -0,0 +1,46 @@ +import L from 'leaflet'; +import _ from 'lodash'; +import ScaledCircles from './scaled_circles'; + +export default class ShadedCircles extends ScaledCircles { + getMarkerFunction() { + // multiplier to reduce size of all circles + const scaleFactor = 0.8; + return (feature, latlng) => { + const radius = this._geohashMinDistance(feature) * scaleFactor; + return L.circle(latlng, radius); + }; + } + + + /** + * _geohashMinDistance returns a min distance in meters for sizing + * circle markers to fit within geohash grid rectangle + * + * @method _geohashMinDistance + * @param feature {Object} + * @return {Number} + */ + _geohashMinDistance(feature) { + const centerPoint = _.get(feature, 'properties.center'); + const geohashRect = _.get(feature, 'properties.rectangle'); + + // centerPoint is an array of [lat, lng] + // geohashRect is the 4 corners of the geoHash rectangle + // an array that starts at the southwest corner and proceeds + // clockwise, each value being an array of [lat, lng] + + // center lat and southeast lng + const east = L.latLng([centerPoint[0], geohashRect[2][1]]); + // southwest lat and center lng + const north = L.latLng([geohashRect[3][0], centerPoint[1]]); + + // get latLng of geohash center point + const center = L.latLng([centerPoint[0], centerPoint[1]]); + + // get smallest radius at center of geohash grid rectangle + const eastRadius = Math.floor(center.distanceTo(east)); + const northRadius = Math.floor(center.distanceTo(north)); + return _.min([eastRadius, northRadius]); + } +} diff --git a/src/ui/public/vis_maps/visualizations/_chart.js b/src/ui/public/vis_maps/visualizations/_chart.js deleted file mode 100644 index 59fec8338b314..0000000000000 --- a/src/ui/public/vis_maps/visualizations/_chart.js +++ /dev/null @@ -1,59 +0,0 @@ -import d3 from 'd3'; -import VislibLibDispatchProvider from '../lib/dispatch'; -import TooltipProvider from 'ui/vis/components/tooltip'; -export default function ChartBaseClass(Private) { - - const Dispatch = Private(VislibLibDispatchProvider); - const Tooltip = Private(TooltipProvider); - /** - * The Base Class for all visualizations. - * - * @class Chart - * @constructor - * @param handler {Object} Reference to the Handler Class Constructor - * @param el {HTMLElement} HTML element to which the chart will be appended - * @param chartData {Object} Elasticsearch query results for this specific chart - */ - class Chart { - constructor(handler, el, chartData) { - this.handler = handler; - this.chartEl = el; - this.chartData = chartData; - this.tooltips = []; - - const events = this.events = new Dispatch(handler); - - if (this.handler.visConfig && this.handler.visConfig.get('addTooltip', false)) { - const $el = this.handler.el; - const formatter = this.handler.data.get('tooltipFormatter'); - - // Add tooltip - this.tooltip = new Tooltip('chart', $el, formatter, events); - this.tooltips.push(this.tooltip); - } - } - - render() { - const selection = d3.select(this.chartEl); - selection.selectAll('*').remove(); - selection.call(this.draw()); - } - - - /** - * Removes all DOM elements from the root element - * - * @method destroy - */ - destroy() { - const selection = d3.select(this.chartEl); - this.events.removeAllListeners(); - this.tooltips.forEach(function (tooltip) { - tooltip.destroy(); - }); - selection.remove(); - } - } - - return Chart; -} diff --git a/src/ui/public/vis_maps/visualizations/_map.js b/src/ui/public/vis_maps/visualizations/_map.js deleted file mode 100644 index f77279dec63c5..0000000000000 --- a/src/ui/public/vis_maps/visualizations/_map.js +++ /dev/null @@ -1,362 +0,0 @@ -import _ from 'lodash'; -import $ from 'jquery'; -import L from 'leaflet'; -import VislibVisualizationsMarkerTypesScaledCirclesProvider from './marker_types/scaled_circles'; -import VislibVisualizationsMarkerTypesShadedCirclesProvider from './marker_types/shaded_circles'; -import VislibVisualizationsMarkerTypesGeohashGridProvider from './marker_types/geohash_grid'; -import VislibVisualizationsMarkerTypesHeatmapProvider from './marker_types/heatmap'; -import '../lib/tilemap_settings'; - -export default function MapFactory(Private, tilemapSettings) { - const defaultMapZoom = 2; - const defaultMapCenter = [15, 5]; - const defaultMarkerType = 'Scaled Circle Markers'; - - const markerTypes = { - 'Scaled Circle Markers': Private(VislibVisualizationsMarkerTypesScaledCirclesProvider), - 'Shaded Circle Markers': Private(VislibVisualizationsMarkerTypesShadedCirclesProvider), - 'Shaded Geohash Grid': Private(VislibVisualizationsMarkerTypesGeohashGridProvider), - 'Heatmap': Private(VislibVisualizationsMarkerTypesHeatmapProvider), - }; - - /** - * Tile Map Maps - * - * @class Map - * @constructor - * @param container {HTML Element} Element to render map into - * @param chartData {Object} Elasticsearch query results for this map - * @param params {Object} Parameters used to build a map - */ - class TileMapMap { - constructor(container, chartData, params) { - - this._container = $(container).get(0); - this._chartData = chartData; - - // keep a reference to all of the optional params - this._events = _.get(params, 'events'); - this._markerType = markerTypes[params.markerType] ? params.markerType : defaultMarkerType; - this._valueFormatter = params.valueFormatter || _.identity; - this._tooltipFormatter = params.tooltipFormatter; - this._geoJson = _.get(this._chartData, 'geoJson'); - this._attr = params.attr || {}; - - const { minZoom, maxZoom } = tilemapSettings.getMinMaxZoom(this._isWMSEnabled()); - const zoom = typeof params.zoom === 'number' ? params.zoom : defaultMapZoom; - this._mapZoom = Math.max(Math.min(zoom, maxZoom), minZoom); - this._mapCenter = params.center || defaultMapCenter; - this._createMap(); - } - - addBoundingControl() { - if (this._boundingControl) return; - - const self = this; - const drawOptions = { draw: {} }; - - _.each(['polyline', 'polygon', 'circle', 'marker', 'rectangle'], function (drawShape) { - if (self._events && !self._events.listenerCount(drawShape)) { - drawOptions.draw[drawShape] = false; - } else { - drawOptions.draw[drawShape] = { - shapeOptions: { - stroke: false, - color: '#000' - } - }; - } - }); - - this._boundingControl = new L.Control.Draw(drawOptions); - this.map.addControl(this._boundingControl); - } - - addFitControl() { - if (this._fitControl) return; - - const self = this; - const fitContainer = L.DomUtil.create('div', 'leaflet-control leaflet-bar leaflet-control-fit'); - - // Add button to fit container to points - const FitControl = L.Control.extend({ - options: { - position: 'topleft' - }, - onAdd: function () { - $(fitContainer).html('') - .on('click', function (e) { - e.preventDefault(); - self._fitBounds(); - }); - - return fitContainer; - }, - onRemove: function () { - $(fitContainer).off('click'); - } - }); - - this._fitControl = new FitControl(); - this.map.addControl(this._fitControl); - } - - /** - * Adds label div to each map when data is split - * - * @method addTitle - * @param mapLabel {String} - * @return {undefined} - */ - addTitle(mapLabel) { - if (this._label) return; - - const label = this._label = L.control(); - - label.onAdd = function () { - this._div = L.DomUtil.create('div', 'tilemap-info tilemap-label'); - this.update(); - return this._div; - }; - label.update = function () { - this._div.innerHTML = '

' + _.escape(mapLabel) + '

'; - }; - - // label.addTo(this.map); - this.map.addControl(label); - } - - /** - * remove css class for desat filters on map tiles - * - * @method saturateTiles - * @return undefined - */ - saturateTiles() { - if (!this._attr.isDesaturated) { - $('img.leaflet-tile-loaded').addClass('filters-off'); - } - } - - updateSize() { - this.map.invalidateSize({ - debounceMoveend: true - }); - } - - destroy() { - if (this._label) this._label.removeFrom(this.map); - if (this._fitControl) this._fitControl.removeFrom(this.map); - if (this._boundingControl) this._boundingControl.removeFrom(this.map); - if (this._markers) this._markers.destroy(); - this.map.remove(); - this.map = undefined; - } - - /** - * Switch type of data overlay for map: - * creates featurelayer from mapData (geoJson) - * - * @method _addMarkers - */ - _addMarkers() { - if (!this._geoJson) return; - if (this._markers) this._markers.destroy(); - - this._markers = this._createMarkers({ - tooltipFormatter: this._tooltipFormatter, - valueFormatter: this._valueFormatter, - attr: this._attr - }); - - if (this._geoJson.features.length > 1) { - this._markers.addLegend(); - } - } - - /** - * Create the marker instance using the given options - * - * @method _createMarkers - * @param options {Object} options to give to marker class - * @return {Object} marker layer - */ - _createMarkers(options) { - const MarkerType = markerTypes[this._markerType]; - return new MarkerType(this.map, this._geoJson, options); - } - - _attachEvents() { - const self = this; - const saturateTiles = self.saturateTiles.bind(self); - - this._tileLayer.on('tileload', saturateTiles); - this._tileLayer.on('load', () => { - - if (!self._events) { - return; - } - - self._events.emit('rendered', { - chart: self._chartData, - map: self.map, - center: self._mapCenter, - zoom: self._mapZoom, - }); - }); - - this.map.on('unload', function () { - self._tileLayer.off('tileload', saturateTiles); - }); - - this.map.on('moveend', function setZoomCenter() { - if (!self.map) return; - // update internal center and zoom references - const uglyCenter = self.map.getCenter(); - self._mapCenter = [uglyCenter.lat, uglyCenter.lng]; - self._mapZoom = self.map.getZoom(); - self._addMarkers(); - - if (!self._events) return; - - self._events.emit('mapMoveEnd', { - chart: self._chartData, - map: self.map, - center: self._mapCenter, - zoom: self._mapZoom, - }); - }); - - this.map.on('draw:created', function (e) { - const drawType = e.layerType; - if (!self._events || !self._events.listenerCount(drawType)) return; - - // TODO: Different drawTypes need differ info. Need a switch on the object creation - const bounds = e.layer.getBounds(); - - const southEast = bounds.getSouthEast(); - const northWest = bounds.getNorthWest(); - let southEastLng = southEast.lng; - if (southEastLng > 180) { - southEastLng -= 360; - } - let northWestLng = northWest.lng; - if (northWestLng < -180) { - northWestLng += 360; - } - - const southEastLat = southEast.lat; - const northWestLat = northWest.lat; - - //Bounds cannot be created unless they form a box with larger than 0 dimensions - //Invalid areas are rejected by ES. - if (southEastLat === northWestLat || southEastLng === northWestLng) { - return; - } - - self._events.emit(drawType, { - e: e, - chart: self._chartData, - bounds: { - bottom_right: { - lat: southEastLat, - lon: southEastLng - }, - top_left: { - lat: northWestLat, - lon: northWestLng - } - } - }); - }); - - this.map.on('zoomend', function () { - if (!self.map) return; - self._mapZoom = self.map.getZoom(); - if (!self._events) return; - - self._events.emit('mapZoomEnd', { - chart: self._chartData, - map: self.map, - zoom: self._mapZoom, - }); - }); - } - - _isWMSEnabled() { - return this._attr.wms ? this._attr.wms.enabled : false; - } - - _createTileLayer() { - if (this._isWMSEnabled()) { - const wmsOpts = this._attr.wms; - const { minZoom, maxZoom } = tilemapSettings.getMinMaxZoom(true); - // http://leafletjs.com/reference.html#tilelayer-wms-options - return L.tileLayer.wms(wmsOpts.url, { - // user settings - ...wmsOpts.options, - minZoom: minZoom, - maxZoom: maxZoom, - }); - } - - const tileUrl = tilemapSettings.hasError() ? '' : tilemapSettings.getUrl(); - const leafletOptions = tilemapSettings.getTMSOptions(); - return L.tileLayer(tileUrl, leafletOptions); - } - - /** - * Create the leaflet Map object. In our implementation this is basically just - * a container for the layer created by `this._createTileLayer()`. User settings - * are passed as options to the layer and inherited by the map so we can keep - * this function pretty generic. - * - * The map is responsible for the current center and zoom level though, as those - * are global to each map. - * - * @return undefined - */ - _createMap() { - if (this.map) this.destroy(); - - // expose at `this._tileLayer`, `this._attachEvents()` accesses it this way - this._tileLayer = this._createTileLayer(); - - // http://leafletjs.com/reference.html#map-options - this.map = L.map(this._container, { - center: this._mapCenter, - zoom: this._mapZoom, - layers: [this._tileLayer], - maxBounds: L.latLngBounds([-90, -220], [90, 220]), - scrollWheelZoom: false, - fadeAnimation: true, - }); - - this._attachEvents(); - this._addMarkers(); - } - - /** - * zoom map to fit all features in featureLayer - * - * @method _fitBounds - * @param map {Leaflet Object} - * @return {boolean} - */ - _fitBounds() { - this.map.fitBounds(this._getDataRectangles()); - } - - /** - * Get the Rectangles representing the geohash grid - * - * @return {LatLngRectangles[]} - */ - _getDataRectangles() { - if (!this._geoJson) return []; - return _.pluck(this._geoJson.features, 'properties.rectangle'); - } - } - - return TileMapMap; -} diff --git a/src/ui/public/vis_maps/visualizations/marker_types/base_marker.js b/src/ui/public/vis_maps/visualizations/marker_types/base_marker.js deleted file mode 100644 index 7a6e1deeffc10..0000000000000 --- a/src/ui/public/vis_maps/visualizations/marker_types/base_marker.js +++ /dev/null @@ -1,286 +0,0 @@ -import d3 from 'd3'; -import _ from 'lodash'; -import $ from 'jquery'; -import L from 'leaflet'; -export default function MarkerFactory() { - - /** - * Base map marker overlay, all other markers inherit from this class - * - * @param map {Leaflet Object} - * @param geoJson {geoJson Object} - * @param params {Object} - */ - class BaseMarker { - constructor(map, geoJson, params) { - this.map = map; - this.geoJson = geoJson; - this.popups = []; - - this._tooltipFormatter = params.tooltipFormatter || null; - this._valueFormatter = params.valueFormatter || _.identity; - this._attr = params.attr || {}; - - // set up the default legend colors - this.quantizeLegendColors(); - } - - getLabel() { - if (this.popups.length) { - return this.popups[0].feature.properties.aggConfigResult.aggConfig.makeLabel(); - } - return ''; - } - /** - * Adds legend div to each map when data is split - * uses d3 scale from BaseMarker.prototype.quantizeLegendColors - * - * @method addLegend - * @return {undefined} - */ - addLegend() { - // ensure we only ever create 1 legend - if (this._legend) return; - - const self = this; - - // create the legend control, keep a reference - self._legend = L.control({ position: this._attr.legendPosition }); - - self._legend.onAdd = function () { - // creates all the neccessary DOM elements for the control, adds listeners - // on relevant map events, and returns the element containing the control - const $wrapper = $('
').addClass('tilemap-legend-wrapper'); - const $div = $('
').addClass('tilemap-legend'); - $wrapper.append($div); - - const titleText = self.getLabel(); - const $title = $('
').addClass('tilemap-legend-title').text(titleText); - $div.append($title); - - _.each(self._legendColors, function (color) { - const labelText = self._legendQuantizer - .invertExtent(color) - .map(self._valueFormatter) - .join(' – '); - - const label = $('
'); - - const icon = $('').css({ - background: color, - 'border-color': self.darkerColor(color) - }); - - const text = $('').text(labelText); - - label.append(icon); - label.append(text); - $div.append(label); - }); - - return $wrapper.get(0); - }; - - self._legend.addTo(self.map); - } - - /** - * Apply style with shading to feature - * - * @method applyShadingStyle - * @param value {Object} - * @return {Object} - */ - applyShadingStyle(value) { - const color = this._legendQuantizer(value); - - return { - fillColor: color, - color: this.darkerColor(color), - weight: 1.5, - opacity: 1, - fillOpacity: 0.75 - }; - } - - /** - * Binds popup and events to each feature on map - * - * @method bindPopup - * @param feature {Object} - * @param layer {Object} - * return {undefined} - */ - bindPopup(feature, layer) { - const self = this; - - const popup = layer.on({ - mouseover: function (e) { - const layer = e.target; - // bring layer to front if not older browser - if (!L.Browser.ie && !L.Browser.opera) { - layer.bringToFront(); - } - self._showTooltip(feature); - }, - mouseout: function () { - self._hidePopup(); - } - }); - - self.popups.push(popup); - } - - /** - * d3 method returns a darker hex color, - * used for marker stroke color - * - * @method darkerColor - * @param color {String} hex color - * @param amount? {Number} amount to darken by - * @return {String} hex color - */ - darkerColor(color, amount) { - amount = amount || 1.3; - return d3.hcl(color).darker(amount).toString(); - } - - destroy() { - const self = this; - - // remove popups - self.popups = self.popups.filter(function (popup) { - popup.off('mouseover').off('mouseout'); - }); - - if (self._legend) { - self.map.removeControl(self._legend); - self._legend = undefined; - } - - // remove marker layer from map - if (self._markerGroup) { - self.map.removeLayer(self._markerGroup); - self._markerGroup = undefined; - } - } - - _addToMap() { - this.map.addLayer(this._markerGroup); - } - - /** - * Creates leaflet marker group, passing options to L.geoJson - * - * @method _createMarkerGroup - * @param options {Object} Options to pass to L.geoJson - */ - _createMarkerGroup(options) { - const self = this; - const defaultOptions = { - onEachFeature: function (feature, layer) { - self.bindPopup(feature, layer); - }, - style: function (feature) { - const value = _.get(feature, 'properties.value'); - return self.applyShadingStyle(value); - }, - filter: self._filterToMapBounds() - }; - - this._markerGroup = L.geoJson(this.geoJson, _.defaults(defaultOptions, options)); - this._addToMap(); - } - - /** - * return whether feature is within map bounds - * - * @method _filterToMapBounds - * @param map {Leaflet Object} - * @return {boolean} - */ - _filterToMapBounds() { - const self = this; - return function (feature) { - const mapBounds = self.map.getBounds(); - const bucketRectBounds = _.get(feature, 'properties.rectangle'); - return mapBounds.intersects(bucketRectBounds); - }; - } - - /** - * Checks if event latlng is within bounds of mapData - * features and shows tooltip for that feature - * - * @method _showTooltip - * @param feature {LeafletFeature} - * @param latLng? {Leaflet latLng} - * @return undefined - */ - _showTooltip(feature, latLng) { - const hasMap = !!this.map; - const hasTooltip = !!this._attr.addTooltip; - if (!hasMap || !hasTooltip) { - return; - } - const lat = _.get(feature, 'geometry.coordinates.1'); - const lng = _.get(feature, 'geometry.coordinates.0'); - latLng = latLng || L.latLng(lat, lng); - - const content = this._tooltipFormatter(feature); - - if (!content) return; - this._createTooltip(content, latLng); - } - - _createTooltip(content, latLng) { - this._popup = L.popup({ autoPan: false }); - this._popup.setLatLng(latLng) - .setContent(content) - .openOn(this.map); - } - - /** - * Closes the tooltip on the map - * - * @method _hidePopup - * @return undefined - */ - _hidePopup() { - if (!this.map) return; - - this._popup = null; - this.map.closePopup(); - } - - /** - * d3 quantize scale returns a hex color, used for marker fill color - * - * @method quantizeLegendColors - * return {undefined} - */ - quantizeLegendColors() { - const min = _.get(this.geoJson, 'properties.allmin', 0); - const max = _.get(this.geoJson, 'properties.allmax', 1); - const quantizeDomain = (min !== max) ? [min, max] : d3.scale.quantize().domain(); - - const reds1 = ['#ff6128']; - const reds3 = ['#fecc5c', '#fd8d3c', '#e31a1c']; - const reds5 = ['#fed976', '#feb24c', '#fd8d3c', '#f03b20', '#bd0026']; - const bottomCutoff = 2; - const middleCutoff = 24; - - if (max - min <= bottomCutoff) { - this._legendColors = reds1; - } else if (max - min <= middleCutoff) { - this._legendColors = reds3; - } else { - this._legendColors = reds5; - } - - this._legendQuantizer = d3.scale.quantize().domain(quantizeDomain).range(this._legendColors); - } - } - - return BaseMarker; -} diff --git a/src/ui/public/vis_maps/visualizations/marker_types/geohash_grid.js b/src/ui/public/vis_maps/visualizations/marker_types/geohash_grid.js deleted file mode 100644 index 99f63311b6873..0000000000000 --- a/src/ui/public/vis_maps/visualizations/marker_types/geohash_grid.js +++ /dev/null @@ -1,34 +0,0 @@ -import L from 'leaflet'; -import VislibVisualizationsMarkerTypesBaseMarkerProvider from './base_marker'; -export default function GeohashGridMarkerFactory(Private) { - - const BaseMarker = Private(VislibVisualizationsMarkerTypesBaseMarkerProvider); - - /** - * Map overlay: rectangles that show the geohash grid bounds - * - * @param map {Leaflet Object} - * @param geoJson {geoJson Object} - * @param params {Object} - */ - class GeohashGridMarker extends BaseMarker { - constructor(map, geoJson, params) { - super(map, geoJson, params); - - this._createMarkerGroup({ - pointToLayer: function (feature) { - const geohashRect = feature.properties.rectangle; - // get bounds from northEast[3] and southWest[1] - // corners in geohash rectangle - const corners = [ - [geohashRect[3][0], geohashRect[3][1]], - [geohashRect[1][0], geohashRect[1][1]] - ]; - return L.rectangle(corners); - } - }); - } - } - - return GeohashGridMarker; -} diff --git a/src/ui/public/vis_maps/visualizations/marker_types/heatmap.js b/src/ui/public/vis_maps/visualizations/marker_types/heatmap.js deleted file mode 100644 index 46507c47c9607..0000000000000 --- a/src/ui/public/vis_maps/visualizations/marker_types/heatmap.js +++ /dev/null @@ -1,206 +0,0 @@ -import d3 from 'd3'; -import _ from 'lodash'; -import L from 'leaflet'; -import VislibVisualizationsMarkerTypesBaseMarkerProvider from './base_marker'; -export default function HeatmapMarkerFactory(Private) { - - const BaseMarker = Private(VislibVisualizationsMarkerTypesBaseMarkerProvider); - - /** - * Map overlay: canvas layer with leaflet.heat plugin - * - * @param map {Leaflet Object} - * @param geoJson {geoJson Object} - * @param params {Object} - */ - class HeatmapMarker extends BaseMarker { - constructor(map, geoJson, params) { - super(map, geoJson, params); - this._disableTooltips = false; - - this._createMarkerGroup({ - radius: +this._attr.heatRadius, - blur: +this._attr.heatBlur, - maxZoom: +this._attr.heatMaxZoom, - minOpacity: +this._attr.heatMinOpacity - }); - - this.addLegend = _.noop; - - this._getLatLng = _.memoize(function (feature) { - return L.latLng( - feature.geometry.coordinates[1], - feature.geometry.coordinates[0] - ); - }, function (feature) { - // turn coords into a string for the memoize cache - return [feature.geometry.coordinates[1], feature.geometry.coordinates[0]].join(','); - }); - } - - _createMarkerGroup(options) { - const max = _.get(this.geoJson, 'properties.allmax'); - const points = this._dataToHeatArray(max); - - this._markerGroup = L.heatLayer(points, options); - this._fixTooltips(); - this._addToMap(); - } - - _fixTooltips() { - const self = this; - const debouncedMouseMoveLocation = _.debounce(mouseMoveLocation.bind(this), 15, { - 'leading': true, - 'trailing': false - }); - - if (!this._disableTooltips && this._attr.addTooltip) { - this.map.on('mousemove', debouncedMouseMoveLocation); - this.map.on('mouseout', function () { - self.map.closePopup(); - }); - this.map.on('mousedown', function () { - self._disableTooltips = true; - self.map.closePopup(); - }); - this.map.on('mouseup', function () { - self._disableTooltips = false; - }); - } - - function mouseMoveLocation(e) { - const latlng = e.latlng; - // unhighlight all svgs - d3.selectAll('path.geohash', this.chartEl).classed('geohash-hover', false); - - if (!this.geoJson.features.length || this._disableTooltips) { - this._hidePopup(); - return; - } - - // find nearest feature to event latlng - const feature = this._nearestFeature(latlng); - - // show tooltip if close enough to event latlng - if (this._tooltipProximity(latlng, feature)) { - if (this.currentFeature !== feature) { - this._hidePopup(); - this.currentFeature = feature; - this._showTooltip(feature, latlng); - } else { - if (this._popup) { - this._popup.setLatLng(latlng); - } - } - } else { - this._hidePopup(); - this.currentFeature = null; - } - } - } - - /** - * Finds nearest feature in mapData to event latlng - * - * @method _nearestFeature - * @param latLng {Leaflet latLng} - * @return nearestPoint {Leaflet latLng} - */ - _nearestFeature(latLng) { - const self = this; - let nearest; - - if (latLng.lng < -180 || latLng.lng > 180) { - return; - } - - _.reduce(this.geoJson.features, function (distance, feature) { - const featureLatLng = self._getLatLng(feature); - const dist = latLng.distanceTo(featureLatLng); - - if (dist < distance) { - nearest = feature; - return dist; - } - - return distance; - }, Infinity); - - return nearest; - } - - /** - * display tooltip if feature is close enough to event latlng - * - * @method _tooltipProximity - * @param latlng {Leaflet latLng Object} - * @param feature {geoJson Object} - * @return {Boolean} - */ - _tooltipProximity(latlng, feature) { - if (!feature) return; - - let showTip = false; - const featureLatLng = this._getLatLng(feature); - - // zoomScale takes map zoom and returns proximity value for tooltip display - // domain (input values) is map zoom (min 1 and max 18) - // range (output values) is distance in meters - // used to compare proximity of event latlng to feature latlng - const zoomScale = d3.scale.linear() - .domain([1, 4, 7, 10, 13, 16, 18]) - .range([1000000, 300000, 100000, 15000, 2000, 150, 50]); - - const proximity = zoomScale(this.map.getZoom()); - const distance = latlng.distanceTo(featureLatLng); - - // maxLngDif is max difference in longitudes - // to prevent feature tooltip from appearing 360° - // away from event latlng - const maxLngDif = 40; - const lngDif = Math.abs(latlng.lng - featureLatLng.lng); - - if (distance < proximity && lngDif < maxLngDif) { - showTip = true; - } - - d3.scale.pow().exponent(0.2) - .domain([1, 18]) - .range([1500000, 50]); - return showTip; - } - - - /** - * returns data for data for heat map intensity - * if heatNormalizeData attribute is checked/true - • normalizes data for heat map intensity - * - * @method _dataToHeatArray - * @param max {Number} - * @return {Array} - */ - _dataToHeatArray(max) { - const self = this; - - return this.geoJson.features.map(function (feature) { - const lat = feature.properties.center[0]; - const lng = feature.properties.center[1]; - let heatIntensity; - - if (!self._attr.heatNormalizeData) { - // show bucket value on heatmap - heatIntensity = feature.properties.value; - } else { - // show bucket value normalized to max value - heatIntensity = feature.properties.value / max; - } - - return [lat, lng, heatIntensity]; - }); - } - } - - - return HeatmapMarker; -} diff --git a/src/ui/public/vis_maps/visualizations/marker_types/scaled_circles.js b/src/ui/public/vis_maps/visualizations/marker_types/scaled_circles.js deleted file mode 100644 index 7eed1d6653977..0000000000000 --- a/src/ui/public/vis_maps/visualizations/marker_types/scaled_circles.js +++ /dev/null @@ -1,59 +0,0 @@ -import _ from 'lodash'; -import L from 'leaflet'; -import VislibVisualizationsMarkerTypesBaseMarkerProvider from './base_marker'; -export default function ScaledCircleMarkerFactory(Private) { - - const BaseMarker = Private(VislibVisualizationsMarkerTypesBaseMarkerProvider); - - /** - * Map overlay: circle markers that are scaled to illustrate values - * - * @param map {Leaflet Object} - * @param mapData {geoJson Object} - * @param params {Object} - */ - class ScaledCircleMarker extends BaseMarker { - constructor(map, geoJson, params) { - super(map, geoJson, params); - - // multiplier to reduce size of all circles - const scaleFactor = 0.6; - - this._createMarkerGroup({ - pointToLayer: (feature, latlng) => { - const value = feature.properties.value; - const scaledRadius = this._radiusScale(value) * scaleFactor; - return L.circleMarker(latlng).setRadius(scaledRadius); - } - }); - } - - /** - * radiusScale returns a number for scaled circle markers - * for relative sizing of markers - * - * @method _radiusScale - * @param value {Number} - * @return {Number} - */ - _radiusScale(value) { - const precisionBiasBase = 5; - const precisionBiasNumerator = 200; - const zoom = this.map.getZoom(); - const maxValue = this.geoJson.properties.allmax; - const precision = _.max(this.geoJson.features.map(function (feature) { - return String(feature.properties.geohash).length; - })); - - const pct = Math.abs(value) / Math.abs(maxValue); - const zoomRadius = 0.5 * Math.pow(2, zoom); - const precisionScale = precisionBiasNumerator / Math.pow(precisionBiasBase, precision); - - // square root value percentage - return Math.pow(pct, 0.5) * zoomRadius * precisionScale; - } - } - - - return ScaledCircleMarker; -} diff --git a/src/ui/public/vis_maps/visualizations/marker_types/shaded_circles.js b/src/ui/public/vis_maps/visualizations/marker_types/shaded_circles.js deleted file mode 100644 index a498056af1070..0000000000000 --- a/src/ui/public/vis_maps/visualizations/marker_types/shaded_circles.js +++ /dev/null @@ -1,65 +0,0 @@ -import _ from 'lodash'; -import L from 'leaflet'; -import VislibVisualizationsMarkerTypesBaseMarkerProvider from './base_marker'; -export default function ShadedCircleMarkerFactory(Private) { - - const BaseMarker = Private(VislibVisualizationsMarkerTypesBaseMarkerProvider); - - /** - * Map overlay: circle markers that are shaded to illustrate values - * - * @param map {Leaflet Object} - * @param mapData {geoJson Object} - * @return {Leaflet object} featureLayer - */ - class ShadedCircleMarker extends BaseMarker { - constructor(map, geoJson, params) { - super(map, geoJson, params); - - // multiplier to reduce size of all circles - const scaleFactor = 0.8; - - this._createMarkerGroup({ - pointToLayer: (feature, latlng) => { - const radius = this._geohashMinDistance(feature) * scaleFactor; - return L.circle(latlng, radius); - } - }); - } - - - /** - * _geohashMinDistance returns a min distance in meters for sizing - * circle markers to fit within geohash grid rectangle - * - * @method _geohashMinDistance - * @param feature {Object} - * @return {Number} - */ - _geohashMinDistance(feature) { - const centerPoint = _.get(feature, 'properties.center'); - const geohashRect = _.get(feature, 'properties.rectangle'); - - // centerPoint is an array of [lat, lng] - // geohashRect is the 4 corners of the geoHash rectangle - // an array that starts at the southwest corner and proceeds - // clockwise, each value being an array of [lat, lng] - - // center lat and southeast lng - const east = L.latLng([centerPoint[0], geohashRect[2][1]]); - // southwest lat and center lng - const north = L.latLng([geohashRect[3][0], centerPoint[1]]); - - // get latLng of geohash center point - const center = L.latLng([centerPoint[0], centerPoint[1]]); - - // get smallest radius at center of geohash grid rectangle - const eastRadius = Math.floor(center.distanceTo(east)); - const northRadius = Math.floor(center.distanceTo(north)); - return _.min([eastRadius, northRadius]); - } - } - - - return ShadedCircleMarker; -} diff --git a/src/ui/public/vis_maps/visualizations/tile_map.js b/src/ui/public/vis_maps/visualizations/tile_map.js deleted file mode 100644 index 1c423d6fee24a..0000000000000 --- a/src/ui/public/vis_maps/visualizations/tile_map.js +++ /dev/null @@ -1,128 +0,0 @@ -import _ from 'lodash'; -import $ from 'jquery'; -import VislibVisualizationsChartProvider from './_chart'; -import VislibVisualizationsMapProvider from './_map'; -export default function TileMapFactory(Private) { - - const Chart = Private(VislibVisualizationsChartProvider); - const TileMapMap = Private(VislibVisualizationsMapProvider); - - /** - * Tile Map Visualization: renders maps - * - * @class TileMap - * @constructor - * @extends Chart - * @param handler {Object} Reference to the Handler Class Constructor - * @param chartEl {HTMLElement} HTML element to which the map will be appended - * @param chartData {Object} Elasticsearch query results for this map - */ - class TileMap extends Chart { - constructor(handler, chartEl, chartData) { - super(handler, chartEl, chartData); - - // track the map objects - this.maps = []; - this._chartData = chartData || {}; - _.assign(this, this._chartData); - - this._appendGeoExtents(); - } - - /** - * Draws tile map, called on chart render - * - * @method draw - * @return {Function} - function to add a map to a selection - */ - draw() { - const self = this; - - return function (selection) { - selection.each(function () { - self._appendMap(this); - }); - }; - } - - /** - * Invalidate the size of the map, so that leaflet will resize to fit. - * then moves to center - * - * @method resizeArea - * @return {undefined} - */ - resizeArea() { - this.maps.forEach(function (map) { - map.updateSize(); - }); - } - - /** - * clean up the maps - * - * @method destroy - * @return {undefined} - */ - destroy() { - this.maps = this.maps.filter(function (map) { - map.destroy(); - }); - } - - /** - * Adds allmin and allmax properties to geoJson data - * - * @method _appendMap - * @param selection {Object} d3 selection - */ - _appendGeoExtents() { - // add allmin and allmax to geoJson - const geoMinMax = this.handler.data.getGeoExtents(); - this.geoJson.properties.allmin = geoMinMax.min; - this.geoJson.properties.allmax = geoMinMax.max; - } - - /** - * Renders map - * - * @method _appendMap - * @param selection {Object} d3 selection - */ - _appendMap(selection) { - const container = $(selection).addClass('tilemap'); - const uiStateParams = { - mapCenter: this.handler.uiState.get('mapCenter'), - mapZoom: this.handler.uiState.get('mapZoom') - }; - - const params = _.assign({}, _.get(this._chartData, 'geoAgg.vis.params'), uiStateParams); - - const tooltipFormatter = this.handler.visConfig.get('addTooltip') ? this.tooltipFormatter : null; - const map = new TileMapMap(container, this._chartData, { - center: params.mapCenter, - zoom: params.mapZoom, - events: this.events, - markerType: this.handler.visConfig.get('mapType'), - tooltipFormatter: tooltipFormatter, - valueFormatter: this.valueFormatter, - attr: this.handler.visConfig._values - }); - - // add title for splits - if (this.title) { - map.addTitle(this.title); - } - - // add fit to bounds control - if (_.get(this.geoJson, 'features.length') > 0) { - map.addFitControl(); - map.addBoundingControl(); - } - - this.maps.push(map); - } - } - - return TileMap; -} diff --git a/test/functional/apps/visualize/_tile_map.js b/test/functional/apps/visualize/_tile_map.js deleted file mode 100644 index 586e121049797..0000000000000 --- a/test/functional/apps/visualize/_tile_map.js +++ /dev/null @@ -1,330 +0,0 @@ - -import expect from 'expect.js'; - -import { bdd } from '../../../support'; - -import PageObjects from '../../../support/page_objects'; - -bdd.describe('visualize app', function describeIndexTests() { - const fromTime = '2015-09-19 06:31:44.000'; - const toTime = '2015-09-23 18:31:44.000'; - - bdd.before(function () { - - PageObjects.common.debug('navigateToApp visualize'); - return PageObjects.common.navigateToUrl('visualize', 'new') - .then(function () { - PageObjects.common.debug('clickTileMap'); - return PageObjects.visualize.clickTileMap(); - }) - .then(function () { - return PageObjects.visualize.clickNewSearch(); - }) - .then(function () { - PageObjects.common.debug('Set absolute time range from \"' + fromTime + '\" to \"' + toTime + '\"'); - return PageObjects.header.setAbsoluteRange(fromTime, toTime); - }) - .then(function () { - PageObjects.common.debug('select bucket Geo Coordinates'); - return PageObjects.visualize.clickBucket('Geo Coordinates'); - }) - .then(function () { - PageObjects.common.debug('Click aggregation Geohash'); - return PageObjects.visualize.selectAggregation('Geohash'); - }) - .then(function () { - PageObjects.common.debug('Click field geo.coordinates'); - return PageObjects.common.try(function tryingForTime() { - return PageObjects.visualize.selectField('geo.coordinates'); - }); - }) - .then(function () { - return PageObjects.visualize.clickGo(); - }) - .then(function () { - return PageObjects.header.waitUntilLoadingHasFinished(); - }); - }); - - bdd.describe('tile map chart', function indexPatternCreation() { - - bdd.it('should show correct tile map data on default zoom level', function () { - const expectedTableData = [ 'dn 1,429', 'dp 1,418', '9y 1,215', '9z 1,099', 'dr 1,076', - 'dj 982', '9v 938', '9q 722', '9w 475', 'cb 457', 'c2 453', '9x 420', 'dq 399', - '9r 396', '9t 274', 'c8 271', 'dh 214', 'b6 207', 'bd 206', 'b7 167', 'f0 141', - 'be 128', '9m 126', 'bf 85', 'de 73', 'bg 71', '9p 71', 'c1 57', 'c4 50', '9u 48', - 'f2 46', '8e 45', 'b3 38', 'bs 36', 'c0 31', '87 28', 'bk 23', '8f 18', 'b5 14', - '84 14', 'dx 9', 'bu 9', 'b1 9', 'b4 6', '9n 3', '8g 3' - ]; - - return PageObjects.visualize.collapseChart() - .then(function () { - return PageObjects.settings.setPageSize('All'); - }) - .then(function getDataTableData() { - return PageObjects.visualize.getDataTableData() - .then(function showData(data) { - expect(data.trim().split('\n')).to.eql(expectedTableData); - return PageObjects.visualize.collapseChart(); - }); - }); - }); - - - bdd.it('should zoom out to level 1 from default level 2', function () { - const expectedPrecision2Circles = [ { color: '#750000', radius: 48 }, - { color: '#750000', radius: 48 }, - { color: '#750000', radius: 44 }, - { color: '#a40000', radius: 42 }, - { color: '#a40000', radius: 42 }, - { color: '#a40000', radius: 40 }, - { color: '#a40000', radius: 39 }, - { color: '#b45100', radius: 34 }, - { color: '#b67501', radius: 28 }, - { color: '#b67501', radius: 27 }, - { color: '#b67501', radius: 27 }, - { color: '#b67501', radius: 26 }, - { color: '#b67501', radius: 25 }, - { color: '#b67501', radius: 25 }, - { color: '#b99939', radius: 21 }, - { color: '#b99939', radius: 21 }, - { color: '#b99939', radius: 19 }, - { color: '#b99939', radius: 18 }, - { color: '#b99939', radius: 18 }, - { color: '#b99939', radius: 16 }, - { color: '#b99939', radius: 15 }, - { color: '#b99939', radius: 14 }, - { color: '#b99939', radius: 14 }, - { color: '#b99939', radius: 12 }, - { color: '#b99939', radius: 11 }, - { color: '#b99939', radius: 11 }, - { color: '#b99939', radius: 11 }, - { color: '#b99939', radius: 10 }, - { color: '#b99939', radius: 9 }, - { color: '#b99939', radius: 9 }, - { color: '#b99939', radius: 9 }, - { color: '#b99939', radius: 9 }, - { color: '#b99939', radius: 8 }, - { color: '#b99939', radius: 8 }, - { color: '#b99939', radius: 7 }, - { color: '#b99939', radius: 7 }, - { color: '#b99939', radius: 6 }, - { color: '#b99939', radius: 5 }, - { color: '#b99939', radius: 5 }, - { color: '#b99939', radius: 5 }, - { color: '#b99939', radius: 4 }, - { color: '#b99939', radius: 4 }, - { color: '#b99939', radius: 4 }, - { color: '#b99939', radius: 3 }, - { color: '#b99939', radius: 2 }, - { color: '#b99939', radius: 2 } - ]; - - return PageObjects.visualize.clickMapZoomOut() - .then(function () { - return PageObjects.visualize.getMapZoomOutEnabled(); - }) - // we can tell we're at level 1 because zoom out is disabled - .then(function () { - return PageObjects.common.try(function tryingForTime() { - return PageObjects.visualize.getMapZoomOutEnabled() - .then(function (enabled) { - //should be able to zoom more as current config has 0 as min level. - expect(enabled).to.be(true); - }); - }); - }) - .then(function () { - return PageObjects.common.try(function tryingForTime() { - return PageObjects.visualize.getTileMapData() - .then(function (data) { - expect(data).to.eql(expectedPrecision2Circles); - }); - }); - }) - .then(function takeScreenshot() { - PageObjects.common.debug('Take screenshot (success)'); - PageObjects.common.saveScreenshot('map-after-zoom-from-1-to-2'); - }); - }); - - bdd.it('Fit data bounds should zoom to level 3', function () { - const expectedPrecision2ZoomCircles = [ { color: '#750000', radius: 192 }, - { color: '#750000', radius: 191 }, - { color: '#750000', radius: 177 }, - { color: '#a40000', radius: 168 }, - { color: '#a40000', radius: 167 }, - { color: '#a40000', radius: 159 }, - { color: '#a40000', radius: 156 }, - { color: '#b45100', radius: 136 }, - { color: '#b67501', radius: 111 }, - { color: '#b67501', radius: 109 }, - { color: '#b67501', radius: 108 }, - { color: '#b67501', radius: 104 }, - { color: '#b67501', radius: 101 }, - { color: '#b67501', radius: 101 }, - { color: '#b99939', radius: 84 }, - { color: '#b99939', radius: 84 }, - { color: '#b99939', radius: 74 }, - { color: '#b99939', radius: 73 }, - { color: '#b99939', radius: 73 }, - { color: '#b99939', radius: 66 }, - { color: '#b99939', radius: 60 }, - { color: '#b99939', radius: 57 }, - { color: '#b99939', radius: 57 }, - { color: '#b99939', radius: 47 }, - { color: '#b99939', radius: 43 }, - { color: '#b99939', radius: 43 }, - { color: '#b99939', radius: 43 }, - { color: '#b99939', radius: 38 }, - { color: '#b99939', radius: 36 }, - { color: '#b99939', radius: 35 }, - { color: '#b99939', radius: 34 }, - { color: '#b99939', radius: 34 }, - { color: '#b99939', radius: 31 }, - { color: '#b99939', radius: 30 }, - { color: '#b99939', radius: 28 }, - { color: '#b99939', radius: 27 }, - { color: '#b99939', radius: 24 }, - { color: '#b99939', radius: 22 }, - { color: '#b99939', radius: 19 }, - { color: '#b99939', radius: 19 }, - { color: '#b99939', radius: 15 }, - { color: '#b99939', radius: 15 }, - { color: '#b99939', radius: 15 }, - { color: '#b99939', radius: 12 }, - { color: '#b99939', radius: 9 }, - { color: '#b99939', radius: 9 } - ]; - - return PageObjects.visualize.clickMapFitDataBounds() - .then(function () { - return PageObjects.visualize.getTileMapData(); - }) - .then(function (data) { - expect(data).to.eql(expectedPrecision2ZoomCircles); - }); - }); - - /* - ** NOTE: Since we don't have a reliable way to know the zoom level, we can - ** check some data after we save the viz, then zoom in and check that the data - ** changed, then open the saved viz and check that it's back to the original data. - */ - bdd.it('should save with zoom level and load, take screenshot', function () { - const vizName1 = 'Visualization TileMap'; - const expectedTableData = [ 'dr4 127', 'dr7 92', '9q5 91', '9qc 89', 'drk 87', - 'dps 82', 'dph 82', 'dp3 79', 'dpe 78', 'dp8 77' - ]; - - const expectedTableDataZoomed = [ 'dr5r 21', 'dps8 20', '9q5b 19', 'b6uc 17', - '9y63 17', 'c20g 16', 'dqfz 15', 'dr8h 14', 'dp8p 14', 'dp3k 14' - ]; - - return PageObjects.visualize.clickMapZoomIn() - .then(function () { - return PageObjects.visualize.clickMapZoomIn(); - }) - .then(function () { - return PageObjects.visualize.saveVisualization(vizName1); - }) - .then(function (message) { - PageObjects.common.debug('Saved viz message = ' + message); - expect(message).to.be('Visualization Editor: Saved Visualization \"' + vizName1 + '\"'); - }) - .then(function testVisualizeWaitForToastMessageGone() { - return PageObjects.visualize.waitForToastMessageGone(); - }) - .then(function () { - return PageObjects.visualize.collapseChart(); - }) - // we're not selecting page size all, so we only have to verify the first page of data - .then(function getDataTableData() { - PageObjects.common.debug('first get the zoom level 5 page data and verify it'); - return PageObjects.visualize.getDataTableData(); - }) - .then(function showData(data) { - expect(data.trim().split('\n')).to.eql(expectedTableData); - return PageObjects.visualize.collapseChart(); - }) - .then(function () { - // zoom to level 6, and make sure we go back to the saved level 5 - return PageObjects.visualize.clickMapZoomIn(); - }) - .then(function () { - return PageObjects.visualize.collapseChart(); - }) - .then(function getDataTableData() { - PageObjects.common.debug('second get the zoom level 6 page data and verify it'); - return PageObjects.visualize.getDataTableData(); - }) - .then(function showData(data) { - expect(data.trim().split('\n')).to.eql(expectedTableDataZoomed); - return PageObjects.visualize.collapseChart(); - }) - .then(function () { - return PageObjects.visualize.loadSavedVisualization(vizName1); - }) - .then(function waitForVisualization() { - return PageObjects.visualize.waitForVisualization(); - }) - // sleep a bit before taking the screenshot or it won't show data - .then(function sleep() { - return PageObjects.common.sleep(4000); - }) - .then(function () { - return PageObjects.visualize.collapseChart(); - }) - .then(function getDataTableData() { - PageObjects.common.debug('third get the zoom level 5 page data and verify it'); - return PageObjects.visualize.getDataTableData(); - }) - .then(function showData(data) { - expect(data.trim().split('\n')).to.eql(expectedTableData); - return PageObjects.visualize.collapseChart(); - }) - .then(function takeScreenshot() { - PageObjects.common.debug('Take screenshot'); - PageObjects.common.saveScreenshot('Visualize-site-map'); - }); - }); - - - bdd.it('should zoom in to level 10', function () { - // 6 - return PageObjects.visualize.clickMapZoomIn() - .then(function () { - // 7 - return PageObjects.visualize.clickMapZoomIn(); - }) - .then(function () { - // 8 - return PageObjects.visualize.clickMapZoomIn(); - }) - .then(function () { - // 9 - return PageObjects.visualize.clickMapZoomIn(); - }) - .then(function () { - return PageObjects.common.try(function tryingForTime() { - return PageObjects.visualize.getMapZoomInEnabled() - .then(function (enabled) { - expect(enabled).to.be(true); - }); - }); - }) - .then(function () { - return PageObjects.visualize.clickMapZoomIn(); - }) - .then(function () { - return PageObjects.visualize.getMapZoomInEnabled(); - }) - // now we're at level 10 and zoom out should be disabled - .then(function (enabled) { - expect(enabled).to.be(false); - }); - }); - - - }); -}); diff --git a/test/functional/apps/visualize/index.js b/test/functional/apps/visualize/index.js index d4cf50d7b7fe3..f37c917bd870d 100644 --- a/test/functional/apps/visualize/index.js +++ b/test/functional/apps/visualize/index.js @@ -36,7 +36,6 @@ bdd.describe('visualize app', function () { require('./_data_table'); require('./_metric_chart'); require('./_pie_chart'); - require('./_tile_map'); require('./_vertical_bar_chart'); require('./_heatmap_chart'); require('./_point_series_options');