[Vega] Use mapbox instead of leaflet (#88605) (#89718)

* [WIP][Vega] Use mapbox instead of leaflet #78395

add MapServiceSettings class

some work

add tms_raster_layer

add LayerParameters type

clenup view.ts

some cleeanup

fix grammar

some refactoring and add attribution control

Some refactoring

Add some validation for zoom settings and destroy handler

Some refactoring

some work

fix bundle size

Move getZoomSettings to the separate file

update licence

some work

move logger to createViewConfig

add throttling for updating vega layer

* move EMSClient to a separate bundle

* [unit testing] add tests for validation_helper.ts

* [Bundle optimization] lazy loading of '@elastic/ems-client' only if user open map layer

* [Map] fix cursor: crosshair -> auto

* [unit testing] add tests for tms_raster_layer.test

* [unit testing] add tests for vega_layer.ts

* VSI related code was moved into a separate file / unit tests were added

* Add functional test for vega map

* [unit testing] add tests for map_service_setting.ts

* Add unload in function test and delete some unneeded code from test

* road_map -> road_map_desaturated

* [unit testing] add more tests for map_service_settings.test.ts

* Add unit tests for view.ts

* Fix some remarks

* Fix unit tests

* remove tms_tile_layers enum

* [unit testing] fix map_service_settings.test.ts

* Fix unit test for view.ts

* Fix some comments

* Fix type check

* Fix CI

Co-authored-by: Alexey Antonov <alexwizp@gmail.com>
Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>

Co-authored-by: Alexey Antonov <alexwizp@gmail.com>
Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Uladzislau Lasitsa 2021-02-01 16:23:43 +03:00 committed by GitHub
parent d3c8fa05d8
commit e5406e4adf
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
33 changed files with 1265 additions and 249 deletions

View file

@ -1,7 +1,5 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`VegaVisualizations VegaVisualization - basics should show vega blank rectangle on top of a map (vegamap) 1`] = `"<div class=\\"vgaVis__view leaflet-container leaflet-grab leaflet-touch-drag\\" style=\\"height: 100%; position: relative;\\" tabindex=\\"0\\"><div class=\\"leaflet-pane leaflet-map-pane\\" style=\\"left: 0px; top: 0px;\\"><div class=\\"leaflet-pane leaflet-tile-pane\\"></div><div class=\\"leaflet-pane leaflet-shadow-pane\\"></div><div class=\\"leaflet-pane leaflet-overlay-pane\\"><div class=\\"leaflet-vega-container\\" role=\\"graphics-document\\" aria-roledescription=\\"visualization\\" aria-label=\\"Vega visualization\\" style=\\"left: 0px; top: 0px; cursor: default;\\"><svg xmlns=\\"http://www.w3.org/2000/svg\\" xmlns:xlink=\\"http://www.w3.org/1999/xlink\\" version=\\"1.1\\" class=\\"marks\\" width=\\"0\\" height=\\"0\\" viewBox=\\"0 0 0 0\\" style=\\"background-color: transparent;\\"><g fill=\\"none\\" stroke-miterlimit=\\"10\\" transform=\\"translate(0,0)\\"><g class=\\"mark-group role-frame root\\" role=\\"graphics-object\\" aria-roledescription=\\"group mark container\\"><g transform=\\"translate(0,0)\\"><path class=\\"background\\" aria-hidden=\\"true\\" d=\\"M0,0h0v0h0Z\\"></path><g><g class=\\"mark-rect role-mark\\" role=\\"graphics-symbol\\" aria-roledescription=\\"rect mark container\\"><path d=\\"M0,0h0v0h0Z\\" fill=\\"#0f0\\"></path></g></g><path class=\\"foreground\\" aria-hidden=\\"true\\" d=\\"\\" display=\\"none\\"></path></g></g></g></svg></div></div><div class=\\"leaflet-pane leaflet-marker-pane\\"></div><div class=\\"leaflet-pane leaflet-tooltip-pane\\"></div><div class=\\"leaflet-pane leaflet-popup-pane\\"></div></div><div class=\\"leaflet-control-container\\"><div class=\\"leaflet-top leaflet-left\\"><div class=\\"leaflet-control-zoom leaflet-bar leaflet-control\\"><a class=\\"leaflet-control-zoom-in\\" href=\\"#\\" title=\\"Zoom in\\" role=\\"button\\" aria-label=\\"Zoom in\\">+</a><a class=\\"leaflet-control-zoom-out\\" href=\\"#\\" title=\\"Zoom out\\" role=\\"button\\" aria-label=\\"Zoom out\\"></a></div></div><div class=\\"leaflet-top leaflet-right\\"></div><div class=\\"leaflet-bottom leaflet-left\\"></div><div class=\\"leaflet-bottom leaflet-right\\"><div class=\\"leaflet-control-attribution leaflet-control\\"></div></div></div></div><div class=\\"vgaVis__controls vgaVis__controls--column\\"></div>"`;
exports[`VegaVisualizations VegaVisualization - basics should show vega graph (may fail in dev env) 1`] = `"<div class=\\"vgaVis__view\\" style=\\"height: 100%; cursor: default;\\" role=\\"graphics-document\\" aria-roledescription=\\"visualization\\" aria-label=\\"Vega visualization\\"><svg xmlns=\\"http://www.w3.org/2000/svg\\" xmlns:xlink=\\"http://www.w3.org/1999/xlink\\" version=\\"1.1\\" class=\\"marks\\" width=\\"512\\" height=\\"512\\" viewBox=\\"0 0 512 512\\" style=\\"background-color: transparent;\\"><g fill=\\"none\\" stroke-miterlimit=\\"10\\" transform=\\"translate(0,0)\\"><g class=\\"mark-group role-frame root\\" role=\\"graphics-object\\" aria-roledescription=\\"group mark container\\"><g transform=\\"translate(0,0)\\"><path class=\\"background\\" aria-hidden=\\"true\\" d=\\"M0,0h512v512h-512Z\\"></path><g><g class=\\"mark-group role-scope\\" role=\\"graphics-object\\" aria-roledescription=\\"group mark container\\"><g transform=\\"translate(0,0)\\"><path class=\\"background\\" aria-hidden=\\"true\\" d=\\"M0,0h0v0h0Z\\"></path><g><g class=\\"mark-area role-mark\\" role=\\"graphics-symbol\\" aria-roledescription=\\"area mark container\\"><path d=\\"M0,512C18.962962962962962,512,37.925925925925924,512,56.888888888888886,512C75.85185185185185,512,94.81481481481481,512,113.77777777777777,512C132.74074074074073,512,151.7037037037037,512,170.66666666666666,512C189.62962962962962,512,208.59259259259258,512,227.55555555555554,512C246.5185185185185,512,265.48148148148147,512,284.44444444444446,512C303.4074074074074,512,322.3703703703704,512,341.3333333333333,512C360.29629629629625,512,379.25925925925924,512,398.2222222222222,512C417.18518518518516,512,436.1481481481481,512,455.1111111111111,512C474.0740740740741,512,493.037037037037,512,512,512L512,355.2C493.037037037037,324.79999999999995,474.0740740740741,294.4,455.1111111111111,294.4C436.1481481481481,294.4,417.18518518518516,457.6,398.2222222222222,457.6C379.25925925925924,457.6,360.29629629629625,233.60000000000002,341.3333333333333,233.60000000000002C322.3703703703704,233.60000000000002,303.4074074074074,435.2,284.44444444444446,435.2C265.48148148148147,435.2,246.5185185185185,345.6,227.55555555555554,345.6C208.59259259259258,345.6,189.62962962962962,451.2,170.66666666666666,451.2C151.7037037037037,451.2,132.74074074074073,252.8,113.77777777777777,252.8C94.81481481481481,252.8,75.85185185185185,346.1333333333333,56.888888888888886,374.4C37.925925925925924,402.66666666666663,18.962962962962962,412.5333333333333,0,422.4Z\\" fill=\\"#54B399\\" fill-opacity=\\"1\\"></path></g></g><path class=\\"foreground\\" aria-hidden=\\"true\\" d=\\"\\" display=\\"none\\"></path></g><g transform=\\"translate(0,0)\\"><path class=\\"background\\" aria-hidden=\\"true\\" d=\\"M0,0h0v0h0Z\\"></path><g><g class=\\"mark-area role-mark\\" role=\\"graphics-symbol\\" aria-roledescription=\\"area mark container\\"><path d=\\"M0,422.4C18.962962962962962,412.5333333333333,37.925925925925924,402.66666666666663,56.888888888888886,374.4C75.85185185185185,346.1333333333333,94.81481481481481,252.8,113.77777777777777,252.8C132.74074074074073,252.8,151.7037037037037,451.2,170.66666666666666,451.2C189.62962962962962,451.2,208.59259259259258,345.6,227.55555555555554,345.6C246.5185185185185,345.6,265.48148148148147,435.2,284.44444444444446,435.2C303.4074074074074,435.2,322.3703703703704,233.60000000000002,341.3333333333333,233.60000000000002C360.29629629629625,233.60000000000002,379.25925925925924,457.6,398.2222222222222,457.6C417.18518518518516,457.6,436.1481481481481,294.4,455.1111111111111,294.4C474.0740740740741,294.4,493.037037037037,324.79999999999995,512,355.2L512,307.2C493.037037037037,275.2,474.0740740740741,243.2,455.1111111111111,243.2C436.1481481481481,243.2,417.18518518518516,371.2,398.2222222222222,371.2C379.25925925925924,371.2,360.29629629629625,22.399999999999977,341.3333333333333,22.399999999999977C322.3703703703704,22.399999999999977,303.4074074074074,278.4,284.44444444444446,278.4C265.48148148148147,278.4,246.5185185185185,204.8,227.55555555555554,192C208.59259259259258,179.20000000000002,189.62962962962962,185.6,170.66666666666666,172.8C151.7037037037037,160.00000000000003,132.74074074074073,83.19999999999999,113.77777777777777,83.19999999999999C94.81481481481481,83.19999999999999,75.85185185185185,83.19999999999999,56.888888888888886,83.19999999999999C37.925925925925924,83.19999999999999,18.962962962962962,164.79999999999998,0,246.39999999999998Z\\" fill=\\"#6092C0\\" fill-opacity=\\"1\\"></path></g></g><path class=\\"foreground\\" aria-hidden=\\"true\\" d=\\"\\" display=\\"none\\"></path></g></g></g><path class=\\"foreground\\" aria-hidden=\\"true\\" d=\\"\\" display=\\"none\\"></path></g></g></g></svg></div><div class=\\"vgaVis__controls vgaVis__controls--column\\"></div>"`;
exports[`VegaVisualizations VegaVisualization - basics should show vegalite graph and update on resize (may fail in dev env) 1`] = `"<ul class=\\"vgaVis__messages\\"><li class=\\"vgaVis__message vgaVis__message--warn\\"><pre class=\\"vgaVis__messageCode\\">\\"width\\" and \\"height\\" params are ignored because \\"autosize\\" is enabled. Set \\"autosize\\": \\"none\\" to disable</pre></li></ul><div class=\\"vgaVis__view\\" style=\\"height: 100%; cursor: default;\\" role=\\"graphics-document\\" aria-roledescription=\\"visualization\\" aria-label=\\"Vega visualization\\"><svg xmlns=\\"http://www.w3.org/2000/svg\\" xmlns:xlink=\\"http://www.w3.org/1999/xlink\\" version=\\"1.1\\" class=\\"marks\\" width=\\"0\\" height=\\"0\\" viewBox=\\"0 0 0 0\\" style=\\"background-color: transparent;\\"><g fill=\\"none\\" stroke-miterlimit=\\"10\\" transform=\\"translate(7,7)\\"><g class=\\"mark-group role-frame root\\" role=\\"graphics-object\\" aria-roledescription=\\"group mark container\\"><g transform=\\"translate(0,0)\\"><path class=\\"background\\" aria-hidden=\\"true\\" d=\\"M0.5,0.5h0v0h0Z\\" fill=\\"transparent\\" stroke=\\"#ddd\\"></path><g><g class=\\"mark-line role-mark marks\\" role=\\"graphics-object\\" aria-roledescription=\\"line mark container\\"><path aria-label=\\"key: Dec 11, 2017; doc_count: 0\\" role=\\"graphics-symbol\\" aria-roledescription=\\"line mark\\" d=\\"M0,0L0,0L0,0L0,0L0,0L0,0L0,0L0,0L0,0L0,0\\" stroke=\\"#54B399\\" stroke-width=\\"2\\"></path></g></g><path class=\\"foreground\\" aria-hidden=\\"true\\" d=\\"\\" display=\\"none\\"></path></g></g></g></svg></div><div class=\\"vgaVis__controls vgaVis__controls--column\\"></div>"`;

