[GIS] Add Maps Plugin (#24804)

This adds the MVP of the Phase 1 version of the Maps Plugin to Kibana (https://github.com/elastic/kibana/issues/19582).

This is added as a new Stack Feature, requiring a basic license.
This commit is contained in:
Thomas Neirynck 2018-12-19 16:14:41 -05:00 committed by GitHub
parent 80246a71cc
commit ffc8bae820
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
157 changed files with 17156 additions and 18 deletions

View file

@ -25,6 +25,7 @@ bower_components
/packages/kbn-ui-framework/dist
/packages/kbn-ui-framework/doc_site/build
/packages/kbn-ui-framework/generator-kui/*/templates/
/x-pack/plugins/gis/public/vendor/**
/x-pack/coverage
/x-pack/build
/x-pack/plugins/**/__tests__/fixtures/**

View file

@ -299,6 +299,16 @@ module.exports = {
},
},
/**
* GIS overrides
*/
{
files: ['x-pack/plugins/gis/**/*'],
rules: {
'react/prefer-stateless-function': [0, { ignorePureComponents: false }],
},
},
/**
* Graph overrides
*/

View file

@ -50,6 +50,7 @@ export const LICENSE_WHITELIST = [
'CC-BY',
'CC-BY-3.0',
'CC-BY-4.0',
'Eclipse Distribution License - v 1.0',
'ISC',
'ISC*',
'MIT OR GPL-2.0',
@ -75,6 +76,8 @@ export const LICENSE_OVERRIDES = {
'scriptjs@2.5.8': ['MIT'], // license header appended in the dist
'react-lib-adler32@1.0.1': ['BSD'], // adler32 extracted from react source,
'cycle@1.0.3': ['CC0-1.0'], // conversion to a public-domain like license
'jsts@1.1.2': ['Eclipse Distribution License - v 1.0'], //cf. https://github.com/bjornharrtell/jsts
'@mapbox/jsonlint-lines-primitives@2.0.2': ['MIT'], //license in readme https://github.com/tmcw/jsonlint
// TODO can be removed once we upgrade past elasticsearch-browser@14.0.0
'elasticsearch-browser@13.0.1': ['Apache-2.0'],

View file

@ -66,6 +66,7 @@ class RequestSelector extends Component {
}}
toolTipContent={request.description}
toolTipPosition="left"
data-test-subj={`inspectorRequestChooser${request.name}`}
>
<EuiTextColor color={hasFailed ? 'danger' : 'default'}>
{request.name}
@ -89,6 +90,7 @@ class RequestSelector extends Component {
iconSide="right"
size="s"
onClick={this.togglePopover}
data-test-subj="inspectorRequestChooser"
>
{this.props.selectedRequest.name}
</EuiButtonEmpty>

View file

@ -68,6 +68,7 @@ class RequestsViewComponent extends Component {
return (
<InspectorView useFlex={true}>
<EuiEmptyPrompt
data-test-subj="inspectorNoRequestsMessage"
title={<h2>No requests logged</h2>}
body={
<React.Fragment>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Before After
Before After

View file

@ -24,10 +24,10 @@ import chrome from 'ui/chrome';
import { EuiComboBox } from '@elastic/eui';
const getIndexPatterns = async (search) => {
const getIndexPatterns = async (search, fields) => {
const resp = await chrome.getSavedObjectsClient().find({
type: 'index-pattern',
fields: ['title'],
fields,
search: `${search}*`,
search_fields: ['title'],
perPage: 100
@ -100,7 +100,27 @@ export class IndexPatternSelect extends Component {
}
debouncedFetch = _.debounce(async (searchValue) => {
const savedObjects = await getIndexPatterns(searchValue);
const { fieldTypes } = this.props;
const savedObjectFields = ['title'];
if (fieldTypes) {
savedObjectFields.push('fields');
}
let savedObjects = await getIndexPatterns(searchValue, savedObjectFields);
if (fieldTypes) {
savedObjects = savedObjects.filter(savedObject => {
try {
const indexPatternFields = JSON.parse(savedObject.attributes.fields);
return indexPatternFields.some(({ type }) => {
return fieldTypes.includes(type);
});
} catch (err) {
// Unable to parse fields JSON, invalid index pattern
return false;
}
});
}
if (!this._isMounted) {
return;
@ -135,6 +155,7 @@ export class IndexPatternSelect extends Component {
render() {
const {
fieldTypes, // eslint-disable-line no-unused-vars
onChange, // eslint-disable-line no-unused-vars
indexPatternId, // eslint-disable-line no-unused-vars
placeholder,
@ -160,4 +181,8 @@ IndexPatternSelect.propTypes = {
onChange: PropTypes.func.isRequired,
indexPatternId: PropTypes.string,
placeholder: PropTypes.string,
/**
* Filter index patterns to only those that include the field types
*/
fieldTypes: PropTypes.arrayOf(PropTypes.string),
};

View file

@ -17,13 +17,13 @@
* under the License.
*/
export function getLegendColors(colorRamp) {
export function getLegendColors(colorRamp, numLegendColors = 4) {
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);
for (let i = 1; i < numLegendColors - 1; i++) {
colors[i] = getColor(colorRamp, Math.floor(colorRamp.length * i / numLegendColors));
}
colors[numLegendColors - 1] = getColor(colorRamp, colorRamp.length - 1);
return colors;
}

View file

@ -18,6 +18,7 @@ import { dashboardMode } from './plugins/dashboard_mode';
import { logstash } from './plugins/logstash';
import { beats } from './plugins/beats_management';
import { apm } from './plugins/apm';
import { gis } from './plugins/gis';
import { licenseManagement } from './plugins/license_management';
import { cloud } from './plugins/cloud';
import { indexManagement } from './plugins/index_management';
@ -50,6 +51,7 @@ module.exports = function (kibana) {
logstash(kibana),
beats(kibana),
apm(kibana),
gis(kibana),
canvas(kibana),
licenseManagement(kibana),
cloud(kibana),

View file

@ -196,6 +196,7 @@
"lodash.topath": "^4.5.2",
"lodash.uniqby": "^4.7.0",
"lz-string": "^1.4.4",
"mapbox-gl": "0.45.0",
"markdown-it": "^8.4.1",
"mime": "^2.2.2",
"mkdirp": "0.5.1",
@ -204,6 +205,7 @@
"moment-timezone": "^0.5.14",
"ngreact": "^0.5.1",
"nodemailer": "^4.6.4",
"node-fetch": "^2.1.2",
"object-path-immutable": "^0.5.3",
"oppsy": "^2.0.0",
"papaparse": "^4.6.0",
@ -255,12 +257,16 @@
"tinycolor2": "1.3.0",
"tinymath": "1.1.1",
"tslib": "^1.9.3",
"topojson-client": "3.0.0",
"turf": "3.0.14",
"@turf/boolean-contains": "6.0.1",
"typescript-fsa": "^2.5.0",
"typescript-fsa-reducers": "^0.4.5",
"ui-select": "0.19.4",
"unbzip2-stream": "1.0.9",
"unstated": "^2.1.1",
"uuid": "3.0.1",
"url-parse": "1.3.0",
"venn.js": "0.2.9",
"xregexp": "3.2.0"
},

View file

@ -0,0 +1,30 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
/**
* on the license information extracted from the xPackInfo.
* @param {XPackInfo} xPackInfo XPackInfo instance to extract license information from.
* @returns {LicenseCheckResult}
*/
export function checkLicense(xPackInfo) {
if (!xPackInfo.isAvailable()) {
return {
gis: false,
};
}
const isAnyXpackLicense = xPackInfo.license.isOneOf(['basic', 'platinum', 'trial']);
if (!isAnyXpackLicense) {
return {
gis: false,
};
}
return {
gis: true,
};
}

View file

@ -0,0 +1,11 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
export const GIS_API_PATH = 'api/gis';
export const DECIMAL_DEGREES_PRECISION = 5; // meters precision
export const ZOOM_PRECISION = 2;

View file

@ -0,0 +1,124 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import _ from 'lodash';
import MarkdownIt from 'markdown-it';
import fetch from 'node-fetch';
import URL from 'url-parse';
const extendUrl = (url, params) => {
const parsed = new URL(url);
for (const key in params) {
if (params.hasOwnProperty(key)) {
parsed.set(key, params[key]);
}
}
return parsed.href;
};
const markdownIt = new MarkdownIt({
html: false,
linkify: true
});
const unescapeTemplateVars = url => {
const ENCODED_TEMPLATE_VARS_RE = /%7B(\w+?)%7D/g;
return url.replace(ENCODED_TEMPLATE_VARS_RE, (total, varName) => `{${varName}}`);
};
const sanitizeHtml = x => x;//todo
/**
* Adapted from service_settings. Consider refactor and include in Kibana OSS
*/
export class EMS_V2 {
constructor(options) {
this._queryParams = {
my_app_version: options.kbnVersion,
license: options.license
};
this._manifestServiceUrl = options.manifestServiceUrl;
this._emsLandingPageUrl = options.emsLandingPageUrl;
}
async _loadCatalogue() {
try {
return await this._getManifest(this._manifestServiceUrl, this._queryParams);
} catch (e) {
if (!e) {
e = new Error('Unknown error');
}
if (!(e instanceof Error)) {
e = new Error(e || `status ${e.statusText || e.status}`);
}
throw new Error(`Could not retrieve manifest from the tile service: ${e.message}`);
}
}
async _loadFileLayers() {
const catalogue = await this._loadCatalogue();
const fileService = catalogue.services.find(service => service.type === 'file');
if (!fileService) {
return [];
}
const manifest = await this._getManifest(fileService.manifest, this._queryParams);
const layers = manifest.layers.filter(layer => layer.format === 'geojson' || layer.format === 'topojson');
layers.forEach((layer) => {
layer.url = this._extendUrlWithParams(layer.url);
layer.attribution = sanitizeHtml(markdownIt.render(layer.attribution));
});
return layers;
}
async _loadTMSServices() {
const catalogue = await this._loadCatalogue();
const tmsService = catalogue.services.find((service) => service.type === 'tms');
if (!tmsService) {
return [];
}
const tmsManifest = await this._getManifest(tmsService.manifest, this._queryParams);
return tmsManifest.services.map((tmsService) => {
const preppedService = _.cloneDeep(tmsService);
preppedService.attribution = sanitizeHtml(markdownIt.render(preppedService.attribution));
preppedService.subdomains = preppedService.subdomains || [];
preppedService.url = this._extendUrlWithParams(preppedService.url);
return preppedService;
});
}
_extendUrlWithParams(url) {
const extended = extendUrl(url, {
query: this._queryParams
});
return unescapeTemplateVars(extended);
}
async _getManifest(manifestUrl) {
const extendedUrl = extendUrl(manifestUrl, { query: this._queryParams });
const response = await fetch(extendedUrl);
return await response.json();
}
async getFileLayers() {
return await this._loadFileLayers();
}
async getTMSServices() {
return await this._loadTMSServices();
}
getEMSHotLink(fileLayer) {
const id = `file/${fileLayer.name}`;
return `${this._emsLandingPageUrl}#${id}`;
}
}

View file

@ -0,0 +1,72 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { resolve } from 'path';
import { initRoutes } from './server/routes';
import { kySaltTrucksSpecProvider } from './server/sample_data/ky_salt_trucks';
import webLogsSavedObjects from './server/sample_data/web_logs_saved_objects.json';
import mappings from './mappings.json';
import { checkLicense } from './check_license';
import { watchStatusAndLicenseToInitialize } from
'../../server/lib/watch_status_and_license_to_initialize';
export function gis(kibana) {
return new kibana.Plugin({
require: ['kibana', 'elasticsearch', 'xpack_main'],
id: 'gis',
configPrefix: 'xpack.gis',
publicDir: resolve(__dirname, 'public'),
uiExports: {
app: {
title: 'Maps',
description: 'Map application',
main: 'plugins/gis/index',
icon: 'plugins/gis/icon.svg',
euiIconType: 'gisApp',
},
inspectorViews: [
'plugins/gis/inspector/views/register_views',
],
home: ['plugins/gis/register_feature'],
styleSheetPaths: `${__dirname}/public/index.scss`,
mappings
},
config(Joi) {
return Joi.object({
enabled: Joi.boolean().default(true),
}).default();
},
init(server) {
const thisPlugin = this;
const xpackMainPlugin = server.plugins.xpack_main;
watchStatusAndLicenseToInitialize(xpackMainPlugin, thisPlugin,
async license => {
if (license) {
if (license.gis) {
// TODO: Replace with content to 'activate' app on license verify
console.info('Valid GIS License');
} else {
console.warn('No GIS for YOU!');
}
return license;
}
});
xpackMainPlugin.info
.feature(thisPlugin.id)
.registerLicenseCheckResultsGenerator(checkLicense);
initRoutes(server);
server.registerSampleDataset(kySaltTrucksSpecProvider);
server.addSavedObjectsToSampleDataset('logs', webLogsSavedObjects);
server.injectUiAppVars('gis', async () => {
return await server.getInjectedUiAppVars('kibana');
});
}
});
}

View file

@ -0,0 +1,28 @@
{
"gis-map": {
"properties": {
"description": {
"type": "text"
},
"title": {
"type": "text"
},
"version": {
"type": "integer"
},
"bounds": {
"type": "geo_shape",
"tree": "quadtree"
},
"mapStateJSON": {
"type": "text"
},
"layerListJSON": {
"type": "text"
},
"uiStateJSON": {
"type": "text"
}
}
}
}

View file

@ -0,0 +1,255 @@
#gis-plugin {
display: flex;
flex-direction: column;
height: 100vh;
width: 100%;
overflow: hidden;
}
#react-gis-root {
flex-grow: 1;
display: flex;
flex-direction: column;
}
.gisMapWrapper {
display: flex;
flex-direction: column;
position: relative;
}
.layerToast {
margin-top: -150px !important;
pointer-events: none;
}
.mapContainer {
flex-grow: 1;
}
.LayerControl {
position: absolute;
z-index: 100;
min-width: 17rem;
top: $euiSizeM;
right: $euiSizeM;
max-width: 24rem;
padding-bottom: 8px;
border-color: transparent;
&.euiPanel--shadow {
@include euiBottomShadowLarge;
}
.LayerControl--header {
padding: 16px 16px 8px;
}
}
.layerEntry {
padding: 8px 16px;
position: relative;
}
.alphaRange {
.euiFieldNumber {
max-width: 5.5em !important;
}
}
.colorPicker {
.euiColorPickerPopUp {
z-index: 3000;
}
}
.colorGradient {
width: 100%;
height: 20px;
position: relative;
top: 0px;
right: 0px;
bottom: 0px;
left: 0px;
}
.layerEntry > .visible {
opacity: 1;
}
.layerEntry > .notvisible { //ugh, already global classname named `invisible`
opacity: 0.5;
}
.layerEntry .grab:hover {
cursor: -webkit-grab;
cursor: grab;
}
.layerSettings__type {
.euiIcon {
margin-top: -2px;
}
}
.hidden {
display: none
}
// HOTFIX coming from EUI
.sourceSelectItem {
width: 374px;
}
.mapboxgl-popup {
z-index: 100;
}
.gisLayerPanel {
background-color: $euiColorLightestShade;
width: 0;
overflow: hidden;
> * {
width: $euiSizeXXL * 11;
}
&-isVisible {
width: $euiSizeXXL * 11;
transition: width $euiAnimSpeedNormal $euiAnimSlightResistance;
}
}
.gisViewPanel__header,
.gisViewPanel__body,
.gisViewPanel__footer {
padding: $euiSize;
}
.gisViewPanel__header {
padding-bottom: 0;
flex-shrink: 0;
z-index: 2;
box-shadow: 0 $euiSize $euiSize (-$euiSize / 2) $euiColorLightestShade;
}
.gisViewPanel__title {
svg {
margin: -.1em .5em 0 0;
}
}
.gisViewPanel__body {
overflow: hidden;
overflow-y: auto;
@include euiScrollBar;
> *:not(:last-child) {
margin-bottom: $euiSize;
flex: 0 0 auto;
}
}
.gisViewPanel__footer {
padding-top: 0;
flex-shrink: 0;
z-index: 2;
box-shadow: 0 ($euiSize *-1) $euiSize (-$euiSize / 2) $euiColorLightestShade;
}
// EUIFIXTODO:
.euiColorPicker__emptySwatch {
position: relative;
}
.visibilityToggle {
position: relative;
display: inline-block;
min-height: 20px;
color: $euiColorMediumShade;
.visibilityToggle__body {
line-height: 19px;
> * {
vertical-align: baseline;
}
}
.visibilityToggle__content {
svg {
display: inline-block;
vertical-align: middle;
}
.filter {
display: none;
}
}
.visibilityToggle__eye,
.visibilityToggle__eyeClosed,
.visibilityToggle__content {
transition: opacity .2s ease-in-out;
}
.visibilityToggle__eye,
.visibilityToggle__eyeClosed {
position: absolute;
left: 0;
top: 0;
opacity: 0;
z-index: 1;
}
.euiSwitch__input {
z-index: 2;
position: absolute;
opacity: 0;
width: 100%;
height: 100%;
cursor: pointer;
}
.visibilityToggle__body {
display: block;
display: block;
height: 16px;
width: 16px;
}
&:hover,
&:focus {
cursor: pointer;
color: $euiColorPrimary;
.visibilityToggle__content {
opacity: 0;
}
.euiSwitch__input:checked {
+ .visibilityToggle__body > .visibilityToggle__eye {
opacity: 1;
}
}
.euiSwitch__input:not(:checked) {
+ .visibilityToggle__body > .visibilityToggle__content {
opacity: 0;
}
+ .visibilityToggle__body > .visibilityToggle__eyeClosed {
opacity: 1;
}
}
}
}
.euiComboBox {
.euiComboBox__inputWrap {
display: flex;
}
}

View file

@ -0,0 +1,379 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import turf from 'turf';
import turfBooleanContains from '@turf/boolean-contains';
import { GIS_API_PATH } from '../../common/constants';
import { getLayerList, getLayerListRaw, getDataFilters, getSelectedLayer } from '../selectors/map_selectors';
export const SET_SELECTED_LAYER = 'SET_SELECTED_LAYER';
export const UPDATE_LAYER_ORDER = 'UPDATE_LAYER_ORDER';
export const ADD_LAYER = 'ADD_LAYER';
export const REMOVE_LAYER = 'REMOVE_LAYER';
export const PROMOTE_TEMPORARY_LAYERS = 'PROMOTE_TEMPORARY_LAYERS';
export const CLEAR_TEMPORARY_LAYERS = 'CLEAR_TEMPORARY_LAYERS';
export const SET_META = 'SET_META';
export const TOGGLE_LAYER_VISIBLE = 'TOGGLE_LAYER_VISIBLE';
export const MAP_EXTENT_CHANGED = 'MAP_EXTENT_CHANGED';
export const MAP_READY = 'MAP_READY';
export const MAP_DESTROYED = 'MAP_DESTROYED';
export const LAYER_DATA_LOAD_STARTED = 'LAYER_DATA_LOAD_STARTED';
export const LAYER_DATA_LOAD_ENDED = 'LAYER_DATA_LOAD_ENDED';
export const LAYER_DATA_LOAD_ERROR = 'LAYER_DATA_LOAD_ERROR';
export const REPLACE_LAYERLIST = 'REPLACE_LAYERLIST';
export const SET_JOINS = 'SET_JOINS';
export const SET_TIME_FILTERS = 'SET_TIME_FILTERS';
export const UPDATE_LAYER_PROP = 'UPDATE_LAYER_PROP';
export const UPDATE_LAYER_STYLE_FOR_SELECTED_LAYER = 'UPDATE_LAYER_STYLE';
export const PROMOTE_TEMPORARY_STYLES = 'PROMOTE_TEMPORARY_STYLES';
export const CLEAR_TEMPORARY_STYLES = 'CLEAR_TEMPORARY_STYLES';
export const TOUCH_LAYER = 'TOUCH_LAYER';
export const UPDATE_LAYER_ALPHA_VALUE = 'UPDATE_LAYER_ALPHA_VALUE';
export const UPDATE_SOURCE_PROP = 'UPDATE_SOURCE_PROP';
const GIS_API_RELATIVE = `../${GIS_API_PATH}`;
function getLayerLoadingCallbacks(dispatch, layerId) {
return {
startLoading: (dataId, requestToken, meta) => dispatch(startDataLoad(layerId, dataId, requestToken, meta)),
stopLoading: (dataId, requestToken, data, meta) => dispatch(endDataLoad(layerId, dataId, requestToken, data, meta)),
onLoadError: (dataId, requestToken, errorMessage) => dispatch(onDataLoadError(layerId, dataId, requestToken, errorMessage)),
onRefreshStyle: async () => {
await dispatch({
type: TOUCH_LAYER,
layerId: layerId
});
}
};
}
async function syncDataForAllLayers(getState, dispatch, dataFilters) {
const state = getState();
const layerList = getLayerList(state);
const syncs = layerList.map(layer => {
const loadingFunctions = getLayerLoadingCallbacks(dispatch, layer.getId());
return layer.syncData({ ...loadingFunctions, dataFilters });
});
await Promise.all(syncs);
}
export function replaceLayerList(newLayerList) {
return async (dispatch, getState) => {
await dispatch({
type: REPLACE_LAYERLIST,
layerList: newLayerList
});
const dataFilters = getDataFilters(getState());
await syncDataForAllLayers(getState, dispatch, dataFilters);
};
}
export function toggleLayerVisible(layerId) {
return {
type: TOGGLE_LAYER_VISIBLE,
layerId
};
}
export function setSelectedLayer(layerId) {
return {
type: SET_SELECTED_LAYER,
selectedLayerId: layerId
};
}
export function updateLayerOrder(newLayerOrder) {
return {
type: UPDATE_LAYER_ORDER,
newLayerOrder
};
}
export function addLayer(layer) {
return (dispatch) => {
dispatch(clearTemporaryLayers());
dispatch({
type: ADD_LAYER,
layer,
});
};
}
export function promoteTemporaryStyles() {
return {
type: PROMOTE_TEMPORARY_STYLES
};
}
export function promoteTemporaryLayers() {
return {
type: PROMOTE_TEMPORARY_LAYERS
};
}
export function clearTemporaryStyles() {
return {
type: CLEAR_TEMPORARY_STYLES
};
}
export function clearTemporaryLayers() {
return (dispatch, getState) => {
getLayerListRaw(getState()).forEach(({ temporary, id }) => {
if (temporary) {
dispatch(removeLayer(id));
}
});
};
}
export function mapReady() {
return {
type: MAP_READY
};
}
export function mapDestroyed() {
return {
type: MAP_DESTROYED
};
}
export function mapExtentChanged(newMapConstants) {
return async (dispatch, getState) => {
const state = getState();
const dataFilters = getDataFilters(state);
const { extent, zoom: newZoom } = newMapConstants;
const { buffer, zoom: currentZoom } = dataFilters;
if (extent) {
let doesBufferContainExtent = false;
if (buffer) {
const bufferGeometry = turf.bboxPolygon([
buffer.min_lon,
buffer.min_lat,
buffer.max_lon,
buffer.max_lat
]);
const extentGeometry = turf.bboxPolygon([
extent.min_lon,
extent.min_lat,
extent.max_lon,
extent.max_lat
]);
doesBufferContainExtent = turfBooleanContains(bufferGeometry, extentGeometry);
}
if (!doesBufferContainExtent || currentZoom !== newZoom) {
const scaleFactor = 0.5; // TODO put scale factor in store and fetch with selector
const width = extent.max_lon - extent.min_lon;
const height = extent.max_lat - extent.min_lat;
dataFilters.buffer = {
min_lon: extent.min_lon - width * scaleFactor,
min_lat: extent.min_lat - height * scaleFactor,
max_lon: extent.max_lon + width * scaleFactor,
max_lat: extent.max_lat + height * scaleFactor
};
}
}
dispatch({
type: MAP_EXTENT_CHANGED,
mapState: {
...dataFilters,
...newMapConstants
}
});
const newDataFilters = { ...dataFilters, ...newMapConstants };
await syncDataForAllLayers(getState, dispatch, newDataFilters);
};
}
export function startDataLoad(layerId, dataId, requestToken, meta = {}) {
return ({
meta,
type: LAYER_DATA_LOAD_STARTED,
layerId,
dataId,
requestToken
});
}
export function endDataLoad(layerId, dataId, requestToken, data, meta) {
return ({
type: LAYER_DATA_LOAD_ENDED,
layerId,
dataId,
data,
meta,
requestToken
});
}
export function onDataLoadError(layerId, dataId, requestToken, errorMessage) {
return ({
type: LAYER_DATA_LOAD_ERROR,
layerId,
dataId,
requestToken,
errorMessage
});
}
export function addPreviewLayer(layer, position) {
const layerDescriptor = layer.toLayerDescriptor();
return (dispatch) => {
dispatch(addLayer(layerDescriptor, position));
dispatch(syncDataForLayer(layer.getId()));
};
}
export function updateSourceProp(layerId, propName, value) {
return (dispatch) => {
dispatch({
type: UPDATE_SOURCE_PROP,
layerId,
propName,
value,
});
dispatch(syncDataForLayer(layerId));
};
}
export function syncDataForLayer(layerId) {
return (dispatch, getState) => {
const targetLayer = getLayerList(getState()).find(layer => {
return layer.getId() === layerId;
});
if (targetLayer) {
const dataFilters = getDataFilters(getState());
const loadingFunctions = getLayerLoadingCallbacks(dispatch, layerId);
targetLayer.syncData({ ...loadingFunctions, dataFilters });
}
};
}
export function updateLayerLabel(id, newLabel) {
return {
type: UPDATE_LAYER_PROP,
id,
propName: 'label',
newValue: newLabel,
};
}
export function updateLayerMinZoom(id, minZoom) {
return {
type: UPDATE_LAYER_PROP,
id,
propName: 'minZoom',
newValue: minZoom,
};
}
export function updateLayerMaxZoom(id, maxZoom) {
return {
type: UPDATE_LAYER_PROP,
id,
propName: 'maxZoom',
newValue: maxZoom,
};
}
export function updateLayerAlphaValue(id, newAlphaValue) {
return {
type: UPDATE_LAYER_ALPHA_VALUE,
id,
newAlphaValue
};
}
export function removeSelectedLayer() {
return (dispatch, getState) => {
const state = getState();
const layer = getSelectedLayer(state);
dispatch(removeLayer(layer.getId()));
};
}
export function removeLayer(id) {
return (dispatch, getState) => {
const layerGettingRemoved = getLayerList(getState()).find(layer => {
return id === layer.getId();
});
if (layerGettingRemoved) {
layerGettingRemoved.destroy();
}
dispatch({
type: REMOVE_LAYER,
id
});
};
}
export function setMeta(metaJson) {
return async dispatch => {
dispatch({
type: SET_META,
meta: metaJson
});
};
}
export function setTimeFilters(timeFilters) {
return async (dispatch, getState) => {
dispatch({
type: SET_TIME_FILTERS,
...timeFilters
});
const dataFilters = getDataFilters(getState());
const newDataFilters = { ...dataFilters, timeFilters: { ...timeFilters } };
await syncDataForAllLayers(getState, dispatch, newDataFilters);
};
}
export function updateLayerStyleForSelectedLayer(style, temporary = true) {
return async (dispatch, getState) => {
await dispatch({
type: UPDATE_LAYER_STYLE_FOR_SELECTED_LAYER,
style: {
...style,
temporary
},
});
const layer = getSelectedLayer(getState());
dispatch(syncDataForLayer(layer.getId()));
};
}
export function setJoinsForLayer(layer, joins) {
return async (dispatch) => {
console.warn('Not Implemented: must remove any styles that are driven by join-fields that are no longer present');
await dispatch({
type: SET_JOINS,
layer: layer,
joins: joins
});
dispatch(syncDataForLayer(layer.getId()));
};
}
export async function loadMetaResources(dispatch) {
const meta = await fetch(`${GIS_API_RELATIVE}/meta`);
const metaJson = await meta.json();
await dispatch(setMeta(metaJson));
}

View file

@ -0,0 +1,193 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
jest.mock('../selectors/map_selectors', () => ({}));
import { mapExtentChanged } from './store_actions';
const getStoreMock = jest.fn();
const dispatchMock = jest.fn();
describe('store_actions', () => {
afterEach(() => {
jest.resetAllMocks();
});
describe('mapExtentChanged', () => {
beforeEach(() => {
// getLayerList mocked to return emtpy array because
// syncDataForAllLayers is triggered by selector and internally calls getLayerList
require('../selectors/map_selectors').getLayerList = () => {
return [];
};
});
describe('store mapState is empty', () => {
beforeEach(() => {
require('../selectors/map_selectors').getDataFilters = () => {
return {};
};
});
it('should add newMapConstants to dispatch action mapState', async () => {
const action = mapExtentChanged({ zoom: 5 });
await action(dispatchMock, getStoreMock);
expect(dispatchMock).toHaveBeenCalledWith({
mapState: {
zoom: 5,
},
type: 'MAP_EXTENT_CHANGED',
});
});
it('should add buffer to dispatch action mapState', async () => {
const action = mapExtentChanged({
extent: {
max_lat: 10,
max_lon: 100,
min_lat: 5,
min_lon: 95,
}
});
await action(dispatchMock, getStoreMock);
expect(dispatchMock).toHaveBeenCalledWith({
mapState: {
extent: {
max_lat: 10,
max_lon: 100,
min_lat: 5,
min_lon: 95,
},
buffer: {
max_lat: 12.5,
max_lon: 102.5,
min_lat: 2.5,
min_lon: 92.5,
}
},
type: 'MAP_EXTENT_CHANGED',
});
});
});
describe('store mapState is populated', () => {
const initialZoom = 10;
beforeEach(() => {
require('../selectors/map_selectors').getDataFilters = () => {
return {
zoom: initialZoom,
buffer: {
max_lat: 12.5,
max_lon: 102.5,
min_lat: 2.5,
min_lon: 92.5,
}
};
};
});
it('should not update buffer if extent is contained in existing buffer', async () => {
const action = mapExtentChanged({
zoom: initialZoom,
extent: {
max_lat: 11,
max_lon: 101,
min_lat: 6,
min_lon: 96,
}
});
await action(dispatchMock, getStoreMock);
expect(dispatchMock).toHaveBeenCalledWith({
mapState: {
zoom: 10,
extent: {
max_lat: 11,
max_lon: 101,
min_lat: 6,
min_lon: 96,
},
buffer: {
max_lat: 12.5,
max_lon: 102.5,
min_lat: 2.5,
min_lon: 92.5,
}
},
type: 'MAP_EXTENT_CHANGED',
});
});
it('should update buffer if extent is outside of existing buffer', async () => {
const action = mapExtentChanged({
zoom: initialZoom,
extent: {
max_lat: 5,
max_lon: 90,
min_lat: 0,
min_lon: 85,
}
});
await action(dispatchMock, getStoreMock);
expect(dispatchMock).toHaveBeenCalledWith({
mapState: {
zoom: 10,
extent: {
max_lat: 5,
max_lon: 90,
min_lat: 0,
min_lon: 85,
},
buffer: {
max_lat: 7.5,
max_lon: 92.5,
min_lat: -2.5,
min_lon: 82.5,
}
},
type: 'MAP_EXTENT_CHANGED',
});
});
it('should update buffer when zoom changes', async () => {
const action = mapExtentChanged({
zoom: initialZoom + 1,
extent: {
max_lat: 11,
max_lon: 101,
min_lat: 6,
min_lon: 96,
}
});
await action(dispatchMock, getStoreMock);
expect(dispatchMock).toHaveBeenCalledWith({
mapState: {
zoom: 11,
extent: {
max_lat: 11,
max_lon: 101,
min_lat: 6,
min_lon: 96,
},
buffer: {
max_lat: 13.5,
max_lon: 103.5,
min_lat: 3.5,
min_lon: 93.5,
}
},
type: 'MAP_EXTENT_CHANGED',
});
});
});
});
});

View file

@ -0,0 +1,13 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
export const RESET_LAYER_LOAD = 'RESET_LAYER_LOAD';
export const resetLayerLoad = () => {
return {
type: RESET_LAYER_LOAD
};
};

View file

@ -0,0 +1,5 @@
<map-listing
find="find"
delete="delete"
listing-limit="listingLimit"
/>

View file

@ -0,0 +1,29 @@
<div id="gis-plugin">
<div id="gis-top-nav">
<div>
<kbn-top-nav name="gis" config="topNavMenu">
<div data-transclude-slots>
<!-- Title. -->
<div data-transclude-slot="topLeftCorner">
<div
class="kuiLocalBreadcrumbs"
data-test-subj="breadcrumbs"
role="heading"
aria-level="1"
ng-if="showPluginBreadcrumbs">
<div class="kuiLocalBreadcrumb">
<a class="kuiLocalBreadcrumb__link" href="#">Map</a>
</div>
<div class="kuiLocalBreadcrumb">
{{ getMapTitle() }}
</div>
</div>
</div>
</div>
</kbn-top-nav>
</div>
</div>
<div id="react-gis-root"></div>
</div>

View file

@ -0,0 +1,218 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import chrome from 'ui/chrome';
import React from 'react';
import { render, unmountComponentAtNode } from 'react-dom';
import { uiModules } from 'ui/modules';
import { applyTheme } from 'ui/theme';
import { timefilter } from 'ui/timefilter';
import { Provider } from 'react-redux';
import { getStore } from '../store/store';
import { GisMap } from '../components/gis_map';
import { setSelectedLayer, setTimeFilters, mapExtentChanged, replaceLayerList } from '../actions/store_actions';
import { getIsDarkTheme, updateFlyout, FLYOUT_STATE } from '../store/ui';
import { Inspector } from 'ui/inspector';
import { inspectorAdapters } from '../kibana_services';
import { SavedObjectSaveModal } from 'ui/saved_objects/components/saved_object_save_modal';
import { showSaveModal } from 'ui/saved_objects/show_saved_object_save_modal';
import { showOptionsPopover } from '../components/top_nav/show_options_popover';
import { toastNotifications } from 'ui/notify';
import { getMapReady, getTimeFilters } from "../selectors/map_selectors";
const REACT_ANCHOR_DOM_ELEMENT_ID = 'react-gis-root';
const app = uiModules.get('app/gis', []);
app.controller('GisMapController', ($scope, $route, config, kbnUrl) => {
let isLayersListInitializedFromSavedObject = false;
const savedMap = $scope.map = $route.current.locals.map;
let isDarkTheme;
let unsubscribe;
inspectorAdapters.requests.reset();
getStore().then(store => {
// clear old UI state
store.dispatch(setSelectedLayer(null));
store.dispatch(updateFlyout(FLYOUT_STATE.NONE));
handleStoreChanges(store);
unsubscribe = store.subscribe(() => {
handleStoreChanges(store);
});
// sync store with savedMap mapState
// layerList is synced after map has initialized and extent is known.
if (savedMap.mapStateJSON) {
const mapState = JSON.parse(savedMap.mapStateJSON);
const timeFilters = mapState.timeFilters ? mapState.timeFilters : timefilter.getTime();
store.dispatch(setTimeFilters(timeFilters));
store.dispatch(mapExtentChanged({
zoom: mapState.zoom,
center: mapState.center,
}));
}
const root = document.getElementById(REACT_ANCHOR_DOM_ELEMENT_ID);
render(
<Provider store={store}>
<GisMap/>
</Provider>,
root);
});
function handleStoreChanges(store) {
if (isDarkTheme !== getIsDarkTheme(store.getState())) {
isDarkTheme = getIsDarkTheme(store.getState());
updateTheme();
}
const storeTime = getTimeFilters(store.getState());
const kbnTime = timefilter.getTime();
if (storeTime && (storeTime.to !== kbnTime.to || storeTime.from !== kbnTime.from)) {
timefilter.setTime(storeTime);
}
// Part of initial syncing of store from saved object
// Delayed until after map is ready so map extent is known
if (!isLayersListInitializedFromSavedObject && getMapReady(store.getState())) {
isLayersListInitializedFromSavedObject = true;
const layerList = savedMap.layerListJSON ? JSON.parse(savedMap.layerListJSON) : [];
store.dispatch(replaceLayerList(layerList));
}
}
timefilter.on('timeUpdate', dispatchTimeUpdate);
$scope.$on('$destroy', () => {
if (unsubscribe) {
unsubscribe();
}
timefilter.off('timeUpdate', dispatchTimeUpdate);
const node = document.getElementById(REACT_ANCHOR_DOM_ELEMENT_ID);
if (node) {
unmountComponentAtNode(node);
}
});
$scope.getMapTitle = function () {
return $scope.map.title;
};
// k7design breadcrumbs
// TODO subscribe to store change and change when store updates title
chrome.breadcrumbs.set([
{ text: 'Map', href: '#' },
{ text: $scope.getMapTitle() }
]);
config.watch('k7design', (val) => $scope.showPluginBreadcrumbs = !val);
async function doSave(saveOptions) {
const store = await getStore();
savedMap.syncWithStore(store.getState());
let id;
try {
id = await savedMap.save(saveOptions);
} catch(err) {
toastNotifications.addDanger({
title: `Error on saving '${savedMap.title}'`,
text: err.message,
'data-test-subj': 'saveMapError',
});
return { error: err };
}
if (id) {
toastNotifications.addSuccess({
title: `Saved '${savedMap.title}'`,
'data-test-subj': 'saveMapSuccess',
});
if (savedMap.id !== $route.current.params.id) {
kbnUrl.change(`map/{{id}}`, { id: savedMap.id });
}
}
return { id };
}
$scope.topNavMenu = [{
key: 'inspect',
description: 'Open Inspector',
testId: 'openInspectorButton',
run() {
Inspector.open(inspectorAdapters, {});
}
}, {
key: 'options',
description: 'Options',
testId: 'optionsButton',
run: async (menuItem, navController, anchorElement) => {
showOptionsPopover(anchorElement);
}
}, {
key: 'save',
description: 'Save map',
testId: 'mapSaveButton',
run: async () => {
const onSave = ({ newTitle, newCopyOnSave, isTitleDuplicateConfirmed, onTitleDuplicate }) => {
const currentTitle = savedMap.title;
savedMap.title = newTitle;
savedMap.copyOnSave = newCopyOnSave;
const saveOptions = {
confirmOverwrite: false,
isTitleDuplicateConfirmed,
onTitleDuplicate,
};
return doSave(saveOptions).then(({ id, error }) => {
// If the save wasn't successful, put the original values back.
if (!id || error) {
savedMap.title = currentTitle;
}
return { id, error };
});
};
const saveModal = (
<SavedObjectSaveModal
onSave={onSave}
onClose={() => {}}
title={savedMap.title}
showCopyOnSave={savedMap.id ? true : false}
objectType={'gis-map'}
/>);
showSaveModal(saveModal);
}
}];
timefilter.enableTimeRangeSelector();
timefilter.enableAutoRefreshSelector();
async function dispatchTimeUpdate() {
const timeFilters = timefilter.getTime();
const store = await getStore();
store.dispatch(setTimeFilters(timeFilters));
}
function updateTheme() {
$scope.$evalAsync(() => {
isDarkTheme ? setDarkTheme() : setLightTheme();
});
}
function setDarkTheme() {
chrome.removeApplicationClass(['theme-light']);
chrome.addApplicationClass('theme-dark');
applyTheme('dark');
}
function setLightTheme() {
chrome.removeApplicationClass(['theme-dark']);
chrome.addApplicationClass('theme-light');
applyTheme('light');
}
});

View file

@ -0,0 +1,26 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import './saved_gis_map';
import { uiModules } from 'ui/modules';
import { SavedObjectLoader } from 'ui/courier/saved_object/saved_object_loader';
import { SavedObjectRegistryProvider } from 'ui/saved_objects/saved_object_registry';
import { SavedObjectsClientProvider } from 'ui/saved_objects';
const module = uiModules.get('app/gis');
// Register this service with the saved object registry so it can be
// edited by the object editor.
SavedObjectRegistryProvider.register({
service: 'gisMapSavedObjectLoader',
title: 'gisMaps'
});
// This is the only thing that gets injected into controllers
module.service('gisMapSavedObjectLoader', function (Private, SavedGisMap, kbnIndex, kbnUrl, $http, chrome) {
const savedObjectClient = Private(SavedObjectsClientProvider);
return new SavedObjectLoader(SavedGisMap, kbnIndex, kbnUrl, $http, chrome, savedObjectClient);
});

View file

@ -0,0 +1,111 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { uiModules } from 'ui/modules';
import { createLegacyClass } from 'ui/utils/legacy_class';
import { SavedObjectProvider } from 'ui/courier';
import {
getTimeFilters,
getMapZoom,
getMapCenter,
getLayerListRaw,
getMapExtent,
} from '../../selectors/map_selectors';
import { getIsDarkTheme } from '../../store/ui';
import { TileStyle } from '../../shared/layers/styles/tile_style';
const module = uiModules.get('app/gis');
module.factory('SavedGisMap', function (Private) {
const SavedObject = Private(SavedObjectProvider);
createLegacyClass(SavedGisMap).inherits(SavedObject);
function SavedGisMap(id) {
SavedGisMap.Super.call(this, {
type: SavedGisMap.type,
mapping: SavedGisMap.mapping,
searchSource: SavedGisMap.searchsource,
// if this is null/undefined then the SavedObject will be assigned the defaults
id: id,
// default values that will get assigned if the doc is new
defaults: {
title: 'New Map',
description: '',
layerListJSON: JSON.stringify([
{
id: "0hmz5",
sourceDescriptor: { "type": "EMS_TMS", "id": "road_map" },
visible: true,
temporary: false,
style: {
type: TileStyle.type,
properties: {
alphaValue: 1
}
},
type: "TILE",
minZoom: 0,
maxZoom: 24,
}
])
},
});
this.showInRecentlyAccessed = true;
}
SavedGisMap.type = 'gis-map';
// Mappings are used to place object properties into saved object _source
SavedGisMap.mapping = {
title: 'text',
description: 'text',
mapStateJSON: 'text',
layerListJSON: 'text',
uiStateJSON: 'text',
bounds: {
type: 'object'
}
};
SavedGisMap.fieldOrder = ['title', 'description'];
SavedGisMap.searchsource = false;
SavedGisMap.prototype.getFullPath = function () {
return `/app/gis#map/${this.id}`;
};
SavedGisMap.prototype.syncWithStore = function (state) {
const layerList = getLayerListRaw(state);
// Layer list from store contains requested data.
// We do not want to store this in the saved object so it is getting removed
const layerListConfigOnly = layerList.map(layer => {
delete layer.dataRequests;
return layer;
});
this.layerListJSON = JSON.stringify(layerListConfigOnly);
this.mapStateJSON = JSON.stringify({
zoom: getMapZoom(state),
center: getMapCenter(state),
timeFilters: getTimeFilters(state),
});
this.uiStateJSON = JSON.stringify({
isDarkMode: getIsDarkTheme(state),
});
const mapExtent = getMapExtent(state);
this.bounds = {
"type": "envelope",
"coordinates": [ [mapExtent.min_lon, mapExtent.max_lat], [mapExtent.max_lon, mapExtent.min_lat] ]
};
};
return SavedGisMap;
});

View file

@ -0,0 +1 @@
@import './layer_panel/join_editor/resources/join';

View file

@ -0,0 +1,21 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { connect } from 'react-redux';
import { GisMap } from './view';
import { getFlyoutDisplay, FLYOUT_STATE } from '../../store/ui';
function mapStateToProps(state = {}) {
const flyoutDisplay = getFlyoutDisplay(state);
return {
layerDetailsVisible: flyoutDisplay === FLYOUT_STATE.LAYER_PANEL,
addLayerVisible: flyoutDisplay === FLYOUT_STATE.ADD_LAYER_WIZARD,
noFlyoutVisible: flyoutDisplay === FLYOUT_STATE.NONE
};
}
const connectedGisMap = connect(mapStateToProps)(GisMap);
export { connectedGisMap as GisMap };

View file

@ -0,0 +1,51 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React from 'react';
import { MBMapContainer } from '../map/mb';
import { LayerControl } from '../layer_control/index';
import { LayerPanel } from '../layer_panel/index';
import { AddLayerPanel } from '../layer_addpanel/index';
import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import { Toasts } from '../toasts';
export function GisMap(props) {
const {
layerDetailsVisible,
addLayerVisible,
noFlyoutVisible
} = props;
let currentPanel;
let currentPanelClassName;
if (noFlyoutVisible) {
currentPanel = null;
} else if (addLayerVisible) {
currentPanelClassName = "gisLayerPanel-isVisible";
currentPanel = <AddLayerPanel/>;
} else if (layerDetailsVisible) {
currentPanelClassName = "gisLayerPanel-isVisible";
currentPanel = (
<LayerPanel/>
);
}
return (
<EuiFlexGroup gutterSize="none" responsive={false}>
<EuiFlexItem className="gisMapWrapper">
<MBMapContainer/>
<LayerControl/>
</EuiFlexItem>
<EuiFlexItem className={`gisLayerPanel ${currentPanelClassName}`} grow={false}>
{currentPanel}
</EuiFlexItem>
<Toasts/>
</EuiFlexGroup>
);
}

View file

@ -0,0 +1,53 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { connect } from 'react-redux';
import { AddLayerPanel } from './view';
import { getFlyoutDisplay, updateFlyout, FLYOUT_STATE }
from '../../store/ui';
import { getTemporaryLayers, getDataSources } from "../../selectors/map_selectors";
import {
addPreviewLayer,
removeLayer,
clearTemporaryLayers,
setSelectedLayer,
} from "../../actions/store_actions";
import _ from 'lodash';
function mapStateToProps(state = {}) {
const dataSourceMeta = getDataSources(state);
function isLoading() {
const tmp = getTemporaryLayers(state);
return tmp.some((layer) => layer.isLayerLoading());
}
return {
flyoutVisible: getFlyoutDisplay(state) !== FLYOUT_STATE.NONE,
dataSourcesMeta: dataSourceMeta,
layerLoading: isLoading(),
temporaryLayers: !_.isEmpty(getTemporaryLayers(state))
};
}
function mapDispatchToProps(dispatch) {
return {
closeFlyout: () => {
dispatch(updateFlyout(FLYOUT_STATE.NONE));
dispatch(clearTemporaryLayers());
},
previewLayer: (layer) => {
dispatch(addPreviewLayer(layer));
},
removeLayer: id => dispatch(removeLayer(id)),
nextAction: id => {
dispatch(setSelectedLayer(id));
dispatch(updateFlyout(FLYOUT_STATE.LAYER_PANEL));
},
};
}
const connectedFlyOut = connect(mapStateToProps, mapDispatchToProps, null, { withRef: true })(AddLayerPanel);
export { connectedFlyOut as AddLayerPanel };

View file

@ -0,0 +1,280 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { Fragment } from 'react';
import { XYZTMSSource } from '../../shared/layers/sources/xyz_tms_source';
import { WMSSource } from '../../shared/layers/sources/wms_source';
import { EMSFileSource } from '../../shared/layers/sources/ems_file_source';
import { ESGeohashGridSource } from '../../shared/layers/sources/es_geohashgrid_source';
import { ESSearchSource } from '../../shared/layers/sources/es_search_source';
import { KibanaRegionmapSource } from '../../shared/layers/sources/kibana_regionmap_source';
import {
EuiText,
EuiSpacer,
EuiButton,
EuiHorizontalRule,
EuiButtonEmpty,
EuiFlexGroup,
EuiFlexItem,
EuiTitle,
EuiForm,
EuiFormRow,
EuiSuperSelect,
EuiPanel,
} from '@elastic/eui';
export class AddLayerPanel extends React.Component {
constructor() {
super();
this.state = {
label: '',
sourceType: '',
minZoom: 0,
maxZoom: 24,
alphaValue: 1
};
}
componentDidUpdate() {
if (this.layer && this.state.alphaValue === null) {
const defaultAlphaValue = this.layer._descriptor.type === 'TILE' ? 1 : 1;
if (this.state.alphaValue !== defaultAlphaValue) {
this.setState({
alphaValue: defaultAlphaValue
});
}
}
}
_previewLayer = (source) => {
this.layer = source.createDefaultLayer({
temporary: true,
label: this.state.label,
minZoom: this.state.minZoom,
maxZoom: this.state.maxZoom,
});
this.props.previewLayer(this.layer);
};
_onSourceTypeChange = (sourceType) => {
this.setState({
sourceType,
});
if (this.layer) {
this.props.removeLayer(this.layer.getId());
}
}
_renderNextBtn() {
const { layerLoading, temporaryLayers, nextAction } = this.props;
const addToMapBtnText = 'Next';
return (
<EuiButton
style={{ width: '9rem' }}
disabled={!temporaryLayers || layerLoading}
isLoading={layerLoading}
iconSide="right"
iconType={'sortRight'}
onClick={() => {
const layerId = this.layer.getId();
this.layer = null;
return nextAction(layerId);
}}
fill
>
{addToMapBtnText}
</EuiButton>
);
}
_renderSourceSelect() {
const sourceOptions = [
{
value: ESSearchSource.type,
inputDisplay: ESSearchSource.typeDisplayName,
dropdownDisplay: (
<Fragment>
<strong>{ESSearchSource.typeDisplayName}</strong>
<EuiSpacer size="xs" />
<EuiText size="s" color="subdued">
<p className="euiTextColor--subdued">
Display documents from an elasticsearch index.
</p>
</EuiText>
</Fragment>
),
},
{
value: ESGeohashGridSource.type,
inputDisplay: ESGeohashGridSource.typeDisplayName,
dropdownDisplay: (
<Fragment>
<strong>{ESGeohashGridSource.typeDisplayName}</strong>
<EuiSpacer size="xs" />
<EuiText size="s" color="subdued">
<p className="euiTextColor--subdued">
Group documents into grid cells and display metrics for each cell.
Great for displaying large datasets.
</p>
</EuiText>
</Fragment>
),
},
{
value: EMSFileSource.type,
inputDisplay: EMSFileSource.typeDisplayName,
dropdownDisplay: (
<Fragment>
<strong>{EMSFileSource.typeDisplayName}</strong>
<EuiSpacer size="xs" />
<EuiText size="s" color="subdued">
<p className="euiTextColor--subdued">Political boundry vectors hosted by EMS.</p>
</EuiText>
</Fragment>
),
},
{
value: KibanaRegionmapSource.type,
inputDisplay: KibanaRegionmapSource.typeDisplayName,
dropdownDisplay: (
<Fragment>
<strong>{KibanaRegionmapSource.typeDisplayName}</strong>
<EuiSpacer size="xs" />
<EuiText size="s" color="subdued">
<p className="euiTextColor--subdued">
Region map boundary layers configured in your config/kibana.yml file.
</p>
</EuiText>
</Fragment>
),
},
{
value: XYZTMSSource.type,
inputDisplay: XYZTMSSource.typeDisplayName,
dropdownDisplay: (
<Fragment>
<strong>{XYZTMSSource.typeDisplayName}</strong>
<EuiSpacer size="xs" />
<EuiText size="s" color="subdued">
<p className="euiTextColor--subdued">Tile Map Service with XYZ url.</p>
</EuiText>
</Fragment>
),
},
{
value: WMSSource.type,
inputDisplay: WMSSource.typeDisplayName,
dropdownDisplay: (
<Fragment>
<strong>{WMSSource.typeDisplayName}</strong>
<EuiSpacer size="xs" />
<EuiText size="s" color="subdued">
<p className="euiTextColor--subdued">Web Map Service (WMS)</p>
</EuiText>
</Fragment>
),
},
];
return (
<EuiFormRow label="Data source">
<EuiSuperSelect
itemClassName="sourceSelectItem"
options={sourceOptions}
valueOfSelected={this.state.sourceType}
onChange={this._onSourceTypeChange}
itemLayoutAlign="top"
hasDividers
/>
</EuiFormRow>
);
}
_renderSourceEditor() {
if (!this.state.sourceType) {
return;
}
const editorProperties = {
onPreviewSource: this._previewLayer,
dataSourcesMeta: this.props.dataSourcesMeta
};
switch(this.state.sourceType) {
case EMSFileSource.type:
return EMSFileSource.renderEditor(editorProperties);
case XYZTMSSource.type:
return XYZTMSSource.renderEditor(editorProperties);
case ESGeohashGridSource.type:
return ESGeohashGridSource.renderEditor(editorProperties);
case ESSearchSource.type:
return ESSearchSource.renderEditor(editorProperties);
case KibanaRegionmapSource.type:
return KibanaRegionmapSource.renderEditor(editorProperties);
case WMSSource.type:
return WMSSource.renderEditor(editorProperties);
default:
throw new Error(`Unexepected source type: ${this.state.sourceType}`);
}
}
_renderAddLayerForm() {
return (
<EuiForm>
{this._renderSourceSelect()}
{this._renderSourceEditor()}
</EuiForm>
);
}
_renderFlyout() {
return (
<EuiFlexGroup
direction="column"
gutterSize="none"
>
<EuiFlexItem grow={false} className="gisViewPanel__header">
<EuiTitle size="s">
<h1>Add layer</h1>
</EuiTitle>
<EuiSpacer size="m"/>
<EuiHorizontalRule margin="none"/>
</EuiFlexItem>
<EuiFlexItem className="gisViewPanel__body">
<EuiPanel>
{this._renderAddLayerForm()}
</EuiPanel>
</EuiFlexItem>
<EuiFlexItem grow={false} className="gisViewPanel__footer">
<EuiHorizontalRule margin="none"/>
<EuiSpacer size="m"/>
<EuiFlexGroup justifyContent="spaceBetween" responsive={false}>
<EuiFlexItem grow={false}>
<EuiButtonEmpty
onClick={this.props.closeFlyout}
flush="left"
>
Cancel
</EuiButtonEmpty>
</EuiFlexItem>
<EuiFlexItem grow={false}>
{this._renderNextBtn()}
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
</EuiFlexGroup>
);
}
render() {
return (this.props.flyoutVisible) ? this._renderFlyout() : null;
}
}

View file

@ -0,0 +1,38 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { connect } from 'react-redux';
import { LayerControl } from './view';
import {
getIsSetViewOpen,
closeSetView,
openSetView,
updateFlyout,
FLYOUT_STATE
} from '../../store/ui';
function mapStateToProps(state = {}) {
return {
isSetViewOpen: getIsSetViewOpen(state),
};
}
function mapDispatchToProps(dispatch) {
return {
showAddLayerWizard: () => {
dispatch(updateFlyout(FLYOUT_STATE.ADD_LAYER_WIZARD));
},
closeSetView: () => {
dispatch(closeSetView());
},
openSetView: () => {
dispatch(openSetView());
}
};
}
const connectedLayerControl = connect(mapStateToProps, mapDispatchToProps)(LayerControl);
export { connectedLayerControl as LayerControl };

View file

@ -0,0 +1,79 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React from 'react';
import {
EuiFlexGroup,
EuiFlexItem,
EuiPanel,
EuiButtonEmpty,
EuiTitle,
EuiPopover
} from '@elastic/eui';
import { LayerTOC } from '../layer_toc';
import { SetView } from '../set_view';
export function LayerControl(props) {
const addLayer = (
<EuiButtonEmpty size="xs" flush="right" onClick={props.showAddLayerWizard}>
Add layer
</EuiButtonEmpty>);
const toggleSetViewVisibility = () => {
if (props.isSetViewOpen) {
props.closeSetView();
return;
}
props.openSetView();
};
const setView = (
<EuiPopover
button={(
<EuiButtonEmpty
size="xs"
onClick={toggleSetViewVisibility}
>
Set view
</EuiButtonEmpty>)}
isOpen={props.isSetViewOpen}
closePopover={props.closeSetView}
>
<SetView />
</EuiPopover>
);
return (
<EuiPanel className="LayerControl" hasShadow paddingSize="none">
<EuiFlexGroup
className="LayerControl--header"
justifyContent="spaceBetween"
alignItems="center"
responsive={false}
gutterSize="none"
>
<EuiFlexItem>
<EuiTitle size="s">
<h1>Layers</h1>
</EuiTitle>
</EuiFlexItem>
<EuiFlexItem grow={false}>
{setView}
</EuiFlexItem>
<EuiFlexItem grow={false}>
{addLayer}
</EuiFlexItem>
</EuiFlexGroup>
<EuiFlexGroup>
<EuiFlexItem>
<LayerTOC />
</EuiFlexItem>
</EuiFlexGroup>
</EuiPanel>
);
}

View file

@ -0,0 +1,45 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { connect } from 'react-redux';
import { FlyoutFooter } from './view';
import { updateFlyout, FLYOUT_STATE } from '../../../store/ui';
import { promoteTemporaryStyles, clearTemporaryStyles, clearTemporaryLayers,
setSelectedLayer, removeSelectedLayer, promoteTemporaryLayers } from '../../../actions/store_actions';
import { getSelectedLayer } from '../../../selectors/map_selectors';
const mapStateToProps = state => {
const selectedLayer = getSelectedLayer(state);
return {
isNewLayer: selectedLayer.isTemporary()
};
};
const mapDispatchToProps = dispatch => {
return {
cancelLayerPanel: () => {
dispatch(updateFlyout(FLYOUT_STATE.NONE));
dispatch(clearTemporaryStyles());
dispatch(clearTemporaryLayers());
},
saveLayerEdits: isNewLayer => {
dispatch(updateFlyout(FLYOUT_STATE.NONE));
dispatch(promoteTemporaryStyles());
if (isNewLayer) {
dispatch(promoteTemporaryLayers());
}
dispatch(setSelectedLayer(null));
},
removeLayer: () => {
dispatch(updateFlyout(FLYOUT_STATE.NONE));
dispatch(removeSelectedLayer());
dispatch(setSelectedLayer(null));
}
};
};
const connectedFlyoutFooter = connect(mapStateToProps, mapDispatchToProps)(FlyoutFooter);
export { connectedFlyoutFooter as FlyoutFooter };

View file

@ -0,0 +1,58 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React from 'react';
import {
EuiButton,
EuiFlexGroup,
EuiFlexItem,
EuiSpacer,
EuiButtonEmpty,
} from '@elastic/eui';
export const FlyoutFooter = ({ cancelLayerPanel, saveLayerEdits, removeLayer,
isNewLayer }) => {
const removeBtn = isNewLayer
? null
: (
<EuiFlexItem grow={false}>
<EuiButtonEmpty
color="danger"
onClick={removeLayer}
flush="right"
data-test-subj="mapRemoveLayerButton"
>
Remove layer
</EuiButtonEmpty>
</EuiFlexItem>
);
return (
<EuiFlexGroup responsive={false}>
<EuiFlexItem grow={false}>
<EuiButtonEmpty
onClick={cancelLayerPanel}
flush="left"
>
Cancel
</EuiButtonEmpty>
</EuiFlexItem>
<EuiFlexItem>
<EuiSpacer/>
</EuiFlexItem>
{removeBtn}
<EuiFlexItem grow={false}>
<EuiButton
iconType="check"
onClick={() => saveLayerEdits(isNewLayer)}
fill
>
Save &amp; close
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
);
};

View file

@ -0,0 +1,33 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { connect } from 'react-redux';
import { LayerPanel } from './view';
import { getSelectedLayer } from '../../selectors/map_selectors';
import {
updateLayerLabel,
updateLayerMaxZoom,
updateLayerMinZoom,
updateLayerAlphaValue
} from '../../actions/store_actions';
function mapStateToProps(state = {}) {
return {
selectedLayer: getSelectedLayer(state)
};
}
function mapDispatchToProps(dispatch) {
return {
updateLabel: (id, label) => dispatch(updateLayerLabel(id, label)),
updateMinZoom: (id, minZoom) => dispatch(updateLayerMinZoom(id, minZoom)),
updateMaxZoom: (id, maxZoom) => dispatch(updateLayerMaxZoom(id, maxZoom)),
updateAlphaValue: (id, alphaValue) => dispatch(updateLayerAlphaValue(id, alphaValue))
};
}
const connectedLayerPanel = connect(mapStateToProps, mapDispatchToProps)(LayerPanel);
export { connectedLayerPanel as LayerPanel };

View file

@ -0,0 +1,29 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { connect } from 'react-redux';
import { JoinEditor } from './view';
import { setJoinsForLayer } from '../../../actions/store_actions';
function mapDispatchToProps(dispatch) {
return {
onChange: (layer, joins) => {
dispatch(setJoinsForLayer(layer, joins));
}
};
}
function mapStateToProps({}, props) {
return {
joins: props.layer.getJoins().map(join => {
return join.toDescriptor();
}),
layer: props.layer,
};
}
const connectedJoinEditor = connect(mapStateToProps, mapDispatchToProps)(JoinEditor);
export { connectedJoinEditor as JoinEditor };

View file

@ -0,0 +1,32 @@
.gisJoinItem {
background: tintOrShade($euiColorLightShade, 85%, 0);
border-radius: $euiBorderRadius;
margin: $euiSizeXS 0;
padding: $euiSizeXS;
position: relative;
&:last-of-type {
margin-bottom: 0;
}
&:hover,
&:focus {
.gisJoinItem__delete {
visibility: visible;
opacity: 1;
}
}
}
.gisJoinItem__delete {
position: absolute;
right: 0;
top: 50%;
margin-right: -8px;
margin-top: -12px;
background: $euiColorEmptyShade;
@include euiBottomShadowSmall;
padding: $euiSizeXS;
visibility: hidden;
opacity: 0;
}

View file

@ -0,0 +1,16 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React from 'react';
export function FromExpression({ leftSourceName }) {
return (
<div className="euiExpressionButton euiText">
<span className="euiExpressionButton__description">FROM</span>{' '}
<span className="euiExpressionButton__value">{`${leftSourceName} left`}</span>
</div>
);
}

View file

@ -0,0 +1,21 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React from 'react';
import PropTypes from 'prop-types';
export function GroupByExpression({ term }) {
return (
<div className="euiExpressionButton euiText">
<span className="euiExpressionButton__description">GROUP BY</span>{' '}
<span className="euiExpressionButton__value">{`right.${term}`}</span>
</div>
);
}
GroupByExpression.propTypes = {
term: PropTypes.string.isRequired,
};

View file

@ -0,0 +1,240 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import _ from 'lodash';
import React, { Component } from 'react';
import {
EuiFlexItem,
EuiFlexGroup,
EuiButtonIcon,
} from '@elastic/eui';
import { FromExpression } from './from_expression';
import { GroupByExpression } from './group_by_expression';
import { JoinExpression } from './join_expression';
import { OnExpression } from './on_expression';
import { SelectExpression } from './select_expression';
import {
indexPatternService,
} from '../../../../kibana_services';
const getIndexPatternId = (props) => {
return _.get(props, 'join.right.indexPatternId');
};
/*
* SELECT <metric_agg>
* FROM <left_source (can not be changed)>
* LEFT JOIN <right_source (index-pattern)>
* ON <left_field>
* GROUP BY <right_field>
*/
export class Join extends Component {
state = {
leftFields: null,
leftSourceName: '',
rightFields: undefined,
loadError: undefined,
prevIndexPatternId: getIndexPatternId(this.props),
};
componentDidMount() {
this._isMounted = true;
this._loadLeftFields();
this._loadLeftSourceName();
}
componentWillUnmount() {
this._isMounted = false;
}
componentDidUpdate() {
if (!this.state.rigthFields && getIndexPatternId(this.props)) {
this._loadRightFields(getIndexPatternId(this.props));
}
}
static getDerivedStateFromProps(nextProps, prevState) {
const nextIndexPatternId = getIndexPatternId(nextProps);
if (nextIndexPatternId !== prevState.prevIndexPatternId) {
return {
rightFields: undefined,
loadError: undefined,
prevIndexPatternId: nextIndexPatternId,
};
}
return null;
}
async _loadRightFields(indexPatternId) {
if (!indexPatternId) {
return;
}
let indexPattern;
try {
indexPattern = await indexPatternService.get(indexPatternId);
} catch (err) {
if (this._isMounted) {
this.setState({
loadError: `Unable to find Index pattern ${indexPatternId}`
});
}
return;
}
if (indexPatternId !== this.state.prevIndexPatternId) {
// ignore out of order responses
return;
}
if (!this._isMounted) {
return;
}
this.setState({ rightFields: indexPattern.fields });
}
async _loadLeftSourceName() {
const leftSourceName = await this.props.layer.getSourceName();
if (!this._isMounted) {
return;
}
this.setState({ leftSourceName });
}
async _loadLeftFields() {
const stringFields = await this.props.layer.getStringFields();
if (!this._isMounted) {
return;
}
this.setState({ leftFields: stringFields });
}
_onLeftFieldChange = (leftField) => {
this.props.onChange({
leftField: leftField,
right: this.props.join.right,
});
};
_onRightSourceChange = ({ indexPatternId, indexPatternTitle }) => {
this.props.onChange({
leftField: this.props.join.leftField,
right: {
id: this.props.join.right.id,
indexPatternId,
indexPatternTitle,
},
});
}
_onRightFieldChange = (term) => {
this.props.onChange({
leftField: this.props.join.leftField,
right: {
...this.props.join.right,
term
},
});
}
_onMetricsChange = (metrics) => {
this.props.onChange({
leftField: this.props.join.leftField,
right: {
...this.props.join.right,
metrics,
},
});
}
render() {
const {
join,
onRemove,
} = this.props;
const {
leftSourceName,
leftFields,
rightFields,
} = this.state;
const right = _.get(join, 'right', {});
const rightSourceName = right.indexPatternTitle ? right.indexPatternTitle : right.indexPatternId;
let onExpression;
if (leftFields && rightFields) {
onExpression = (
<EuiFlexItem grow={false}>
<OnExpression
leftValue={join.leftField}
leftFields={leftFields}
onLeftChange={this._onLeftFieldChange}
rightValue={right.term}
rightFields={rightFields}
onRightChange={this._onRightFieldChange}
/>
</EuiFlexItem>
);
}
let groupByExpression;
if (right.indexPatternId && right.term) {
groupByExpression = (
<EuiFlexItem grow={false}>
<GroupByExpression
rightSourceName={rightSourceName}
term={right.term}
/>
</EuiFlexItem>
);
}
return (
<EuiFlexGroup className="gisJoinItem" responsive={false} wrap={true} gutterSize="s">
<EuiFlexItem grow={false}>
<SelectExpression
metrics={right.metrics}
rightFields={rightFields}
onChange={this._onMetricsChange}
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<FromExpression
leftSourceName={leftSourceName}
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<JoinExpression
indexPatternId={right.indexPatternId}
rightSourceName={rightSourceName}
onChange={this._onRightSourceChange}
/>
</EuiFlexItem>
{onExpression}
{groupByExpression}
<EuiButtonIcon
className="gisJoinItem__delete"
iconType="trash"
color="danger"
aria-label="Delete join"
title="Delete join"
onClick={onRemove}
/>
</EuiFlexGroup>
);
}
}

View file

@ -0,0 +1,97 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import {
EuiPopover,
EuiPopoverTitle,
EuiExpressionButton,
EuiFormRow,
} from '@elastic/eui';
import { IndexPatternSelect } from 'ui/index_patterns/components/index_pattern_select';
import {
indexPatternService,
} from '../../../../kibana_services';
export class JoinExpression extends Component {
state = {
isPopoverOpen: false,
};
_togglePopover = () => {
this.setState((prevState) => ({
isPopoverOpen: !prevState.isPopoverOpen,
}));
};
_closePopover = () => {
this.setState({
isPopoverOpen: false,
});
};
_onChange = async (indexPatternId) => {
this.setState({ isPopoverOpen: false });
try {
const indexPattern = await indexPatternService.get(indexPatternId);
this.props.onChange({
indexPatternId,
indexPatternTitle: indexPattern.title,
});
} catch (err) {
// do not call onChange with when unable to get indexPatternId
}
}
render() {
const {
indexPatternId,
rightSourceName
} = this.props;
return (
<EuiPopover
id="joinPopover"
isOpen={this.state.isPopoverOpen}
closePopover={this._closePopover}
ownFocus
initialFocus="body" /* avoid initialFocus on Combobox */
withTitle
button={
<EuiExpressionButton
onClick={this._togglePopover}
description="JOIN"
buttonValue={rightSourceName ? `${rightSourceName} right` : '-- select --'}
/>
}
>
<div style={{ width: 300 }}>
<EuiPopoverTitle>JOIN</EuiPopoverTitle>
<EuiFormRow
label="Index pattern"
helpText={`Select right source`}
>
<IndexPatternSelect
placeholder="Select index pattern"
indexPatternId={indexPatternId}
onChange={this._onChange}
isClearable={false}
/>
</EuiFormRow>
</div>
</EuiPopover>
);
}
}
JoinExpression.propTypes = {
indexPatternId: PropTypes.string,
rightSourceName: PropTypes.string,
};

View file

@ -0,0 +1,151 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import {
EuiPopover,
EuiPopoverTitle,
EuiExpressionButton,
EuiFormRow,
EuiComboBox,
} from '@elastic/eui';
import { SingleFieldSelect } from '../../../../shared/components/single_field_select';
export class OnExpression extends Component {
state = {
isPopoverOpen: false,
};
_togglePopover = () => {
this.setState((prevState) => ({
isPopoverOpen: !prevState.isPopoverOpen,
}));
};
_closePopover = () => {
this.setState({
isPopoverOpen: false,
});
};
_onLeftFieldChange = (selectedFields) => {
const selectedField = selectedFields.length > 0 ? selectedFields[0].value : null;
this.props.onLeftChange(selectedField.name);
};
_renderLeftFieldSelect() {
const {
leftValue,
leftFields,
} = this.props;
const options = leftFields.map(field => {
return {
value: field,
label: field.label,
};
});
let leftFieldOption;
if (leftValue) {
leftFieldOption = options.find((option) => {
const field = option.value;
return field.name === leftValue;
});
}
const selectedOptions = leftFieldOption
? [leftFieldOption]
: [];
return (
<EuiComboBox
placeholder="Select field"
singleSelection={true}
isClearable={false}
options={options}
selectedOptions={selectedOptions}
onChange={this._onLeftFieldChange}
/>
);
}
_renderRightFieldSelect() {
const filterStringOrNumberFields = (field) => {
return field.type === 'string' || field.type === 'number';
};
return (
<SingleFieldSelect
placeholder="Select field"
value={this.props.rightValue}
onChange={this.props.onRightChange}
filterField={filterStringOrNumberFields}
fields={this.props.rightFields}
isClearable={false}
/>
);
}
render() {
const {
leftValue,
rightValue,
} = this.props;
return (
<EuiPopover
id="onPopover"
isOpen={this.state.isPopoverOpen}
closePopover={this._closePopover}
ownFocus
withTitle
initialFocus="body" /* avoid initialFocus on Combobox */
button={
<EuiExpressionButton
onClick={this._togglePopover}
description="ON"
buttonValue={
leftValue && rightValue
? `left.${leftValue} = right.${rightValue}`
: '-- select --'
}
/>
}
>
<div style={{ width: 300 }}>
<EuiPopoverTitle>ON</EuiPopoverTitle>
<EuiFormRow
label="left field"
>
{this._renderLeftFieldSelect()}
</EuiFormRow>
<EuiFormRow
label="right field"
>
{this._renderRightFieldSelect()}
</EuiFormRow>
</div>
</EuiPopover>
);
}
}
OnExpression.propTypes = {
leftValue: PropTypes.string,
leftFields: PropTypes.arrayOf(PropTypes.shape({
label: PropTypes.string.isRequired,
name: PropTypes.string.isRequired,
})).isRequired,
onLeftChange: PropTypes.func.isRequired,
rightValue: PropTypes.string,
rightFields: PropTypes.object.isRequired, // indexPattern.fields IndexedArray object
onRightChange: PropTypes.func.isRequired,
};

View file

@ -0,0 +1,108 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import {
EuiPopover,
EuiPopoverTitle,
EuiExpressionButton,
EuiFormErrorText,
} from '@elastic/eui';
import { MetricsEditor } from '../../../../shared/components/metrics_editor';
export class SelectExpression extends Component {
state = {
isPopoverOpen: false,
};
_togglePopover = () => {
this.setState((prevState) => ({
isPopoverOpen: !prevState.isPopoverOpen,
}));
}
_closePopover = () => {
this.setState({
isPopoverOpen: false,
});
}
_renderMetricsEditor = () => {
if (!this.props.rightFields) {
return (
<EuiFormErrorText>JOIN must be set</EuiFormErrorText>
);
}
return (
<MetricsEditor
fields={this.props.rightFields}
metrics={this.props.metrics}
onChange={this.props.onChange}
/>
);
}
render() {
const metricExpressions = this.props.metrics
.filter(({ type, field }) => {
if (type === 'count') {
return true;
}
if (field) {
return true;
}
return false;
})
.map(({ type, field }) => {
if (type === 'count') {
return 'count(*)';
}
return `${type}(right.${field})`;
});
return (
<EuiPopover
id="selectPopover"
isOpen={this.state.isPopoverOpen}
closePopover={this._closePopover}
ownFocus
initialFocus="body" /* avoid initialFocus on Combobox */
withTitle
button={
<EuiExpressionButton
onClick={this._togglePopover}
description="SELECT"
buttonValue={metricExpressions.length > 0 ? metricExpressions.join(', ') : 'count(*)'}
/>
}
>
<div style={{ width: 400 }}>
<EuiPopoverTitle>SELECT</EuiPopoverTitle>
{this._renderMetricsEditor()}
</div>
</EuiPopover>
);
}
}
SelectExpression.propTypes = {
metrics: PropTypes.array,
rightFields: PropTypes.object, // indexPattern.fields IndexedArray object
onChange: PropTypes.func.isRequired,
};
SelectExpression.defaultProps = {
metrics: [
{ type: 'count' }
]
};

View file

@ -0,0 +1,81 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { } from 'react';
import uuid from 'uuid/v4';
import {
EuiFlexGroup,
EuiFlexItem,
EuiButtonIcon,
EuiTitle,
EuiSpacer
} from '@elastic/eui';
import { Join } from './resources/join';
export function JoinEditor({ joins, layer, onChange }) {
const renderJoins = () => {
return joins.map((joinDescriptor, index) => {
const handleOnChange = (updatedDescriptor) => {
onChange(layer, [
...joins.slice(0, index),
updatedDescriptor,
...joins.slice(index + 1)
]);
};
const handleOnRemove = () => {
onChange(layer, [
...joins.slice(0, index),
...joins.slice(index + 1)
]);
};
return (
<Join
key={index}
join={joinDescriptor}
layer={layer}
onChange={handleOnChange}
onRemove={handleOnRemove}
/>
);
});
};
const addJoin = () => {
onChange(layer, [
...joins,
{
right: {
id: uuid()
}
}
]);
};
if (!layer.isJoinable()) {
return null;
}
return (
<div>
<EuiFlexGroup responsive={false}>
<EuiFlexItem>
<EuiTitle size="xs"><h5>Joins</h5></EuiTitle>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButtonIcon iconType="plusInCircle" onClick={addJoin} aria-label="Add join" title="Add join" />
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer size="m" />
{renderJoins()}
</div>
);
}

View file

@ -0,0 +1,44 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import _ from 'lodash';
import { connect } from 'react-redux';
import { SettingsPanel } from './settings_panel';
import { getSelectedLayer } from '../../../selectors/map_selectors';
import {
updateLayerLabel,
updateLayerMaxZoom,
updateLayerMinZoom,
updateLayerAlphaValue,
updateSourceProp,
} from '../../../actions/store_actions';
function mapStateToProps(state = {}) {
const selectedLayer = getSelectedLayer(state);
return {
alphaValue: _.get(selectedLayer.getCurrentStyle(), '_descriptor.properties.alphaValue', 1),
label: selectedLayer.getLabel(),
layerId: selectedLayer.getId(),
maxZoom: selectedLayer.getMaxZoom(),
minZoom: selectedLayer.getMinZoom(),
alphaValue: _.get(selectedLayer.getCurrentStyle(), '_descriptor.properties.alphaValue', 1),
renderSourceDetails: selectedLayer.renderSourceDetails,
renderSourceSettingsEditor: selectedLayer.renderSourceSettingsEditor,
};
}
function mapDispatchToProps(dispatch) {
return {
updateLabel: (id, label) => dispatch(updateLayerLabel(id, label)),
updateMinZoom: (id, minZoom) => dispatch(updateLayerMinZoom(id, minZoom)),
updateMaxZoom: (id, maxZoom) => dispatch(updateLayerMaxZoom(id, maxZoom)),
updateAlphaValue: (id, alphaValue) => dispatch(updateLayerAlphaValue(id, alphaValue)),
updateSourceProp: (id, propName, value) => dispatch(updateSourceProp(id, propName, value)),
};
}
const connectedSettingsPanel = connect(mapStateToProps, mapDispatchToProps)(SettingsPanel);
export { connectedSettingsPanel as SettingsPanel };

View file

@ -0,0 +1,175 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { Component, Fragment } from 'react';
import {
EuiFlexGroup,
EuiFlexItem,
EuiTitle,
EuiPanel,
EuiFormRow,
EuiFieldText,
EuiRange,
EuiSpacer,
EuiLink,
} from '@elastic/eui';
export class SettingsPanel extends Component {
state = {
showSourceDetails: false,
}
toggleSourceDetails = () => {
this.setState((prevState) => ({
showSourceDetails: !prevState.showSourceDetails,
}));
}
onLabelChange = (event) => {
const label = event.target.value;
this.props.updateLabel(this.props.layerId, label);
}
onMinZoomChange = (event) => {
const zoom = parseInt(event.target.value, 10);
this.props.updateMinZoom(this.props.layerId, zoom);
}
onMaxZoomChange = (event) => {
const zoom = parseInt(event.target.value, 10);
this.props.updateMaxZoom(this.props.layerId, zoom);
}
onAlphaValueChange = (event) => {
const sanitizedValue = parseFloat(event.target.value);
const alphaValue = isNaN(sanitizedValue) ? '' : sanitizedValue;
this.props.updateAlphaValue(this.props.layerId, alphaValue);
}
onSourceChange = ({ propName, value }) => {
this.props.updateSourceProp(this.props.layerId, propName, value);
}
renderZoomSliders() {
return (
<EuiFormRow
helpText="Dislay layer when map is within zoom level range."
>
<EuiFlexGroup>
<EuiFlexItem>
<EuiFormRow
label="Min zoom"
>
<EuiRange
min={0}
max={24}
value={this.props.minZoom.toString()}
onChange={this.onMinZoomChange}
showInput
showRange
/>
</EuiFormRow>
</EuiFlexItem>
<EuiFlexItem>
<EuiFormRow
label="Max zoom"
>
<EuiRange
min={0}
max={24}
value={this.props.maxZoom.toString()}
onChange={this.onMaxZoomChange}
showInput
showRange
/>
</EuiFormRow>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFormRow>
);
}
renderLabel() {
return (
<EuiFormRow
label="Layer display name"
>
<EuiFieldText
value={this.props.label}
onChange={this.onLabelChange}
/>
</EuiFormRow>
);
}
renderAlphaSlider() {
return (
<EuiFormRow
label="Layer opacity"
>
<div className="alphaRange">
<EuiRange
min={.00}
max={1.00}
step={.05}
value={this.props.alphaValue.toString()} // EuiRange value must be string
onChange={this.onAlphaValueChange}
showLabels
showInput
showRange
/>
</div>
</EuiFormRow>
);
}
renderSourceDetails() {
if (!this.state.showSourceDetails) {
return null;
}
return (
<Fragment>
{this.props.renderSourceDetails()}
<EuiSpacer margin="m"/>
</Fragment>
);
}
render() {
return (
<EuiPanel>
<EuiFlexGroup>
<EuiFlexItem>
<EuiTitle size="xs"><h5>Settings</h5></EuiTitle>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiLink
onClick={this.toggleSourceDetails}
>
source details
</EuiLink>
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer margin="m"/>
{this.renderSourceDetails()}
{this.renderLabel()}
{this.renderZoomSliders()}
{this.renderAlphaSlider()}
{this.props.renderSourceSettingsEditor({ onChange: this.onSourceChange })}
</EuiPanel>
);
}
}

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;
* you may not use this file except in compliance with the Elastic License.
*/
import { connect } from 'react-redux';
import { StyleTabs } from './view';
import { updateLayerStyleForSelectedLayer, clearTemporaryStyles } from '../../../actions/store_actions';
function mapDispatchToProps(dispatch) {
return {
updateStyle: styleDescriptor => {
dispatch(updateLayerStyleForSelectedLayer(styleDescriptor));
},
reset: () => dispatch(clearTemporaryStyles())
};
}
function mapStateToProps({}, props) {
return {
layer: props.layer
};
}
const connectedFlyoutBody = connect(mapStateToProps, mapDispatchToProps)(StyleTabs);
export { connectedFlyoutBody as StyleTabs };

View file

@ -0,0 +1,28 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React from 'react';
import {
EuiTab
} from '@elastic/eui';
import _ from 'lodash';
export function StyleTab(props) {
const { name, selected, onClick } = props;
return name && (
<EuiTab
id={_.camelCase(name)}
key={_.camelCase(name)}
name={name}
onClick={(() => {
const tabName = name;
return () => onClick(tabName);
})()}
isSelected={name === selected}
>{ name }
</EuiTab>
) || null;
}

View file

@ -0,0 +1,66 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React from 'react';
import {
EuiTitle,
EuiPanel,
EuiSpacer
} from '@elastic/eui';
export class StyleTabs extends React.Component {
constructor(props) {
super();
this.state = {
currentStyle: props.layer && props.layer.getCurrentStyle()
};
}
static getDerivedStateFromProps(nextProps, prevState) {
const currentStyle = nextProps.layer.getCurrentStyle();
if (currentStyle) {
return {
...prevState,
currentStyle
};
} else {
return {};
}
}
render() {
const { currentStyle } = this.state;
const supportedStyles = this.props.layer.getSupportedStyles();
const styleEditors = supportedStyles.map((style, index) => {
const seedStyle = (style.canEdit(currentStyle)) ? currentStyle : null;
const editorHeader = (
<EuiTitle size="xs"><h5>{style.getDisplayName()}</h5></EuiTitle>
);
const styleEditor = this.props.layer.renderStyleEditor(style, {
handleStyleChange: (styleDescriptor) => {
this.props.updateStyle(styleDescriptor);
},
style: seedStyle,
resetStyle: () => this.props.reset()
});
return (
<EuiPanel key={index}>
{editorHeader}
<EuiSpacer margin="m"/>
{styleEditor}
</EuiPanel>
);
});
return (styleEditors);
}
}

View file

@ -0,0 +1,90 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React from 'react';
import { StyleTabs } from './style_tabs';
import { JoinEditor } from './join_editor';
import { FlyoutFooter } from './flyout_footer';
import { SettingsPanel } from './settings_panel';
import {
EuiHorizontalRule,
EuiFlexItem,
EuiTitle,
EuiSpacer,
EuiPanel,
EuiFlexGroup,
} from '@elastic/eui';
export class LayerPanel extends React.Component {
state = {
displayName: null
}
componentDidMount() {
this._isMounted = true;
this.loadDisplayName();
}
componentWillUnmount() {
this._isMounted = false;
}
loadDisplayName = async () => {
const displayName = await this.props.selectedLayer.getDisplayName();
if (this._isMounted) {
this.setState({ displayName });
}
}
_renderJoinSection() {
if (!this.props.selectedLayer.isJoinable()) {
return null;
}
return (
<EuiPanel>
<JoinEditor layer={this.props.selectedLayer}/>
</EuiPanel>
);
}
render() {
const { selectedLayer } = this.props;
return (
<EuiFlexGroup
direction="column"
gutterSize="none"
>
<EuiFlexItem grow={false} className="gisViewPanel__header">
<EuiTitle size="s" className="gisViewPanel__title">
<h1>
{selectedLayer.getIcon()}
{this.state.displayName}
</h1>
</EuiTitle>
<EuiSpacer size="m"/>
<EuiHorizontalRule margin="none"/>
</EuiFlexItem>
<EuiFlexItem className="gisViewPanel__body">
<SettingsPanel/>
{this._renderJoinSection()}
<StyleTabs layer={selectedLayer}/>
</EuiFlexItem>
<EuiFlexItem grow={false} className="gisViewPanel__footer">
<EuiHorizontalRule margin="none"/>
<EuiSpacer size="m"/>
<FlyoutFooter/>
</EuiFlexItem>
</EuiFlexGroup>
);
}
}

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;
* you may not use this file except in compliance with the Elastic License.
*/
import { connect } from 'react-redux';
import { LayerTOC } from './view';
import { updateLayerOrder } from "../../actions/store_actions";
import { getLayerList } from "../../selectors/map_selectors";
const mapDispatchToProps = {
updateLayerOrder: newOrder => updateLayerOrder(newOrder)
};
function mapStateToProps(state = {}) {
return {
layerList: getLayerList(state)
};
}
const connectedLayerTOC = connect(mapStateToProps, mapDispatchToProps, null,
{ withRef: true })(LayerTOC);
export { connectedLayerTOC as LayerTOC };

View file

@ -0,0 +1,30 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import _ from 'lodash';
import { connect } from 'react-redux';
import { TOCEntry } from './toc_entry';
import { updateFlyout, FLYOUT_STATE } from '../../../store/ui';
import { setSelectedLayer, toggleLayerVisible } from '../../../actions/store_actions';
function mapStateToProps(state = {}) {
return {
zoom: _.get(state, 'map.mapState.zoom', 0)
};
}
function mapDispatchToProps(dispatch) {
return ({
openLayerPanel: layerId => {
dispatch(setSelectedLayer(layerId));
dispatch(updateFlyout(FLYOUT_STATE.LAYER_PANEL));
},
toggleVisible: layerId => dispatch(toggleLayerVisible(layerId))
});
}
const connectedTOCEntry = connect(mapStateToProps, mapDispatchToProps)(TOCEntry);
export { connectedTOCEntry as TOCEntry };

View file

@ -0,0 +1,136 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React from 'react';
import {
EuiFlexGroup,
EuiFlexItem,
EuiIcon,
EuiLoadingSpinner,
EuiToolTip,
EuiIconTip,
EuiSpacer
} from '@elastic/eui';
import { VisibilityToggle } from '../../../shared/components/visibility_toggle';
export class TOCEntry extends React.Component {
constructor() {
super();
this.state = {
displayName: null };
}
componentDidMount() {
this._isMounted = true;
}
componentWillUnmount() {
this._isMounted = false;
}
_renderVisibilityToggle() {
const { layer, toggleVisible } = this.props;
return (
<VisibilityToggle
id={layer.getId()}
checked={layer.isVisible()}
onChange={() => toggleVisible(layer.getId())}
size={'l'}
>
{layer.getIcon()}
</VisibilityToggle>
);
}
render() {
const { layer, openLayerPanel, zoom } = this.props;
const displayName = layer.getDisplayName();
Promise.resolve(displayName).then(label => {
if (this._isMounted) {
if (label !== this.state.displayName) {
this.setState({
displayName: label
});
}
}
});
let visibilityIndicator;
if (layer.dataHasLoadError()) {
visibilityIndicator = (
<EuiIconTip
aria-label="Load warning"
size="m"
type="alert"
color="warning"
content={layer.getDataLoadError()}
/>
);
} else if (layer.isLayerLoading()) {
visibilityIndicator = <EuiLoadingSpinner size="m"/>;
} else if (!layer.showAtZoomLevel(zoom)) {
const { minZoom, maxZoom } = layer.getZoomConfig();
visibilityIndicator = (
<EuiToolTip
position="left"
content={`Map is at zoom level ${zoom}.
This layer is only visible between zoom levels ${minZoom} to ${maxZoom}.`}
>
{this._renderVisibilityToggle()}
</EuiToolTip>
);
} else {
visibilityIndicator = this._renderVisibilityToggle();
}
let tocDetails = layer.getTOCDetails();
if (tocDetails) {
tocDetails = (
<EuiFlexItem>
<EuiSpacer size="s"/>
{tocDetails}
</EuiFlexItem>
);
}
return (
<div
className="layerEntry"
id={layer.getId()}
data-layerid={layer.getId()}
>
<EuiFlexGroup
gutterSize="s"
alignItems="center"
responsive={false}
className={layer.isVisible() && layer.showAtZoomLevel(zoom) && !layer.dataHasLoadError() ? 'visible' : 'notvisible'}
>
<EuiFlexItem grow={false} className="layerEntry--visibility">
{visibilityIndicator}
</EuiFlexItem>
<EuiFlexItem>
<button
onClick={() => openLayerPanel(layer.getId())}
data-test-subj={`mapOpenLayerButton${this.state.displayName}`}
>
<div style={{ width: 180 }} className="eui-textTruncate eui-textLeft">
{this.state.displayName}
</div>
</button>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<span className="grab"><EuiIcon type="grab" className="grab"/></span>
</EuiFlexItem>
</EuiFlexGroup>
{tocDetails}
</div>
);
}
}

View file

@ -0,0 +1,60 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React from 'react';
import { TOCEntry } from './toc_entry';
import $ from 'jquery';
export class LayerTOC extends React.Component {
constructor(props) {
super(props);
this._domContainer = null;
}
componentDidMount() {
this._attachSortHandler();
}
_attachSortHandler() {
const tocEntries = this._domContainer;
let length;
$(tocEntries).sortable({
start: (evt, { item }) => {
length = tocEntries.children.length;
$(this).attr('data-previndex', length - item.index() - 2);
},
update: (evt, { item }) => {
const prevIndex = +$(this).attr('data-previndex');
length = tocEntries.children.length;
const newIndex = length - item.index() - 1;
const newOrder = Array.from(tocEntries.children)
.map((el, idx) => idx);
newOrder.splice(prevIndex, 1);
newOrder.splice(newIndex, 0, prevIndex);
this.props.updateLayerOrder(newOrder);
}
});
}
_renderLayers() {
return [ ...this.props.layerList ]
.reverse()
.map((layer) => {
return (<TOCEntry key={layer.getId()} layer={layer} displayName={layer.getDisplayName()}/>);
});
}
render() {
const layerEntries = this._renderLayers();
return (
<div className="layerTOC" ref={node => this._domContainer = node}>
{layerEntries}
</div>
);
}
}

View file

@ -0,0 +1,21 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React from 'react';
export function FeatureTooltip({ properties }) {
return Object.keys(properties).map(propertyName => {
return (
<div key={propertyName}>
<strong>{propertyName}</strong>
{' '}
{properties[propertyName]}
</div>
);
});
}

View file

@ -0,0 +1,36 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { connect } from 'react-redux';
import { MBMapContainer } from './view';
import { mapExtentChanged, mapReady, mapDestroyed } from '../../../actions/store_actions';
import { getLayerList, getMapState, getMapReady } from "../../../selectors/map_selectors";
function mapStateToProps(state = {}) {
return {
isMapReady: getMapReady(state),
mapState: getMapState(state),
layerList: getLayerList(state),
};
}
function mapDispatchToProps(dispatch) {
return {
extentChanged: (e) => {
dispatch(mapExtentChanged(e));
},
onMapReady: (e) => {
dispatch(mapExtentChanged(e));
dispatch(mapReady());
},
onMapDestroyed: () => {
dispatch(mapDestroyed());
}
};
}
const connectedMBMapContainer = connect(mapStateToProps, mapDispatchToProps, null, { withRef: true })(MBMapContainer);
export { connectedMBMapContainer as MBMapContainer };

View file

@ -0,0 +1,89 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import _ from 'lodash';
import mapboxgl from 'mapbox-gl';
export async function createMbMapInstance(node, initialZoom, initialCenter) {
return new Promise((resolve) => {
const options = {
container: node,
style: {
version: 8,
sources: {},
layers: [],
},
};
if (initialZoom) {
options.zoom = initialZoom;
}
if (initialCenter) {
options.center = {
lng: initialCenter.lon,
lat: initialCenter.lat
};
}
const mbMap = new mapboxgl.Map(options);
mbMap.dragRotate.disable();
mbMap.touchZoomRotate.disableRotation();
mbMap.on('load', () => {
resolve(mbMap);
});
});
}
export function removeOrphanedSourcesAndLayers(mbMap, layerList) {
const layerIds = layerList.map((layer) => layer.getId());
const mbStyle = mbMap.getStyle();
const mbSourcesToRemove = [];
for (const sourceId in mbStyle.sources) {
if (layerIds.indexOf(sourceId) === -1) {
mbSourcesToRemove.push(sourceId);
}
}
const mbLayersToRemove = [];
mbStyle.layers.forEach(layer => {
if (mbSourcesToRemove.indexOf(layer.source) >= 0) {
mbLayersToRemove.push(layer.id);
}
});
mbLayersToRemove.forEach((layerId) => {
mbMap.removeLayer(layerId);
});
mbSourcesToRemove.forEach(sourceId => {
mbMap.removeSource(sourceId);
});
}
export function syncLayerOrder(mbMap, layerList) {
if (layerList && layerList.length) {
const mbLayers = mbMap.getStyle().layers.slice();
const currentLayerOrder = _.uniq( // Consolidate layers and remove suffix
mbLayers.map(({ id }) => id.substring(0, id.lastIndexOf('_'))));
const newLayerOrder = layerList.map(l => l.getId());
let netPos = 0;
let netNeg = 0;
const movementArr = currentLayerOrder.reduce((accu, id, idx) => {
const movement = newLayerOrder.findIndex(newOId => newOId === id) - idx;
movement > 0 ? netPos++ : movement < 0 && netNeg++;
accu.push({ id, movement });
return accu;
}, []);
if (netPos === 0 && netNeg === 0) { return; }
const movedLayer = (netPos >= netNeg) && movementArr.find(l => l.movement < 0).id ||
(netPos < netNeg) && movementArr.find(l => l.movement > 0).id;
const nextLayerIdx = newLayerOrder.findIndex(layerId => layerId === movedLayer) + 1;
const nextLayerId = nextLayerIdx === newLayerOrder.length ? null :
mbLayers.find(({ id }) => id.startsWith(newLayerOrder[nextLayerIdx])).id;
mbLayers.forEach(({ id }) => {
if (id.startsWith(movedLayer)) {
mbMap.moveLayer(id, nextLayerId);
}
});
}
}

View file

@ -0,0 +1,211 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import _ from 'lodash';
import React from 'react';
import { ResizeChecker } from 'ui/resize_checker';
import { syncLayerOrder, removeOrphanedSourcesAndLayers, createMbMapInstance } from './utils';
import { inspectorAdapters } from '../../../kibana_services';
import { DECIMAL_DEGREES_PRECISION, ZOOM_PRECISION } from '../../../../common/constants';
export class MBMapContainer extends React.Component {
constructor() {
super();
this._mbMap = null;
this._listeners = new Map(); // key is mbLayerId, value eventHandlers map
}
_debouncedSync = _.debounce(() => {
if (this._isMounted) {
this._syncMbMapWithLayerList();
this._syncMbMapWithInspector();
}
}, 256);
_getMapState() {
const zoom = this._mbMap.getZoom();
const mbCenter = this._mbMap.getCenter();
const mbBounds = this._mbMap.getBounds();
return {
zoom: _.round(zoom, ZOOM_PRECISION),
center: {
lon: _.round(mbCenter.lng, DECIMAL_DEGREES_PRECISION),
lat: _.round(mbCenter.lat, DECIMAL_DEGREES_PRECISION)
},
extent: {
min_lon: _.round(mbBounds.getWest(), DECIMAL_DEGREES_PRECISION),
min_lat: _.round(mbBounds.getSouth(), DECIMAL_DEGREES_PRECISION),
max_lon: _.round(mbBounds.getEast(), DECIMAL_DEGREES_PRECISION),
max_lat: _.round(mbBounds.getNorth(), DECIMAL_DEGREES_PRECISION)
}
};
}
componentDidMount() {
this._initializeMap();
this._isMounted = true;
}
componentWillUnmount() {
this._isMounted = false;
this._checker.destroy();
if (this._mbMap) {
this._mbMap.remove();
this._mbMap = null;
}
this.props.onMapDestroyed();
}
async _initializeMap() {
const initialZoom = this.props.mapState.zoom;
const initialCenter = this.props.mapState.center;
this._mbMap = await createMbMapInstance(this.refs.mapContainer, initialZoom, initialCenter);
window._mbMap = this._mbMap;
// Override mapboxgl.Map "on" and "removeLayer" methods so we can track layer listeners
// Tracked layer listerners are used to clean up event handlers
const originalMbBoxOnFunc = this._mbMap.on;
const originalMbBoxRemoveLayerFunc = this._mbMap.removeLayer;
this._mbMap.on = (...args) => {
// args do not identify layer so there is nothing to track
if (args.length <= 2) {
originalMbBoxOnFunc.apply(this._mbMap, args);
return;
}
const eventType = args[0];
const mbLayerId = args[1];
const handler = args[2];
this._addListener(eventType, mbLayerId, handler);
originalMbBoxOnFunc.apply(this._mbMap, args);
};
this._mbMap.removeLayer = (id) => {
this._removeListeners(id);
originalMbBoxRemoveLayerFunc.apply(this._mbMap, [id]);
};
this.assignSizeWatch();
this._mbMap.on('moveend', () => {
this.props.extentChanged(this._getMapState());
});
this.props.onMapReady(this._getMapState());
}
_addListener(eventType, mbLayerId, handler) {
this._removeListener(eventType, mbLayerId);
const eventHandlers = !this._listeners.has(mbLayerId)
? new Map()
: this._listeners.get(mbLayerId);
eventHandlers.set(eventType, handler);
this._listeners.set(mbLayerId, eventHandlers);
}
_removeListeners(mbLayerId) {
if (this._listeners.has(mbLayerId)) {
const eventHandlers = this._listeners.get(mbLayerId);
eventHandlers.forEach((value, eventType) => {
this._removeListener(eventType, mbLayerId);
});
this._listeners.delete(mbLayerId);
}
}
_removeListener(eventType, mbLayerId) {
if (this._listeners.has(mbLayerId)) {
const eventHandlers = this._listeners.get(mbLayerId);
if (eventHandlers.has(eventType)) {
this._mbMap.off(eventType, mbLayerId, eventHandlers.get(eventType));
eventHandlers.delete(eventType);
}
}
}
assignSizeWatch() {
this._checker = new ResizeChecker(this.refs.mapContainer);
this._checker.on('resize', (() => {
let lastWidth = window.innerWidth;
let lastHeight = window.innerHeight;
return () => {
if (lastWidth === window.innerWidth
&& lastHeight === window.innerHeight && this._mbMap) {
this._mbMap.resize();
}
lastWidth = window.innerWidth;
lastHeight = window.innerHeight;
};
})());
}
_syncMbMapWithMapState = () => {
const {
isMapReady,
mapState,
} = this.props;
if (!isMapReady) {
return;
}
const zoom = _.round(this._mbMap.getZoom(), ZOOM_PRECISION);
if (typeof mapState.zoom === 'number' && mapState.zoom !== zoom) {
this._mbMap.setZoom(mapState.zoom);
}
const center = this._mbMap.getCenter();
if (mapState.center &&
(mapState.center.lat !== _.round(center.lat, DECIMAL_DEGREES_PRECISION)
|| mapState.center.lon !== _.round(center.lng, DECIMAL_DEGREES_PRECISION))) {
this._mbMap.setCenter({
lng: mapState.center.lon,
lat: mapState.center.lat
});
}
}
_syncMbMapWithLayerList = () => {
const {
isMapReady,
layerList,
} = this.props;
if (!isMapReady) {
return;
}
removeOrphanedSourcesAndLayers(this._mbMap, layerList);
layerList.forEach((layer) => {
layer.syncLayerWithMB(this._mbMap);
});
syncLayerOrder(this._mbMap, layerList);
}
_syncMbMapWithInspector = () => {
if (!this.props.isMapReady) {
return;
}
const stats = {
center: this._mbMap.getCenter().toArray(),
zoom: this._mbMap.getZoom(),
};
inspectorAdapters.map.setMapState({
stats,
style: this._mbMap.getStyle(),
});
}
render() {
// do not debounce syncing zoom and center
this._syncMbMapWithMapState();
this._debouncedSync();
return (
<div id={'mapContainer'} className="mapContainer" ref="mapContainer"/>
);
}
}

View file

@ -0,0 +1,30 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { connect } from 'react-redux';
import { SetView } from './set_view';
import { mapExtentChanged } from '../../actions/store_actions';
import { getMapZoom, getMapCenter } from "../../selectors/map_selectors";
import { closeSetView } from '../../store/ui';
function mapStateToProps(state = {}) {
return {
zoom: getMapZoom(state),
center: getMapCenter(state),
};
}
function mapDispatchToProps(dispatch) {
return {
onSubmit: (newMapConstants) => {
dispatch(closeSetView());
dispatch(mapExtentChanged(newMapConstants));
}
};
}
const connectedSetView = connect(mapStateToProps, mapDispatchToProps, null, { withRef: true })(SetView);
export { connectedSetView as SetView };

View file

@ -0,0 +1,132 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React from 'react';
import PropTypes from 'prop-types';
import {
EuiForm,
EuiFormRow,
EuiButton,
EuiFieldNumber,
} from '@elastic/eui';
export class SetView extends React.Component {
state = {
lat: this.props.center.lat,
lon: this.props.center.lon,
zoom: this.props.zoom,
}
_onLatChange = evt => {
this._onChange('lat', evt);
};
_onLonChange = evt => {
this._onChange('lon', evt);
};
_onZoomChange = evt => {
this._onChange('zoom', evt);
};
_onChange = (name, evt) => {
const sanitizedValue = parseInt(evt.target.value, 10);
this.setState({
[name]: isNaN(sanitizedValue) ? '' : sanitizedValue,
});
}
_renderNumberFormRow = ({ value, min, max, onChange, label }) => {
const isInvalid = value === '' || value > max || value < min;
const error = isInvalid ? `Must be between ${min} and ${max}` : null;
return {
isInvalid,
component: (
<EuiFormRow
label={label}
isInvalid={isInvalid}
error={error}
>
<EuiFieldNumber
value={value}
onChange={onChange}
isInvalid={isInvalid}
/>
</EuiFormRow>
)
};
}
onSubmit = () => {
const {
lat,
lon,
zoom
} = this.state;
const center = {
lat,
lon
};
this.props.onSubmit({ center, zoom });
}
render() {
const { isInvalid: isLatInvalid, component: latFormRow } = this._renderNumberFormRow({
value: this.state.lat,
min: -90,
max: 90,
onChange: this._onLatChange,
label: 'latitude'
});
const { isInvalid: isLonInvalid, component: lonFormRow } = this._renderNumberFormRow({
value: this.state.lon,
min: -180,
max: 180,
onChange: this._onLonChange,
label: 'longitude'
});
const { isInvalid: isZoomInvalid, component: zoomFormRow } = this._renderNumberFormRow({
value: this.state.zoom,
min: 0,
max: 24,
onChange: this._onZoomChange,
label: 'zoom'
});
return (
<EuiForm>
{latFormRow}
{lonFormRow}
{zoomFormRow}
<EuiFormRow hasEmptyLabelSpace>
<EuiButton
disabled={isLatInvalid || isLonInvalid || isZoomInvalid}
onClick={this.onSubmit}
>
Go
</EuiButton>
</EuiFormRow>
</EuiForm>
);
}
}
SetView.propTypes = {
zoom: PropTypes.number.isRequired,
center: PropTypes.shape({
lat: PropTypes.number.isRequired,
lon: PropTypes.number.isRequired
}),
onSubmit: PropTypes.func.isRequired,
};

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;
* you may not use this file except in compliance with the Elastic License.
*/
import { connect } from 'react-redux';
import { LAYER_LOAD_STATE } from '../../store/ui';
import { Toasts } from './view';
import { resetLayerLoad } from '../../actions/ui_actions';
const layerLoadStatus = ({ ui }) => {
const toastStatuses = {
error: 'error',
success: 'success'
};
const { layerLoad } = ui;
return layerLoad.status === LAYER_LOAD_STATE.complete && toastStatuses.success ||
layerLoad.status === LAYER_LOAD_STATE.error && toastStatuses.error;
};
function mapStateToProps(state = {}) {
return {
layerLoadToast: layerLoadStatus(state)
};
}
function mapDispatchToProps(dispatch) {
return {
clearLayerLoadToast: () => dispatch(resetLayerLoad())
};
}
const connectedToast = connect(mapStateToProps, mapDispatchToProps)(Toasts);
export { connectedToast as Toasts };

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;
* you may not use this file except in compliance with the Elastic License.
*/
import { toastNotifications } from 'ui/notify';
export function Toasts({ layerLoadToast, clearLayerLoadToast }) {
if (layerLoadToast === 'success') {
toastNotifications.add({
title: 'Layer added',
className: 'layerToast'
}) && clearLayerLoadToast();
} else if (layerLoadToast === 'error') {
toastNotifications.addDanger({
title: 'Error adding layer',
className: 'layerToast'
}) && clearLayerLoadToast();
} else {
// Do nothing
}
return null; // renderless component
}

View file

@ -0,0 +1,40 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React from 'react';
import PropTypes from 'prop-types';
import {
EuiForm,
EuiFormRow,
EuiSwitch,
} from '@elastic/eui';
export function OptionsMenu({ isDarkTheme, onDarkThemeChange }) {
const handleDarkThemeChange = (evt) => {
onDarkThemeChange(evt.target.checked);
};
return (
<EuiForm
data-test-subj="gisOptionsMenu"
>
<EuiFormRow>
<EuiSwitch
label="Use dark theme"
checked={isDarkTheme}
onChange={handleDarkThemeChange}
/>
</EuiFormRow>
</EuiForm>
);
}
OptionsMenu.propTypes = {
isDarkTheme: PropTypes.bool.isRequired,
onDarkThemeChange: PropTypes.func.isRequired,
};

View file

@ -0,0 +1,26 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { connect } from 'react-redux';
import { getIsDarkTheme, updateIsDarkTheme } from '../../store/ui';
import { OptionsMenu } from './options_menu';
function mapStateToProps(state = {}) {
return {
isDarkTheme: getIsDarkTheme(state),
};
}
function mapDispatchToProps(dispatch) {
return {
onDarkThemeChange: (isDarkTheme) => {
dispatch(updateIsDarkTheme(isDarkTheme));
},
};
}
const connectedOptionsMenu = connect(mapStateToProps, mapDispatchToProps)(OptionsMenu);
export { connectedOptionsMenu as OptionsMenu };

View file

@ -0,0 +1,53 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React from 'react';
import ReactDOM from 'react-dom';
import { Provider } from 'react-redux';
import { OptionsMenu } from './options_menu_container';
import { getStore } from '../../store/store';
import {
EuiWrappingPopover,
} from '@elastic/eui';
let isOpen = false;
const container = document.createElement('div');
const onClose = () => {
ReactDOM.unmountComponentAtNode(container);
isOpen = false;
};
export async function showOptionsPopover(anchorElement) {
if (isOpen) {
onClose();
return;
}
isOpen = true;
const store = await getStore();
document.body.appendChild(container);
const element = (
<Provider store={store}>
<EuiWrappingPopover
className="navbar__popover"
id="popover"
button={anchorElement}
isOpen={true}
closePopover={onClose}
>
<OptionsMenu/>
</EuiWrappingPopover>
</Provider>
);
ReactDOM.render(element, container);
}

View file

@ -0,0 +1,184 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import _ from 'lodash';
export function hitsToGeoJson(hits, geoFieldName, geoFieldType) {
const features = hits
.filter(hit => {
return _.has(hit, `_source[${geoFieldName}]`);
})
.map(hit => {
const value = _.get(hit, `_source[${geoFieldName}]`);
let geometry;
if (geoFieldType === 'geo_point') {
geometry = geoPointToGeometry(value);
} else if (geoFieldType === 'geo_shape') {
geometry = geoShapeToGeometry(value);
} else {
throw new Error(`Unsupported field type, expected: geo_shape or geo_point, you provided: ${geoFieldType}`);
}
const properties = {};
for (const fieldName in hit._source) {
if (hit._source.hasOwnProperty(fieldName)) {
if (fieldName !== geoFieldName) {
properties[fieldName] = hit._source[fieldName];
}
}
}
// hit.fields contains calculated values from docvalue_fields and script_fields
for (const fieldName in hit.fields) {
if (hit.fields.hasOwnProperty(fieldName)) {
const val = hit.fields[fieldName];
properties[fieldName] = Array.isArray(val) && val.length === 1 ? val[0] : val;
}
}
return {
type: 'Feature',
geometry: geometry,
properties: properties
};
});
return {
type: 'FeatureCollection',
features: features
};
}
export function geoPointToGeometry(value) {
let lat;
let lon;
if (typeof value === 'string') {
const commaSplit = value.split(',');
if (commaSplit.length === 1) {
// Geo-point expressed as a geohash.
throw new Error(`Unable to convert to geojson, geohash not supported`);
}
// Geo-point expressed as a string with the format: "lat,lon".
lat = parseFloat(commaSplit[0]);
lon = parseFloat(commaSplit[1]);
} else if (Array.isArray(value)) {
// Geo-point expressed as an array with the format: [ lon, lat]
lat = value[1];
lon = value[0];
} else if (value !== null && typeof value === 'object') {
lat = value.lat;
lon = value.lon;
}
return {
type: 'Point',
coordinates: [lon, lat]
};
}
export function makeGeohashGridPolygon(geohashGridFeature) {
const esBbox = geohashGridFeature.properties.geohash_meta.rectangle;
return {
type: 'Polygon',
coordinates: [
[
[esBbox[0][1], esBbox[0][0]],
[esBbox[1][1], esBbox[1][0]],
[esBbox[2][1], esBbox[2][0]],
[esBbox[3][1], esBbox[3][0]],
[esBbox[0][1], esBbox[0][0]],
]
]
};
}
export function geoShapeToGeometry(value) {
// TODO handle case where value is WKT and convert to geojson
if (typeof value === "string") {
throw new Error(`Unable to convert WKT to geojson, not supported`);
}
const geoJson = _.cloneDeep(value);
// https://www.elastic.co/guide/en/elasticsearch/reference/current/geo-shape.html#input-structure
// For some unknown compatibility nightmarish reason, Elasticsearch types are not capitalized the same as geojson types
// For example: 'LineString' geojson type is 'linestring' in elasticsearch
// Convert feature types to geojson spec values
switch (geoJson.type) {
case 'point':
geoJson.type = 'Point';
break;
case 'linestring':
geoJson.type = 'LineString';
break;
case 'polygon':
geoJson.type = 'Polygon';
break;
case 'multipoint':
geoJson.type = 'MultiPoint';
break;
case 'multilinestring':
geoJson.type = 'MultiLineString';
break;
case 'geometrycollection':
geoJson.type = 'GeometryCollection';
break;
}
// TODO handle envelope and circle geometry types which exist in elasticsearch but not in geojson
if (geoJson.type === 'envelope' || geoJson.type === 'circle') {
throw new Error(`Unable to convert ${geoJson.type} geometry to geojson, not supported`);
}
return geoJson;
}
export function createExtentFilter(mapExtent, geoFieldName, geoFieldType) {
// TODO this is not a complete implemenation. Need to handle other cases:
// 1) bounds are all east of 180
// 2) bounds are all west of -180
const noWrapMapExtent = {
min_lon: mapExtent.min_lon < -180 ? -180 : mapExtent.min_lon,
min_lat: mapExtent.min_lat < -90 ? -90 : mapExtent.min_lat,
max_lon: mapExtent.max_lon > 180 ? 180 : mapExtent.max_lon,
max_lat: mapExtent.max_lat > 90 ? 90 : mapExtent.max_lat,
};
if (geoFieldType === 'geo_point') {
return {
geo_bounding_box: {
[geoFieldName]: {
top_left: {
lat: noWrapMapExtent.max_lat,
lon: noWrapMapExtent.min_lon
},
bottom_right: {
lat: noWrapMapExtent.min_lat,
lon: noWrapMapExtent.max_lon
}
}
}
};
} else if (geoFieldType === 'geo_shape') {
return {
geo_shape: {
[geoFieldName]: {
shape: {
type: 'envelope',
coordinates: [
[noWrapMapExtent.min_lon, noWrapMapExtent.max_lat],
[noWrapMapExtent.max_lon, noWrapMapExtent.min_lat]
]
},
relation: 'INTERSECTS'
}
}
};
} else {
throw new Error(`Unsupported field type, expected: geo_shape or geo_point, you provided: ${geoFieldType}`);
}
}

View file

@ -0,0 +1,204 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import {
hitsToGeoJson,
geoPointToGeometry,
geoShapeToGeometry,
createExtentFilter,
} from './elasticsearch_geo_utils';
const geoFieldName = 'location';
const mapExtent = {
max_lat: 39,
max_lon: -83,
min_lat: 35,
min_lon: -89,
};
describe('hitsToGeoJson', () => {
it('Should convert elasitcsearch hits to geojson', () => {
const hits = [
{
_source: {
[geoFieldName]: { lat: 20, lon: 100 }
}
},
{
_source: {
[geoFieldName]: { lat: 30, lon: 110 }
}
},
];
const geojson = hitsToGeoJson(hits, geoFieldName, 'geo_point');
expect(geojson.type).toBe('FeatureCollection');
expect(geojson.features.length).toBe(2);
expect(geojson.features[0]).toEqual({
geometry: {
coordinates: [100, 20],
type: 'Point',
},
properties: {},
type: 'Feature',
});
});
it('Should handle documents where geoField is not populated', () => {
const hits = [
{
_source: {
[geoFieldName]: { lat: 20, lon: 100 }
}
},
{
_source: {}
},
];
const geojson = hitsToGeoJson(hits, geoFieldName, 'geo_point');
expect(geojson.type).toBe('FeatureCollection');
expect(geojson.features.length).toBe(1);
});
it('Should populate properties from _source and fields', () => {
const hits = [
{
_source: {
[geoFieldName]: { lat: 20, lon: 100 },
myField: 8,
},
fields: {
myScriptedField: 10
}
}
];
const geojson = hitsToGeoJson(hits, geoFieldName, 'geo_point');
expect(geojson.features.length).toBe(1);
const feature = geojson.features[0];
expect(feature.properties.myField).toBe(8);
expect(feature.properties.myScriptedField).toBe(10);
});
it('Should unwrap computed fields', () => {
const hits = [
{
_source: {
[geoFieldName]: { lat: 20, lon: 100 },
},
fields: {
myScriptedField: [ 10 ] // script_fields are returned in an array
}
}
];
const geojson = hitsToGeoJson(hits, geoFieldName, 'geo_point');
expect(geojson.features.length).toBe(1);
const feature = geojson.features[0];
expect(feature.properties.myScriptedField).toBe(10);
});
});
describe('geoPointToGeometry', () => {
const lat = 41.12;
const lon = -71.34;
it('Should convert value stored as geo-point string', () => {
const value = `${lat},${lon}`;
const out = geoPointToGeometry(value);
expect(out.type).toBe('Point');
expect(out.coordinates).toEqual([lon, lat]);
});
it('Should convert value stored as geo-point array', () => {
const value = [lon, lat];
const out = geoPointToGeometry(value);
expect(out.type).toBe('Point');
expect(out.coordinates).toEqual([lon, lat]);
});
it('Should convert value stored as geo-point object', () => {
const value = {
lat,
lon,
};
const out = geoPointToGeometry(value);
expect(out.type).toBe('Point');
expect(out.coordinates).toEqual([lon, lat]);
});
});
describe('geoShapeToGeometry', () => {
it('Should convert value stored as geojson', () => {
const coordinates = [[-77.03653, 38.897676], [-77.009051, 38.889939]];
const value = {
type: 'linestring',
coordinates: coordinates
};
const out = geoShapeToGeometry(value);
expect(out.type).toBe('LineString');
expect(out.coordinates).toEqual(coordinates);
});
});
describe('createExtentFilter', () => {
it('should return elasticsearch geo_bounding_box filter for geo_point field', () => {
const filter = createExtentFilter(mapExtent, geoFieldName, 'geo_point');
expect(filter).toEqual({
geo_bounding_box: {
[geoFieldName]: {
top_left: {
lat: mapExtent.max_lat,
lon: mapExtent.min_lon
},
bottom_right: {
lat: mapExtent.min_lat,
lon: mapExtent.max_lon
}
}
}
});
});
it('should return elasticsearch geo_shape filter for geo_shape field', () => {
const filter = createExtentFilter(mapExtent, geoFieldName, 'geo_shape');
expect(filter).toEqual({
geo_shape: {
[geoFieldName]: {
shape: {
type: 'envelope',
coordinates: [
[mapExtent.min_lon, mapExtent.max_lat],
[mapExtent.max_lon, mapExtent.min_lat]
]
},
relation: 'INTERSECTS'
}
}
});
});
it('should clamp longitudes to -180 to 180', () => {
const mapExtent = {
max_lat: 39,
max_lon: 209,
min_lat: 35,
min_lon: -191,
};
const filter = createExtentFilter(mapExtent, geoFieldName, 'geo_shape');
expect(filter).toEqual({
geo_shape: {
[geoFieldName]: {
shape: {
type: 'envelope',
coordinates: [
[-180, mapExtent.max_lat],
[180, mapExtent.min_lat]
]
},
relation: 'INTERSECTS'
}
}
});
});
});

View file

@ -0,0 +1,6 @@
<svg class="euiIcon euiIcon--xLarge" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 32 32" fill="white">
<path class="euiIcon__fillSecondary" d="M15 24.24L3.86 2 15 6.91 26.14 2 15 24.24zM8.14 6L15 19.76 21.86 6 15 9.09 8.14 6z">
</path>
<path d="M30 25.06V25a2.55 2.55 0 0 0-.1-.37c-.49-1.27-2-2.35-4.41-3.15h-.06l-.69-.21-.18-.05-.68-.22-.31-.08-.57-.12-.44-.1-.48-.09-.51-.09-1-.15-.27 2c.64.09 1.24.19 1.8.3 3.78.75 5.63 1.95 5.86 2.7a.57.57 0 0 1 0 .16c0 1.39-5.18 3.5-13 3.5S2 26.89 2 25.5c0-.82 2.22-2.32 7.14-3.08l-.3-2-.54.09C5.43 21 0 22.37 0 25.5 0 29.11 7.55 31 15 31s15-1.89 15-5.5a2.5 2.5 0 0 0 0-.44z">
</path>
</svg>

After

Width:  |  Height:  |  Size: 661 B

View file

@ -0,0 +1,94 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import './kibana_services';
import './vendor/jquery_ui_sortable.js';
import './vendor/jquery_ui_sortable.css';
// import the uiExports that we want to "use"
import 'uiExports/fieldFormats';
import 'uiExports/inspectorViews';
import 'uiExports/search';
import 'ui/agg_types';
import chrome from 'ui/chrome';
import routes from 'ui/routes';
import { uiModules } from 'ui/modules';
import 'ui/autoload/styles';
import 'ui/autoload/all';
import 'react-vis/dist/style.css';
import "mapbox-gl/dist/mapbox-gl.css";
import 'ui/vis/map/service_settings';
import './angular/services/gis_map_saved_object_loader';
import './angular/map_controller';
import listingTemplate from './angular/listing_ng_wrapper.html';
import mapTemplate from './angular/map.html';
import { MapListing } from './shared/components/map_listing';
const app = uiModules.get('app/gis', ['ngRoute', 'react']);
app.directive('mapListing', function (reactDirective) {
return reactDirective(MapListing);
});
routes.enable();
routes
.when('/', {
template: listingTemplate,
controller($scope, gisMapSavedObjectLoader, config) {
$scope.listingLimit = config.get('savedObjects:listingLimit');
$scope.find = (search) => {
return gisMapSavedObjectLoader.find(search, $scope.listingLimit);
};
$scope.delete = (ids) => {
return gisMapSavedObjectLoader.delete(ids);
};
},
resolve: {
hasMaps: function (kbnUrl) {
chrome.getSavedObjectsClient().find({ type: 'gis-map', perPage: 1 }).then(resp => {
// Do not show empty listing page, just redirect to a new map
if (resp.savedObjects.length === 0) {
kbnUrl.redirect('/map');
}
return true;
});
}
}
})
.when('/map', {
template: mapTemplate,
controller: 'GisMapController',
resolve: {
map: function (gisMapSavedObjectLoader, redirectWhenMissing) {
return gisMapSavedObjectLoader.get()
.catch(redirectWhenMissing({
'map': '/'
}));
}
}
})
.when('/map/:id', {
template: mapTemplate,
controller: 'GisMapController',
resolve: {
map: function (gisMapSavedObjectLoader, redirectWhenMissing, $route) {
const id = $route.current.params.id;
return gisMapSavedObjectLoader.get(id)
.catch(redirectWhenMissing({
'map': '/'
}));
}
}
})
.otherwise({
redirectTo: '/'
});

View file

@ -0,0 +1,15 @@
// Import the EUI global scope so we can use EUI constants
@import 'ui/public/styles/_styling_constants';
/* GIS plugin styles */
// Prefix all styles with "gis" to avoid conflicts.
// Examples
// gisChart
// gisChart__legend
// gisChart__legend--small
// gisChart__legend-isLoading
@import './main';
@import './components/index';
@import './shared/layers/layers';

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;
* you may not use this file except in compliance with the Elastic License.
*/
import { EventEmitter } from 'events';
class MapAdapter extends EventEmitter {
setMapState({ stats, style }) {
this.stats = stats;
this.style = style;
this._onChange();
}
getMapState() {
return { stats: this.stats, style: this.style };
}
_onChange() {
this.emit('change');
}
}
export { MapAdapter };

View file

@ -0,0 +1,120 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import {
EuiTab,
EuiTabs,
EuiCodeBlock,
EuiTable,
EuiTableBody,
EuiTableRow,
EuiTableRowCell,
} from '@elastic/eui';
const DETAILS_TAB_ID = 'details';
const STYLE_TAB_ID = 'mapStyle';
class MapDetails extends Component {
tabs = [{
id: DETAILS_TAB_ID,
name: 'Map details',
dataTestSubj: 'mapDetailsTab',
}, {
id: STYLE_TAB_ID,
name: 'Mapbox style',
dataTestSubj: 'mapboxStyleTab',
}];
state = {
selectedTabId: DETAILS_TAB_ID,
};
onSelectedTabChanged = id => {
this.setState({
selectedTabId: id,
});
}
renderTab = () => {
if (STYLE_TAB_ID === this.state.selectedTabId) {
return (
<div data-test-subj="mapboxStyleContainer">
<EuiCodeBlock
language="json"
paddingSize="s"
>
{ JSON.stringify(this.props.mapStyle, null, 2) }
</EuiCodeBlock>
</div>
);
}
return (
<EuiTable style={{ tableLayout: 'auto' }}>
<EuiTableBody>
<EuiTableRow>
<EuiTableRowCell>
Center lon
</EuiTableRowCell>
<EuiTableRowCell data-test-subj="centerLon">{this.props.centerLon}</EuiTableRowCell>
</EuiTableRow>
<EuiTableRow>
<EuiTableRowCell>
Center lat
</EuiTableRowCell>
<EuiTableRowCell data-test-subj="centerLat">{this.props.centerLat}</EuiTableRowCell>
</EuiTableRow>
<EuiTableRow>
<EuiTableRowCell>
Zoom
</EuiTableRowCell>
<EuiTableRowCell data-test-subj="zoom">{this.props.zoom}</EuiTableRowCell>
</EuiTableRow>
</EuiTableBody>
</EuiTable>
);
}
renderTabs() {
return this.tabs.map((tab, index) => (
<EuiTab
onClick={() => this.onSelectedTabChanged(tab.id)}
isSelected={tab.id === this.state.selectedTabId}
key={index}
data-test-subj={tab.dataTestSubj}
>
{tab.name}
</EuiTab>
));
}
render() {
return (
<div>
<EuiTabs size="s">
{this.renderTabs()}
</EuiTabs>
{this.renderTab()}
</div>
);
}
}
MapDetails.propTypes = {
centerLon: PropTypes.number.isRequired,
centerLat: PropTypes.number.isRequired,
zoom: PropTypes.number.isRequired,
mapStyle: PropTypes.object.isRequired,
};
export { MapDetails };

View file

@ -0,0 +1,66 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import { InspectorView } from 'ui/inspector';
import { MapDetails } from './map_details';
class MapViewComponent extends Component {
constructor(props) {
super(props);
props.adapters.map.on('change', this._onMapChange);
const { stats, style } = props.adapters.map.getMapState();
this.state = {
stats,
mapStyle: style,
};
}
_onMapChange = () => {
const { stats, style } = this.props.adapters.map.getMapState();
this.setState({
stats,
mapStyle: style,
});
}
componentWillUnmount() {
this.props.adapters.map.removeListener('change', this._onMapChange);
}
render() {
return (
<InspectorView>
<MapDetails
centerLon={this.state.stats.center[0]}
centerLat={this.state.stats.center[1]}
zoom={this.state.stats.zoom}
mapStyle={this.state.mapStyle}
/>
</InspectorView>
);
}
}
MapViewComponent.propTypes = {
adapters: PropTypes.object.isRequired,
};
const MapView = {
title: 'Map details',
order: 30,
help: `View the map state`,
shouldShow(adapters) {
return Boolean(adapters.map);
},
component: MapViewComponent
};
export { MapView };

View file

@ -0,0 +1,11 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { MapView } from './map_view';
import { viewRegistry } from 'ui/inspector';
viewRegistry.register(MapView);

View file

@ -0,0 +1,50 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { uiModules } from 'ui/modules';
import { SearchSourceProvider } from 'ui/courier';
import { RequestAdapter } from 'ui/inspector/adapters';
import { MapAdapter } from './inspector/adapters/map_adapter';
import { timefilter } from 'ui/timefilter/timefilter';
import { getRequestInspectorStats, getResponseInspectorStats } from 'ui/courier/utils/courier_inspector_utils';
export const timeService = timefilter;
export let indexPatternService;
export let SearchSource;
export let emsServiceSettings;
export const inspectorAdapters = {
requests: new RequestAdapter(),
map: new MapAdapter(),
};
export async function fetchSearchSourceAndRecordWithInspector({ searchSource, requestId, requestName, requestDesc }) {
const inspectorRequest = inspectorAdapters.requests.start(
requestName,
{ id: requestId, description: requestDesc });
let resp;
try {
inspectorRequest.stats(getRequestInspectorStats(searchSource));
searchSource.getSearchRequestBody().then(body => {
inspectorRequest.json(body);
});
resp = await searchSource.fetch();
inspectorRequest
.stats(getResponseInspectorStats(searchSource, resp))
.ok({ json: resp });
} catch(error) {
inspectorRequest.error({ error });
throw error;
}
return resp;
}
uiModules.get('app/gis').run(($injector) => {
indexPatternService = $injector.get('indexPatterns');
const Private = $injector.get('Private');
SearchSource = Private(SearchSourceProvider);
emsServiceSettings = $injector.get('serviceSettings');
});

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;
* you may not use this file except in compliance with the Elastic License.
*/
import {
FeatureCatalogueRegistryProvider,
FeatureCatalogueCategory
} from 'ui/registry/feature_catalogue';
FeatureCatalogueRegistryProvider.register(() => {
return {
id: 'gis',
title: 'GIS',
description: 'Map and analyze the geo-data from Elasticsearch and the Elastic Maps Service',
icon: 'gisApp', //no gis logo yet
path: '/app/gis',
showOnHomePage: true,
category: FeatureCatalogueCategory.OTHER
};
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

View file

@ -0,0 +1,158 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { createSelector } from 'reselect';
import _ from 'lodash';
import { TileLayer } from '../shared/layers/tile_layer';
import { VectorLayer } from '../shared/layers/vector_layer';
import { HeatmapLayer } from '../shared/layers/heatmap_layer';
import { EMSFileSource } from '../shared/layers/sources/ems_file_source';
import { KibanaRegionmapSource } from '../shared/layers/sources/kibana_regionmap_source';
import { XYZTMSSource } from '../shared/layers/sources/xyz_tms_source';
import { EMSTMSSource } from '../shared/layers/sources/ems_tms_source';
import { WMSSource } from '../shared/layers/sources/wms_source';
import { KibanaTilemapSource } from '../shared/layers/sources/kibana_tilemap_source';
import { ESGeohashGridSource } from '../shared/layers/sources/es_geohashgrid_source';
import { ESSearchSource } from '../shared/layers/sources/es_search_source';
import { VectorStyle } from '../shared/layers/styles/vector_style';
import { HeatmapStyle } from '../shared/layers/styles/heatmap_style';
import { TileStyle } from '../shared/layers/styles/tile_style';
import { timefilter } from 'ui/timefilter';
function createLayerInstance(layerDescriptor, dataSources) {
const source = createSourceInstance(layerDescriptor.sourceDescriptor, dataSources);
const style = createStyleInstance(layerDescriptor.style);
switch (layerDescriptor.type) {
case TileLayer.type:
return new TileLayer({ layerDescriptor, source, style });
case VectorLayer.type:
return new VectorLayer({ layerDescriptor, source, style });
case HeatmapLayer.type:
return new HeatmapLayer({ layerDescriptor, source, style });
default:
throw new Error(`Unrecognized layerType ${layerDescriptor.type}`);
}
}
function createSourceInstance(sourceDescriptor, dataSources) {
switch (sourceDescriptor.type) {
case XYZTMSSource.type:
return new XYZTMSSource(sourceDescriptor);
case EMSTMSSource.type:
const emsTmsServices = _.get(dataSources, 'ems.tms', []);
return new EMSTMSSource(sourceDescriptor, emsTmsServices);
case KibanaTilemapSource.type:
return new KibanaTilemapSource(sourceDescriptor);
case EMSFileSource.type:
const emsregions = _.get(dataSources, 'ems.file', []);
return new EMSFileSource(sourceDescriptor, emsregions);
case KibanaRegionmapSource.type:
const regions = _.get(dataSources, 'kibana.regionmap', []);
return new KibanaRegionmapSource(sourceDescriptor, regions);
case ESGeohashGridSource.type:
return new ESGeohashGridSource(sourceDescriptor);
case ESSearchSource.type:
return new ESSearchSource(sourceDescriptor);
case WMSSource.type:
return new WMSSource(sourceDescriptor);
default:
throw new Error(`Unrecognized sourceType ${sourceDescriptor.type}`);
}
}
function createStyleInstance(styleDescriptor) {
if (!styleDescriptor || !styleDescriptor.type) {
return null;
}
switch (styleDescriptor.type) {
case VectorStyle.type:
return new VectorStyle(styleDescriptor);
case TileStyle.type:
return new TileStyle(styleDescriptor);
case HeatmapStyle.type:
return new HeatmapStyle(styleDescriptor);
default:
throw new Error(`Unrecognized styleType ${styleDescriptor.type}`);
}
}
export const getMapState = ({ map }) => map && map.mapState;
export const getMapReady = ({ map }) => map && map.ready;
const getSelectedLayerId = ({ map }) => {
return (!map.selectedLayerId || !map.layerList) ? null : map.selectedLayerId;
};
export const getLayerListRaw = ({ map }) => map.layerList ? map.layerList : [];
export const getMapExtent = ({ map }) => map.mapState.extent ?
map.mapState.extent : {};
export const getMapBuffer = ({ map }) => map.mapState.buffer ?
map.mapState.buffer : {};
export const getMapZoom = ({ map }) => map.mapState.zoom ?
map.mapState.zoom : 0;
export const getMapCenter = ({ map }) => map.mapState.center ?
map.mapState.center : { lat: 0, lon: 0 };
export const getMapColors = ({ map }) => {
return map.layerList.reduce((accu, layer) => {
// This will evolve as color options are expanded
if (!layer.temporary) {
const color = _.get(layer, 'style.properties.fillColor.options.color');
if (color) accu.push(color);
}
return accu;
}, []);
};
export const getTimeFilters = ({ map }) => map.mapState.timeFilters ?
map.mapState.timeFilters : timefilter.getTime();
export const getMetadata = ({ config }) => config && config.meta;
export const getDataFilters = createSelector(
getMapExtent,
getMapBuffer,
getMapZoom,
getTimeFilters,
(mapExtent, mapBuffer, mapZoom, timeFilters) => {
return {
extent: mapExtent,
buffer: mapBuffer,
zoom: mapZoom,
timeFilters: timeFilters
};
}
);
export const getDataSources = createSelector(getMetadata, metadata => metadata ? metadata.data_sources : null);
export const getSelectedLayer = createSelector(
getSelectedLayerId,
getLayerListRaw,
getDataSources,
(selectedLayerId, layerList, dataSources) => {
const selectedLayer = layerList.find(layerDescriptor => layerDescriptor.id === selectedLayerId);
return createLayerInstance(selectedLayer, dataSources);
});
export const getLayerList = createSelector(
getLayerListRaw,
getDataSources,
(layerList, dataSources) => {
return layerList.map(layerDescriptor =>
createLayerInstance(layerDescriptor, dataSources));
});
export const getTemporaryLayers = createSelector(getLayerList, (layerList) => layerList.filter(layer => layer.isTemporary()));

View file

@ -0,0 +1,51 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
jest.mock('../shared/layers/vector_layer', () => {});
jest.mock('../shared/layers/sources/ems_file_source', () => {});
jest.mock('../shared/layers/sources/es_geohashgrid_source', () => {});
jest.mock('../shared/layers/sources/es_search_source', () => {});
jest.mock('ui/timefilter', () => ({
timefilter: {
getTime: () => {
return {
to: 'now',
from: 'now-15m'
};
}
}
}));
import {
getTimeFilters,
} from './map_selectors';
describe('getTimeFilters', () => {
it('should return timeFilters when contained in state', () => {
const state = {
map: {
mapState: {
timeFilters: {
to: '2001-01-01',
from: '2001-12-31'
}
}
}
};
expect(getTimeFilters(state)).toEqual({ to: '2001-01-01', from: '2001-12-31' });
});
it('should return kibana time filters when not contained in state', () => {
const state = {
map: {
mapState: {
timeFilters: null
}
}
};
expect(getTimeFilters(state)).toEqual({ to: 'now', from: 'now-15m' });
});
});

View file

@ -0,0 +1,46 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { Component } from 'react';
import {
EuiText,
} from '@elastic/eui';
export class ESSourceDetails extends Component {
state = {
indexPatternTitle: null
}
componentDidMount() {
this._isMounted = true;
this.loadDisplayName();
}
componentWillUnmount() {
this._isMounted = false;
}
loadDisplayName = async () => {
const indexPatternTitle = await this.props.source.getDisplayName();
if (this._isMounted) {
this.setState({ indexPatternTitle });
}
}
render() {
return (
<EuiText color="subdued" size="s">
<p className="gisLayerDetails">
<strong className="gisLayerDetails__label">Type </strong><span>{this.props.sourceType}</span><br/>
<strong className="gisLayerDetails__label">Index pattern </strong><span>{this.state.indexPatternTitle}</span><br/>
<strong className="gisLayerDetails__label">{this.props.geoFieldType}</strong><span>{this.props.geoField}</span><br/>
</p>
</EuiText>
);
}
}

View file

@ -0,0 +1,388 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React from 'react';
import PropTypes from 'prop-types';
import _ from 'lodash';
import { toastNotifications } from 'ui/notify';
import {
EuiTitle,
EuiFieldSearch,
EuiBasicTable,
EuiPage,
EuiPageBody,
EuiPageContent,
EuiLink,
EuiFlexGroup,
EuiFlexItem,
EuiButton,
EuiSpacer,
EuiOverlayMask,
EuiConfirmModal,
EuiCallOut,
} from '@elastic/eui';
export const EMPTY_FILTER = '';
export class MapListing extends React.Component {
constructor(props) {
super(props);
this.state = {
hasInitialFetchReturned: false,
isFetchingItems: false,
showDeleteModal: false,
showLimitError: false,
filter: EMPTY_FILTER,
items: [],
selectedIds: [],
page: 0,
perPage: 20,
};
}
componentWillMount() {
this._isMounted = true;
}
componentWillUnmount() {
this._isMounted = false;
this.debouncedFetch.cancel();
}
componentDidMount() {
this.fetchItems();
}
debouncedFetch = _.debounce(async (filter) => {
const response = await this.props.find(filter);
if (!this._isMounted) {
return;
}
// We need this check to handle the case where search results come back in a different
// order than they were sent out. Only load results for the most recent search.
if (filter === this.state.filter) {
this.setState({
hasInitialFetchReturned: true,
isFetchingItems: false,
items: response.hits,
totalItems: response.total,
showLimitError: response.total > this.props.listingLimit,
});
}
}, 300);
fetchItems = () => {
this.setState({
isFetchingItems: true,
}, this.debouncedFetch.bind(null, this.state.filter));
}
deleteSelectedItems = async () => {
try {
await this.props.delete(this.state.selectedIds);
} catch (error) {
toastNotifications.addDanger({
title: `Unable to delete map(s)`,
text: `${error}`,
});
}
this.fetchItems();
this.setState({
selectedIds: []
});
this.closeDeleteModal();
}
closeDeleteModal = () => {
this.setState({ showDeleteModal: false });
};
openDeleteModal = () => {
this.setState({ showDeleteModal: true });
};
onTableChange = ({ page, sort = {} }) => {
const {
index: pageIndex,
size: pageSize,
} = page;
let {
field: sortField,
direction: sortDirection,
} = sort;
// 3rd sorting state that is not captured by sort - native order (no sort)
// when switching from desc to asc for the same field - use native order
if (this.state.sortField === sortField
&& this.state.sortDirection === 'desc'
&& sortDirection === 'asc') {
sortField = null;
sortDirection = null;
}
this.setState({
page: pageIndex,
perPage: pageSize,
sortField,
sortDirection,
});
}
getPageOfItems = () => {
// do not sort original list to preserve elasticsearch ranking order
const itemsCopy = this.state.items.slice();
if (this.state.sortField) {
itemsCopy.sort((a, b) => {
const fieldA = _.get(a, this.state.sortField, '');
const fieldB = _.get(b, this.state.sortField, '');
let order = 1;
if (this.state.sortDirection === 'desc') {
order = -1;
}
return order * fieldA.toLowerCase().localeCompare(fieldB.toLowerCase());
});
}
// If begin is greater than the length of the sequence, an empty array is returned.
const startIndex = this.state.page * this.state.perPage;
// If end is greater than the length of the sequence, slice extracts through to the end of the sequence (arr.length).
const lastIndex = startIndex + this.state.perPage;
return itemsCopy.slice(startIndex, lastIndex);
}
hasNoItems() {
if (!this.state.isFetchingItems && this.state.items.length === 0 && !this.state.filter) {
return true;
}
return false;
}
renderConfirmDeleteModal() {
return (
<EuiOverlayMask>
<EuiConfirmModal
title="Delete selected items?"
onCancel={this.closeDeleteModal}
onConfirm={this.deleteSelectedItems}
cancelButtonText="Cancel"
confirmButtonText="Delete"
defaultFocusedButton="cancel"
>
<p>{`You can't recover deleted items.`}</p>
</EuiConfirmModal>
</EuiOverlayMask>
);
}
renderListingLimitWarning() {
if (this.state.showLimitError) {
return (
<React.Fragment>
<EuiCallOut
title="Listing limit exceeded"
color="warning"
iconType="help"
>
<p>
You have {this.state.totalItems} items,
but your <strong>listingLimit</strong> setting prevents the table below from displaying more than {this.props.listingLimit}.
You can change this setting under <EuiLink href="#/management/kibana/settings">Advanced Settings</EuiLink>.
</p>
</EuiCallOut>
<EuiSpacer size="m" />
</React.Fragment>
);
}
}
renderNoResultsMessage() {
if (this.state.isFetchingItems) {
return '';
}
if (this.hasNoItems()) {
return `Looks like you don't have any maps. Click the create button to create one.`;
}
return 'No items matched your search.';
}
renderSearchBar() {
let deleteBtn;
if (this.state.selectedIds.length > 0) {
deleteBtn = (
<EuiFlexItem grow={false}>
<EuiButton
color="danger"
onClick={this.openDeleteModal}
data-test-subj="deleteSelectedItems"
key="delete"
>
Delete selected
</EuiButton>
</EuiFlexItem>
);
}
return (
<EuiFlexGroup>
{deleteBtn}
<EuiFlexItem grow={true}>
<EuiFieldSearch
aria-label="Filter items"
placeholder="Search..."
fullWidth
value={this.state.filter}
onChange={(e) => {
this.setState({
filter: e.target.value
}, this.fetchItems);
}}
data-test-subj="searchFilter"
/>
</EuiFlexItem>
</EuiFlexGroup>
);
}
renderTable() {
const tableColumns = [
{
field: 'title',
name: 'Title',
sortable: true,
render: (field, record) => (
<EuiLink
href={`#/map/${record.id}`}
data-test-subj={`mapListingTitleLink-${record.title.split(' ').join('-')}`}
>
{field}
</EuiLink>
)
},
{
field: 'description',
name: 'Description',
dataType: 'string',
sortable: true,
}
];
const pagination = {
pageIndex: this.state.page,
pageSize: this.state.perPage,
totalItemCount: this.state.items.length,
pageSizeOptions: [10, 20, 50],
};
const selection = {
onSelectionChange: (selection) => {
this.setState({
selectedIds: selection.map(item => { return item.id; })
});
}
};
const sorting = {};
if (this.state.sortField) {
sorting.sort = {
field: this.state.sortField,
direction: this.state.sortDirection,
};
}
const items = this.state.items.length === 0 ? [] : this.getPageOfItems();
return (
<EuiBasicTable
itemId={'id'}
items={items}
loading={this.state.isFetchingItems}
columns={tableColumns}
selection={selection}
noItemsMessage={this.renderNoResultsMessage()}
pagination={pagination}
sorting={sorting}
onChange={this.onTableChange}
/>
);
}
renderListing() {
let createButton;
if (!this.props.hideWriteControls) {
createButton = (
<EuiFlexItem grow={false}>
<EuiButton
href={`#/map`}
data-test-subj="newMapLink"
>
Create new map
</EuiButton>
</EuiFlexItem>
);
}
return (
<div>
{this.state.showDeleteModal && this.renderConfirmDeleteModal()}
<EuiFlexGroup justifyContent="spaceBetween" alignItems="flexEnd" data-test-subj="top-nav">
<EuiFlexItem grow={false}>
<EuiTitle size="l">
<h1>
GIS maps
</h1>
</EuiTitle>
</EuiFlexItem>
{createButton}
</EuiFlexGroup>
<EuiSpacer size="m" />
{this.renderListingLimitWarning()}
{this.renderSearchBar()}
<EuiSpacer size="m" />
{this.renderTable()}
</div>
);
}
renderPageContent() {
if (!this.state.hasInitialFetchReturned) {
return;
}
return (
<EuiPageContent horizontalPosition="center">
{this.renderListing()}
</EuiPageContent>
);
}
render() {
return (
<EuiPage data-test-subj="gisListingPage" restrictWidth>
<EuiPageBody>
{this.renderPageContent()}
</EuiPageBody>
</EuiPage>
);
}
}
MapListing.propTypes = {
find: PropTypes.func.isRequired,
delete: PropTypes.func.isRequired,
listingLimit: PropTypes.number.isRequired,
};

View file

@ -0,0 +1,50 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React from 'react';
import PropTypes from 'prop-types';
import { EuiComboBox } from '@elastic/eui';
const AGG_OPTIONS = [
{ label: 'Average', value: 'avg' },
{ label: 'Count', value: 'count' },
{ label: 'Max', value: 'max' },
{ label: 'Min', value: 'min' },
{ label: 'Sum', value: 'sum' },
];
export const METRIC_AGGREGATION_VALUES = AGG_OPTIONS.map(({ value }) => { return value; });
export function MetricSelect({ value, onChange }) {
function onAggChange(selectedOptions) {
if (selectedOptions.length === 0) {
return;
}
const aggType = selectedOptions[0].value;
onChange(aggType);
}
return (
<EuiComboBox
placeholder="Select aggregation"
singleSelection={true}
isClearable={false}
options={AGG_OPTIONS}
selectedOptions={AGG_OPTIONS.filter(option => {
return value === option.value;
})}
onChange={onAggChange}
/>
);
}
MetricSelect.propTypes = {
value: PropTypes.oneOf(METRIC_AGGREGATION_VALUES),
onChange: PropTypes.func.isRequired,
};

View file

@ -0,0 +1,149 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { Fragment } from 'react';
import PropTypes from 'prop-types';
import {
EuiFlexGroup,
EuiFlexItem,
EuiFormLabel,
EuiButtonIcon,
} from '@elastic/eui';
import {
MetricSelect,
METRIC_AGGREGATION_VALUES,
} from './metric_select';
import { SingleFieldSelect } from './single_field_select';
export function MetricsEditor({ fields, metrics, onChange }) {
function onMetricChange(metric, index) {
onChange([
...metrics.slice(0, index),
metric,
...metrics.slice(index + 1)
]);
}
function renderMetrics() {
return metrics.map((metric, index) => {
const onAggChange = (metricAggregationType) => {
const updatedMetric = {
...metric,
type: metricAggregationType,
};
onMetricChange(updatedMetric, index);
};
const onFieldChange = (fieldName) => {
const updatedMetric = {
...metric,
field: fieldName,
};
onMetricChange(updatedMetric, index);
};
const onRemove = () => {
onChange([
...metrics.slice(0, index),
...metrics.slice(index + 1)
]);
};
let fieldSelect;
if (metric.type && metric.type !== 'count') {
const filterNumberFields = (field) => {
return field.type === 'number';
};
fieldSelect = (
<EuiFlexItem>
<SingleFieldSelect
placeholder="Select field"
value={metric.field}
onChange={onFieldChange}
filterField={filterNumberFields}
fields={fields}
isClearable={false}
/>
</EuiFlexItem>
);
}
let removeButton;
if (index > 0) {
removeButton = (
<EuiFlexItem grow={false}>
<EuiButtonIcon
iconType="trash"
color="danger"
aria-label="Delete metric"
title="Delete metric"
onClick={onRemove}
/>
</EuiFlexItem>
);
}
return (
<EuiFlexGroup alignItems="center" key={index}>
<EuiFlexItem>
<MetricSelect
onChange={onAggChange}
value={metric.type}
/>
</EuiFlexItem>
{fieldSelect}
{removeButton}
</EuiFlexGroup>
);
});
}
function addMetric() {
onChange([
...metrics,
{},
]);
}
return (
<Fragment>
<EuiFlexGroup alignItems="center">
<EuiFlexItem grow={true}>
<EuiFormLabel style={{ marginBottom: 0 }}>
Metrics
</EuiFormLabel>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButtonIcon
iconType="plusInCircle"
onClick={addMetric}
aria-label="Add metric"
title="Add metric"
/>
</EuiFlexItem>
</EuiFlexGroup>
{renderMetrics()}
</Fragment>
);
}
MetricsEditor.propTypes = {
metrics: PropTypes.arrayOf(PropTypes.shape({
type: PropTypes.oneOf(METRIC_AGGREGATION_VALUES),
field: PropTypes.string,
})),
fields: PropTypes.object, // indexPattern.fields IndexedArray object
onChange: PropTypes.func.isRequired,
};
MetricsEditor.defaultProps = {
metrics: [
{ type: 'count' }
]
};

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;
* you may not use this file except in compliance with the Elastic License.
*/
import _ from 'lodash';
import PropTypes from 'prop-types';
import React from 'react';
import {
EuiComboBox,
} from '@elastic/eui';
// Creates grouped options by grouping fields by field type
export const getGroupedFieldOptions = (fields, filterField) => {
if (!fields) {
return undefined;
}
const fieldsByTypeMap = new Map();
const groupedFieldOptions = [];
fields
.filter(filterField)
.forEach(field => {
if (fieldsByTypeMap.has(field.type)) {
const fieldsList = fieldsByTypeMap.get(field.type);
fieldsList.push(field.name);
fieldsByTypeMap.set(field.type, fieldsList);
} else {
fieldsByTypeMap.set(field.type, [field.name]);
}
});
fieldsByTypeMap.forEach((fieldsList, fieldType) => {
groupedFieldOptions.push({
label: fieldType,
options: fieldsList.sort().map(fieldName => {
return { value: fieldName, label: fieldName };
})
});
});
groupedFieldOptions.sort((a, b) => {
if (a.label < b.label) return -1;
if (a.label > b.label) return 1;
return 0;
});
return groupedFieldOptions;
};
export function SingleFieldSelect({ fields,
filterField,
onChange,
value,
placeholder,
...rest
}) {
const onSelection = (selectedOptions) => {
onChange(_.get(selectedOptions, '0.value'));
};
return (
<EuiComboBox
placeholder={placeholder}
singleSelection={true}
options={getGroupedFieldOptions(fields, filterField)}
selectedOptions={value ? [{ value: value, label: value }] : []}
onChange={onSelection}
isDisabled={!fields}
{...rest}
/>
);
}
SingleFieldSelect.propTypes = {
placeholder: PropTypes.string,
fields: PropTypes.object, // IndexedArray object
onChange: PropTypes.func.isRequired,
value: PropTypes.string, // fieldName
filterField: PropTypes.func,
};
SingleFieldSelect.defaultProps = {
filterField: () => { return true; }
};

View file

@ -0,0 +1,66 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React from 'react';
import classNames from 'classnames';
import PropTypes from 'prop-types';
import {
EuiIcon
} from '@elastic/eui';
//import { EuiIcon } from '../../icon';
export const VisibilityToggle = ({
id,
name,
checked,
disabled,
onChange,
children,
className
}) => {
const classes = classNames('visibilityToggle', className);
return (
<div className={classes}>
<input
className="euiSwitch__input"
name={name}
id={id}
type="checkbox"
checked={checked}
disabled={disabled}
onChange={onChange}
/>
<span className="visibilityToggle__body">
<span className="visibilityToggle__eye" >
<EuiIcon
type={'eye'}
/>
</span>
<span className="visibilityToggle__eyeClosed" >
<EuiIcon
type={'eyeClosed'}
/>
</span>
<span className="visibilityToggle__content">
{children}
</span>
</span>
</div>
);
};
VisibilityToggle.propTypes = {
name: PropTypes.string,
id: PropTypes.string,
label: PropTypes.node,
checked: PropTypes.bool,
onChange: PropTypes.func,
disabled: PropTypes.bool,
children: PropTypes.element.isRequired,
};

View file

@ -0,0 +1,55 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React from 'react';
export const FillableCircle = ({ style }) => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
style={style}
viewBox="0 0 16 16"
>
<defs>
<path
id="fillableCircle"
d={`M8,15 C11.8659932,15 15,11.8659932 15,8 C15,4.13400675 11.8659932,\
1 8,1 C4.13400675,1 1,4.13400675 1,8 C1,11.8659932 4.13400675,15 8,\
15 Z M8,16 C3.581722,16 0,12.418278 0,8 C0,3.581722 3.581722,0 8,\
0 C12.418278,0 16,3.581722 16,8 C16,12.418278 12.418278,16 8,16 Z M8,\
12 C10.209139,12 12,10.209139 12,8 C12,5.790861 10.209139,4 8,\
4 C5.790861,4 4,5.790861 4,8 C4,10.209139 5.790861,12 8,12 Z M8,\
11 C6.34314575,11 5,9.65685425 5,8 C5,6.34314575 6.34314575,5 8,\
5 C9.65685425,5 11,6.34314575 11,8 C11,9.65685425 9.65685425,11 8,\
11 Z M8,9 C8.55228475,9 9,8.55228475 9,8 C9,7.44771525 8.55228475,7 8,\
7 C7.44771525,7 7,7.44771525 7,8 C7,8.55228475 7.44771525,9 8,9 Z`}
/>
</defs>
<g>
<ellipse
id="path3353"
cx="8.0350437"
cy="7.9660664"
rx="7.3820276"
ry="7.3809776"
transform="matrix(0.99985753,0.01687955,0,1,0,0)"
/>
</g>
</svg>
);
export const FillableVector = ({ style }) => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 16 16"
>
<rect width="15" height="15" x=".5" y=".5" style={style} rx="4"/>
</svg>
);

View file

@ -0,0 +1,36 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React from 'react';
import { vislibColorMaps } from 'ui/vislib/components/color/colormaps';
import { getLegendColors } from 'ui/vis/map/color_util';
const GRADIENT_INTERVALS = 7;
const COLOR_KEYS = Object.keys(vislibColorMaps);
export const ColorGradient = ({ color }) => {
if (!color || !COLOR_KEYS.includes(color)) {
return null;
} else {
const rgbColorStrings = getLegendColors(vislibColorMaps[color].value, GRADIENT_INTERVALS);
const background = getLinearGradient(rgbColorStrings, GRADIENT_INTERVALS);
return (
<div
className="colorGradient"
style={{ background }}
/>
);
}
};
function getLinearGradient(colorStrings, intervals) {
let linearGradient = `linear-gradient(to right, ${colorStrings[0]} 0%,`;
for (let i = 1; i < intervals - 1; i++) {
linearGradient = `${linearGradient} ${colorStrings[i]} \
${Math.floor(100 * i / (intervals - 1))}%,`;
}
return `${linearGradient} ${colorStrings.pop()} 100%)`;
}

View file

@ -0,0 +1,10 @@
.gisLayerDetails__label {
display: inline-block;
min-width: 5.5em;
margin-right: $euiSizeS;
white-space: nowrap;
+ span {
word-break: break-all;
}
}

View file

@ -0,0 +1,143 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import _ from 'lodash';
import React from 'react';
import { ALayer } from './layer';
import { EuiIcon } from '@elastic/eui';
import { HeatmapStyle } from './styles/heatmap_style';
import { ZOOM_TO_PRECISION } from '../utils/zoom_to_precision';
export class HeatmapLayer extends ALayer {
static type = "HEATMAP";
static createDescriptor(options) {
const heatmapLayerDescriptor = super.createDescriptor(options);
heatmapLayerDescriptor.type = HeatmapLayer.type;
const defaultStyle = HeatmapStyle.createDescriptor('coarse');
heatmapLayerDescriptor.style = defaultStyle;
return heatmapLayerDescriptor;
}
constructor({ layerDescriptor, source, style }) {
super({ layerDescriptor, source, style });
if (!style) {
const defaultStyle = HeatmapStyle.createDescriptor('coarse');
this._style = new HeatmapStyle(defaultStyle);
}
}
getSupportedStyles() {
return [HeatmapStyle];
}
syncLayerWithMB(mbMap) {
const mbSource = mbMap.getSource(this.getId());
const heatmapLayerId = this.getId() + '_heatmap';
if (!mbSource) {
mbMap.addSource(this.getId(), {
type: 'geojson',
data: { 'type': 'FeatureCollection', 'features': [] }
});
mbMap.addLayer({
id: heatmapLayerId,
type: 'heatmap',
source: this.getId(),
paint: {}
});
}
const mbSourceAfter = mbMap.getSource(this.getId());
const sourceDataRequest = this.getSourceDataRequest();
const featureCollection = sourceDataRequest ? sourceDataRequest.getData() : null;
if (!featureCollection) {
mbSourceAfter.setData({ 'type': 'FeatureCollection', 'features': [] });
return;
}
const scaledPropertyName = '__kbn_heatmap_weight__';
const propertyName = 'doc_count';
const dataBoundToMap = ALayer.getBoundDataForSource(mbMap, this.getId());
if (featureCollection !== dataBoundToMap) {
let max = 0;
for (let i = 0; i < featureCollection.features.length; i++) {
max = Math.max(featureCollection.features[i].properties[propertyName], max);
}
for (let i = 0; i < featureCollection.features.length; i++) {
featureCollection.features[i].properties[scaledPropertyName] = featureCollection.features[i].properties[propertyName] / max;
}
mbSourceAfter.setData(featureCollection);
}
mbMap.setLayoutProperty(heatmapLayerId, 'visibility', this.isVisible() ? 'visible' : 'none');
this._style.setMBPaintProperties(mbMap, heatmapLayerId, scaledPropertyName);
mbMap.setLayerZoomRange(heatmapLayerId, this._descriptor.minZoom, this._descriptor.maxZoom);
}
async syncData({ startLoading, stopLoading, onLoadError, dataFilters }) {
if (!this.isVisible() || !this.showAtZoomLevel(dataFilters.zoom)) {
return;
}
if (!dataFilters.buffer) {
return;
}
const sourceDataRequest = this.getSourceDataRequest();
const dataMeta = sourceDataRequest ? sourceDataRequest.getMeta() : {};
const targetPrecision = ZOOM_TO_PRECISION[Math.round(dataFilters.zoom)] + this._style.getPrecisionRefinementDelta();
const isSamePrecision = dataMeta.precision === targetPrecision;
const isSameTime = _.isEqual(dataMeta.timeFilters, dataFilters.timeFilters);
const updateDueToExtent = this.updateDueToExtent(this._source, dataMeta, dataFilters);
if (isSamePrecision && isSameTime && !updateDueToExtent) {
return;
}
const newDataMeta = {
...dataFilters,
precision: targetPrecision
};
return this._fetchNewData({ startLoading, stopLoading, onLoadError, dataMeta: newDataMeta });
}
async _fetchNewData({ startLoading, stopLoading, onLoadError, dataMeta }) {
const { precision, timeFilters, buffer } = dataMeta;
const requestToken = Symbol(`layer-source-refresh: this.getId()`);
startLoading('source', requestToken, dataMeta);
try {
const layerName = await this.getDisplayName();
const data = await this._source.getGeoJsonPointsWithTotalCount({
precision,
extent: buffer,
timeFilters,
layerName,
});
stopLoading('source', requestToken, data);
} catch (error) {
onLoadError('source', requestToken, error.message);
}
}
getIcon() {
return (
<EuiIcon
type={'heatmap'}
/>
);
}
}

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;
* you may not use this file except in compliance with the Elastic License.
*/
import { ESJoinSource } from '../sources/es_join_source';
export class LeftInnerJoin {
static toHash(descriptor) {
return JSON.stringify(descriptor);
}
constructor(joinDescriptor) {
this._descriptor = joinDescriptor;
this._rightSource = new ESJoinSource(joinDescriptor.right);
}
destroy() {
this._rightSource.destroy();
}
hasCompleteConfig() {
if (this._descriptor.leftField && this._rightSource) {
return this._rightSource.hasCompleteConfig();
}
return false;
}
getJoinFields() {
return this._rightSource.getMetricFields().map(({ propertyKey: name, propertyLabel: label }) => {
return { label, name };
});
}
getSourceId() {
return LeftInnerJoin.toHash(this._descriptor);
}
getLeftFieldName() {
return this._descriptor.leftField;
}
joinPropertiesToFeatureCollection(featureCollection, propertiesMap) {
featureCollection.features.forEach(feature => {
const joinKey = feature.properties[this._descriptor.leftField];
if (propertiesMap.has(joinKey)) {
feature.properties = {
...feature.properties,
...propertiesMap.get(joinKey),
};
}
});
}
getJoinSource() {
return this._rightSource;
}
getId() {
return this._descriptor.id;
}
toDescriptor() {
return this._descriptor;
}
filterAndFormatPropertiesForTooltip(properties) {
const joinFields = this.getJoinFields();
const tooltipProps = {};
joinFields.forEach((joinField) => {
for (const key in properties) {
if (properties.hasOwnProperty(key)) {
if (joinField.name === key) {
tooltipProps[joinField.label] = properties[key];
}
}
}
});
return tooltipProps;
}
}

View file

@ -0,0 +1,200 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import _ from 'lodash';
import turf from 'turf';
import turfBooleanContains from '@turf/boolean-contains';
import { DataRequest } from './util/data_request';
const SOURCE_UPDATE_REQUIRED = true;
const NO_SOURCE_UPDATE_REQUIRED = false;
export class ALayer {
constructor({ layerDescriptor, source, style }) {
this._descriptor = layerDescriptor;
this._source = source;
this._style = style;
if (this._descriptor.dataRequests) {
this._dataRequests = this._descriptor.dataRequests.map(dataRequest => new DataRequest(dataRequest));
} else {
this._dataRequests = [];
}
}
static getBoundDataForSource(mbMap, sourceId) {
const mbStyle = mbMap.getStyle();
return mbStyle.sources[sourceId].data;
}
static createDescriptor(options) {
const layerDescriptor = {};
layerDescriptor.dataRequests = [];
layerDescriptor.id = Math.random().toString(36).substr(2, 5);
layerDescriptor.label = options.label && options.label.length > 0 ? options.label : null;
layerDescriptor.minZoom = _.get(options, 'minZoom', 0);
layerDescriptor.maxZoom = _.get(options, 'maxZoom', 24);
layerDescriptor.source = options.source;
layerDescriptor.sourceDescriptor = options.sourceDescriptor;
layerDescriptor.visible = options.visible || true;
layerDescriptor.temporary = options.temporary || false;
layerDescriptor.style = options.style || {};
return layerDescriptor;
}
destroy() {
if(this._source) {
this._source.destroy();
}
}
isJoinable() {
return false;
}
async getDisplayName() {
if (this._descriptor.label) {
return this._descriptor.label;
}
return (await this._source.getDisplayName()) || `Layer ${this._descriptor.id}`;
}
getLabel() {
return this._descriptor.label ? this._descriptor.label : '';
}
getIcon() {
console.warn('Icon not available for this layer type');
}
getTOCDetails() {
return null;
}
getId() {
return this._descriptor.id;
}
getSource() {
return this._source;
}
isVisible() {
return this._descriptor.visible;
}
showAtZoomLevel(zoom) {
if (zoom >= this._descriptor.minZoom && zoom <= this._descriptor.maxZoom) {
return true;
}
return false;
}
getMinZoom() {
return this._descriptor.minZoom;
}
getMaxZoom() {
return this._descriptor.maxZoom;
}
getZoomConfig() {
return {
minZoom: this._descriptor.minZoom,
maxZoom: this._descriptor.maxZoom,
};
}
isTemporary() {
return this._descriptor.temporary;
}
getSupportedStyles() {
return [];
}
getCurrentStyle() {
return this._style;
}
renderSourceDetails = () => {
return this._source.renderDetails();
}
renderSourceSettingsEditor = ({ onChange }) => {
return this._source.renderSourceSettingsEditor({ onChange });
}
isLayerLoading() {
return this._dataRequests.some(dataRequest => dataRequest.isLoading());
}
dataHasLoadError() {
return this._dataRequests.some(dataRequest => dataRequest.hasLoadError());
}
getDataLoadError() {
const loadErrors = this._dataRequests.filter(dataRequest => dataRequest.hasLoadError());
return loadErrors.join(',');//todo
}
toLayerDescriptor() {
return this._descriptor;
}
async syncData() {
//no-op by default
}
updateDueToExtent(source, meta = {}, dataFilters = {}) {
const extentAware = source.isFilterByMapBounds();
if (!extentAware) {
return NO_SOURCE_UPDATE_REQUIRED;
}
const { buffer: previousBuffer } = meta;
const { buffer: newBuffer } = dataFilters;
if (!previousBuffer) {
return SOURCE_UPDATE_REQUIRED;
}
if (_.isEqual(previousBuffer, newBuffer)) {
return NO_SOURCE_UPDATE_REQUIRED;
}
const previousBufferGeometry = turf.bboxPolygon([
previousBuffer.min_lon,
previousBuffer.min_lat,
previousBuffer.max_lon,
previousBuffer.max_lat
]);
const newBufferGeometry = turf.bboxPolygon([
newBuffer.min_lon,
newBuffer.min_lat,
newBuffer.max_lon,
newBuffer.max_lat
]);
const doesPreviousBufferContainNewBuffer = turfBooleanContains(previousBufferGeometry, newBufferGeometry);
return doesPreviousBufferContainNewBuffer && !_.get(meta, 'areResultsTrimmed', false)
? NO_SOURCE_UPDATE_REQUIRED
: SOURCE_UPDATE_REQUIRED;
}
renderStyleEditor(style, options) {
return style.renderEditor(options);
}
getSourceDataRequest() {
return this._dataRequests.find(dataRequest => dataRequest.getDataId() === 'source');
}
}

View file

@ -0,0 +1,113 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { ALayer } from './layer';
describe('layer', () => {
const layer = new ALayer({ layerDescriptor: {} });
describe('updateDueToExtent', () => {
it('should be false when the source is not extent aware', async () => {
const sourceMock = {
isFilterByMapBounds: () => { return false; }
};
const updateDueToExtent = layer.updateDueToExtent(sourceMock);
expect(updateDueToExtent).toBe(false);
});
it('should be false when buffers are the same', async () => {
const sourceMock = {
isFilterByMapBounds: () => { return true; }
};
const oldBuffer = {
max_lat: 12.5,
max_lon: 102.5,
min_lat: 2.5,
min_lon: 92.5,
};
const newBuffer = {
max_lat: 12.5,
max_lon: 102.5,
min_lat: 2.5,
min_lon: 92.5,
};
const updateDueToExtent = layer.updateDueToExtent(sourceMock, { buffer: oldBuffer }, { buffer: newBuffer });
expect(updateDueToExtent).toBe(false);
});
it('should be false when the new buffer is contained in the old buffer', async () => {
const sourceMock = {
isFilterByMapBounds: () => { return true; }
};
const oldBuffer = {
max_lat: 12.5,
max_lon: 102.5,
min_lat: 2.5,
min_lon: 92.5,
};
const newBuffer = {
max_lat: 10,
max_lon: 100,
min_lat: 5,
min_lon: 95,
};
const updateDueToExtent = layer.updateDueToExtent(sourceMock, { buffer: oldBuffer }, { buffer: newBuffer });
expect(updateDueToExtent).toBe(false);
});
it('should be true when the new buffer is contained in the old buffer and the past results were truncated', async () => {
const sourceMock = {
isFilterByMapBounds: () => { return true; }
};
const oldBuffer = {
max_lat: 12.5,
max_lon: 102.5,
min_lat: 2.5,
min_lon: 92.5,
};
const newBuffer = {
max_lat: 10,
max_lon: 100,
min_lat: 5,
min_lon: 95,
};
const updateDueToExtent = layer.updateDueToExtent(
sourceMock,
{ buffer: oldBuffer, areResultsTrimmed: true },
{ buffer: newBuffer });
expect(updateDueToExtent).toBe(true);
});
it('should be true when meta has no old buffer', async () => {
const sourceMock = {
isFilterByMapBounds: () => { return true; }
};
const updateDueToExtent = layer.updateDueToExtent(sourceMock);
expect(updateDueToExtent).toBe(true);
});
it('should be true when the new buffer is not contained in the old buffer', async () => {
const sourceMock = {
isFilterByMapBounds: () => { return true; }
};
const oldBuffer = {
max_lat: 12.5,
max_lon: 102.5,
min_lat: 2.5,
min_lon: 92.5,
};
const newBuffer = {
max_lat: 7.5,
max_lon: 92.5,
min_lat: -2.5,
min_lon: 82.5,
};
const updateDueToExtent = layer.updateDueToExtent(sourceMock, { buffer: oldBuffer }, { buffer: newBuffer });
expect(updateDueToExtent).toBe(true);
});
});
});

View file

@ -0,0 +1,106 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { VectorSource } from './vector_source';
import React from 'react';
import {
EuiLink,
EuiText,
EuiSelect,
EuiFormRow
} from '@elastic/eui';
import { GIS_API_PATH } from '../../../../common/constants';
import { emsServiceSettings } from '../../../kibana_services';
export class EMSFileSource extends VectorSource {
static type = 'EMS_FILE';
static typeDisplayName = 'Elastic Maps Service region boundaries';
static createDescriptor(name) {
return {
type: EMSFileSource.type,
name: name
};
}
static renderEditor({ dataSourcesMeta, onPreviewSource }) {
const emsVectorOptionsRaw = (dataSourcesMeta) ? dataSourcesMeta.ems.file : [];
const emsVectorOptions = emsVectorOptionsRaw ? emsVectorOptionsRaw.map((file) => ({
value: file.name,
text: file.name
})) : [];
const onChange = ({ target }) => {
const selectedName = target.options[target.selectedIndex].text;
const emsFileSourceDescriptor = EMSFileSource.createDescriptor(selectedName);
const emsFileSource = new EMSFileSource(emsFileSourceDescriptor, emsVectorOptionsRaw);
onPreviewSource(emsFileSource);
};
return (
<EuiFormRow label="Layer">
<EuiSelect
hasNoInitialSelection
options={emsVectorOptions}
onChange={onChange}
/>
</EuiFormRow>
);
}
constructor(descriptor, emsFiles) {
super(descriptor);
this._emsFiles = emsFiles;
}
async getGeoJsonWithMeta() {
const fileSource = this._emsFiles.find((source => source.name === this._descriptor.name));
const fetchUrl = `../${GIS_API_PATH}/data/ems?name=${encodeURIComponent(this._descriptor.name)}`;
const featureCollection = await VectorSource.getGeoJson(fileSource, fetchUrl);
return {
data: featureCollection,
meta: {}
};
}
renderDetails() {
const emsHotLink = emsServiceSettings.getEMSHotLink(this._descriptor.name);
return (
<EuiText color="subdued" size="s">
<p className="gisLayerDetails">
<strong className="gisLayerDetails__label">Source </strong><span>Elastic Maps Service</span><br/>
<strong className="gisLayerDetails__label">Name </strong><span>{this._descriptor.name}</span><br/>
<EuiLink href={emsHotLink} target="_blank">Preview on landing page</EuiLink><br/>
</p>
</EuiText>
);
}
async getDisplayName() {
return this._descriptor.name;
}
async getStringFields() {
//todo: use map/service-settings instead.
const fileSource = this._emsFiles.find((source => source.name === this._descriptor.name));
return fileSource.fields.map(f => {
return { name: f.name, label: f.description };
});
}
async isTimeAware() {
return false;
}
canFormatFeatureProperties() {
return true;
}
}

View file

@ -0,0 +1,71 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React from 'react';
import { TMSSource } from './tms_source';
import { TileLayer } from '../tile_layer';
import { EuiText } from '@elastic/eui';
export class EMSTMSSource extends TMSSource {
static type = 'EMS_TMS';
static typeDisplayName = 'TMS';
static createDescriptor(serviceId) {
return {
type: EMSTMSSource.type,
id: serviceId
};
}
constructor(descriptor, emsTileServices) {
super(descriptor);
this._emsTileServices = emsTileServices;
}
renderDetails() {
return (
<EuiText color="subdued" size="s">
<p className="gisLayerDetails">
<strong className="gisLayerDetails__label">Source </strong><span>Elastic Maps Service</span><br/>
<strong className="gisLayerDetails__label">Type </strong><span>Tile</span><br/>
<strong className="gisLayerDetails__label">Id </strong><span>{this._descriptor.id}</span><br/>
</p>
</EuiText>
);
}
_getTMSOptions() {
return this._emsTileServices.find(service => {
return service.id === this._descriptor.id;
});
}
_createDefaultLayerDescriptor(options) {
return TileLayer.createDescriptor({
sourceDescriptor: this._descriptor,
...options
});
}
createDefaultLayer(options) {
return new TileLayer({
layerDescriptor: this._createDefaultLayerDescriptor(options),
source: this
});
}
async getDisplayName() {
return this._descriptor.id;
}
getUrlTemplate() {
const service = this._getTMSOptions();
return service.url;
}
}

View file

@ -0,0 +1,122 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { decodeGeoHash } from 'ui/utils/decode_geo_hash';
import { gridDimensions } from 'ui/vis/map/grid_dimensions';
/*
* Fork of ui/public/vis/map/convert_to_geojson.js that supports multiple metrics
*/
export function convertToGeoJson(tabifiedResponse) {
let features;
const min = Infinity;
const max = -Infinity;
let geoAgg;
if (tabifiedResponse && tabifiedResponse.rows) {
const table = tabifiedResponse;
const geohashColumn = table.columns.find(column => column.aggConfig.type.dslName === 'geohash_grid');
if (!geohashColumn) {
features = [];
} else {
geoAgg = geohashColumn.aggConfig;
const metricColumns = table.columns.filter(column => {
return column.aggConfig.type.type === 'metrics'
&& column.aggConfig.type.dslName !== 'geo_centroid';
});
const geocentroidColumn = table.columns.find(column => column.aggConfig.type.dslName === 'geo_centroid');
features = table.rows.map(row => {
const geohash = row[geohashColumn.id];
if (!geohash) return false;
const geohashLocation = decodeGeoHash(geohash);
let pointCoordinates;
if (geocentroidColumn) {
const location = row[geocentroidColumn.id];
pointCoordinates = [location.lon, location.lat];
} else {
pointCoordinates = [geohashLocation.longitude[2], geohashLocation.latitude[2]];
}
const rectangle = [
[geohashLocation.latitude[0], geohashLocation.longitude[0]],
[geohashLocation.latitude[0], geohashLocation.longitude[1]],
[geohashLocation.latitude[1], geohashLocation.longitude[1]],
[geohashLocation.latitude[1], geohashLocation.longitude[0]],
];
const centerLatLng = [
geohashLocation.latitude[2],
geohashLocation.longitude[2]
];
if (geoAgg.params.useGeocentroid) {
// see https://github.com/elastic/elasticsearch/issues/24694 for why clampGrid is used
pointCoordinates[0] = clampGrid(pointCoordinates[0], geohashLocation.longitude[0], geohashLocation.longitude[1]);
pointCoordinates[1] = clampGrid(pointCoordinates[1], geohashLocation.latitude[0], geohashLocation.latitude[1]);
}
const metrics = {};
metricColumns.forEach(metricColumn => {
metrics[metricColumn.aggConfig.id] = row[metricColumn.id];
});
//const value = row[metricColumn.id];
//min = Math.min(min, value);
//max = Math.max(max, value);
return {
type: 'Feature',
geometry: {
type: 'Point',
coordinates: pointCoordinates
},
properties: {
geohash: geohash,
geohash_meta: {
center: centerLatLng,
rectangle: rectangle
},
...metrics
}
};
}).filter(row => row);
}
} else {
features = [];
}
const featureCollection = {
type: 'FeatureCollection',
features: features
};
return {
featureCollection: featureCollection,
meta: {
min: min,
max: max,
geohashPrecision: geoAgg && geoAgg.params.precision,
geohashGridDimensionsAtEquator: geoAgg && gridDimensions(geoAgg.params.precision)
}
};
}
function clampGrid(val, min, max) {
if (val > max) val = max;
else if (val < min) val = min;
return val;
}

View file

@ -0,0 +1,195 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import _ from 'lodash';
import React, { Fragment, Component } from 'react';
import PropTypes from 'prop-types';
import { IndexPatternSelect } from 'ui/index_patterns/components/index_pattern_select';
import { SingleFieldSelect } from '../../../components/single_field_select';
import { RENDER_AS } from './render_as';
import { indexPatternService } from '../../../../kibana_services';
import {
EuiFormRow,
EuiComboBox
} from '@elastic/eui';
function filterGeoField({ type }) {
return ['geo_point'].includes(type);
}
export class CreateSourceEditor extends Component {
static propTypes = {
onSelect: PropTypes.func.isRequired,
};
constructor() {
super();
this._requestTypeOptions = [
{
label: 'heatmap',
value: RENDER_AS.HEATMAP
}, {
label: 'points',
value: RENDER_AS.POINT
},
{
label: 'grid rectangles',
value: RENDER_AS.GRID
}
];
this.state = {
isLoadingIndexPattern: false,
indexPatternId: '',
geoField: '',
requestType: this._requestTypeOptions[0],
};
}
componentWillUnmount() {
this._isMounted = false;
}
componentDidMount() {
this._isMounted = true;
}
onIndexPatternSelect = (indexPatternId) => {
this.setState({
indexPatternId,
}, this.loadIndexPattern.bind(null, indexPatternId));
};
loadIndexPattern = (indexPatternId) => {
this.setState({
isLoadingIndexPattern: true,
indexPattern: undefined,
geoField: undefined,
}, this.debouncedLoad.bind(null, indexPatternId));
};
debouncedLoad = _.debounce(async (indexPatternId) => {
if (!indexPatternId || indexPatternId.length === 0) {
return;
}
let indexPattern;
try {
indexPattern = await indexPatternService.get(indexPatternId);
} catch (err) {
// index pattern no longer exists
return;
}
if (!this._isMounted) {
return;
}
// props.indexPatternId may be updated before getIndexPattern returns
// ignore response when fetched index pattern does not match active index pattern
if (indexPattern.id !== indexPatternId) {
return;
}
this.setState({
isLoadingIndexPattern: false,
indexPattern: indexPattern
});
//make default selection
const geoFields = indexPattern.fields.filter(filterGeoField);
if (geoFields[0]) {
this._onGeoFieldSelect(geoFields[0].name);
}
}, 300);
_onGeoFieldSelect = (geoField) => {
this.setState({
geoField
}, this.previewLayer);
};
_onRequestTypeSelect = (selectedOptions) => {
this.setState({
requestType: selectedOptions[0]
}, this.previewLayer);
};
previewLayer = () => {
const {
indexPatternId,
geoField,
requestType
} = this.state;
if (indexPatternId && geoField) {
this.props.onSelect({
indexPatternId,
geoField,
requestType: requestType.value
});
}
};
_renderGeoSelect() {
if (!this.state.indexPattern) {
return null;
}
return (
<EuiFormRow label="Geospatial field">
<SingleFieldSelect
placeholder="Select geo field"
value={this.state.geoField}
onChange={this._onGeoFieldSelect}
filterField={filterGeoField}
fields={this.state.indexPattern ? this.state.indexPattern.fields : undefined}
/>
</EuiFormRow>
);
}
_renderLayerSelect() {
return (
<EuiFormRow label="Show as">
<EuiComboBox
placeholder="Select a single option"
singleSelection={{ asPlainText: true }}
options={this._requestTypeOptions}
selectedOptions={[this.state.requestType]}
onChange={this._onRequestTypeSelect}
isClearable={false}
/>
</EuiFormRow>);
}
_renderIndexPatternSelect() {
return (
<EuiFormRow label="Index pattern">
<IndexPatternSelect
indexPatternId={this.state.indexPatternId}
onChange={this.onIndexPatternSelect}
placeholder="Select index pattern"
fieldTypes={['geo_point']}
/>
</EuiFormRow>
);
}
render() {
return (
<Fragment>
{this._renderIndexPatternSelect()}
{this._renderGeoSelect()}
{this._renderLayerSelect()}
</Fragment>
);
}
}

View file

@ -0,0 +1,368 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import _ from 'lodash';
import React from 'react';
import uuid from 'uuid/v4';
import { VectorSource } from '../vector_source';
import { HeatmapLayer } from '../../heatmap_layer';
import { VectorLayer } from '../../vector_layer';
import { Schemas } from 'ui/vis/editors/default/schemas';
import {
indexPatternService,
fetchSearchSourceAndRecordWithInspector,
inspectorAdapters,
SearchSource,
timeService,
} from '../../../../kibana_services';
import { createExtentFilter, makeGeohashGridPolygon } from '../../../../elasticsearch_geo_utils';
import { AggConfigs } from 'ui/vis/agg_configs';
import { tabifyAggResponse } from 'ui/agg_response/tabify';
import { convertToGeoJson } from './convert_to_geojson';
import { ESSourceDetails } from '../../../components/es_source_details';
import { ZOOM_TO_PRECISION } from '../../../utils/zoom_to_precision';
import { VectorStyle } from '../../styles/vector_style';
import { RENDER_AS } from './render_as';
import { CreateSourceEditor } from './create_source_editor';
import { UpdateSourceEditor } from './update_source_editor';
const COUNT_PROP_LABEL = 'Count';
const COUNT_PROP_NAME = 'doc_count';
const aggSchemas = new Schemas([
{
group: 'metrics',
name: 'metric',
title: 'Value',
min: 1,
max: Infinity,
aggFilter: ['avg', 'count', 'max', 'min', 'sum'],
defaults: [
{ schema: 'metric', type: 'count' }
]
},
{
group: 'buckets',
name: 'segment',
title: 'Geo Coordinates',
aggFilter: 'geohash_grid',
min: 1,
max: 1
}
]);
export class ESGeohashGridSource extends VectorSource {
static type = 'ES_GEOHASH_GRID';
static typeDisplayName = 'Elasticsearch geohash aggregation';
static createDescriptor({ indexPatternId, geoField, requestType }) {
return {
type: ESGeohashGridSource.type,
id: uuid(),
indexPatternId: indexPatternId,
geoField: geoField,
requestType: requestType
};
}
static renderEditor({ onPreviewSource }) {
const onSelect = (sourceConfig) => {
const sourceDescriptor = ESGeohashGridSource.createDescriptor(sourceConfig);
const source = new ESGeohashGridSource(sourceDescriptor);
onPreviewSource(source);
};
return (<CreateSourceEditor onSelect={onSelect}/>);
}
renderSourceSettingsEditor({ onChange }) {
return (
<UpdateSourceEditor
indexPatternId={this._descriptor.indexPatternId}
onChange={onChange}
metrics={this._descriptor.metrics}
renderAs={this._descriptor.requestType}
/>
);
}
renderDetails() {
return (
<ESSourceDetails
source={this}
geoField={this._descriptor.geoField}
geoFieldType="Point field"
sourceType={ESGeohashGridSource.typeDisplayName}
/>
);
}
destroy() {
inspectorAdapters.requests.resetRequest(this._descriptor.id);
}
async getGeoJsonWithMeta({ layerName }, searchFilters) {
let targetPrecision = ZOOM_TO_PRECISION[Math.round(searchFilters.zoom)];
targetPrecision += 0;//should have refinement param, similar to heatmap style
const featureCollection = await this.getGeoJsonPointsWithTotalCount({
precision: targetPrecision,
extent: searchFilters.buffer,
timeFilters: searchFilters.timeFilters,
layerName,
});
if (this._descriptor.requestType === RENDER_AS.GRID) {
featureCollection.features.forEach((feature) => {
//replace geometries with the polygon
feature.geometry = makeGeohashGridPolygon(feature);
});
}
return {
data: featureCollection,
meta: {
areResultsTrimmed: true
}
};
}
isFieldAware() {
return true;
}
getFieldNames() {
return this.getMetricFields().map(({ propertyKey }) => {
return propertyKey;
});
}
async getNumberFields() {
return this.getMetricFields().map(({ propertyKey: name, propertyLabel: label }) => {
return { label, name };
});
}
async getGeoJsonPointsWithTotalCount({ precision, extent, timeFilters, layerName }) {
let indexPattern;
try {
indexPattern = await indexPatternService.get(this._descriptor.indexPatternId);
} catch (error) {
throw new Error(`Unable to find Index pattern ${this._descriptor.indexPatternId}`);
}
const geoField = indexPattern.fields.byName[this._descriptor.geoField];
if (!geoField) {
throw new Error(`Index pattern ${indexPattern.title} no longer contains the geo field ${this._descriptor.geoField}`);
}
const aggConfigs = new AggConfigs(indexPattern, this._makeAggConfigs(precision), aggSchemas.all);
let resp;
try {
const searchSource = new SearchSource();
searchSource.setField('index', indexPattern);
searchSource.setField('size', 0);
searchSource.setField('aggs', aggConfigs.toDsl());
searchSource.setField('filter', () => {
const filters = [];
filters.push(createExtentFilter(extent, geoField.name, geoField.type));
filters.push(timeService.createFilter(indexPattern, timeFilters));
return filters;
});
resp = await fetchSearchSourceAndRecordWithInspector({
searchSource,
requestName: layerName,
requestId: this._descriptor.id,
requestDesc: 'Elasticsearch geohash_grid aggregation request'
});
} catch(error) {
throw new Error(`Elasticsearch search request failed, error: ${error.message}`);
}
const tabifiedResp = tabifyAggResponse(aggConfigs, resp);
const { featureCollection } = convertToGeoJson(tabifiedResp);
return featureCollection;
}
async isTimeAware() {
const indexPattern = await this._getIndexPattern();
const timeField = indexPattern.timeFieldName;
return !!timeField;
}
isFilterByMapBounds() {
return true;
}
async _getIndexPattern() {
let indexPattern;
try {
indexPattern = await indexPatternService.get(this._descriptor.indexPatternId);
} catch (error) {
throw new Error(`Unable to find Index pattern ${this._descriptor.indexPatternId}`);
}
return indexPattern;
}
_getValidMetrics() {
const metrics = _.get(this._descriptor, 'metrics', []).filter(({ type, field }) => {
if (type === 'count') {
return true;
}
if (field) {
return true;
}
return false;
});
if (metrics.length === 0) {
metrics.push({ type: 'count' });
}
return metrics;
}
getMetricFields() {
return this._getValidMetrics().map(metric => {
return {
...metric,
propertyKey: metric.type !== 'count' ? `${metric.type}_of_${metric.field}` : COUNT_PROP_NAME,
propertyLabel: metric.type !== 'count' ? `${metric.type} of ${metric.field}` : COUNT_PROP_LABEL,
};
});
}
_makeAggConfigs(precision) {
const metricAggConfigs = this.getMetricFields().map(metric => {
const metricAggConfig = {
id: metric.propertyKey,
enabled: true,
type: metric.type,
schema: 'metric',
params: {}
};
if (metric.type !== 'count') {
metricAggConfig.params = { field: metric.field };
}
return metricAggConfig;
});
return [
...metricAggConfigs,
{
id: 'grid',
enabled: true,
type: 'geohash_grid',
schema: 'segment',
params: {
field: this._descriptor.geoField,
isFilteredByCollar: false, // map extent filter is in query so no need to filter in aggregation
useGeocentroid: true, // TODO make configurable
autoPrecision: false, // false so we can define our own precision levels based on styling
precision: precision,
}
}
];
}
_createDefaultLayerDescriptor(options) {
if (this._descriptor.requestType === RENDER_AS.HEATMAP) {
return HeatmapLayer.createDescriptor({
sourceDescriptor: this._descriptor,
...options
});
}
const descriptor = VectorLayer.createDescriptor({
sourceDescriptor: this._descriptor,
...options
});
descriptor.style = {
...descriptor.style,
type: 'VECTOR',
properties: {
fillColor: {
type: 'DYNAMIC',
options: {
field: {
label: COUNT_PROP_LABEL,
name: COUNT_PROP_NAME,
origin: 'source'
},
color: 'Blues'
}
},
lineColor: {
type: 'STATIC',
options: {
color: '#cccccc'
}
},
lineWidth: {
type: 'STATIC',
options: {
size: 1
}
},
iconSize: {
type: 'DYNAMIC',
options: {
field: {
label: COUNT_PROP_LABEL,
name: COUNT_PROP_NAME,
origin: 'source'
},
minSize: 4,
maxSize: 32,
}
},
alphaValue: 1
}
};
return descriptor;
}
createDefaultLayer(options) {
if (this._descriptor.requestType === RENDER_AS.HEATMAP) {
return new HeatmapLayer({
layerDescriptor: this._createDefaultLayerDescriptor(options),
source: this
});
}
const layerDescriptor = this._createDefaultLayerDescriptor(options);
const style = new VectorStyle(layerDescriptor.style);
return new VectorLayer({
layerDescriptor: layerDescriptor,
source: this,
style: style
});
}
async getDisplayName() {
const indexPattern = await this._getIndexPattern();
return indexPattern.title;
}
canFormatFeatureProperties() {
return true;
}
async filterAndFormatProperties(properties) {
properties = await super.filterAndFormatProperties(properties);
const allProps = {};
for (const key in properties) {
if (key !== 'geohash_meta') {
allProps[key] = properties[key];
}
}
return allProps;
}
}

View file

@ -0,0 +1,7 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
export { ESGeohashGridSource } from './es_geohashgrid_source';

View file

@ -0,0 +1,11 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
export const RENDER_AS = {
HEATMAP: 'heatmap',
POINT: 'point',
GRID: 'grid',
};

View file

@ -0,0 +1,73 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { Fragment, Component } from 'react';
import { RENDER_AS } from './render_as';
import { MetricsEditor } from '../../../components/metrics_editor';
import { indexPatternService } from '../../../../kibana_services';
export class UpdateSourceEditor extends Component {
state = {
fields: null,
}
componentDidMount() {
this._isMounted = true;
this.loadFields();
}
componentWillUnmount() {
this._isMounted = false;
}
async loadFields() {
let indexPattern;
try {
indexPattern = await indexPatternService.get(this.props.indexPatternId);
} catch (err) {
if (this._isMounted) {
this.setState({
loadError: `Unable to find Index pattern ${this.props.indexPatternId}`
});
}
return;
}
if (!this._isMounted) {
return;
}
this.setState({ fields: indexPattern.fields });
}
onMetricsChange = (metrics) => {
this.props.onChange({ propName: 'metrics', value: metrics });
}
renderMetricsEditor() {
if (this.props.renderAs === RENDER_AS.HEATMAP) {
return null;
}
return (
<MetricsEditor
fields={this.state.fields}
metrics={this.props.metrics}
onChange={this.onMetricsChange}
/>
);
}
render() {
return (
<Fragment>
{this.renderMetricsEditor()}
</Fragment>
);
}
}

View file

@ -0,0 +1,241 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import _ from 'lodash';
import React, { Fragment } from 'react';
import { ASource } from './source';
import { Schemas } from 'ui/vis/editors/default/schemas';
import {
fetchSearchSourceAndRecordWithInspector,
inspectorAdapters,
indexPatternService,
SearchSource,
} from '../../../kibana_services';
import { AggConfigs } from 'ui/vis/agg_configs';
import { timefilter } from 'ui/timefilter/timefilter';
const TERMS_AGG_NAME = 'join';
const aggSchemas = new Schemas([
{
group: 'metrics',
name: 'metric',
title: 'Value',
min: 1,
max: Infinity,
aggFilter: ['avg', 'count', 'max', 'min', 'sum'],
defaults: [
{ schema: 'metric', type: 'count' }
]
},
{
group: 'buckets',
name: 'segment',
title: 'Terms',
aggFilter: 'terms',
min: 1,
max: 1
}
]);
export function extractPropertiesMap(resp, propertyNames, countPropertyName) {
const propertiesMap = new Map();
_.get(resp, ['aggregations', TERMS_AGG_NAME, 'buckets'], []).forEach(termBucket => {
const properties = {};
if (countPropertyName) {
properties[countPropertyName] = termBucket.doc_count;
}
propertyNames.forEach(propertyName => {
if (_.has(termBucket, [propertyName, 'value'])) {
properties[propertyName] = _.get(termBucket, [propertyName, 'value']);
}
});
propertiesMap.set(termBucket.key, properties);
});
return propertiesMap;
}
export class ESJoinSource extends ASource {
static type = 'ES_JOIN_SOURCE';
static renderEditor({}) {
return `<div>editor details</div>`;
}
renderDetails() {
return (<Fragment>table source details</Fragment>);
}
hasCompleteConfig() {
if (_.has(this._descriptor, 'indexPatternId') && _.has(this._descriptor, 'term')) {
return true;
}
return false;
}
destroy() {
inspectorAdapters.requests.resetRequest(this._descriptor.id);
}
async getPropertiesMap(searchFilters, leftSourceName, leftFieldName) {
if (!this.hasCompleteConfig()) {
return [];
}
const indexPattern = await this._getIndexPattern();
const timeAware = await this.isTimeAware();
const configStates = this._makeAggConfigs();
const aggConfigs = new AggConfigs(indexPattern, configStates, aggSchemas.all);
let resp;
try {
const searchSource = new SearchSource();
searchSource.setField('index', indexPattern);
searchSource.setField('size', 0);
searchSource.setField('filter', () => {
const filters = [];
if (timeAware) {
filters.push(timefilter.createFilter(indexPattern, searchFilters.timeFilters));
}
return filters;
});
const dsl = aggConfigs.toDsl();
searchSource.setField('aggs', dsl);
resp = await fetchSearchSourceAndRecordWithInspector({
searchSource,
requestName: `${this._descriptor.indexPatternTitle}.${this._descriptor.term}`,
requestId: this._descriptor.id,
requestDesc: this.getJoinDescription(leftSourceName, leftFieldName),
});
} catch (error) {
throw new Error(`Elasticsearch search request failed, error: ${error.message}`);
}
const metricPropertyNames = configStates
.filter(configState => {
return configState.schema === 'metric' && configState.type !== 'count';
})
.map(configState => {
return configState.id;
});
const countConfigState = configStates.find(configState => {
return configState.type === 'count';
});
const countPropertyName = _.get(countConfigState, 'id');
return {
rawData: resp,
propertiesMap: extractPropertiesMap(resp, metricPropertyNames, countPropertyName),
};
}
async _getIndexPattern() {
let indexPattern;
try {
indexPattern = await indexPatternService.get(this._descriptor.indexPatternId);
} catch (error) {
throw new Error(`Unable to find Index pattern ${this._descriptor.indexPatternId}`);
}
return indexPattern;
}
async isTimeAware() {
const indexPattern = await this._getIndexPattern();
const timeField = indexPattern.timeFieldName;
return !!timeField;
}
isFilterByMapBounds() {
//todo
return false;
}
getJoinDescription(leftSourceName, leftFieldName) {
const metrics = this._getValidMetrics().map(metric => {
return metric.type !== 'count' ? `${metric.type}(${metric.field})` : 'count(*)';
});
const joinStatement = [];
joinStatement.push(`SELECT ${metrics.join(',')}`);
joinStatement.push(`FROM ${leftSourceName} left`);
joinStatement.push(`JOIN ${this._descriptor.indexPatternTitle} right`);
joinStatement.push(`ON left.${leftFieldName} right.${this._descriptor.term}`);
joinStatement.push(`GROUP BY right.${this._descriptor.term}`);
return `Elasticsearch terms aggregation request for join: "${joinStatement.join(' ')}"`;
}
_getValidMetrics() {
const metrics = _.get(this._descriptor, 'metrics', []).filter(({ type, field }) => {
if (type === 'count') {
return true;
}
if (field) {
return true;
}
return false;
});
if (metrics.length === 0) {
metrics.push({ type: 'count' });
}
return metrics;
}
getMetricFields() {
return this._getValidMetrics().map(metric => {
const metricKey = metric.type !== 'count' ? `${metric.type}_of_${metric.field}` : metric.type;
const metricLabel = metric.type !== 'count' ? `${metric.type}(${metric.field})` : 'count(*)';
return {
...metric,
propertyKey: `__kbnjoin__${metricKey}_groupby_${this._descriptor.indexPatternTitle}.${this._descriptor.term}`,
propertyLabel: `${metricLabel} group by ${this._descriptor.indexPatternTitle}.${this._descriptor.term}`,
};
});
}
_makeAggConfigs() {
const metricAggConfigs = this.getMetricFields().map(metric => {
const metricAggConfig = {
id: metric.propertyKey,
enabled: true,
type: metric.type,
schema: 'metric',
params: {}
};
if (metric.type !== 'count') {
metricAggConfig.params = { field: metric.field };
}
return metricAggConfig;
});
return [
...metricAggConfigs,
{
id: TERMS_AGG_NAME,
enabled: true,
type: 'terms',
schema: 'segment',
params: {
field: this._descriptor.term,
size: 10000
}
}
];
}
async getDisplayName() {
return `es_table ${this._descriptor.indexPatternId}`;
}
}

Some files were not shown because too many files have changed in this diff Show more