[Backport] Add Region Map Visualization (#12112)

- Users can now create choropleth maps. This requires configuring an inner join between the results of a term-aggregation and a reference vector layer. This vector layer needs to be in the GeoJson format. By default, Kibana uses vector layers serverd by a data service hosted by Elastic. Users can also bring in their own layers by adding configuration entries in the kibana.yml. These need to point to a CORS-enabled data service that accepts requests from the Kibana application.
- For clarity, the tilemap is renamed to Coordinate Map.
- A new manifest is published by Elastic. this includes metadata for the available tilemap services, as well as metadata for the available vector data layers.

Required manual edits to resolve

Conflicts:
	src/core_plugins/kibana/inject_vars.js
This commit is contained in:
Thomas Neirynck 2017-06-01 10:57:21 -04:00 committed by GitHub
parent a110df05ab
commit f6be1e4698
38 changed files with 1402 additions and 458 deletions

View file

@ -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.

View file

@ -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.

View file

@ -36,7 +36,7 @@ To create a visualization:
<<metric-chart,Metric>>:: Display a single number.
* *Maps*
[horizontal]
<<tilemap,Tile map>>:: Associate the results of an aggregation with geographic
<<tilemap,Coordinate map>>:: Associate the results of an aggregation with geographic
locations.
* *Time Series*
[horizontal]

View file

@ -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 <<tilemap-settings,tilemap settings>>
@ -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

View file

View file

@ -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,

View file

@ -43,6 +43,7 @@ module.exports = function (kibana) {
'docViews'
],
injectVars: function (server) {
const serverConfig = server.config();
//DEPRECATED SETTINGS
@ -51,14 +52,21 @@ module.exports = function (kibana) {
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,
config: tilemapConfig,
},
manifestServiceUrl: serverConfig.get('tilemap.manifestServiceUrl')
}
}
};
},

View file

@ -0,0 +1,27 @@
export function injectVars(server) {
const serverConfig = server.config();
//DEPRECATED SETTINGS
//if the url is set, the old settings must be used.
//keeping this logic for backward compatibilty.
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,
config: tilemapConfig,
}
}
};
}

View file

@ -0,0 +1,9 @@
export default function (kibana) {
return new kibana.Plugin({
uiExports: {
visTypes: ['plugins/region_map/region_map_vis']
}
});
}

View file

@ -0,0 +1,4 @@
{
"name": "region_map",
"version": "kibana"
}

View file

@ -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 = $('<div>').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 = $('<div>');
const icon = $('<i>').css({
background: color,
'border-color': makeColorDarker(color)
});
const text = $('<span>').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;
}

View file

@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" width="50" height="50" viewBox="0 0 50 50">
<g fill-rule="evenodd">
<polygon points="0 8.654 0 19.947 14.999 11.934 14.999 .541"/>
<polygon points="0 22.147 0 49.038 14.999 41.827 14.999 14.135"/>
<polygon points="34.999 26.511 34.999 49.458 49.999 41.346 49.999 18.027"/>
<polygon points="49.999 0 34.999 8.114 34.999 24.28 49.999 15.796"/>
<polygon points="16.999 26.298 32.999 33.655 32.999 8.173 16.999 .48"/>
<polygon points="16.999 41.827 32.999 49.519 32.999 35.76 16.999 28.403"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 569 B

View file

@ -0,0 +1,7 @@
.region-map-vis {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}

View file

@ -0,0 +1,3 @@
<div ng-controller="KbnRegionMapController" class="region-map-vis">
</div>

View file

@ -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);
}
});

View file

@ -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: '<region_map-vis-params></region_map-vis-params>'
},
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']
}
])
});
});

View file

@ -0,0 +1,70 @@
<div class="form-group">
<div class="kuiSideBarSectionTitle">
<div class="kuiSideBarSectionTitle__text">
Layer Settings
</div>
</div>
<div class="kuiSideBarFormRow">
<label class="kuiSideBarFormRow__label" for="regionMap">
Vector map
</label>
<div class="kuiSideBarFormRow__control">
<select
id="regionMap"
class="kuiSelect kuiSideBarSelect"
ng-model="vis.params.selectedLayer"
ng-options="layer.name for layer in vis.type.params.vectorLayers"
ng-change="onLayerChange()"
ng-init="vis.params.selectedLayer=vis.type.params.vectorLayers[0]"
></select>
</div>
</div>
<div class="kuiSideBarFormRow">
<label class="kuiSideBarFormRow__label" for="joinField">
Join on field
</label>
<div class="kuiSideBarFormRow__control">
<select id="joinField"
ng-model="vis.params.selectedJoinField"
ng-options="field.description for field in vis.params.selectedLayer.fields"
ng-init="vis.params.selectedJoinField=vis.params.selectedLayer.fields[0]"
>
<option value=''>Select</option></select>
</div>
</div>
<div class="kuiSideBarSectionTitle">
<div class="kuiSideBarSectionTitle__text">
Style Settings
</div>
</div>
<div class="kuiSideBarFormRow" >
<label class="kuiSideBarFormRow__label" for="colorSchema">
Color Schema
</label>
<div class="kuiSideBarFormRow__control">
<select
id="colorSchema"
class="kuiSelect kuiSideBarSelect"
ng-model="vis.params.colorSchema"
ng-options="mode for mode in vis.type.params.colorSchemas"
></select>
</div>
</div>
<div class="kuiSideBarSectionTitle">
<div class="kuiSideBarSectionTitle__text">
Basic Settings
</div>
</div>
<vislib-basic-options></vislib-basic-options>
</div>

View file

@ -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];
}
}
};
});

View file

@ -0,0 +1,8 @@
<table>
<tbody>
<tr ng-repeat="detail in details" >
<td class="tooltip-label"><b>{{detail.label}}</b></td>
<td class="tooltip-value">{{detail.value}}</td>
</tr>
</tbody>
</table>

View file

@ -0,0 +1,32 @@
import $ from 'jquery';
export default function TileMapTooltipFormatter($compile, $rootScope) {
const $tooltipScope = $rootScope.$new();
const $el = $('<div>').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();
};
}

View file

@ -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: {

View file

@ -163,12 +163,14 @@ module.exports = () => Joi.object({
allowAnonymous: Joi.boolean().default(false),
v6ApiFormat: 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(),
@ -182,6 +184,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

View file

@ -1,4 +1,3 @@
/* eslint-disable */
/*
* Decodes geohash to object containing
* top-left and bottom-right corners of

View file

@ -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 () {

View file

@ -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('&#169;');
});
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]);
});
});
});
});
});

View file

@ -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');
});
});
});

View file

@ -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('&#169;'); // 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');
});
});
});

View file

@ -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);

View file

@ -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();
});

View file

@ -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();
});

View file

@ -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();
@ -107,7 +104,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() {
@ -180,9 +181,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 }

View file

@ -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));
}
}

View file

@ -133,7 +133,11 @@ export function getDefaultSettings() {
}
}, null, 2),
type: 'json',
description: 'Default <a href="http://leafletjs.com/reference.html#tilelayer-wms" target="_blank">properties</a> for the WMS map server support in the tile map'
description: 'Default <a href="http://leafletjs.com/reference.html#tilelayer-wms" target="_blank">properties</a> 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',

View file

@ -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 = [
@ -25,7 +26,8 @@ export default function ({ getService, getPageObjects }) {
'Gauge',
'Goal',
'Metric',
'Tile Map',
'Coordinate Map',
'Region Map',
'Timelion',
'Visual Builder',
'Markdown',

View file

@ -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);
});
});
});
});
}

View file

@ -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'));

52
test/functional/index.js Normal file
View file

@ -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;
});
});
});

View file

@ -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)
@ -67,7 +105,7 @@ export function VisualizePageProvider({ getService, getPageObjects }) {
clickTileMap() {
return remote
.setFindTimeout(defaultFindTimeout)
.findByPartialLinkText('Tile Map')
.findByPartialLinkText('Coordinate Map')
.click();
}
@ -288,6 +326,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)