diff --git a/docs/getting-started/tutorial-visualizing.asciidoc b/docs/getting-started/tutorial-visualizing.asciidoc index 6f8255bb5b1c1..9247ce63956cb 100644 --- a/docs/getting-started/tutorial-visualizing.asciidoc +++ b/docs/getting-started/tutorial-visualizing.asciidoc @@ -129,10 +129,10 @@ make the differences stand out, starting the Y-axis at a value closer to the min Save this chart with the name _Bar Example_. -Next, we're going to use a tile map chart to visualize geographic information in our log file sample data. +Next, we're going to use a coordinate map chart to visualize geographic information in our log file sample data. . Click *New*. -. Select *Tile map*. +. Select *Coordinate map*. . Select the `logstash-*` index pattern. . Set the time window for the events we're exploring: . Click the time picker in the Kibana toolbar. diff --git a/docs/management/advanced-options.asciidoc b/docs/management/advanced-options.asciidoc index b484ef0a3bf99..12397d3b7bdd2 100644 --- a/docs/management/advanced-options.asciidoc +++ b/docs/management/advanced-options.asciidoc @@ -48,7 +48,7 @@ increase request processing time. when necessary. `visualization:tileMap:maxPrecision`:: The maximum geoHash precision displayed on tile maps: 7 is high, 10 is very high, 12 is the maximum. {es-ref}search-aggregations-bucket-geohashgrid-aggregation.html#_cell_dimensions_at_the_equator[Explanation of cell dimensions]. -`visualization:tileMap:WMSdefaults`:: Default properties for the WMS map server support in the tile map. +`visualization:tileMap:WMSdefaults`:: Default properties for the WMS map server support in the coordinate map. `visualization:colorMapping`:: Maps values to specified colors within visualizations. `visualization:loadingDelay`:: Time to wait before dimming visualizations during query. `visualization:dimmingOpacity`:: When part of a visualization is highlighted, by hovering over it for example, ths is the opacity applied to the other elements. A higher number means other elements will be less opaque. diff --git a/docs/visualize.asciidoc b/docs/visualize.asciidoc index fc3e99511f37f..4a3187459c776 100644 --- a/docs/visualize.asciidoc +++ b/docs/visualize.asciidoc @@ -36,7 +36,7 @@ To create a visualization: <>:: Display a single number. * *Maps* [horizontal] -<>:: Associate the results of an aggregation with geographic +<>:: Associate the results of an aggregation with geographic locations. * *Time Series* [horizontal] diff --git a/docs/visualize/tilemap.asciidoc b/docs/visualize/tilemap.asciidoc index cebfa684728f6..4c48dfee38722 100644 --- a/docs/visualize/tilemap.asciidoc +++ b/docs/visualize/tilemap.asciidoc @@ -1,7 +1,7 @@ [[tilemap]] == Tile Maps -A tile map displays a geographic area overlaid with circles keyed to the data determined by the buckets you specify. +A coordinate map displays a geographic area overlaid with circles keyed to the data determined by the buckets you specify. NOTE: By default, Kibana uses the https://www.elastic.co/elastic-tile-service[Elastic Tile Service] to display map tiles. To use other tile service providers, configure the <> @@ -13,7 +13,7 @@ in `kibana.yml`. ===== Metrics -The default _metrics_ aggregation for a tile map is the *Count* aggregation. You can select any of the following +The default _metrics_ aggregation for a coordinate map is the *Count* aggregation. You can select any of the following aggregations as the metrics aggregation: *Count*:: The {es-ref}search-aggregations-metrics-valuecount-aggregation.html[_count_] aggregation returns a raw count of diff --git a/optimize/.empty b/optimize/.empty deleted file mode 100644 index e69de29bb2d1d..0000000000000 diff --git a/src/core_plugins/kbn_vislib_vis_types/public/tile_map.js b/src/core_plugins/kbn_vislib_vis_types/public/tile_map.js index a9742a6f68f30..f1b7275ca6831 100644 --- a/src/core_plugins/kbn_vislib_vis_types/public/tile_map.js +++ b/src/core_plugins/kbn_vislib_vis_types/public/tile_map.js @@ -14,7 +14,7 @@ export default function TileMapVisType(Private, getAppState, courier, config) { return new MapsVisType({ name: 'tile_map', - title: 'Tile Map', + title: 'Coordinate Map', image, description: 'Plot latitude and longitude coordinates on a map', category: VisType.CATEGORY.MAP, diff --git a/src/core_plugins/kibana/inject_vars.js b/src/core_plugins/kibana/inject_vars.js index 9671e38644065..41b678817951f 100644 --- a/src/core_plugins/kibana/inject_vars.js +++ b/src/core_plugins/kibana/inject_vars.js @@ -7,15 +7,21 @@ export function injectVars(server) { const configuredUrl = server.config().get('tilemap.url'); const isOverridden = typeof configuredUrl === 'string' && configuredUrl !== ''; const tilemapConfig = serverConfig.get('tilemap'); + const regionmapsConfig = serverConfig.get('regionmap'); + const mapConfig = serverConfig.get('map'); + + + regionmapsConfig.layers = (regionmapsConfig.layers) ? regionmapsConfig.layers : []; return { kbnDefaultAppId: serverConfig.get('kibana.defaultAppId'), + regionmapsConfig: regionmapsConfig, + mapConfig: mapConfig, tilemapsConfig: { deprecated: { - isOverridden, + isOverridden: isOverridden, config: tilemapConfig, - }, - manifestServiceUrl: serverConfig.get('tilemap.manifestServiceUrl') + } } }; } diff --git a/src/core_plugins/region_map/index.js b/src/core_plugins/region_map/index.js new file mode 100644 index 0000000000000..d0f4df3704d7c --- /dev/null +++ b/src/core_plugins/region_map/index.js @@ -0,0 +1,9 @@ +export default function (kibana) { + + return new kibana.Plugin({ + uiExports: { + visTypes: ['plugins/region_map/region_map_vis'] + } + }); + +} diff --git a/src/core_plugins/region_map/package.json b/src/core_plugins/region_map/package.json new file mode 100644 index 0000000000000..62274b3b9ae45 --- /dev/null +++ b/src/core_plugins/region_map/package.json @@ -0,0 +1,4 @@ +{ + "name": "region_map", + "version": "kibana" +} diff --git a/src/core_plugins/region_map/public/choropleth_layer.js b/src/core_plugins/region_map/public/choropleth_layer.js new file mode 100644 index 0000000000000..a774e68ca6abf --- /dev/null +++ b/src/core_plugins/region_map/public/choropleth_layer.js @@ -0,0 +1,291 @@ +import $ from 'jquery'; +import L from 'leaflet'; +import _ from 'lodash'; +import d3 from 'd3'; +import { KibanaMapLayer } from 'ui/vis_maps/kibana_map_layer'; +import { truncatedColorMaps } from 'ui/vislib/components/color/truncated_colormaps'; + +export default class ChoroplethLayer extends KibanaMapLayer { + + constructor(geojsonUrl) { + super(); + + + this._metrics = null; + this._joinField = null; + this._colorRamp = truncatedColorMaps[Object.keys(truncatedColorMaps)[0]]; + 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; + this._error = false; + $.ajax({ + dataType: 'json', + url: geojsonUrl, + success: (data) => { + this._leafletLayer.addData(data); + this._loaded = true; + this._setStyle(); + }, + error: () => { + this._loaded = true; + this._error = true; + } + }); + } + + _setStyle() { + if (this._error || (!this._loaded || !this._metrics || !this._joinField)) { + return; + } + + const styler = makeChoroplethStyler(this._metrics, this._colorRamp, this._joinField); + this._leafletLayer.setStyle(styler.getLeafletStyleFunction); + + 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', { + mismatches: styler.getMismatches() + }); + } + + 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 makeChoroplethStyler(data, colorramp, joinField) { + + + if (data.length === 0) { + return { + getLeafletStyleFunction: function () { + return emptyStyle(); + }, + getMismatches: function () { + return []; + } + }; + } + + const { min, max } = getMinMax(data); + const outstandingFeatures = data.slice(); + return { + getLeafletStyleFunction: function (geojsonFeature) { + let lastIndex = -1; + const match = outstandingFeatures.find((bucket, index) => { + lastIndex = index; + 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(); + } + + outstandingFeatures.splice(lastIndex, 1); + return { + fillColor: getChoroplethColor(match.value, min, max, colorramp), + weight: 2, + opacity: 1, + color: 'white', + fillOpacity: 0.7 + }; + }, + /** + * should not be called until getLeafletStyleFunction has been called + * @return {Array} + */ + getMismatches: function () { + return outstandingFeatures.map((bucket) => bucket.term); + } + }; + + +} + + +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/region_map/public/images/icon-vector-map.svg b/src/core_plugins/region_map/public/images/icon-vector-map.svg new file mode 100644 index 0000000000000..2eabb777ea55c --- /dev/null +++ b/src/core_plugins/region_map/public/images/icon-vector-map.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/src/core_plugins/region_map/public/region_map.less b/src/core_plugins/region_map/public/region_map.less new file mode 100644 index 0000000000000..192ca71e05b80 --- /dev/null +++ b/src/core_plugins/region_map/public/region_map.less @@ -0,0 +1,7 @@ +.region-map-vis { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; +} diff --git a/src/core_plugins/region_map/public/region_map_controller.html b/src/core_plugins/region_map/public/region_map_controller.html new file mode 100644 index 0000000000000..0fca4d19fde2c --- /dev/null +++ b/src/core_plugins/region_map/public/region_map_controller.html @@ -0,0 +1,3 @@ +
+ +
diff --git a/src/core_plugins/region_map/public/region_map_controller.js b/src/core_plugins/region_map/public/region_map_controller.js new file mode 100644 index 0000000000000..0245f51f551ec --- /dev/null +++ b/src/core_plugins/region_map/public/region_map_controller.js @@ -0,0 +1,145 @@ +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 ChoroplethLayer from './choropleth_layer'; +import { truncatedColorMaps } from 'ui/vislib/components/color/truncated_colormaps'; +import AggResponsePointSeriesTooltipFormatterProvider from './tooltip_formatter'; +import { ResizeCheckerProvider } from 'ui/resize_checker'; +import 'ui/vis_maps/lib/service_settings'; + + +const module = uiModules.get('kibana/region_map', ['kibana']); +module.controller('KbnRegionMapController', function ($scope, $element, Private, Notifier, getAppState, + serviceSettings, config) { + + const tooltipFormatter = Private(AggResponsePointSeriesTooltipFormatterProvider); + const ResizeChecker = Private(ResizeCheckerProvider); + const notify = new Notifier({ location: 'Region map' }); + const resizeChecker = new ResizeChecker($element); + + let kibanaMap = null; + resizeChecker.on('resize', () => { + if (kibanaMap) { + kibanaMap.resize(); + } + }); + let choroplethLayer = null; + 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: metricsAgg.getValue(bucket) + }; + }); + } + + if (!$scope.vis.params.selectedJoinField && $scope.vis.params.selectedLayer) { + $scope.vis.params.selectedJoinField = $scope.vis.params.selectedLayer.fields[0]; + } + + if (!$scope.vis.params.selectedLayer) { + return; + } + + updateChoroplethLayer($scope.vis.params.selectedLayer.url); + choroplethLayer.setMetrics(results, metricsAgg); + setTooltipFormatter(); + + kibanaMap.useUiStateFromVisualization($scope.vis); + kibanaMap.resize(); + $element.trigger('renderComplete'); + }); + }); + + $scope.$watch('vis.params', (visParams) => { + kibanaMapReady.then(() => { + if (!visParams.selectedJoinField && visParams.selectedLayer) { + visParams.selectedJoinField = visParams.selectedLayer.fields[0]; + } + + if (!visParams.selectedJoinField || !visParams.selectedLayer) { + return; + } + + updateChoroplethLayer(visParams.selectedLayer.url); + choroplethLayer.setJoinField(visParams.selectedJoinField.name); + choroplethLayer.setColorRamp(truncatedColorMaps[visParams.colorSchema]); + setTooltipFormatter(); + + kibanaMap.setShowTooltip(visParams.addTooltip); + kibanaMap.setLegendPosition(visParams.legendPosition); + kibanaMap.useUiStateFromVisualization($scope.vis); + kibanaMap.resize(); + $element.trigger('renderComplete'); + }); + }); + + async function makeKibanaMap() { + const tmsSettings = await serviceSettings.getTMSService(); + const minMaxZoom = tmsSettings.getMinMaxZoom(false); + kibanaMap = new KibanaMap($element[0], minMaxZoom); + const url = tmsSettings.getUrl(); + const options = tmsSettings.getTMSOptions(); + kibanaMap.setBaseLayer({ baseLayerType: 'tms', options: { url, ...options } }); + kibanaMap.addLegendControl(); + kibanaMap.addFitControl(); + kibanaMap.persistUiStateForVisualization($scope.vis); + } + + function setTooltipFormatter() { + const metricsAgg = _.first($scope.vis.aggs.bySchemaName.metric); + if ($scope.vis.aggs.bySchemaName.segment && $scope.vis.aggs.bySchemaName.segment[0]) { + const fieldName = $scope.vis.aggs.bySchemaName.segment[0].makeLabel(); + choroplethLayer.setTooltipFormatter(tooltipFormatter, metricsAgg, fieldName); + } else { + choroplethLayer.setTooltipFormatter(tooltipFormatter, metricsAgg, null); + } + } + + function updateChoroplethLayer(url) { + + if (choroplethLayer && choroplethLayer.equalsGeoJsonUrl(url)) {//no need to recreate the layer + 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 aggs = $scope.vis.aggs.getResponseAggs(); + const aggConfigResult = new AggConfigResult(aggs[0], false, event, event); + $scope.vis.listeners.click({ point: { aggConfigResult: aggConfigResult } }); + }); + choroplethLayer.on('styleChanged', function (event) { + if (event.mismatches.length > 0 && config.get('visualization:regionmap:showWarnings')) { + notify.warning( + `Could not show ${event.mismatches.length} ${event.mismatches.length > 1 ? 'results' : 'result'} on the map.` + + ` To avoid this, ensure that each term can be joined to a corresponding shape on that shape's join field.` + + ` Could not join following terms: ${event.mismatches.join(',')}` + ); + } + }); + kibanaMap.addLayer(choroplethLayer); + } + + +}); + + diff --git a/src/core_plugins/region_map/public/region_map_vis.js b/src/core_plugins/region_map/public/region_map_vis.js new file mode 100644 index 0000000000000..e2ab2437baf34 --- /dev/null +++ b/src/core_plugins/region_map/public/region_map_vis.js @@ -0,0 +1,81 @@ +import './region_map.less'; +import './region_map_controller'; +import './region_map_vis_params'; +import regionTemplate from './region_map_controller.html'; +import image from './images/icon-vector-map.svg'; +import { TemplateVisTypeProvider } from 'ui/template_vis_type/template_vis_type'; +import { VisSchemasProvider } from 'ui/vis/schemas'; +import { VisTypesRegistryProvider } from 'ui/registry/vis_types'; +import { VisVisTypeProvider } from 'ui/vis/vis_type'; +import { truncatedColorMaps } from 'ui/vislib/components/color/truncated_colormaps'; + +VisTypesRegistryProvider.register(function RegionMapProvider(Private, regionmapsConfig) { + + const VisType = Private(VisVisTypeProvider); + const TemplateVisType = Private(TemplateVisTypeProvider); + const Schemas = Private(VisSchemasProvider); + + const vectorLayers = regionmapsConfig.layers; + const selectedLayer = vectorLayers[0]; + const selectedJoinField = selectedLayer ? vectorLayers[0].fields[0] : null; + + return new TemplateVisType({ + name: 'region_map', + title: 'Region 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.', + category: VisType.CATEGORY.MAP, + image, + template: regionTemplate, + params: { + defaults: { + legendPosition: 'bottomright', + addTooltip: true, + colorSchema: 'Yellow to Red', + selectedLayer: selectedLayer, + selectedJoinField: selectedJoinField + }, + legendPositions: [{ + value: 'bottomleft', + text: 'bottom left', + }, { + value: 'bottomright', + text: 'bottom right', + }, { + value: 'topleft', + text: 'top left', + }, { + value: 'topright', + text: 'top right', + }], + colorSchemas: Object.keys(truncatedColorMaps), + vectorLayers: vectorLayers, + editor: '' + }, + schemas: new Schemas([ + { + group: 'metrics', + name: 'metric', + title: 'Value', + min: 1, + max: 1, + aggFilter: ['count', 'avg', 'sum', 'min', 'max', 'cardinality', 'top_hits', 'sum_bucket', 'min_bucket', 'max_bucket', 'avg_bucket'], + 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/region_map/public/region_map_vis_params.html b/src/core_plugins/region_map/public/region_map_vis_params.html new file mode 100644 index 0000000000000..7e5e691b753e7 --- /dev/null +++ b/src/core_plugins/region_map/public/region_map_vis_params.html @@ -0,0 +1,70 @@ +
+ +
+
+ Layer Settings +
+
+ +
+ +
+ +
+
+ +
+ +
+ +
+
+ +
+
+ Style Settings +
+
+ +
+ +
+ +
+
+ + +
+
+ Basic Settings +
+
+ + + + + +
diff --git a/src/core_plugins/region_map/public/region_map_vis_params.js b/src/core_plugins/region_map/public/region_map_vis_params.js new file mode 100644 index 0000000000000..88189c43307e5 --- /dev/null +++ b/src/core_plugins/region_map/public/region_map_vis_params.js @@ -0,0 +1,54 @@ +import { uiModules } from 'ui/modules'; +import regionMapVisParamsTemplate from './region_map_vis_params.html'; +import _ from 'lodash'; + +uiModules.get('kibana/region_map') + .directive('regionMapVisParams', function (serviceSettings, Notifier) { + + + const notify = new Notifier({ location: 'Region map' }); + + return { + restrict: 'E', + template: regionMapVisParamsTemplate, + link: function ($scope) { + + $scope.onLayerChange = onLayerChange; + serviceSettings.getFileLayers() + .then(function (layersFromService) { + + const newVectorLayers = $scope.vis.type.params.vectorLayers.slice(); + for (let i = 0; i < layersFromService.length; i += 1) { + const layerFromService = layersFromService[i]; + const alreadyAdded = newVectorLayers.some((layer) =>_.eq(layerFromService, layer)); + if (!alreadyAdded) { + newVectorLayers.push(layerFromService); + } + } + + $scope.vis.type.params.vectorLayers = newVectorLayers; + + if ($scope.vis.type.params.vectorLayers[0] && !$scope.vis.params.selectedLayer) { + $scope.vis.params.selectedLayer = $scope.vis.type.params.vectorLayers[0]; + onLayerChange(); + } + + + //the dirty flag is set to true because the change in vector layers config causes an update of the scope.params + //temp work-around. addressing this issue with the visualize refactor for 6.0 + setTimeout(function () { + $scope.dirty = false; + }, 0); + + }) + .catch(function (error) { + notify.warning(error.message); + }); + + function onLayerChange() { + $scope.vis.params.selectedJoinField = $scope.vis.params.selectedLayer.fields[0]; + } + + } + }; + }); diff --git a/src/core_plugins/region_map/public/tooltip.html b/src/core_plugins/region_map/public/tooltip.html new file mode 100644 index 0000000000000..b0d07cb80e4b3 --- /dev/null +++ b/src/core_plugins/region_map/public/tooltip.html @@ -0,0 +1,8 @@ + + + + + + + +
{{detail.label}}{{detail.value}}
diff --git a/src/core_plugins/region_map/public/tooltip_formatter.js b/src/core_plugins/region_map/public/tooltip_formatter.js new file mode 100644 index 0000000000000..d78cea59c5f52 --- /dev/null +++ b/src/core_plugins/region_map/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/tests_bundle/tests_entry_template.js b/src/core_plugins/tests_bundle/tests_entry_template.js index f5208e78de347..628526a940a37 100644 --- a/src/core_plugins/tests_bundle/tests_entry_template.js +++ b/src/core_plugins/tests_bundle/tests_entry_template.js @@ -30,17 +30,18 @@ window.__KBN__ = { esRequestTimeout: '300000', tilemapsConfig: { deprecated: { - isOverridden: true, - config: { - url: 'https://tiles.elastic.co/v1/default/{z}/{x}/{y}.png?my_app_name=kibana&my_app_version=1.2.3&elastic_tile_service_tos=agree', - options: { - minZoom: 1, - maxZoom: 10, - attribution: '© [Elastic Tile Service](https://www.elastic.co/elastic_tile_service)' + isOverridden: false, + config: { + options: { } } - }, - manifestServiceUrl: 'https://proxy-tiles.elastic.co/v1/manifest' + } + }, + regionmapsConfig: { + layers: [] + }, + mapConfig: { + manifestServiceUrl: 'https://geo.elastic.co/v1/manifest' } }, uiSettings: { diff --git a/src/server/config/schema.js b/src/server/config/schema.js index beec94a3bfe0d..2b06e77fc3288 100644 --- a/src/server/config/schema.js +++ b/src/server/config/schema.js @@ -162,12 +162,14 @@ module.exports = () => Joi.object({ status: Joi.object({ allowAnonymous: Joi.boolean().default(false) }).default(), - tilemap: Joi.object({ + map: Joi.object({ manifestServiceUrl: Joi.when('$dev', { is: true, - then: Joi.string().default('https://tiles-stage.elastic.co/v2/manifest'), - otherwise: Joi.string().default('https://tiles.elastic.co/v2/manifest') - }), + then: Joi.string().default('https://geo.elastic.co/v1/manifest'), + otherwise: Joi.string().default('https://geo.elastic.co/v1/manifest') + }) + }).default(), + tilemap: Joi.object({ url: Joi.string(), options: Joi.object({ attribution: Joi.string(), @@ -181,6 +183,17 @@ module.exports = () => Joi.object({ bounds: Joi.array().items(Joi.array().items(Joi.number()).min(2).required()).min(2) }).default() }).default(), + regionmap: 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() + })) + })) + }).default(), uiSettings: Joi.object({ // this is used to prevent the uiSettings from initializing. Since they // require the elasticsearch plugin in order to function we need to turn diff --git a/src/ui/public/utils/decode_geo_hash.js b/src/ui/public/utils/decode_geo_hash.js index d6e10d05dd7dc..09b36d9e8795e 100644 --- a/src/ui/public/utils/decode_geo_hash.js +++ b/src/ui/public/utils/decode_geo_hash.js @@ -1,4 +1,3 @@ -/* eslint-disable */ /* * Decodes geohash to object containing * top-left and bottom-right corners of diff --git a/src/ui/public/vis_maps/__tests__/kibana_map.js b/src/ui/public/vis_maps/__tests__/kibana_map.js index a3752e42c2d64..e48a8cec39d90 100644 --- a/src/ui/public/vis_maps/__tests__/kibana_map.js +++ b/src/ui/public/vis_maps/__tests__/kibana_map.js @@ -40,13 +40,13 @@ describe('kibana_map tests', function () { teardownDOM(); }); - it('should instantiate with world in view', function () { + it('should instantiate at zoom level 2', function () { const bounds = kibanaMap.getBounds(); - expect(bounds.bottom_right.lon).to.equal(180); - expect(bounds.top_left.lon).to.equal(-180); + expect(bounds.bottom_right.lon).to.equal(90); + expect(bounds.top_left.lon).to.equal(-90); expect(kibanaMap.getCenter().lon).to.equal(0); expect(kibanaMap.getCenter().lat).to.equal(0); - expect(kibanaMap.getZoomLevel()).to.equal(1); + expect(kibanaMap.getZoomLevel()).to.equal(2); }); it('should resize to fit container', function () { diff --git a/src/ui/public/vis_maps/__tests__/service_settings.js b/src/ui/public/vis_maps/__tests__/service_settings.js new file mode 100644 index 0000000000000..fdd3d41443378 --- /dev/null +++ b/src/ui/public/vis_maps/__tests__/service_settings.js @@ -0,0 +1,202 @@ +import expect from 'expect.js'; +import ngMock from 'ng_mock'; +import url from 'url'; +import sinon from 'sinon'; + +describe('service_settings (FKA tilemaptest)', function () { + + + let serviceSettings; + let mapsConfig; + + const manifestUrl = 'https://geo.elastic.co/v1/manifest'; + const tmsManifestUrl = `https://tiles.elastic.co/v2/manifest`; + const vectorManifestUrl = `https://layers.geo.elastic.co/v1/manifest`; + const manifestUrl2 = 'https://foobar/v1/manifest'; + + const manifest = { + 'services': [{ + 'id': 'tiles_v2', + 'name': 'Elastic Tile Service', + 'manifest': tmsManifestUrl, + 'type': 'tms' + }, + { + 'id': 'geo_layers', + 'name': 'Elastic Layer Service', + 'manifest': vectorManifestUrl, + 'type': 'file' + } + ] + }; + + const tmsManifest = { + 'services': [{ + 'id': 'road_map', + 'url': 'https://tiles.elastic.co/v2/default/{z}/{x}/{y}.png?elastic_tile_service_tos=agree&my_app_name=kibana', + 'minZoom': 0, + 'maxZoom': 10, + 'attribution': '© [OpenStreetMap](http://www.openstreetmap.org/copyright) © [Elastic Tile Service](https://www.elastic.co/elastic-tile-service)' + }] + }; + + const vectorManifest = { + 'layers': [{ + 'attribution': '', + 'name': 'US States', + 'format': 'geojson', + 'url': 'https://storage.googleapis.com/elastic-layer.appspot.com/L2FwcGhvc3RpbmdfcHJvZC9ibG9icy9BRW5CMlVvNGJ0aVNidFNJR2dEQl9rbTBjeXhKMU01WjRBeW1kN3JMXzM2Ry1qc3F6QjF4WE5XdHY2ODlnQkRpZFdCY2g1T2dqUGRHSFhSRTU3amlxTVFwZjNBSFhycEFwV2lYR29vTENjZjh1QTZaZnRpaHBzby5VXzZoNk1paGJYSkNPalpI?elastic_tile_service_tos=agree', + 'fields': [{ 'name': 'postal', 'description': 'Two letter abbreviation' }, { + 'name': 'name', + 'description': 'State name' + }], + 'created_at': '2017-04-26T19:45:22.377820', + 'id': 5086441721823232 + }, { + 'attribution': '© [Elastic Tile Service](https://www.elastic.co/elastic-tile-service)', + 'name': 'World Countries', + 'format': 'geojson', + 'url': 'https://storage.googleapis.com/elastic-layer.appspot.com/L2FwcGhvc3RpbmdfcHJvZC9ibG9icy9BRW5CMlVwWTZTWnhRRzNmUk9HUE93TENjLXNVd2IwdVNpc09SRXRyRzBVWWdqOU5qY2hldGJLOFNZSFpUMmZmZWdNZGx0NWprT1R1ZkZ0U1JEdFBtRnkwUWo0S0JuLTVYY1I5RFdSMVZ5alBIZkZuME1qVS04TS5oQTRNTl9yRUJCWk9tMk03?elastic_tile_service_tos=agree', + 'fields': [{ 'name': 'iso2', 'description': 'Two letter abbreviation' }, { + 'name': 'name', + 'description': 'Country name' + }, { 'name': 'iso3', 'description': 'Three letter abbreviation' }], + 'created_at': '2017-04-26T17:12:15.978370', + 'id': 5659313586569216 + }] + }; + + + beforeEach(ngMock.module('kibana', ($provide) => { + + $provide.decorator('mapConfig', () => { + return { + manifestServiceUrl: manifestUrl + }; + }); + })); + + beforeEach(ngMock.inject(function ($injector, $rootScope) { + + serviceSettings = $injector.get('serviceSettings'); + mapsConfig = $injector.get('mapConfig'); + + sinon.stub(serviceSettings, '_getManifest', function (url) { + let contents = null; + if (url.startsWith(tmsManifestUrl)) { + contents = tmsManifest; + } else if (url.startsWith(vectorManifestUrl)) { + contents = vectorManifest; + } else if (url.startsWith(manifestUrl)) { + contents = manifest; + } else if (url.startsWith(manifestUrl2)) { + contents = manifest; + } + return { + data: contents + }; + }); + $rootScope.$digest(); + })); + + afterEach(function () { + serviceSettings._getManifest.restore(); + }); + + describe('TMS', function () { + + it('should get url', async function () { + const tmsService = await serviceSettings.getTMSService(); + const mapUrl = tmsService.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'); + 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 tmsService = await serviceSettings.getTMSService(); + const options = tmsService.getTMSOptions(); + expect(options).to.have.property('minZoom'); + expect(options).to.have.property('maxZoom'); + expect(options).to.have.property('attribution').contain('©'); + }); + + describe('modify - url', function () { + + let tilemapSettings; + + 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', async() => { + serviceSettings.addQueryParams({ foo: 'bar' }); + tilemapSettings = await serviceSettings.getTMSService(); + assertQuery({ foo: 'bar' }); + }); + + it('merged additions with previous values', async() => { + // ensure that changes are always additive + serviceSettings.addQueryParams({ foo: 'bar' }); + serviceSettings.addQueryParams({ bar: 'stool' }); + tilemapSettings = await serviceSettings.getTMSService(); + assertQuery({ foo: 'bar', bar: 'stool' }); + }); + + it('overwrites conflicting previous values', async() => { + // ensure that conflicts are overwritten + serviceSettings.addQueryParams({ foo: 'bar' }); + serviceSettings.addQueryParams({ bar: 'stool' }); + serviceSettings.addQueryParams({ foo: 'tstool' }); + tilemapSettings = await serviceSettings.getTMSService(); + assertQuery({ foo: 'tstool', bar: 'stool' }); + }); + + it('when overridden, should continue to work', async() => { + mapsConfig.manifestServiceUrl = manifestUrl2; + serviceSettings.addQueryParams({ foo: 'bar' }); + tilemapSettings = await serviceSettings.getTMSService(); + assertQuery({ foo: 'bar' }); + }); + + }); + + + }); + + describe('File layers', function () { + + + it('should load manifest', async function () { + serviceSettings.addQueryParams({ foo:'bar' }); + const fileLayers = await serviceSettings.getFileLayers(); + fileLayers.forEach(function (fileLayer, index) { + const expected = vectorManifest.layers[index]; + expect(expected.attribution).to.eql(fileLayer.attribution); + expect(expected.format).to.eql(fileLayer.format); + expect(expected.fields).to.eql(fileLayer.fields); + expect(expected.name).to.eql(fileLayer.name); + expect(expected.created_at).to.eql(fileLayer.created_at); + + const urlObject = url.parse(fileLayer.url, true); + Object.keys({ foo:'bar', elastic_tile_service_tos: 'agree' }).forEach(key => { + expect(urlObject.query).to.have.property(key, expected[key]); + }); + + }); + }); + + + + }); +}); diff --git a/src/ui/public/vis_maps/__tests__/tilemap_settings.js b/src/ui/public/vis_maps/__tests__/tilemap_settings.js deleted file mode 100644 index e5d8982aac2e5..0000000000000 --- a/src/ui/public/vis_maps/__tests__/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__/tilemap_settings_mocked.js b/src/ui/public/vis_maps/__tests__/tilemap_settings_mocked.js deleted file mode 100644 index 3f34c40e9c1ed..0000000000000 --- a/src/ui/public/vis_maps/__tests__/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/kibana_map.js b/src/ui/public/vis_maps/kibana_map.js index 1afbd443ac186..e44f95462cd5f 100644 --- a/src/ui/public/vis_maps/kibana_map.js +++ b/src/ui/public/vis_maps/kibana_map.js @@ -101,7 +101,7 @@ export class KibanaMap extends EventEmitter { minZoom: options.minZoom, maxZoom: options.maxZoom, center: options.center ? options.center : [0, 0], - zoom: options.zoom ? options.zoom : 0 + zoom: options.zoom ? options.zoom : 2 }; this._leafletMap = L.map(containerNode, leafletOptions); diff --git a/src/ui/public/vis_maps/lib/service_settings.js b/src/ui/public/vis_maps/lib/service_settings.js new file mode 100644 index 0000000000000..9983a705cbba4 --- /dev/null +++ b/src/ui/public/vis_maps/lib/service_settings.js @@ -0,0 +1,175 @@ +import { uiModules } from 'ui/modules'; +import _ from 'lodash'; +import marked from 'marked'; +import { modifyUrl } from 'ui/url'; +marked.setOptions({ + gfm: true, // Github-flavored markdown + sanitize: true // Sanitize HTML tags +}); + +uiModules.get('kibana') + .service('serviceSettings', function ($http, $sanitize, mapConfig, tilemapsConfig, kbnVersion) { + + + const attributionFromConfig = $sanitize(marked(tilemapsConfig.deprecated.config.options.attribution || '')); + const tmsOptionsFromConfig = _.assign({}, tilemapsConfig.deprecated.config.options, { attribution: attributionFromConfig }); + + const extendUrl = (url, props) => ( + modifyUrl(url, parsed => _.merge(parsed, props)) + ); + + /** + * Unescape a url template that was escaped by encodeURI() so leaflet + * will be able to correctly locate the varables in the template + * @param {String} url + * @return {String} + */ + const unescapeTemplateVars = url => { + const ENCODED_TEMPLATE_VARS_RE = /%7B(\w+?)%7D/g; + return url.replace(ENCODED_TEMPLATE_VARS_RE, (total, varName) => `{${varName}}`); + }; + + + + class ServiceSettings { + + constructor() { + this._queryParams = { + my_app_version: kbnVersion + }; + + this._loadCatalogue = null; + this._loadFileLayers = null; + this._loadTMSServices = null; + + this._invalidateSettings(); + } + _invalidateSettings() { + + this._loadCatalogue = _.once(async() => { + try { + const response = await this._getManifest(mapConfig.manifestServiceUrl, this._queryParams); + return response.data; + } catch (e) { + if (!e) { + e = new Error('Unkown error'); + } + if (!(e instanceof Error)) { + e = new Error(e.data || `status ${e.statusText || e.status}`); + } + throw new Error(`Could not retrieve manifest from the tile service: ${e.message}`); + } + }); + + + this._loadFileLayers = _.once(async() => { + const catalogue = await this._loadCatalogue(); + const fileService = catalogue.services.filter((service) => service.type === 'file')[0]; + const manifest = await this._getManifest(fileService.manifest, this._queryParams); + const layers = manifest.data.layers.filter(layer => layer.format === 'geojson'); + layers.forEach((layer) => { + layer.url = this._extendUrlWithParams(layer.url); + }); + return layers; + }); + + this._loadTMSServices = _.once(async() => { + + if (tilemapsConfig.deprecated.isOverridden) {//use settings from yml (which are overridden) + const tmsService = _.cloneDeep(tmsOptionsFromConfig); + tmsService.url = tilemapsConfig.deprecated.config.url; + return tmsService; + } + + const catalogue = await this._loadCatalogue(); + const tmsService = catalogue.services.filter((service) => service.type === 'tms')[0]; + const manifest = await this._getManifest(tmsService.manifest, this._queryParams); + const services = manifest.data.services; + + const firstService = _.cloneDeep(services[0]); + if (!firstService) { + throw new Error('Manifest response does not include sufficient service data.'); + } + + + firstService.attribution = $sanitize(marked(firstService.attribution)); + firstService.subdomains = firstService.subdomains || []; + firstService.url = this._extendUrlWithParams(firstService.url); + return firstService; + }); + + } + + _extendUrlWithParams(url) { + return unescapeTemplateVars(extendUrl(url, { + query: this._queryParams + })); + } + + async _getManifest(manifestUrl) { + return $http({ + url: extendUrl(manifestUrl, { query: this._queryParams }), + method: 'GET' + }); + } + + + async getFileLayers() { + return await this._loadFileLayers(); + } + + async getTMSService() { + + const tmsService = await this._loadTMSServices(); + + return { + getUrl: function () { + return tmsService.url; + }, + getMinMaxZoom: (isWMSEnabled) => { + if (isWMSEnabled) { + return { + minZoom: 0, + maxZoom: 18 + }; + } + //Otherwise, we use the settings from the yml. + //note that it is no longer possible to only override the zoom-settings, since all options are read from the manifest + //by default. + //For a custom configuration, users will need to override tilemap.url as well. + return { + minZoom: tmsService.minZoom, + maxZoom: tmsService.maxZoom + }; + }, + getTMSOptions: function () { + return tmsService; + } + }; + } + + getFallbackZoomSettings(isWMSEnabled) { + return (isWMSEnabled) ? { minZoom: 0, maxZoom: 18 } : { minZoom: 0, maxZoom: 10 }; + } + + /** + * Add optional query-parameters to all requests + * + * @param additionalQueryParams + */ + addQueryParams(additionalQueryParams) { + for (const key in additionalQueryParams) { + if (additionalQueryParams.hasOwnProperty(key)) { + if (additionalQueryParams[key] !== this._queryParams[key]) { + //changes detected. + this._queryParams = _.assign({}, this._queryParams, additionalQueryParams); + this._invalidateSettings(); + break; + } + } + } + } + } + + return new ServiceSettings(); + }); diff --git a/src/ui/public/vis_maps/lib/tilemap_settings.js b/src/ui/public/vis_maps/lib/tilemap_settings.js deleted file mode 100644 index 54571ecc0d3a5..0000000000000 --- a/src/ui/public/vis_maps/lib/tilemap_settings.js +++ /dev/null @@ -1,208 +0,0 @@ -import { uiModules } from 'ui/modules'; -import _ from 'lodash'; -import marked from 'marked'; -import { modifyUrl } from 'ui/url'; - -marked.setOptions({ - gfm: true, // Github-flavored markdown - sanitize: true // Sanitize HTML tags -}); - -uiModules.get('kibana') - .service('tilemapSettings', function ($http, tilemapsConfig, $sanitize, kbnVersion) { - const attributionFromConfig = $sanitize(marked(tilemapsConfig.deprecated.config.options.attribution || '')); - const optionsFromConfig = _.assign({}, tilemapsConfig.deprecated.config.options, { attribution: attributionFromConfig }); - const extendUrl = (url, props) => ( - modifyUrl(url, parsed => _.merge(parsed, props)) - ); - - /** - * Unescape a url template that was escaped by encodeURI() so leaflet - * will be able to correctly locate the varables in the template - * @param {String} url - * @return {String} - */ - const unescapeTemplateVars = url => { - const ENCODED_TEMPLATE_VARS_RE = /%7B(\w+?)%7D/g; - return url.replace(ENCODED_TEMPLATE_VARS_RE, (total, varName) => `{${varName}}`); - }; - - class TilemapSettings { - - constructor() { - - this._queryParams = { - my_app_version: kbnVersion - }; - this._error = null; - - //initialize settings with the default of the configuration - this._url = tilemapsConfig.deprecated.config.url; - this._tmsOptions = optionsFromConfig; - - this._invalidateSettings(); - - } - - - _invalidateSettings() { - - this._settingsInitialized = false; - this._loadSettings = _.once(async() => { - - if (tilemapsConfig.deprecated.isOverridden) {//if settings are overridden, we will use those. - this._settingsInitialized = true; - } - - if (this._settingsInitialized) { - return true; - } - - return this._getTileServiceManifest(tilemapsConfig.manifestServiceUrl, this._queryParams) - .then(response => { - const service = _.get(response, 'data.services[0]'); - if (!service) { - throw new Error('Manifest response does not include sufficient service data.'); - } - - this._error = null; - this._tmsOptions = { - attribution: $sanitize(marked(service.attribution)), - minZoom: service.minZoom, - maxZoom: service.maxZoom, - subdomains: service.subdomains || [] - }; - - this._url = unescapeTemplateVars(extendUrl(service.url, { - query: this._queryParams - })); - - this._settingsInitialized = true; - }) - .catch(e => { - this._settingsInitialized = true; - - if (!e) { - e = new Error('Unkown error'); - } - - if (!(e instanceof Error)) { - e = new Error(e.data || `status ${e.statusText || e.status}`); - } - - this._error = new Error(`Could not retrieve manifest from the tile service: ${e.message}`); - }) - .then(() => { - return true; - }); - }); - } - - /** - * Must be called before getUrl/getTMSOptions/getMapOptions can be called. - */ - loadSettings() { - return this._loadSettings(); - } - - /** - * Add optional query-parameters for the request. - * These are only applied when requesting dfrom the manifest. - * - * @param additionalQueryParams - */ - addQueryParams(additionalQueryParams) { - - //check if there are any changes in the settings. - let changes = false; - for (const key in additionalQueryParams) { - if (additionalQueryParams.hasOwnProperty(key)) { - if (additionalQueryParams[key] !== this._queryParams[key]) { - changes = true; - break; - } - } - } - - if (changes) { - this._queryParams = _.assign({}, this._queryParams, additionalQueryParams); - this._invalidateSettings(); - } - - } - - /** - * Get the url of the default TMS - * @return {string} - */ - getUrl() { - if (!this._settingsInitialized) { - throw new Error('Cannot retrieve url before calling .loadSettings first'); - } - return this._url; - } - - /** - * Get the options of the default TMS - * @return {{}} - */ - getTMSOptions() { - if (!this._settingsInitialized) { - throw new Error('Cannot retrieve options before calling .loadSettings first'); - } - return this._tmsOptions; - } - - - /** - * @return {{maxZoom: (*|number), minZoom: (*|number)}} - */ - getMinMaxZoom(isWMSEnabled) { - if (isWMSEnabled) { - return { - minZoom: 0, - maxZoom: 18 - }; - } - - //Otherwise, we use the settings from the yml. - //note that it is no longer possible to only override the zoom-settings, since all options are read from the manifest - //by default. - //For a custom configuration, users will need to override tilemap.url as well. - return { - minZoom: this._tmsOptions.minZoom, - maxZoom: this._tmsOptions.maxZoom - }; - - } - - isInitialized() { - return this._settingsInitialized; - } - - - /** - * Checks if there was an error during initialization of the parameters - */ - hasError() { - return this._error !== null; - } - - getError() { - return this._error; - } - - /** - * Make this a method to allow for overrides by test code - */ - _getTileServiceManifest(manifestUrl) { - return $http({ - url: extendUrl(manifestUrl, { query: this._queryParams }), - method: 'GET' - }); - } - - } - - return new TilemapSettings(); - }); diff --git a/src/ui/public/vis_maps/maps_renderbot.js b/src/ui/public/vis_maps/maps_renderbot.js index ba499cd04d322..9262a07e001a6 100644 --- a/src/ui/public/vis_maps/maps_renderbot.js +++ b/src/ui/public/vis_maps/maps_renderbot.js @@ -5,17 +5,17 @@ import { VislibVisTypeBuildChartDataProvider } from 'ui/vislib_vis_type/build_ch import { FilterBarPushFilterProvider } from 'ui/filter_bar/push_filter'; import { KibanaMap } from './kibana_map'; import { GeohashLayer } from './geohash_layer'; -import './lib/tilemap_settings'; +import './lib/service_settings'; import './styles/_tilemap.less'; import { ResizeCheckerProvider } from 'ui/resize_checker'; -module.exports = function MapsRenderbotFactory(Private, $injector, tilemapSettings, Notifier, courier, getAppState) { +module.exports = function MapsRenderbotFactory(Private, $injector, serviceSettings, Notifier, courier, getAppState) { const ResizeChecker = Private(ResizeCheckerProvider); const Renderbot = Private(VisRenderbotProvider); const buildChartData = Private(VislibVisTypeBuildChartDataProvider); - const notify = new Notifier({ location: 'Tilemap' }); + const notify = new Notifier({ location: 'Coordinate Map' }); class MapsRenderbot extends Renderbot { @@ -26,12 +26,9 @@ module.exports = function MapsRenderbotFactory(Private, $injector, tilemapSettin this._kibanaMap = null; this._$container = $el; 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) { @@ -42,19 +39,19 @@ module.exports = function MapsRenderbotFactory(Private, $injector, tilemapSettin async _makeKibanaMap() { - 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); + try { + this._tmsService = await serviceSettings.getTMSService(); + this._tmsError = null; + } catch (e) { + this._tmsService = null; + this._tmsError = e; + notify.warning(e.message); } if (this._kibanaMap) { this._kibanaMap.destroy(); } + const containerElement = $(this._$container)[0]; const options = _.clone(this._getMinMaxZoom()); const uiState = this.vis.getUiState(); @@ -110,7 +107,11 @@ module.exports = function MapsRenderbotFactory(Private, $injector, tilemapSettin _getMinMaxZoom() { const mapParams = this._getMapsParams(); - return tilemapSettings.getMinMaxZoom(mapParams.wms.enabled); + if (this._tmsError) { + return serviceSettings.getFallbackZoomSettings(mapParams.wms.enabled); + } else { + return this._tmsService.getMinMaxZoom(mapParams.wms.enabled); + } } _recreateGeohashLayer() { @@ -183,9 +184,9 @@ module.exports = function MapsRenderbotFactory(Private, $injector, tilemapSettin this._kibanaMap.setZoomLevel(maxZoom); } - if (!tilemapSettings.hasError()) { - const url = tilemapSettings.getUrl(); - const options = tilemapSettings.getTMSOptions(); + if (!this._tmsError) { + const url = this._tmsService.getUrl(); + const options = this._tmsService.getTMSOptions(); this._kibanaMap.setBaseLayer({ baseLayerType: 'tms', options: { url, ...options } diff --git a/src/ui/public/vislib/components/color/truncated_colormaps.js b/src/ui/public/vislib/components/color/truncated_colormaps.js new file mode 100644 index 0000000000000..a2fda36727674 --- /dev/null +++ b/src/ui/public/vislib/components/color/truncated_colormaps.js @@ -0,0 +1,11 @@ +import { vislibColorMaps } from './colormaps'; + +export const truncatedColorMaps = {}; + +const colormaps = vislibColorMaps; +for (const key in colormaps) { + if (colormaps.hasOwnProperty(key)) { + //slice off lightest colors + truncatedColorMaps[key] = colormaps[key].slice(Math.floor(colormaps[key].length / 4)); + } +} diff --git a/src/ui/settings/defaults.js b/src/ui/settings/defaults.js index 3652559ff4e9d..fd63244aaf884 100644 --- a/src/ui/settings/defaults.js +++ b/src/ui/settings/defaults.js @@ -133,7 +133,11 @@ export default function defaultSettingsProvider() { } }, null, 2), type: 'json', - description: 'Default properties for the WMS map server support in the tile map' + description: 'Default properties for the WMS map server support in the coordinate map' + }, + 'visualization:regionmap:showWarnings': { + value: true, + description: 'Should the vector map show a warning when terms cannot be joined to a shape on the map.' }, 'visualization:colorMapping': { type: 'json', diff --git a/test/functional/apps/visualize/_chart_types.js b/test/functional/apps/visualize/_chart_types.js index 30644869b70b5..2ab077272f5a0 100644 --- a/test/functional/apps/visualize/_chart_types.js +++ b/test/functional/apps/visualize/_chart_types.js @@ -12,6 +12,7 @@ export default function ({ getService, getPageObjects }) { return PageObjects.common.navigateToUrl('visualize', 'new'); }); + describe('chart types', function indexPatternCreation() { it('should show the correct chart types', function () { const expectedChartTypes = [ @@ -23,7 +24,8 @@ export default function ({ getService, getPageObjects }) { 'Vertical Bar', 'Data Table', 'Metric', - 'Tile Map', + 'Coordinate Map', + 'Region Map', 'Timelion', 'Visual Builder', 'Markdown', diff --git a/test/functional/apps/visualize/_region_map.js b/test/functional/apps/visualize/_region_map.js new file mode 100644 index 0000000000000..e094dbac7d493 --- /dev/null +++ b/test/functional/apps/visualize/_region_map.js @@ -0,0 +1,95 @@ +import expect from 'expect.js'; + + +export default function ({ getService, getPageObjects }) { + + + describe('visualize app', function describeIndexTests() { + + const fromTime = '2015-09-19 06:31:44.000'; + const toTime = '2015-09-23 18:31:44.000'; + + const log = getService('log'); + const PageObjects = getPageObjects(['common', 'visualize', 'header', 'settings']); + + before(function () { + + log.debug('navigateToApp visualize'); + return PageObjects.common.navigateToUrl('visualize', 'new') + .then(function () { + log.debug('clickRegionMap'); + return PageObjects.visualize.clickRegionMap(); + }) + .then(function () { + return PageObjects.visualize.clickNewSearch(); + }) + .then(function () { + log.debug('Set absolute time range from \"' + fromTime + '\" to \"' + toTime + '\"'); + return PageObjects.header.setAbsoluteRange(fromTime, toTime); + }) + .then(function clickBucket() { + log.debug('Bucket = shape field'); + return PageObjects.visualize.clickBucket('shape field'); + }) + .then(function selectAggregation() { + log.debug('Aggregation = Terms'); + return PageObjects.visualize.selectAggregation('Terms'); + }) + .then(function selectField() { + log.debug('Field = geo.src'); + return PageObjects.visualize.selectField('geo.src'); + }) + .then(function () { + return PageObjects.visualize.clickGo(); + }) + .then(function () { + return PageObjects.header.waitUntilLoadingHasFinished(); + }); + }); + + describe('vector map', function indexPatternCreation() { + + it('should show results after clicking play (join on states)', function () { + + const expectedColors = [{ color: 'rgb(253,209,109)' }, { color: 'rgb(164,0,37)' }]; + + + return PageObjects.visualize.getVectorMapData() + .then(function (data) { + + log.debug('Actual data-----------------------'); + log.debug(data); + log.debug('---------------------------------'); + + expect(data).to.eql(expectedColors); + }); + }); + + it('should change color ramp', function () { + return PageObjects.visualize.clickOptions() + .then(function () { + return PageObjects.visualize.selectFieldById('Blues', 'colorSchema'); + }) + .then(function () { + return PageObjects.visualize.clickGo(); + }) + .then(function () { + //this should visualize right away, without re-requesting data + return PageObjects.visualize.getVectorMapData(); + }) + .then(function (data) { + + log.debug('Actual data-----------------------'); + log.debug(data); + log.debug('---------------------------------'); + + const expectedColors = [{ color: 'rgb(190,215,236)' }, { color: 'rgb(7,67,136)' }]; + + expect(data).to.eql(expectedColors); + }); + }); + + }); + }); + +} diff --git a/test/functional/apps/visualize/index.js b/test/functional/apps/visualize/index.js index 7f1235ee18d11..edf88914605af 100644 --- a/test/functional/apps/visualize/index.js +++ b/test/functional/apps/visualize/index.js @@ -31,6 +31,7 @@ export default function ({ getService, loadTestFile }) { loadTestFile(require.resolve('./_pie_chart')); loadTestFile(require.resolve('./_tag_cloud')); loadTestFile(require.resolve('./_tile_map')); + loadTestFile(require.resolve('./_region_map')); loadTestFile(require.resolve('./_vertical_bar_chart')); loadTestFile(require.resolve('./_heatmap_chart')); loadTestFile(require.resolve('./_point_series_options')); diff --git a/test/functional/index.js b/test/functional/index.js new file mode 100644 index 0000000000000..4b067580a087f --- /dev/null +++ b/test/functional/index.js @@ -0,0 +1,52 @@ +'use strict'; // eslint-disable-line + +define(function (require) { + require('intern/dojo/node!../support/env_setup'); + + const bdd = require('intern!bdd'); + const intern = require('intern'); + + global.__kibana__intern__ = { intern, bdd }; + + bdd.describe('kibana', function () { + let PageObjects; + let support; + + bdd.before(function () { + PageObjects.init(this.remote); + support.init(this.remote); + }); + const supportPages = [ + 'intern/dojo/node!../support/page_objects', + 'intern/dojo/node!../support' + ]; + + const requestedApps = process.argv.reduce((previous, arg) => { + const option = arg.split('='); + const key = option[0]; + const value = option[1]; + if (key === 'appSuites' && value) return value.split(','); + }); + + const apps = [ + 'intern/dojo/node!./apps/xpack', + 'intern/dojo/node!./apps/discover', + 'intern/dojo/node!./apps/management', + 'intern/dojo/node!./apps/visualize', + 'intern/dojo/node!./apps/console', + 'intern/dojo/node!./apps/dashboard', + 'intern/dojo/node!./status_page', + 'intern/dojo/node!./apps/context' + ].filter((suite) => { + if (!requestedApps) return true; + return requestedApps.reduce((previous, app) => { + return previous || ~suite.indexOf(app); + }, false); + }); + + require(supportPages.concat(apps), (loadedPageObjects, loadedSupport) => { + PageObjects = loadedPageObjects; + support = loadedSupport; + }); + }); +}); diff --git a/test/functional/page_objects/visualize_page.js b/test/functional/page_objects/visualize_page.js index 834fdf519c6ff..2da26e9c6c4f4 100644 --- a/test/functional/page_objects/visualize_page.js +++ b/test/functional/page_objects/visualize_page.js @@ -29,6 +29,44 @@ export function VisualizePageProvider({ getService, getPageObjects }) { .click(); } + + clickRegionMap() { + return remote + .setFindTimeout(defaultFindTimeout) + .findByPartialLinkText('Region Map') + .click(); + } + + getVectorMapData() { + return remote + .setFindTimeout(defaultFindTimeout) + .findAllByCssSelector('path.leaflet-clickable') + .then((chartTypes) => { + + + function getChartType(chart) { + let color; + return chart.getAttribute('fill') + .then((stroke) => { + color = stroke; + }) + .then(() => { + return { color: color }; + }); + } + + const getChartTypesPromises = chartTypes.map(getChartType); + return Promise.all(getChartTypesPromises); + }) + .then((data) => { + data = data.filter((country) => { + //filter empty colors + return country.color !== 'rgb(200,200,200)'; + }); + return data; + }); + } + clickMarkdownWidget() { return remote .setFindTimeout(defaultFindTimeout) @@ -60,7 +98,7 @@ export function VisualizePageProvider({ getService, getPageObjects }) { clickTileMap() { return remote .setFindTimeout(defaultFindTimeout) - .findByPartialLinkText('Tile Map') + .findByPartialLinkText('Coordinate Map') .click(); } @@ -274,6 +312,16 @@ export function VisualizePageProvider({ getService, getPageObjects }) { }); } + selectFieldById(fieldValue, id) { + return retry.try(function tryingForTime() { + return remote + .setFindTimeout(defaultFindTimeout) + // the css below should be more selective + .findByCssSelector(`#${id} > option[label="${fieldValue}"]`) + .click(); + }); + } + orderBy(fieldValue) { return remote .setFindTimeout(defaultFindTimeout)