View file

@ -17,17 +17,18 @@ import {
setData,
setInjectedVars,
setUISettings,
setMapsLegacyConfig,
setInjectedMetadata,
setMapServiceSettings,
} from './services';
import { createVegaFn } from './vega_fn';
import { createVegaTypeDefinition } from './vega_type';
import { IServiceSettings } from '../../maps_legacy/public';
import { IServiceSettings, MapsLegacyPluginSetup } from '../../maps_legacy/public';
import { ConfigSchema } from '../config';
import { getVegaInspectorView } from './vega_inspector';
import { getVegaVisRenderer } from './vega_vis_renderer';
import { MapServiceSettings } from './vega_view/vega_map_view/map_service_settings';
/** @internal */
export interface VegaVisualizationDependencies {
@ -44,7 +45,7 @@ export interface VegaPluginSetupDependencies {
visualizations: VisualizationsSetup;
inspector: InspectorSetup;
data: DataPublicPluginSetup;
mapsLegacy: any;
mapsLegacy: MapsLegacyPluginSetup;
}
/** @internal */
@ -68,8 +69,12 @@ export class VegaPlugin implements Plugin<Promise<void>, void> {
enableExternalUrls: this.initializerContext.config.get().enableExternalUrls,
emsTileLayerId: core.injectedMetadata.getInjectedVar('emsTileLayerId', true),
});
setUISettings(core.uiSettings);
setMapsLegacyConfig(mapsLegacy.config);
setMapServiceSettings(
new MapServiceSettings(mapsLegacy.config, this.initializerContext.env.packageInfo.version)
);
const visualizationDependencies: Readonly<VegaVisualizationDependencies> = {
core,

View file

@ -10,7 +10,7 @@ import { CoreStart, NotificationsStart, IUiSettingsClient } from 'src/core/publi
import { DataPublicPluginStart } from '../../data/public';
import { createGetterSetter } from '../../kibana_utils/public';
import { MapsLegacyConfig } from '../../maps_legacy/config';
import { MapServiceSettings } from './vega_view/vega_map_view/map_service_settings';
export const [getData, setData] = createGetterSetter<DataPublicPluginStart>('Data');
@ -24,13 +24,14 @@ export const [getInjectedMetadata, setInjectedMetadata] = createGetterSetter<
CoreStart['injectedMetadata']
>('InjectedMetadata');
export const [
getMapServiceSettings,
setMapServiceSettings,
] = createGetterSetter<MapServiceSettings>('MapServiceSettings');
export const [getInjectedVars, setInjectedVars] = createGetterSetter<{
enableExternalUrls: boolean;
emsTileLayerId: unknown;
}>('InjectedVars');
export const [getMapsLegacyConfig, setMapsLegacyConfig] = createGetterSetter<MapsLegacyConfig>(
'MapsLegacyConfig'
);
export const getEnableExternalUrls = () => getInjectedVars().enableExternalUrls;

View file

@ -1,7 +1,7 @@
{
"$schema": "https://vega.github.io/schema/vega/v5.json",
"config": {
"kibana": { "renderer": "svg", "type": "map", "mapStyle": false}
"kibana": { "type": "map", "mapStyle": "default", "latitude": 25, "longitude": -70, "zoom": 3}
},
"width": 512,
"height": 512,

View file

@ -18,12 +18,21 @@ interface VegaViewParams {
serviceSettings: IServiceSettings;
filterManager: DataPublicPluginStart['query']['filterManager'];
timefilter: DataPublicPluginStart['query']['timefilter']['timefilter'];
// findIndex: (index: string) => Promise<...>;
}
export class VegaBaseView {
constructor(params: VegaViewParams);
init(): Promise<void>;
onError(error: any): void;
onWarn(error: any): void;
setView(map: any): void;
setDebugValues(view: any, spec: any, vlspec: any): void;
_addDestroyHandler(handler: Function): void;
destroy(): Promise<void>;
_$container: any;
_parser: any;
_vegaViewConfig: any;
_serviceSettings: any;
}

View file

@ -160,8 +160,6 @@ export class VegaBaseView {
createViewConfig() {
const config = {
// eslint-disable-next-line import/namespace
logLevel: vega.Warn, // note: eslint has a false positive here
renderer: this._parser.renderer,
};
@ -189,6 +187,13 @@ export class VegaBaseView {
};
config.loader = loader;
const logger = vega.logger(vega.Warn);
logger.warn = this.onWarn.bind(this);
logger.error = this.onError.bind(this);
config.logger = logger;
return config;
}

View file

@ -1,28 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* and the Server Side Public License, v 1; you may not use this file except in
* compliance with, at your election, the Elastic License or the Server Side
* Public License, v 1.
*/
import { KibanaMapLayer } from '../../../maps_legacy/public';
export class VegaMapLayer extends KibanaMapLayer {
constructor(spec, options, leaflet) {
super();
// Used by super.getAttributions()
this._attribution = options.attribution;
delete options.attribution;
this._leafletLayer = leaflet.vega(spec, options);
}
getVegaView() {
return this._leafletLayer._view;
}
getVegaSpec() {
return this._leafletLayer._spec;
}
}

View file

@ -1,168 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* and the Server Side Public License, v 1; you may not use this file except in
* compliance with, at your election, the Elastic License or the Server Side
* Public License, v 1.
*/
import { i18n } from '@kbn/i18n';
import { vega } from '../lib/vega';
import { VegaBaseView } from './vega_base_view';
import { VegaMapLayer } from './vega_map_layer';
import { getMapsLegacyConfig, getUISettings } from '../services';
import { lazyLoadMapsLegacyModules, TMS_IN_YML_ID } from '../../../maps_legacy/public';
const isUserConfiguredTmsLayer = ({ tilemap }) => Boolean(tilemap.url);
export class VegaMapView extends VegaBaseView {
constructor(opts) {
super(opts);
}
async getMapStyleOptions() {
const isDarkMode = getUISettings().get('theme:darkMode');
const mapsLegacyConfig = getMapsLegacyConfig();
const tmsServices = await this._serviceSettings.getTMSServices();
const mapConfig = this._parser.mapConfig;
let mapStyle;
if (mapConfig.mapStyle !== 'default') {
mapStyle = mapConfig.mapStyle;
} else {
if (isUserConfiguredTmsLayer(mapsLegacyConfig)) {
mapStyle = TMS_IN_YML_ID;
} else {
mapStyle = mapsLegacyConfig.emsTileLayerId.bright;
}
}
const mapOptions = tmsServices.find((s) => s.id === mapStyle);
if (!mapOptions) {
this.onWarn(
i18n.translate('visTypeVega.mapView.mapStyleNotFoundWarningMessage', {
defaultMessage: '{mapStyleParam} was not found',
values: { mapStyleParam: `"mapStyle":${mapStyle}` },
})
);
return null;
}
return {
...mapOptions,
...(await this._serviceSettings.getAttributesForTMSLayer(mapOptions, true, isDarkMode)),
};
}
async _initViewCustomizations() {
const mapConfig = this._parser.mapConfig;
let baseMapOpts;
let limitMinZ = 0;
let limitMaxZ = 25;
// In some cases, Vega may be initialized twice, e.g. after awaiting...
if (!this._$container) return;
if (mapConfig.mapStyle !== false) {
baseMapOpts = await this.getMapStyleOptions();
if (baseMapOpts) {
limitMinZ = baseMapOpts.minZoom;
limitMaxZ = baseMapOpts.maxZoom;
}
}
const validate = (name, value, dflt, min, max) => {
if (value === undefined) {
value = dflt;
} else if (value < min) {
this.onWarn(
i18n.translate('visTypeVega.mapView.resettingPropertyToMinValueWarningMessage', {
defaultMessage: 'Resetting {name} to {min}',
values: { name: `"${name}"`, min },
})
);
value = min;
} else if (value > max) {
this.onWarn(
i18n.translate('visTypeVega.mapView.resettingPropertyToMaxValueWarningMessage', {
defaultMessage: 'Resetting {name} to {max}',
values: { name: `"${name}"`, 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(
i18n.translate('visTypeVega.mapView.minZoomAndMaxZoomHaveBeenSwappedWarningMessage', {
defaultMessage: '{minZoomPropertyName} and {maxZoomPropertyName} have been swapped',
values: {
minZoomPropertyName: '"minZoom"',
maxZoomPropertyName: '"maxZoom"',
},
})
);
[minZoom, maxZoom] = [maxZoom, minZoom];
}
const zoom = validate('zoom', mapConfig.zoom, 2, minZoom, maxZoom);
// let maxBounds = null;
// if (mapConfig.maxBounds) {
// const b = mapConfig.maxBounds;
// eslint-disable-next-line no-undef
// maxBounds = L.latLngBounds(L.latLng(b[1], b[0]), L.latLng(b[3], b[2]));
// }
const modules = await lazyLoadMapsLegacyModules();
this._kibanaMap = new modules.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),
},
modules.L
);
this._kibanaMap.addLayer(vegaMapLayer);
this._addDestroyHandler(() => {
this._kibanaMap.removeLayer(vegaMapLayer);
if (baseMapOpts) {
this._kibanaMap.setBaseLayer(null);
}
this._kibanaMap.destroy();
});
const vegaView = vegaMapLayer.getVegaView();
await this.setView(vegaView);
this.setDebugValues(vegaView, this._parser.spec, this._parser.vlspec);
}
}

View file

@ -0,0 +1,37 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* and the Server Side Public License, v 1; you may not use this file except in
* compliance with, at your election, the Elastic License or the Server Side
* Public License, v 1.
*/
import { TMS_IN_YML_ID } from '../../../../maps_legacy/public';
export const vegaLayerId = 'vega';
export const userConfiguredLayerId = TMS_IN_YML_ID;
export const defaultMapConfig = {
maxZoom: 20,
minZoom: 0,
tileSize: 256,
};
export const defaultMabBoxStyle = {
/**
* according to the MapBox documentation that value should be '8'
* @see (https://docs.mapbox.com/mapbox-gl-js/style-spec/root/#version)
*/
version: 8,
sources: {},
layers: [],
};
export const defaultProjection = {
name: 'projection',
type: 'mercator',
scale: { signal: '512*pow(2,zoom)/2/PI' },
rotate: [{ signal: '-longitude' }, 0, 0],
center: [0, { signal: 'latitude' }],
translate: [{ signal: 'width/2' }, { signal: 'height/2' }],
fit: false,
};

View file

@ -6,6 +6,5 @@
* Public License, v 1.
*/
import { VegaBaseView } from './vega_base_view';
export class VegaMapView extends VegaBaseView {}
export { initTmsRasterLayer } from './tms_raster_layer';
export { initVegaLayer } from './vega_layer';

View file

@ -0,0 +1,54 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* and the Server Side Public License, v 1; you may not use this file except in
* compliance with, at your election, the Elastic License or the Server Side
* Public License, v 1.
*/
import { initTmsRasterLayer } from './tms_raster_layer';
type InitTmsRasterLayerParams = Parameters<typeof initTmsRasterLayer>[0];
type IdType = InitTmsRasterLayerParams['id'];
type MapType = InitTmsRasterLayerParams['map'];
type ContextType = InitTmsRasterLayerParams['context'];
describe('vega_map_view/tms_raster_layer', () => {
let id: IdType;
let map: MapType;
let context: ContextType;
beforeEach(() => {
id = 'foo_tms_layer_id';
map = ({
addSource: jest.fn(),
addLayer: jest.fn(),
} as unknown) as MapType;
context = {
tiles: ['http://some.tile.com/map/{z}/{x}/{y}.jpg'],
maxZoom: 10,
minZoom: 2,
tileSize: 512,
};
});
test('should register a new layer', () => {
initTmsRasterLayer({ id, map, context });
expect(map.addLayer).toHaveBeenCalledWith({
id: 'foo_tms_layer_id',
maxzoom: 10,
minzoom: 2,
source: 'foo_tms_layer_id',
type: 'raster',
});
expect(map.addSource).toHaveBeenCalledWith('foo_tms_layer_id', {
scheme: 'xyz',
tileSize: 512,
tiles: ['http://some.tile.com/map/{z}/{x}/{y}.jpg'],
type: 'raster',
});
});
});

View file

@ -0,0 +1,37 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* and the Server Side Public License, v 1; you may not use this file except in
* compliance with, at your election, the Elastic License or the Server Side
* Public License, v 1.
*/
import type { LayerParameters } from './types';
interface TMSRasterLayerContext {
tiles: string[];
maxZoom: number;
minZoom: number;
tileSize: number;
}
export const initTmsRasterLayer = ({
id,
map,
context: { tiles, maxZoom, minZoom, tileSize },
}: LayerParameters<TMSRasterLayerContext>) => {
map.addSource(id, {
type: 'raster',
tiles,
tileSize,
scheme: 'xyz',
});
map.addLayer({
id,
type: 'raster',
source: id,
maxzoom: maxZoom,
minzoom: minZoom,
});
};

View file

@ -0,0 +1,15 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* and the Server Side Public License, v 1; you may not use this file except in
* compliance with, at your election, the Elastic License or the Server Side
* Public License, v 1.
*/
import type { Map } from 'mapbox-gl';
export interface LayerParameters<TContext extends Record<string, any> = {}> {
id: string;
map: Map;
context: TContext;
}

View file

@ -0,0 +1,65 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* and the Server Side Public License, v 1; you may not use this file except in
* compliance with, at your election, the Elastic License or the Server Side
* Public License, v 1.
*/
import { initVegaLayer } from './vega_layer';
type InitVegaLayerParams = Parameters<typeof initVegaLayer>[0];
type IdType = InitVegaLayerParams['id'];
type MapType = InitVegaLayerParams['map'];
type ContextType = InitVegaLayerParams['context'];
describe('vega_map_view/tms_raster_layer', () => {
let id: IdType;
let map: MapType;
let context: ContextType;
beforeEach(() => {
id = 'foo_vega_layer_id';
map = ({
getCanvasContainer: () => document.createElement('div'),
getCanvas: () => ({
style: {
width: 100,
height: 100,
},
}),
addLayer: jest.fn(),
} as unknown) as MapType;
context = {
vegaView: {
initialize: jest.fn(),
},
updateVegaView: jest.fn(),
};
});
test('should register a new custom layer', () => {
initVegaLayer({ id, map, context });
const calledWith = (map.addLayer as jest.MockedFunction<any>).mock.calls[0][0];
expect(calledWith).toHaveProperty('id', 'foo_vega_layer_id');
expect(calledWith).toHaveProperty('type', 'custom');
});
test('should initialize vega container on "onAdd" hook', () => {
initVegaLayer({ id, map, context });
const { onAdd } = (map.addLayer as jest.MockedFunction<any>).mock.calls[0][0];
onAdd(map);
expect(context.vegaView.initialize).toHaveBeenCalled();
});
test('should update vega view on "render" hook', () => {
initVegaLayer({ id, map, context });
const { render } = (map.addLayer as jest.MockedFunction<any>).mock.calls[0][0];
expect(context.updateVegaView).not.toHaveBeenCalled();
render();
expect(context.updateVegaView).toHaveBeenCalled();
});
});

View file

@ -0,0 +1,47 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* and the Server Side Public License, v 1; you may not use this file except in
* compliance with, at your election, the Elastic License or the Server Side
* Public License, v 1.
*/
import type { Map, CustomLayerInterface } from 'mapbox-gl';
import type { LayerParameters } from './types';
// @ts-ignore
import { vega } from '../../lib/vega';
export interface VegaLayerContext {
vegaView: vega.View;
updateVegaView: (map: Map, view: vega.View) => void;
}
export function initVegaLayer({
id,
map: mapInstance,
context: { vegaView, updateVegaView },
}: LayerParameters<VegaLayerContext>) {
const vegaLayer: CustomLayerInterface = {
id,
type: 'custom',
onAdd(map: Map) {
const mapContainer = map.getCanvasContainer();
const mapCanvas = map.getCanvas();
const vegaContainer = document.createElement('div');
vegaContainer.style.position = 'absolute';
vegaContainer.style.top = '0px';
vegaContainer.style.width = mapCanvas.style.width;
vegaContainer.style.height = mapCanvas.style.height;
mapContainer.appendChild(vegaContainer);
vegaView.initialize(vegaContainer);
},
render() {
updateVegaView(mapInstance, vegaView);
},
};
mapInstance.addLayer(vegaLayer);
}

View file

@ -0,0 +1,105 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* and the Server Side Public License, v 1; you may not use this file except in
* compliance with, at your election, the Elastic License or the Server Side
* Public License, v 1.
*/
import { get } from 'lodash';
import { uiSettingsServiceMock } from 'src/core/public/mocks';
import { MapServiceSettings, getAttributionsForTmsService } from './map_service_settings';
import { MapsLegacyConfig } from '../../../../maps_legacy/config';
import { EMSClient, TMSService } from '@elastic/ems-client';
import { setUISettings } from '../../services';
const getPrivateField = <T>(mapServiceSettings: MapServiceSettings, privateField: string) =>
get(mapServiceSettings, privateField) as T;
describe('vega_map_view/map_service_settings', () => {
describe('MapServiceSettings', () => {
const appVersion = '99';
let config: MapsLegacyConfig;
let getUiSettingsMockedValue: any;
beforeEach(() => {
config = {
emsTileLayerId: {
desaturated: 'road_map_desaturated',
dark: 'dark_map',
},
} as MapsLegacyConfig;
setUISettings({
...uiSettingsServiceMock.createSetupContract(),
get: () => getUiSettingsMockedValue,
});
});
test('should be able to create instance of MapServiceSettings', () => {
const mapServiceSettings = new MapServiceSettings(config, appVersion);
expect(mapServiceSettings instanceof MapServiceSettings).toBeTruthy();
expect(mapServiceSettings.hasUserConfiguredTmsLayer()).toBeFalsy();
expect(mapServiceSettings.defaultTmsLayer()).toBe('road_map_desaturated');
});
test('should be able to set user configured base layer through config', () => {
const mapServiceSettings = new MapServiceSettings(
{
...config,
tilemap: {
url: 'http://some.tile.com/map/{z}/{x}/{y}.jpg',
options: {
attribution: 'attribution',
minZoom: 0,
maxZoom: 4,
},
},
},
appVersion
);
expect(mapServiceSettings.defaultTmsLayer()).toBe('TMS in config/kibana.yml');
expect(mapServiceSettings.hasUserConfiguredTmsLayer()).toBeTruthy();
});
test('should load ems client only on executing getTmsService method', async () => {
const mapServiceSettings = new MapServiceSettings(config, appVersion);
expect(getPrivateField<EMSClient>(mapServiceSettings, 'emsClient')).toBeUndefined();
await mapServiceSettings.getTmsService('road_map');
expect(
getPrivateField<EMSClient>(mapServiceSettings, 'emsClient') instanceof EMSClient
).toBeTruthy();
});
test('should set isDarkMode value on executing getTmsService method', async () => {
const mapServiceSettings = new MapServiceSettings(config, appVersion);
getUiSettingsMockedValue = true;
expect(getPrivateField<EMSClient>(mapServiceSettings, 'isDarkMode')).toBeFalsy();
await mapServiceSettings.getTmsService('road_map');
expect(getPrivateField<EMSClient>(mapServiceSettings, 'isDarkMode')).toBeTruthy();
});
test('getAttributionsForTmsService method should return attributes in a correct form', () => {
const tmsService = ({
getAttributions: jest.fn(() => [
{ url: 'https://fist_attr.com', label: 'fist_attr' },
{ url: 'https://second_attr.com', label: 'second_attr' },
]),
} as unknown) as TMSService;
expect(getAttributionsForTmsService(tmsService)).toMatchInlineSnapshot(`
Array [
"<a rel=\\"noreferrer noopener\\" href=\\"https://fist_attr.com\\">fist_attr</a>",
"<a rel=\\"noreferrer noopener\\" href=\\"https://second_attr.com\\">second_attr</a>",
]
`);
});
});
});

View file

@ -0,0 +1,88 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* and the Server Side Public License, v 1; you may not use this file except in
* compliance with, at your election, the Elastic License or the Server Side
* Public License, v 1.
*/
import { i18n } from '@kbn/i18n';
import type { EMSClient, TMSService } from '@elastic/ems-client';
import { getUISettings } from '../../services';
import { userConfiguredLayerId } from './constants';
import type { MapsLegacyConfig } from '../../../../maps_legacy/config';
type EmsClientConfig = ConstructorParameters<typeof EMSClient>[0];
const hasUserConfiguredTmsService = (config: MapsLegacyConfig) => Boolean(config.tilemap?.url);
const initEmsClientAsync = async (config: Partial<EmsClientConfig>) => {
/**
* Build optimization: '@elastic/ems-client' should be loaded from a separate chunk
*/
const emsClientModule = await import('@elastic/ems-client');
return new emsClientModule.EMSClient({
language: i18n.getLocale(),
appName: 'kibana',
// Wrap to avoid errors passing window fetch
fetchFunction(input: RequestInfo, init?: RequestInit) {
return fetch(input, init);
},
...config,
} as EmsClientConfig);
};
export class MapServiceSettings {
private emsClient?: EMSClient;
private isDarkMode: boolean = false;
constructor(public config: MapsLegacyConfig, private appVersion: string) {}
private isInitialized() {
return Boolean(this.emsClient);
}
public hasUserConfiguredTmsLayer() {
return hasUserConfiguredTmsService(this.config);
}
public defaultTmsLayer() {
const { dark, desaturated } = this.config.emsTileLayerId;
if (this.hasUserConfiguredTmsLayer()) {
return userConfiguredLayerId;
}
return this.isDarkMode ? dark : desaturated;
}
private async initialize() {
this.isDarkMode = getUISettings().get('theme:darkMode');
this.emsClient = await initEmsClientAsync({
appVersion: this.appVersion,
fileApiUrl: this.config.emsFileApiUrl,
tileApiUrl: this.config.emsTileApiUrl,
landingPageUrl: this.config.emsLandingPageUrl,
});
}
public async getTmsService(tmsTileLayer: string) {
if (!this.isInitialized()) {
await this.initialize();
}
return this.emsClient?.findTMSServiceById(tmsTileLayer);
}
}
export function getAttributionsForTmsService(tmsService: TMSService) {
return tmsService.getAttributions().map(({ label, url }) => {
const anchorTag = document.createElement('a');
anchorTag.textContent = label;
anchorTag.setAttribute('rel', 'noreferrer noopener');
anchorTag.setAttribute('href', url);
return anchorTag.outerHTML;
});
}

View file

@ -0,0 +1,10 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* and the Server Side Public License, v 1; you may not use this file except in
* compliance with, at your election, the Elastic License or the Server Side
* Public License, v 1.
*/
export { validateZoomSettings } from './validation_helper';
export { injectMapPropsIntoSpec } from './vsi_helper';

View file

@ -0,0 +1,112 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* and the Server Side Public License, v 1; you may not use this file except in
* compliance with, at your election, the Elastic License or the Server Side
* Public License, v 1.
*/
import { validateZoomSettings } from './validation_helper';
type ValidateZoomSettingsParams = Parameters<typeof validateZoomSettings>;
type MapConfigType = ValidateZoomSettingsParams[0];
type LimitsType = ValidateZoomSettingsParams[1];
type OnWarnType = ValidateZoomSettingsParams[2];
describe('vega_map_view/validation_helper', () => {
describe('validateZoomSettings', () => {
let mapConfig: MapConfigType;
let limits: LimitsType;
let onWarn: OnWarnType;
beforeEach(() => {
onWarn = jest.fn();
mapConfig = {
maxZoom: 10,
minZoom: 5,
zoom: 5,
};
limits = {
maxZoom: 15,
minZoom: 2,
};
});
test('should return validated interval', () => {
expect(validateZoomSettings(mapConfig, limits, onWarn)).toEqual({
maxZoom: 10,
minZoom: 5,
zoom: 5,
});
});
test('should return default interval in case if mapConfig not provided', () => {
mapConfig = {} as MapConfigType;
expect(validateZoomSettings(mapConfig, limits, onWarn)).toEqual({
maxZoom: 15,
minZoom: 2,
zoom: 3,
});
});
test('should reset MaxZoom if the passed value is greater than the limit', () => {
mapConfig = {
...mapConfig,
maxZoom: 20,
};
const result = validateZoomSettings(mapConfig, limits, onWarn);
expect(onWarn).toBeCalledWith('Resetting "maxZoom" to 15');
expect(result.maxZoom).toEqual(15);
});
test('should reset MinZoom if the passed value is greater than the limit', () => {
mapConfig = {
...mapConfig,
minZoom: 0,
};
const result = validateZoomSettings(mapConfig, limits, onWarn);
expect(onWarn).toBeCalledWith('Resetting "minZoom" to 2');
expect(result.minZoom).toEqual(2);
});
test('should reset Zoom if the passed value is greater than the max limit', () => {
mapConfig = {
...mapConfig,
zoom: 45,
};
const result = validateZoomSettings(mapConfig, limits, onWarn);
expect(onWarn).toBeCalledWith('Resetting "zoom" to 10');
expect(result.zoom).toEqual(10);
});
test('should reset Zoom if the passed value is greater than the min limit', () => {
mapConfig = {
...mapConfig,
zoom: 0,
};
const result = validateZoomSettings(mapConfig, limits, onWarn);
expect(onWarn).toBeCalledWith('Resetting "zoom" to 5');
expect(result.zoom).toEqual(5);
});
test('should swap min <--> max values', () => {
mapConfig = {
maxZoom: 10,
minZoom: 15,
};
const result = validateZoomSettings(mapConfig, limits, onWarn);
expect(onWarn).toBeCalledWith('"minZoom" and "maxZoom" have been swapped');
expect(result).toEqual({ maxZoom: 15, minZoom: 10, zoom: 10 });
});
});
});

View file

@ -0,0 +1,80 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* and the Server Side Public License, v 1; you may not use this file except in
* compliance with, at your election, the Elastic License or the Server Side
* Public License, v 1.
*/
import { i18n } from '@kbn/i18n';
function validate(
name: string,
value: number,
defaultValue: number,
min: number,
max: number,
onWarn: (message: string) => void
) {
if (value === undefined) {
value = defaultValue;
} else if (value < min) {
onWarn(
i18n.translate('visTypeVega.mapView.resettingPropertyToMinValueWarningMessage', {
defaultMessage: 'Resetting {name} to {min}',
values: { name: `"${name}"`, min },
})
);
value = min;
} else if (value > max) {
onWarn(
i18n.translate('visTypeVega.mapView.resettingPropertyToMaxValueWarningMessage', {
defaultMessage: 'Resetting {name} to {max}',
values: { name: `"${name}"`, max },
})
);
value = max;
}
return value;
}
export function validateZoomSettings(
mapConfig: {
maxZoom: number;
minZoom: number;
zoom?: number;
},
limits: {
maxZoom: number;
minZoom: number;
},
onWarn: (message: any) => void
) {
const DEFAULT_ZOOM = 3;
let { maxZoom, minZoom, zoom = DEFAULT_ZOOM } = mapConfig;
minZoom = validate('minZoom', minZoom, limits.minZoom, limits.minZoom, limits.maxZoom, onWarn);
maxZoom = validate('maxZoom', maxZoom, limits.maxZoom, limits.minZoom, limits.maxZoom, onWarn);
if (minZoom > maxZoom) {
onWarn(
i18n.translate('visTypeVega.mapView.minZoomAndMaxZoomHaveBeenSwappedWarningMessage', {
defaultMessage: '{minZoomPropertyName} and {maxZoomPropertyName} have been swapped',
values: {
minZoomPropertyName: '"minZoom"',
maxZoomPropertyName: '"maxZoom"',
},
})
);
[minZoom, maxZoom] = [maxZoom, minZoom];
}
zoom = validate('zoom', zoom, DEFAULT_ZOOM, minZoom, maxZoom, onWarn);
return {
zoom,
minZoom,
maxZoom,
};
}

View file

@ -0,0 +1,80 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* and the Server Side Public License, v 1; you may not use this file except in
* compliance with, at your election, the Elastic License or the Server Side
* Public License, v 1.
*/
import { injectMapPropsIntoSpec } from './vsi_helper';
import { VegaSpec } from '../../../data_model/types';
describe('vega_map_view/vsi_helper', () => {
describe('injectMapPropsIntoSpec', () => {
test('should inject map properties into vega spec', () => {
const spec = ({
$schema: 'https://vega.github.io/schema/vega/v5.json',
config: {
kibana: { type: 'map', latitude: 25, longitude: -70, zoom: 3 },
},
} as unknown) as VegaSpec;
expect(injectMapPropsIntoSpec(spec)).toMatchInlineSnapshot(`
Object {
"$schema": "https://vega.github.io/schema/vega/v5.json",
"autosize": "none",
"config": Object {
"kibana": Object {
"latitude": 25,
"longitude": -70,
"type": "map",
"zoom": 3,
},
},
"projections": Array [
Object {
"center": Array [
0,
Object {
"signal": "latitude",
},
],
"fit": false,
"name": "projection",
"rotate": Array [
Object {
"signal": "-longitude",
},
0,
0,
],
"scale": Object {
"signal": "512*pow(2,zoom)/2/PI",
},
"translate": Array [
Object {
"signal": "width/2",
},
Object {
"signal": "height/2",
},
],
"type": "mercator",
},
],
"signals": Array [
Object {
"name": "zoom",
},
Object {
"name": "latitude",
},
Object {
"name": "longitude",
},
],
}
`);
});
});
});

View file

@ -0,0 +1,24 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* and the Server Side Public License, v 1; you may not use this file except in
* compliance with, at your election, the Elastic License or the Server Side
* Public License, v 1.
*/
// @ts-expect-error
// eslint-disable-next-line import/no-extraneous-dependencies
import Vsi from 'vega-spec-injector';
import { VegaSpec } from '../../../data_model/types';
import { defaultProjection } from '../constants';
export const injectMapPropsIntoSpec = (spec: VegaSpec) => {
const vsi = new Vsi();
vsi.overrideField(spec, 'autosize', 'none');
vsi.addToList(spec, 'signals', ['zoom', 'latitude', 'longitude']);
vsi.addToList(spec, 'projections', [defaultProjection]);
return spec;
};

View file

@ -0,0 +1,7 @@
@import '~mapbox-gl/dist/mapbox-gl.css';
.vgaVis {
.mapboxgl-canvas-container {
cursor: auto;
}
}

View file

@ -0,0 +1,197 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* and the Server Side Public License, v 1; you may not use this file except in
* compliance with, at your election, the Elastic License or the Server Side
* Public License, v 1.
*/
import 'jest-canvas-mock';
import type { TMSService } from '@elastic/ems-client';
import { VegaMapView } from './view';
import { VegaViewParams } from '../vega_base_view';
import { VegaParser } from '../../data_model/vega_parser';
import { TimeCache } from '../../data_model/time_cache';
import { SearchAPI } from '../../data_model/search_api';
import vegaMap from '../../test_utils/vega_map_test.json';
import { coreMock } from '../../../../../core/public/mocks';
import { dataPluginMock } from '../../../../data/public/mocks';
import { IServiceSettings } from '../../../../maps_legacy/public';
import type { MapsLegacyConfig } from '../../../../maps_legacy/config';
import { MapServiceSettings } from './map_service_settings';
import { userConfiguredLayerId } from './constants';
import {
setInjectedVars,
setData,
setNotifications,
setMapServiceSettings,
setUISettings,
} from '../../services';
jest.mock('../../lib/vega', () => ({
vega: jest.requireActual('vega'),
vegaLite: jest.requireActual('vega-lite'),
}));
jest.mock('mapbox-gl', () => ({
Map: jest.fn().mockImplementation(() => ({
getLayer: () => '',
removeLayer: jest.fn(),
once: (eventName: string, handler: Function) => handler(),
remove: () => jest.fn(),
getCanvas: () => ({ clientWidth: 512, clientHeight: 512 }),
getCenter: () => ({ lat: 20, lng: 20 }),
getZoom: () => 3,
addControl: jest.fn(),
addLayer: jest.fn(),
})),
MapboxOptions: jest.fn(),
NavigationControl: jest.fn(),
}));
jest.mock('./layers', () => ({
initVegaLayer: jest.fn(),
initTmsRasterLayer: jest.fn(),
}));
import { initVegaLayer, initTmsRasterLayer } from './layers';
import { Map, NavigationControl } from 'mapbox-gl';
describe('vega_map_view/view', () => {
describe('VegaMapView', () => {
const coreStart = coreMock.createStart();
const dataPluginStart = dataPluginMock.createStartContract();
const mockGetServiceSettings = async () => {
return {} as IServiceSettings;
};
let vegaParser: VegaParser;
setInjectedVars({
emsTileLayerId: {},
enableExternalUrls: true,
});
setData(dataPluginStart);
setNotifications(coreStart.notifications);
setUISettings(coreStart.uiSettings);
const getTmsService = jest.fn().mockReturnValue(({
getVectorStyleSheet: () => ({
version: 8,
sources: {},
layers: [],
}),
getMaxZoom: async () => 20,
getMinZoom: async () => 0,
getAttributions: () => [{ url: 'tms_attributions' }],
} as unknown) as TMSService);
const config = {
tilemap: {
url: 'test',
options: {
attribution: 'tilemap-attribution',
minZoom: 0,
maxZoom: 20,
},
},
} as MapsLegacyConfig;
function setMapService(defaultTmsLayer: string) {
setMapServiceSettings(({
getTmsService,
defaultTmsLayer: () => defaultTmsLayer,
config,
} as unknown) as MapServiceSettings);
}
async function createVegaMapView() {
await vegaParser.parseAsync();
return new VegaMapView({
vegaParser,
filterManager: dataPluginStart.query.filterManager,
timefilter: dataPluginStart.query.timefilter.timefilter,
fireEvent: (event: any) => {},
parentEl: document.createElement('div'),
} as VegaViewParams);
}
beforeEach(() => {
vegaParser = new VegaParser(
JSON.stringify(vegaMap),
new SearchAPI({
search: dataPluginStart.search,
uiSettings: coreStart.uiSettings,
injectedMetadata: coreStart.injectedMetadata,
}),
new TimeCache(dataPluginStart.query.timefilter.timefilter, 0),
{},
mockGetServiceSettings
);
});
afterEach(() => {
jest.clearAllMocks();
});
test('should be added TmsRasterLayer and do not use tmsService if mapStyle is "user_configured"', async () => {
setMapService(userConfiguredLayerId);
const vegaMapView = await createVegaMapView();
await vegaMapView.init();
const { longitude, latitude, scrollWheelZoom } = vegaMapView._parser.mapConfig;
expect(Map).toHaveBeenCalledWith({
style: {
version: 8,
sources: {},
layers: [],
},
customAttribution: 'tilemap-attribution',
container: vegaMapView._$container.get(0),
minZoom: 0,
maxZoom: 20,
zoom: 3,
scrollZoom: scrollWheelZoom,
center: [longitude, latitude],
});
expect(getTmsService).not.toHaveBeenCalled();
expect(initTmsRasterLayer).toHaveBeenCalled();
expect(initVegaLayer).toHaveBeenCalled();
});
test('should not be added TmsRasterLayer and use tmsService if mapStyle is not "user_configured"', async () => {
setMapService('road_map_desaturated');
const vegaMapView = await createVegaMapView();
await vegaMapView.init();
const { longitude, latitude, scrollWheelZoom } = vegaMapView._parser.mapConfig;
expect(Map).toHaveBeenCalledWith({
style: {
version: 8,
sources: {},
layers: [],
},
customAttribution: ['<a rel="noreferrer noopener" href="tms_attributions"></a>'],
container: vegaMapView._$container.get(0),
minZoom: 0,
maxZoom: 20,
zoom: 3,
scrollZoom: scrollWheelZoom,
center: [longitude, latitude],
});
expect(getTmsService).toHaveBeenCalled();
expect(initTmsRasterLayer).not.toHaveBeenCalled();
expect(initVegaLayer).toHaveBeenCalled();
});
test('should be added NavigationControl', async () => {
setMapService('road_map_desaturated');
const vegaMapView = await createVegaMapView();
await vegaMapView.init();
expect(NavigationControl).toHaveBeenCalled();
});
});
});

View file

@ -0,0 +1,181 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* and the Server Side Public License, v 1; you may not use this file except in
* compliance with, at your election, the Elastic License or the Server Side
* Public License, v 1.
*/
import { i18n } from '@kbn/i18n';
import { Map, Style, NavigationControl, MapboxOptions } from 'mapbox-gl';
import { initTmsRasterLayer, initVegaLayer } from './layers';
import { VegaBaseView } from '../vega_base_view';
import { getMapServiceSettings } from '../../services';
import { getAttributionsForTmsService } from './map_service_settings';
import type { MapServiceSettings } from './map_service_settings';
import {
defaultMapConfig,
defaultMabBoxStyle,
userConfiguredLayerId,
vegaLayerId,
} from './constants';
import { validateZoomSettings, injectMapPropsIntoSpec } from './utils';
// @ts-expect-error
import { vega } from '../../lib/vega';
import './vega_map_view.scss';
async function updateVegaView(mapBoxInstance: Map, vegaView: vega.View) {
const mapCanvas = mapBoxInstance.getCanvas();
const { lat, lng } = mapBoxInstance.getCenter();
let shouldRender = false;
const sendSignal = (sig: string, value: any) => {
if (vegaView.signal(sig) !== value) {
vegaView.signal(sig, value);
shouldRender = true;
}
};
sendSignal('width', mapCanvas.clientWidth);
sendSignal('height', mapCanvas.clientHeight);
sendSignal('latitude', lat);
sendSignal('longitude', lng);
sendSignal('zoom', mapBoxInstance.getZoom());
if (shouldRender) {
await vegaView.runAsync();
}
}
export class VegaMapView extends VegaBaseView {
private mapServiceSettings: MapServiceSettings = getMapServiceSettings();
private mapStyle = this.getMapStyle();
private getMapStyle() {
const { mapStyle } = this._parser.mapConfig;
return mapStyle === 'default' ? this.mapServiceSettings.defaultTmsLayer() : mapStyle;
}
private get shouldShowZoomControl() {
return Boolean(this._parser.mapConfig.zoomControl);
}
private getMapParams(defaults: { maxZoom: number; minZoom: number }): Partial<MapboxOptions> {
const { longitude, latitude, scrollWheelZoom } = this._parser.mapConfig;
const zoomSettings = validateZoomSettings(this._parser.mapConfig, defaults, this.onWarn);
return {
...zoomSettings,
center: [longitude, latitude],
scrollZoom: scrollWheelZoom,
};
}
private async initMapContainer(vegaView: vega.View) {
let style: Style = defaultMabBoxStyle;
let customAttribution: MapboxOptions['customAttribution'] = [];
const zoomSettings = {
minZoom: defaultMapConfig.minZoom,
maxZoom: defaultMapConfig.maxZoom,
};
if (this.mapStyle && this.mapStyle !== userConfiguredLayerId) {
const tmsService = await this.mapServiceSettings.getTmsService(this.mapStyle);
if (!tmsService) {
this.onWarn(
i18n.translate('visTypeVega.mapView.mapStyleNotFoundWarningMessage', {
defaultMessage: '{mapStyleParam} was not found',
values: { mapStyleParam: `"mapStyle":${this.mapStyle}` },
})
);
return;
}
zoomSettings.maxZoom = (await tmsService.getMaxZoom()) ?? defaultMapConfig.maxZoom;
zoomSettings.minZoom = (await tmsService.getMinZoom()) ?? defaultMapConfig.minZoom;
customAttribution = getAttributionsForTmsService(tmsService);
style = (await tmsService.getVectorStyleSheet()) as Style;
} else {
customAttribution = this.mapServiceSettings.config.tilemap.options.attribution;
}
// In some cases, Vega may be initialized twice, e.g. after awaiting...
if (!this._$container) return;
const mapBoxInstance = new Map({
style,
customAttribution,
container: this._$container.get(0),
...this.getMapParams({ ...zoomSettings }),
});
const initMapComponents = () => {
this.initControls(mapBoxInstance);
this.initLayers(mapBoxInstance, vegaView);
this._addDestroyHandler(() => {
if (mapBoxInstance.getLayer(vegaLayerId)) {
mapBoxInstance.removeLayer(vegaLayerId);
}
if (mapBoxInstance.getLayer(userConfiguredLayerId)) {
mapBoxInstance.removeLayer(userConfiguredLayerId);
}
mapBoxInstance.remove();
});
};
mapBoxInstance.once('load', initMapComponents);
}
private initControls(mapBoxInstance: Map) {
if (this.shouldShowZoomControl) {
mapBoxInstance.addControl(new NavigationControl({ showCompass: false }), 'top-left');
}
}
private initLayers(mapBoxInstance: Map, vegaView: vega.View) {
const shouldShowUserConfiguredLayer = this.mapStyle === userConfiguredLayerId;
if (shouldShowUserConfiguredLayer) {
const { url, options } = this.mapServiceSettings.config.tilemap;
initTmsRasterLayer({
id: userConfiguredLayerId,
map: mapBoxInstance,
context: {
tiles: [url!],
maxZoom: options.maxZoom ?? defaultMapConfig.maxZoom,
minZoom: options.minZoom ?? defaultMapConfig.minZoom,
tileSize: options.tileSize ?? defaultMapConfig.tileSize,
},
});
}
initVegaLayer({
id: vegaLayerId,
map: mapBoxInstance,
context: {
vegaView,
updateVegaView,
},
});
}
protected async _initViewCustomizations() {
const vegaView = new vega.View(
vega.parse(injectMapPropsIntoSpec(this._parser.spec)),
this._vegaViewConfig
);
this.setDebugValues(vegaView, this._parser.spec, this._parser.vlspec);
this.setView(vegaView);
await this.initMapContainer(vegaView);
}
}

View file

@ -16,8 +16,6 @@ export class VegaView extends VegaBaseView {
const view = new vega.View(vega.parse(this._parser.spec), this._vegaViewConfig);
view.warn = this.onWarn.bind(this);
view.error = this.onError.bind(this);
if (this._parser.useResize) this.updateVegaSize(view);
view.initialize(this._$container.get(0), this._$controls.get(0));

View file

@ -10,13 +10,10 @@ import 'jest-canvas-mock';
import $ from 'jquery';
import 'leaflet/dist/leaflet.js';
import 'leaflet-vega';
import { createVegaVisualization } from './vega_visualization';
import vegaliteGraph from './test_utils/vegalite_graph.json';
import vegaGraph from './test_utils/vega_graph.json';
import vegaMapGraph from './test_utils/vega_map_test.json';
import { VegaParser } from './data_model/vega_parser';
import { SearchAPI } from './data_model/search_api';
@ -146,32 +143,5 @@ describe('VegaVisualizations', () => {
vegaVis.destroy();
}
});
test('should show vega blank rectangle on top of a map (vegamap)', async () => {
let vegaVis;
try {
vegaVis = new VegaVisualization(domNode, jest.fn());
const vegaParser = new VegaParser(
JSON.stringify(vegaMapGraph),
new SearchAPI({
search: dataPluginStart.search,
uiSettings: coreStart.uiSettings,
injectedMetadata: coreStart.injectedMetadata,
}),
0,
0,
mockGetServiceSettings
);
await vegaParser.parseAsync();
mockedWidthValue = 256;
mockedHeightValue = 256;
await vegaVis.render(vegaParser);
expect(domNode.innerHTML).toMatchSnapshot();
} finally {
vegaVis.destroy();
}
});
});
});

View file

@ -78,7 +78,7 @@ export const createVegaVisualization = ({
};
if (vegaParser.useMap) {
const { VegaMapView } = await import('./vega_view/vega_map_view');
const { VegaMapView } = await import('./vega_view/vega_map_view/view');
this.vegaView = new VegaMapView(vegaViewParams);
} else {
const { VegaView: VegaViewClass } = await import('./vega_view/vega_view');

View file

@ -10,7 +10,9 @@
"include": [
"server/**/*",
"public/**/*",
"*.ts"
"*.ts",
// have to declare *.json explicitly due to https://github.com/microsoft/TypeScript/issues/25636
"public/test_utils/vega_map_test.json"
],
"references": [
{ "path": "../../core/tsconfig.json" },

View file

@ -269,3 +269,24 @@
}
}
}
{
"type": "doc",
"value": {
"id": "visualization:VegaMap",
"index": ".kibana",
"source": {
"type": "visualization",
"visualization": {
"description": "VegaMap",
"kibanaSavedObjectMeta": {
"searchSourceJSON": "{\"query\":{\"query\":\"\",\"language\":\"kuery\"},\"filter\":[]}"
},
"title": "VegaMap",
"uiStateJSON": "{}",
"version": 1,
"visState": "{\"aggs\":[],\"params\":{\"spec\":\"{\\n $schema: https://vega.github.io/schema/vega/v5.json\\n config: {\\n kibana: {type: \\\"map\\\", latitude: 25, longitude: -70, zoom: 3}\\n }\\n data: [\\n {\\n name: table\\n url: {\\n index: kibana_sample_data_flights\\n %context%: true\\n // Uncomment to enable time filtering\\n // %timefield%: timestamp\\n body: {\\n size: 0\\n aggs: {\\n origins: {\\n terms: {field: \\\"OriginAirportID\\\", size: 10000}\\n aggs: {\\n originLocation: {\\n top_hits: {\\n size: 1\\n _source: {\\n includes: [\\\"OriginLocation\\\", \\\"Origin\\\"]\\n }\\n }\\n }\\n distinations: {\\n terms: {field: \\\"DestAirportID\\\", size: 10000}\\n aggs: {\\n destLocation: {\\n top_hits: {\\n size: 1\\n _source: {\\n includes: [\\\"DestLocation\\\"]\\n }\\n }\\n }\\n }\\n }\\n }\\n }\\n }\\n }\\n }\\n format: {property: \\\"aggregations.origins.buckets\\\"}\\n transform: [\\n {\\n type: geopoint\\n projection: projection\\n fields: [\\n originLocation.hits.hits[0]._source.OriginLocation.lon\\n originLocation.hits.hits[0]._source.OriginLocation.lat\\n ]\\n }\\n ]\\n }\\n {\\n name: selectedDatum\\n on: [\\n {trigger: \\\"!selected\\\", remove: true}\\n {trigger: \\\"selected\\\", insert: \\\"selected\\\"}\\n ]\\n }\\n ]\\n signals: [\\n {\\n name: selected\\n value: null\\n on: [\\n {events: \\\"@airport:mouseover\\\", update: \\\"datum\\\"}\\n {events: \\\"@airport:mouseout\\\", update: \\\"null\\\"}\\n ]\\n }\\n ]\\n scales: [\\n {\\n name: airportSize\\n type: linear\\n domain: {data: \\\"table\\\", field: \\\"doc_count\\\"}\\n range: [\\n {signal: \\\"zoom*zoom*0.2+1\\\"}\\n {signal: \\\"zoom*zoom*10+1\\\"}\\n ]\\n }\\n ]\\n marks: [\\n {\\n type: group\\n from: {\\n facet: {\\n name: facetedDatum\\n data: selectedDatum\\n field: distinations.buckets\\n }\\n }\\n data: [\\n {\\n name: facetDatumElems\\n source: facetedDatum\\n transform: [\\n {\\n type: geopoint\\n projection: projection\\n fields: [\\n destLocation.hits.hits[0]._source.DestLocation.lon\\n destLocation.hits.hits[0]._source.DestLocation.lat\\n ]\\n }\\n {type: \\\"formula\\\", expr: \\\"{x:parent.x, y:parent.y}\\\", as: \\\"source\\\"}\\n {type: \\\"formula\\\", expr: \\\"{x:datum.x, y:datum.y}\\\", as: \\\"target\\\"}\\n {type: \\\"linkpath\\\", shape: \\\"diagonal\\\"}\\n ]\\n }\\n ]\\n scales: [\\n {\\n name: lineThickness\\n type: log\\n clamp: true\\n range: [1, 8]\\n }\\n {\\n name: lineOpacity\\n type: log\\n clamp: true\\n range: [0.2, 0.8]\\n }\\n ]\\n marks: [\\n {\\n from: {data: \\\"facetDatumElems\\\"}\\n type: path\\n interactive: false\\n encode: {\\n update: {\\n path: {field: \\\"path\\\"}\\n stroke: {value: \\\"black\\\"}\\n strokeWidth: {scale: \\\"lineThickness\\\", field: \\\"doc_count\\\"}\\n strokeOpacity: {scale: \\\"lineOpacity\\\", field: \\\"doc_count\\\"}\\n }\\n }\\n }\\n ]\\n }\\n {\\n name: airport\\n type: symbol\\n from: {data: \\\"table\\\"}\\n encode: {\\n update: {\\n size: {scale: \\\"airportSize\\\", field: \\\"doc_count\\\"}\\n xc: {signal: \\\"datum.x\\\"}\\n yc: {signal: \\\"datum.y\\\"}\\n tooltip: {\\n signal: \\\"{title: datum.originLocation.hits.hits[0]._source.Origin + ' (' + datum.key + ')', connnections: length(datum.distinations.buckets), flights: datum.doc_count}\\\"\\n }\\n }\\n }\\n }\\n ]\\n}\"},\"title\":\"[Flights] Airport Connections (Hover Over Airport)\",\"type\":\"vega\"}"
}
}
}
}

View file

@ -15,7 +15,11 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) {
return {
...functionalConfig.getAll(),
testFiles: [require.resolve('./tests/console_app'), require.resolve('./tests/discover')],
testFiles: [
require.resolve('./tests/console_app'),
require.resolve('./tests/discover'),
require.resolve('./tests/vega'),
],
services,

View file

@ -0,0 +1,27 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* and the Server Side Public License, v 1; you may not use this file except in
* compliance with, at your election, the Elastic License or the Server Side
* Public License, v 1.
*/
import { DEFAULT_OPTIONS } from '../../services/visual_testing/visual_testing';
import { FtrProviderContext } from '../../ftr_provider_context';
// Width must be the same as visual_testing or canvas image widths will get skewed
const [SCREEN_WIDTH] = DEFAULT_OPTIONS.widths || [];
export default function ({ getService, loadTestFile }: FtrProviderContext) {
const browser = getService('browser');
describe('vega app', function () {
this.tags('ciGroup6');
before(function () {
return browser.setWindowSize(SCREEN_WIDTH, 1000);
});
loadTestFile(require.resolve('./vega_map_visualization'));
});
}

View file

@ -0,0 +1,34 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* and the Server Side Public License, v 1; you may not use this file except in
* compliance with, at your election, the Elastic License or the Server Side
* Public License, v 1.
*/
import { FtrProviderContext } from '../../ftr_provider_context';
export default function ({ getService, getPageObjects }: FtrProviderContext) {
const esArchiver = getService('esArchiver');
const PageObjects = getPageObjects(['common', 'visualize', 'visChart', 'visEditor', 'vegaChart']);
const visualTesting = getService('visualTesting');
describe('vega chart in visualize app', () => {
before(async () => {
await esArchiver.loadIfNeeded('kibana_sample_data_flights');
await esArchiver.loadIfNeeded('visualize');
});
after(async () => {
await esArchiver.unload('kibana_sample_data_flights');
await esArchiver.unload('visualize');
});
it('should show map with vega layer', async function () {
await PageObjects.visualize.gotoVisualizationLandingPage();
await PageObjects.visualize.openSavedVisualization('VegaMap');
await PageObjects.visChart.waitForVisualizationRenderingStabilized();
await visualTesting.snapshot();
});
});
}