mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 01:38:56 -04:00
Added Vega interactive map support (#16512)
Implements https://github.com/elastic/kibana/issues/15842 * Added Vega interactive map support * Support vega-lite maps * handle VL-injected width/height/padding/autosize * clarified min/max zoom limits * moved kibana_map to ui/vis/map, doc fixes * bumped leaflet-vega dep
This commit is contained in:
parent
f0d01928db
commit
3a29e18504
16 changed files with 257 additions and 30 deletions
|
@ -5,7 +5,7 @@ experimental[]
|
|||
__________________________________________________________________________________________________________________________________________________________________________________
|
||||
Build https://vega.github.io/vega/examples/[Vega] and
|
||||
https://vega.github.io/vega-lite/examples/[VegaLite] data visualizations
|
||||
into Kibana.
|
||||
into Kibana, either standalone, or on top of a map.
|
||||
__________________________________________________________________________________________________________________________________________________________________________________
|
||||
|
||||
[[vega-introduction-video]]
|
||||
|
@ -186,6 +186,55 @@ url: {
|
|||
format: {property: "features"}
|
||||
----
|
||||
|
||||
[[vega-with-a-map]]
|
||||
=== Vega with a map
|
||||
|
||||
Kibana's default map can be used as a base of the Vega graph. To enable,
|
||||
the graph must specify `type=map` in the host configuration:
|
||||
|
||||
[source,yaml]
|
||||
----
|
||||
{
|
||||
"config": {
|
||||
"kibana": {
|
||||
"type": "map",
|
||||
|
||||
// Initial map position
|
||||
"latitude": 40.7, // default 0
|
||||
"longitude": -74, // default 0
|
||||
"zoom": 7, // default 2
|
||||
|
||||
// defaults to "default". Use false to disable base layer.
|
||||
"mapStyle": false,
|
||||
|
||||
// default 0
|
||||
"minZoom": 5,
|
||||
|
||||
// defaults to the maximum for the given style,
|
||||
// or 25 when base is disabled
|
||||
"maxZoom": 13,
|
||||
|
||||
// defaults to true, shows +/- buttons to zoom in/out
|
||||
"zoomControl": false,
|
||||
|
||||
// defaults to true, disables mouse wheel zoom
|
||||
"scrollWheelZoom": false,
|
||||
|
||||
// When false, repaints on each move frame.
|
||||
// Makes the graph slower when moving the map
|
||||
"delayRepaint": true, // default true
|
||||
}
|
||||
},
|
||||
/* the rest of Vega JSON */
|
||||
}
|
||||
----
|
||||
|
||||
This visualization will automatically inject a projection called
|
||||
`"projection"`. Use it to calculate positioning of all geo-aware marks.
|
||||
Additionally, you may use `latitude`, `longitude`, and `zoom` signals.
|
||||
These signals can be used in the graph, or can be updated to modify the
|
||||
positioning of the map.
|
||||
|
||||
[[vega-debugging]]
|
||||
== Debugging
|
||||
|
||||
|
@ -255,7 +304,8 @@ values. See link:#sizing-and-positioning[sizing and positioning] below.
|
|||
[[vega-additional-configuration-options]]
|
||||
==== Additional configuration options
|
||||
|
||||
These options are specific to the Kibana.
|
||||
These options are specific to the Kibana. link:#vega-with-a-map[Map support] has
|
||||
additional configuration options.
|
||||
|
||||
[source,yaml]
|
||||
----
|
||||
|
@ -288,3 +338,9 @@ By default, Kibana Vega graphs will use
|
|||
and VegaLite graphs. The `fit` model uses all available space, ignores
|
||||
`width` and `height` values, but respects the padding values. You may
|
||||
override this behaviour by specifying a different `autosize` value.
|
||||
|
||||
[[vega-on-a-map]]
|
||||
Vega on a map
|
||||
|
||||
All Vega graphs will ignore `autosize`, `width`, `height`, and `padding`
|
||||
values, using `fit` model with zero padding.
|
||||
|
|
|
@ -148,6 +148,7 @@
|
|||
"leaflet-draw": "0.4.10",
|
||||
"leaflet-responsive-popup": "0.2.0",
|
||||
"leaflet.heat": "0.2.0",
|
||||
"leaflet-vega": "0.8.3",
|
||||
"less": "2.7.1",
|
||||
"less-loader": "4.0.5",
|
||||
"lodash": "3.10.1",
|
||||
|
|
|
@ -2,7 +2,7 @@ import $ from 'jquery';
|
|||
import L from 'leaflet';
|
||||
import _ from 'lodash';
|
||||
import d3 from 'd3';
|
||||
import { KibanaMapLayer } from '../../tile_map/public/kibana_map_layer';
|
||||
import { KibanaMapLayer } from 'ui/vis/map/kibana_map_layer';
|
||||
import { truncatedColorMaps } from 'ui/vislib/components/color/truncated_colormaps';
|
||||
import * as topojson from 'topojson-client';
|
||||
import { toastNotifications } from 'ui/notify';
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import expect from 'expect.js';
|
||||
import { KibanaMap } from '../kibana_map';
|
||||
import { KibanaMap } from 'ui/vis/map/kibana_map';
|
||||
import { GeohashLayer } from '../geohash_layer';
|
||||
import { GeoHashSampleData } from './geohash_sample_data';
|
||||
import heatmapPng from './heatmap.png';
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import _ from 'lodash';
|
||||
import { KibanaMap } from './kibana_map';
|
||||
import { KibanaMap } from 'ui/vis/map/kibana_map';
|
||||
import { Observable } from 'rxjs/Rx';
|
||||
import 'ui/vis/map/service_settings';
|
||||
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import L from 'leaflet';
|
||||
import _ from 'lodash';
|
||||
|
||||
import { KibanaMapLayer } from './kibana_map_layer';
|
||||
import { KibanaMapLayer } from 'ui/vis/map/kibana_map_layer';
|
||||
import { HeatmapMarkers } from './markers/heatmap';
|
||||
import { ScaledCirclesMarkers } from './markers/scaled_circles';
|
||||
import { ShadedCirclesMarkers } from './markers/shaded_circles';
|
||||
|
|
|
@ -117,7 +117,8 @@ describe('VegaParser._parseMapConfig', () => {
|
|||
latitude: 0,
|
||||
longitude: 0,
|
||||
mapStyle: 'default',
|
||||
zoomControl: true
|
||||
zoomControl: true,
|
||||
scrollWheelZoom: true,
|
||||
}, 0));
|
||||
|
||||
it('filled', test({
|
||||
|
@ -126,6 +127,7 @@ describe('VegaParser._parseMapConfig', () => {
|
|||
longitude: 0,
|
||||
mapStyle: 'default',
|
||||
zoomControl: true,
|
||||
scrollWheelZoom: true,
|
||||
maxBounds: [1, 2, 3, 4],
|
||||
}, {
|
||||
delayRepaint: true,
|
||||
|
@ -133,6 +135,7 @@ describe('VegaParser._parseMapConfig', () => {
|
|||
longitude: 0,
|
||||
mapStyle: 'default',
|
||||
zoomControl: true,
|
||||
scrollWheelZoom: true,
|
||||
maxBounds: [1, 2, 3, 4],
|
||||
}, 0));
|
||||
|
||||
|
@ -143,6 +146,7 @@ describe('VegaParser._parseMapConfig', () => {
|
|||
zoom: 'abc', // ignored
|
||||
mapStyle: 'abc',
|
||||
zoomControl: 'abc',
|
||||
scrollWheelZoom: 'abc',
|
||||
maxBounds: [2, 3, 4],
|
||||
}, {
|
||||
delayRepaint: true,
|
||||
|
@ -150,7 +154,8 @@ describe('VegaParser._parseMapConfig', () => {
|
|||
longitude: 0,
|
||||
mapStyle: 'default',
|
||||
zoomControl: true,
|
||||
}, 4));
|
||||
scrollWheelZoom: true,
|
||||
}, 5));
|
||||
});
|
||||
|
||||
describe('VegaParser._parseConfig', () => {
|
||||
|
|
|
@ -87,14 +87,38 @@ export class VegaParser {
|
|||
* @private
|
||||
*/
|
||||
_compileVegaLite() {
|
||||
if (this.useMap) {
|
||||
throw new Error('"_map" configuration is not compatible with vega-lite spec');
|
||||
}
|
||||
this.vlspec = this.spec;
|
||||
|
||||
const logger = vega.logger(vega.Warn);
|
||||
logger.warn = this._onWarning.bind(this);
|
||||
this.spec = vegaLite.compile(this.vlspec, logger).spec;
|
||||
|
||||
// When using VL with the type=map and user did not provid their own projection settings,
|
||||
// remove the default projection that was generated by VegaLite compiler.
|
||||
// This way we let leaflet-vega library inject a different default projection for tile maps.
|
||||
// Also, VL injects default padding and autosize values, but neither should be set for vega-leaflet.
|
||||
if (this.useMap) {
|
||||
const hasConfig = _.isPlainObject(this.vlspec.config);
|
||||
if (this.vlspec.config === undefined || (hasConfig && !this.vlspec.config.projection)) {
|
||||
// Assume VL generates spec.projections = an array of exactly one object named 'projection'
|
||||
if (!Array.isArray(this.spec.projections) ||
|
||||
this.spec.projections.length !== 1 ||
|
||||
this.spec.projections[0].name !== 'projection'
|
||||
) {
|
||||
throw new Error('Internal error: VL compiler should have generated a single projection object');
|
||||
}
|
||||
delete this.spec.projections;
|
||||
}
|
||||
|
||||
// todo: sizing cleanup might need to be rethought and consolidated
|
||||
if (!this.vlspec.width) delete this.spec.width;
|
||||
if (!this.vlspec.height) delete this.spec.height;
|
||||
if (!this.vlspec.padding && (this.vlspec.config === undefined || (hasConfig && !this.vlspec.config.padding))) {
|
||||
delete this.spec.padding;
|
||||
}
|
||||
if (!this.vlspec.autosize && (this.vlspec.config === undefined || (hasConfig && !this.vlspec.config.autosize))) {
|
||||
delete this.spec.autosize;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -219,15 +243,8 @@ export class VegaParser {
|
|||
res.mapStyle = `default`;
|
||||
}
|
||||
|
||||
const zoomControl = this._config.zoomControl;
|
||||
if (zoomControl === undefined) {
|
||||
res.zoomControl = true;
|
||||
} else if (typeof zoomControl !== 'boolean') {
|
||||
this._onWarning('config.kibana.zoomControl must be a boolean value');
|
||||
res.zoomControl = true;
|
||||
} else {
|
||||
res.zoomControl = zoomControl;
|
||||
}
|
||||
this._parseBool('zoomControl', res, true);
|
||||
this._parseBool('scrollWheelZoom', res, true);
|
||||
|
||||
const maxBounds = this._config.maxBounds;
|
||||
if (maxBounds !== undefined) {
|
||||
|
@ -243,6 +260,18 @@ export class VegaParser {
|
|||
return res;
|
||||
}
|
||||
|
||||
_parseBool(paramName, dstObj, dflt) {
|
||||
const val = this._config[paramName];
|
||||
if (val === undefined) {
|
||||
dstObj[paramName] = dflt;
|
||||
} else if (typeof val !== 'boolean') {
|
||||
this._onWarning(`config.kibana.${paramName} must be a boolean value`);
|
||||
dstObj[paramName] = dflt;
|
||||
} else {
|
||||
dstObj[paramName] = val;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse Vega schema element
|
||||
* @returns {boolean} is this a VegaLite schema?
|
||||
|
|
|
@ -55,10 +55,14 @@ export class VegaBaseView {
|
|||
.appendTo(this._$parentEl);
|
||||
|
||||
this._addDestroyHandler(() => {
|
||||
this._$container.remove();
|
||||
this._$container = null;
|
||||
this._$controls.remove();
|
||||
this._$controls = null;
|
||||
if (this._$container) {
|
||||
this._$container.remove();
|
||||
this._$container = null;
|
||||
}
|
||||
if (this._$controls) {
|
||||
this._$controls.remove();
|
||||
this._$controls = null;
|
||||
}
|
||||
if (this._$messages) {
|
||||
this._$messages.remove();
|
||||
this._$messages = null;
|
||||
|
|
24
src/core_plugins/vega/public/vega_view/vega_map_layer.js
Normal file
24
src/core_plugins/vega/public/vega_view/vega_map_layer.js
Normal file
|
@ -0,0 +1,24 @@
|
|||
import L from 'leaflet';
|
||||
import 'leaflet-vega';
|
||||
import { KibanaMapLayer } from 'ui/vis/map/kibana_map_layer';
|
||||
|
||||
export class VegaMapLayer extends KibanaMapLayer {
|
||||
|
||||
constructor(spec, options) {
|
||||
super();
|
||||
|
||||
// Used by super.getAttributions()
|
||||
this._attribution = options.attribution;
|
||||
delete options.attribution;
|
||||
|
||||
this._leafletLayer = L.vega(spec, options);
|
||||
}
|
||||
|
||||
getVegaView() {
|
||||
return this._leafletLayer._view;
|
||||
}
|
||||
|
||||
getVegaSpec() {
|
||||
return this._leafletLayer._spec;
|
||||
}
|
||||
}
|
93
src/core_plugins/vega/public/vega_view/vega_map_view.js
Normal file
93
src/core_plugins/vega/public/vega_view/vega_map_view.js
Normal file
|
@ -0,0 +1,93 @@
|
|||
import { KibanaMap } from 'ui/vis/map/kibana_map';
|
||||
import * as vega from 'vega-lib';
|
||||
import { VegaBaseView } from './vega_base_view';
|
||||
import { VegaMapLayer } from './vega_map_layer';
|
||||
|
||||
export class VegaMapView extends VegaBaseView {
|
||||
|
||||
async _initViewCustomizations() {
|
||||
const mapConfig = this._parser.mapConfig;
|
||||
let baseMapOpts;
|
||||
let limitMinZ = 0;
|
||||
let limitMaxZ = 25;
|
||||
|
||||
if (mapConfig.mapStyle !== false) {
|
||||
const tmsServices = await this._serviceSettings.getTMSServices();
|
||||
// In some cases, Vega may be initialized twice, e.g. after awaiting...
|
||||
if (!this._$container) return;
|
||||
const mapStyle = mapConfig.mapStyle === 'default' ? 'road_map' : mapConfig.mapStyle;
|
||||
baseMapOpts = tmsServices.find((s) => s.id === mapStyle);
|
||||
if (!baseMapOpts) {
|
||||
this.onWarn(`mapStyle ${JSON.stringify(mapStyle)} was not found`);
|
||||
} else {
|
||||
limitMinZ = baseMapOpts.minZoom;
|
||||
limitMaxZ = baseMapOpts.maxZoom;
|
||||
}
|
||||
}
|
||||
|
||||
const validate = (name, value, dflt, min, max) => {
|
||||
if (value === undefined) {
|
||||
value = dflt;
|
||||
} else if (value < min) {
|
||||
this.onWarn(`Resetting ${name} to ${min}`);
|
||||
value = min;
|
||||
} else if (value > max) {
|
||||
this.onWarn(`Resetting ${name} to ${max}`);
|
||||
value = max;
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
let minZoom = validate('minZoom', mapConfig.minZoom, limitMinZ, limitMinZ, limitMaxZ);
|
||||
let maxZoom = validate('maxZoom', mapConfig.maxZoom, limitMaxZ, limitMinZ, limitMaxZ);
|
||||
if (minZoom > maxZoom) {
|
||||
this.onWarn('minZoom and maxZoom have been swapped');
|
||||
[minZoom, maxZoom] = [maxZoom, minZoom];
|
||||
}
|
||||
const zoom = validate('zoom', mapConfig.zoom, 2, minZoom, maxZoom);
|
||||
|
||||
// let maxBounds = null;
|
||||
// if (mapConfig.maxBounds) {
|
||||
// const b = mapConfig.maxBounds;
|
||||
// maxBounds = L.latLngBounds(L.latLng(b[1], b[0]), L.latLng(b[3], b[2]));
|
||||
// }
|
||||
|
||||
this._kibanaMap = new KibanaMap(
|
||||
this._$container.get(0),
|
||||
{
|
||||
zoom, minZoom, maxZoom,
|
||||
center: [mapConfig.latitude, mapConfig.longitude],
|
||||
zoomControl: mapConfig.zoomControl,
|
||||
scrollWheelZoom: mapConfig.scrollWheelZoom,
|
||||
});
|
||||
|
||||
if (baseMapOpts) {
|
||||
this._kibanaMap.setBaseLayer({
|
||||
baseLayerType: 'tms',
|
||||
options: baseMapOpts
|
||||
});
|
||||
}
|
||||
|
||||
const vegaMapLayer = new VegaMapLayer(this._parser.spec, {
|
||||
vega,
|
||||
bindingsContainer: this._$controls.get(0),
|
||||
delayRepaint: mapConfig.delayRepaint,
|
||||
viewConfig: this._vegaViewConfig,
|
||||
onWarning: this.onWarn.bind(this),
|
||||
onError: this.onError.bind(this),
|
||||
});
|
||||
|
||||
this._kibanaMap.addLayer(vegaMapLayer);
|
||||
|
||||
this.setDebugValues(vegaMapLayer.getVegaView(), vegaMapLayer.getVegaSpec());
|
||||
|
||||
this._addDestroyHandler(() => {
|
||||
this._kibanaMap.removeLayer(vegaMapLayer);
|
||||
if (baseMapOpts) {
|
||||
this._kibanaMap.setBaseLayer(null);
|
||||
}
|
||||
this._kibanaMap.destroy();
|
||||
});
|
||||
}
|
||||
|
||||
}
|
|
@ -1,5 +1,6 @@
|
|||
import { Notifier } from 'ui/notify';
|
||||
import { VegaView } from './vega_view/vega_view';
|
||||
import { VegaMapView } from './vega_view/vega_map_view';
|
||||
|
||||
export function VegaVisualizationProvider(vegaConfig, serviceSettings) {
|
||||
|
||||
|
@ -46,7 +47,7 @@ export function VegaVisualizationProvider(vegaConfig, serviceSettings) {
|
|||
}
|
||||
|
||||
if (vegaParser.useMap) {
|
||||
throw new Error('Map mode is not yet supported in Kibana Core. You must use Kibana Vega plugin');
|
||||
this._vegaView = new VegaMapView(vegaConfig, this._vis.editorMode, this._el, vegaParser, serviceSettings);
|
||||
} else {
|
||||
this._vegaView = new VegaView(vegaConfig, this._vis.editorMode, this._el, vegaParser, serviceSettings);
|
||||
}
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import expect from 'expect.js';
|
||||
import { KibanaMap } from '../kibana_map';
|
||||
import { KibanaMapLayer } from '../kibana_map_layer';
|
||||
import { KibanaMap } from 'ui/vis/map/kibana_map';
|
||||
import { KibanaMapLayer } from 'ui/vis/map/kibana_map_layer';
|
||||
import L from 'leaflet';
|
||||
|
||||
describe('kibana_map tests', function () {
|
|
@ -102,12 +102,16 @@ export class KibanaMap extends EventEmitter {
|
|||
center: options.center ? options.center : [0, 0],
|
||||
zoom: options.zoom ? options.zoom : 2,
|
||||
renderer: L.canvas(),
|
||||
zoomAnimation: false // Desaturate map tiles causes animation rendering artifacts
|
||||
zoomAnimation: false, // Desaturate map tiles causes animation rendering artifacts
|
||||
zoomControl: options.zoomControl === undefined ? true : options.zoomControl,
|
||||
};
|
||||
|
||||
this._leafletMap = L.map(containerNode, leafletOptions);
|
||||
this._leafletMap.attributionControl.setPrefix('');
|
||||
this._leafletMap.scrollWheelZoom.disable();
|
||||
|
||||
if (!options.scrollWheelZoom) {
|
||||
this._leafletMap.scrollWheelZoom.disable();
|
||||
}
|
||||
|
||||
let previousZoom = this._leafletMap.getZoom();
|
||||
this._leafletMap.on('zoomend', () => {
|
10
yarn.lock
10
yarn.lock
|
@ -7205,6 +7205,12 @@ leaflet-responsive-popup@0.2.0:
|
|||
version "0.2.0"
|
||||
resolved "https://registry.yarnpkg.com/leaflet-responsive-popup/-/leaflet-responsive-popup-0.2.0.tgz#119bfcfae147864730f6a01fbd73b5b2ce274728"
|
||||
|
||||
leaflet-vega@0.8.3:
|
||||
version "0.8.3"
|
||||
resolved "https://registry.yarnpkg.com/leaflet-vega/-/leaflet-vega-0.8.3.tgz#6d2572aa1909ed50d94937d5f4c2e42b018f4ac2"
|
||||
dependencies:
|
||||
vega-spec-injector "^0.0.2"
|
||||
|
||||
leaflet.heat@0.2.0:
|
||||
version "0.2.0"
|
||||
resolved "https://registry.yarnpkg.com/leaflet.heat/-/leaflet.heat-0.2.0.tgz#109d8cf586f0adee41f05aff031e27a77fecc229"
|
||||
|
@ -12025,6 +12031,10 @@ vega-schema-url-parser@1.0.0:
|
|||
version "1.0.0"
|
||||
resolved "https://registry.yarnpkg.com/vega-schema-url-parser/-/vega-schema-url-parser-1.0.0.tgz#fc17631e354280d663ed39e3fa8eddb62145402e"
|
||||
|
||||
vega-spec-injector@^0.0.2:
|
||||
version "0.0.2"
|
||||
resolved "https://registry.yarnpkg.com/vega-spec-injector/-/vega-spec-injector-0.0.2.tgz#f1d990109dd9d845c524738f818baa4b72a60ca6"
|
||||
|
||||
vega-statistics@^1.2:
|
||||
version "1.2.1"
|
||||
resolved "https://registry.yarnpkg.com/vega-statistics/-/vega-statistics-1.2.1.tgz#a35b3fc3d0039f8bb0a8ba1381d42a1df79ecb34"
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue