mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 17:28:26 -04:00
[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:
parent
80246a71cc
commit
ffc8bae820
157 changed files with 17156 additions and 18 deletions
|
@ -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/**
|
||||
|
|
10
.eslintrc.js
10
.eslintrc.js
|
@ -299,6 +299,16 @@ module.exports = {
|
|||
},
|
||||
},
|
||||
|
||||
/**
|
||||
* GIS overrides
|
||||
*/
|
||||
{
|
||||
files: ['x-pack/plugins/gis/**/*'],
|
||||
rules: {
|
||||
'react/prefer-stateless-function': [0, { ignorePureComponents: false }],
|
||||
},
|
||||
},
|
||||
|
||||
/**
|
||||
* Graph overrides
|
||||
*/
|
||||
|
|
|
@ -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'],
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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 |
|
@ -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),
|
||||
};
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -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"
|
||||
},
|
||||
|
|
30
x-pack/plugins/gis/check_license.js
Normal file
30
x-pack/plugins/gis/check_license.js
Normal 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,
|
||||
};
|
||||
}
|
11
x-pack/plugins/gis/common/constants.js
Normal file
11
x-pack/plugins/gis/common/constants.js
Normal 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;
|
124
x-pack/plugins/gis/common/ems_v2.js
Normal file
124
x-pack/plugins/gis/common/ems_v2.js
Normal 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}`;
|
||||
}
|
||||
}
|
72
x-pack/plugins/gis/index.js
Normal file
72
x-pack/plugins/gis/index.js
Normal 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');
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
28
x-pack/plugins/gis/mappings.json
Normal file
28
x-pack/plugins/gis/mappings.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
255
x-pack/plugins/gis/public/_main.scss
Normal file
255
x-pack/plugins/gis/public/_main.scss
Normal 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;
|
||||
}
|
||||
}
|
379
x-pack/plugins/gis/public/actions/store_actions.js
Normal file
379
x-pack/plugins/gis/public/actions/store_actions.js
Normal 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));
|
||||
}
|
193
x-pack/plugins/gis/public/actions/store_actions.test.js
Normal file
193
x-pack/plugins/gis/public/actions/store_actions.test.js
Normal 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',
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
});
|
||||
});
|
13
x-pack/plugins/gis/public/actions/ui_actions.js
Normal file
13
x-pack/plugins/gis/public/actions/ui_actions.js
Normal 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
|
||||
};
|
||||
};
|
|
@ -0,0 +1,5 @@
|
|||
<map-listing
|
||||
find="find"
|
||||
delete="delete"
|
||||
listing-limit="listingLimit"
|
||||
/>
|
29
x-pack/plugins/gis/public/angular/map.html
Normal file
29
x-pack/plugins/gis/public/angular/map.html
Normal 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>
|
218
x-pack/plugins/gis/public/angular/map_controller.js
vendored
Normal file
218
x-pack/plugins/gis/public/angular/map_controller.js
vendored
Normal 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');
|
||||
}
|
||||
});
|
26
x-pack/plugins/gis/public/angular/services/gis_map_saved_object_loader.js
vendored
Normal file
26
x-pack/plugins/gis/public/angular/services/gis_map_saved_object_loader.js
vendored
Normal 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);
|
||||
});
|
111
x-pack/plugins/gis/public/angular/services/saved_gis_map.js
vendored
Normal file
111
x-pack/plugins/gis/public/angular/services/saved_gis_map.js
vendored
Normal 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;
|
||||
});
|
1
x-pack/plugins/gis/public/components/_index.scss
Normal file
1
x-pack/plugins/gis/public/components/_index.scss
Normal file
|
@ -0,0 +1 @@
|
|||
@import './layer_panel/join_editor/resources/join';
|
21
x-pack/plugins/gis/public/components/gis_map/index.js
Normal file
21
x-pack/plugins/gis/public/components/gis_map/index.js
Normal 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 };
|
51
x-pack/plugins/gis/public/components/gis_map/view.js
Normal file
51
x-pack/plugins/gis/public/components/gis_map/view.js
Normal 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>
|
||||
);
|
||||
}
|
53
x-pack/plugins/gis/public/components/layer_addpanel/index.js
Normal file
53
x-pack/plugins/gis/public/components/layer_addpanel/index.js
Normal 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 };
|
280
x-pack/plugins/gis/public/components/layer_addpanel/view.js
Normal file
280
x-pack/plugins/gis/public/components/layer_addpanel/view.js
Normal 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;
|
||||
}
|
||||
}
|
38
x-pack/plugins/gis/public/components/layer_control/index.js
Normal file
38
x-pack/plugins/gis/public/components/layer_control/index.js
Normal 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 };
|
79
x-pack/plugins/gis/public/components/layer_control/view.js
Normal file
79
x-pack/plugins/gis/public/components/layer_control/view.js
Normal 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>
|
||||
);
|
||||
}
|
|
@ -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 };
|
|
@ -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 & close
|
||||
</EuiButton>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
);
|
||||
};
|
33
x-pack/plugins/gis/public/components/layer_panel/index.js
Normal file
33
x-pack/plugins/gis/public/components/layer_panel/index.js
Normal 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 };
|
|
@ -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 };
|
|
@ -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;
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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,
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
};
|
|
@ -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,
|
||||
};
|
|
@ -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' }
|
||||
]
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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 };
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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 };
|
|
@ -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;
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
90
x-pack/plugins/gis/public/components/layer_panel/view.js
Normal file
90
x-pack/plugins/gis/public/components/layer_panel/view.js
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
24
x-pack/plugins/gis/public/components/layer_toc/index.js
Normal file
24
x-pack/plugins/gis/public/components/layer_toc/index.js
Normal 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 };
|
|
@ -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 };
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
60
x-pack/plugins/gis/public/components/layer_toc/view.js
Normal file
60
x-pack/plugins/gis/public/components/layer_toc/view.js
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
21
x-pack/plugins/gis/public/components/map/feature_tooltip.js
Normal file
21
x-pack/plugins/gis/public/components/map/feature_tooltip.js
Normal 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>
|
||||
);
|
||||
});
|
||||
}
|
||||
|
36
x-pack/plugins/gis/public/components/map/mb/index.js
Normal file
36
x-pack/plugins/gis/public/components/map/mb/index.js
Normal 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 };
|
89
x-pack/plugins/gis/public/components/map/mb/utils.js
Normal file
89
x-pack/plugins/gis/public/components/map/mb/utils.js
Normal 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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
211
x-pack/plugins/gis/public/components/map/mb/view.js
Normal file
211
x-pack/plugins/gis/public/components/map/mb/view.js
Normal 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"/>
|
||||
);
|
||||
}
|
||||
}
|
30
x-pack/plugins/gis/public/components/set_view/index.js
Normal file
30
x-pack/plugins/gis/public/components/set_view/index.js
Normal 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 };
|
132
x-pack/plugins/gis/public/components/set_view/set_view.js
Normal file
132
x-pack/plugins/gis/public/components/set_view/set_view.js
Normal 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,
|
||||
};
|
34
x-pack/plugins/gis/public/components/toasts/index.js
Normal file
34
x-pack/plugins/gis/public/components/toasts/index.js
Normal 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 };
|
24
x-pack/plugins/gis/public/components/toasts/view.js
Normal file
24
x-pack/plugins/gis/public/components/toasts/view.js
Normal 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
|
||||
}
|
40
x-pack/plugins/gis/public/components/top_nav/options_menu.js
Normal file
40
x-pack/plugins/gis/public/components/top_nav/options_menu.js
Normal 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,
|
||||
};
|
|
@ -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 };
|
|
@ -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);
|
||||
}
|
184
x-pack/plugins/gis/public/elasticsearch_geo_utils.js
Normal file
184
x-pack/plugins/gis/public/elasticsearch_geo_utils.js
Normal 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}`);
|
||||
}
|
||||
}
|
204
x-pack/plugins/gis/public/elasticsearch_geo_utils.test.js
Normal file
204
x-pack/plugins/gis/public/elasticsearch_geo_utils.test.js
Normal 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'
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
6
x-pack/plugins/gis/public/icon.svg
Normal file
6
x-pack/plugins/gis/public/icon.svg
Normal 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 |
94
x-pack/plugins/gis/public/index.js
Normal file
94
x-pack/plugins/gis/public/index.js
Normal 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: '/'
|
||||
});
|
15
x-pack/plugins/gis/public/index.scss
Normal file
15
x-pack/plugins/gis/public/index.scss
Normal 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';
|
24
x-pack/plugins/gis/public/inspector/adapters/map_adapter.js
Normal file
24
x-pack/plugins/gis/public/inspector/adapters/map_adapter.js
Normal 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 };
|
120
x-pack/plugins/gis/public/inspector/views/map_details.js
Normal file
120
x-pack/plugins/gis/public/inspector/views/map_details.js
Normal 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 };
|
66
x-pack/plugins/gis/public/inspector/views/map_view.js
Normal file
66
x-pack/plugins/gis/public/inspector/views/map_view.js
Normal 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 };
|
11
x-pack/plugins/gis/public/inspector/views/register_views.js
Normal file
11
x-pack/plugins/gis/public/inspector/views/register_views.js
Normal 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);
|
50
x-pack/plugins/gis/public/kibana_services.js
Normal file
50
x-pack/plugins/gis/public/kibana_services.js
Normal 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');
|
||||
});
|
24
x-pack/plugins/gis/public/register_feature.js
Normal file
24
x-pack/plugins/gis/public/register_feature.js
Normal 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
|
||||
};
|
||||
});
|
||||
|
BIN
x-pack/plugins/gis/public/resources/ky_outline.png
Normal file
BIN
x-pack/plugins/gis/public/resources/ky_outline.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 23 KiB |
158
x-pack/plugins/gis/public/selectors/map_selectors.js
Normal file
158
x-pack/plugins/gis/public/selectors/map_selectors.js
Normal 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()));
|
51
x-pack/plugins/gis/public/selectors/map_selectors.test.js
Normal file
51
x-pack/plugins/gis/public/selectors/map_selectors.test.js
Normal 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' });
|
||||
});
|
||||
});
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
388
x-pack/plugins/gis/public/shared/components/map_listing.js
Normal file
388
x-pack/plugins/gis/public/shared/components/map_listing.js
Normal 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,
|
||||
};
|
50
x-pack/plugins/gis/public/shared/components/metric_select.js
Normal file
50
x-pack/plugins/gis/public/shared/components/metric_select.js
Normal 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,
|
||||
};
|
149
x-pack/plugins/gis/public/shared/components/metrics_editor.js
Normal file
149
x-pack/plugins/gis/public/shared/components/metrics_editor.js
Normal 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' }
|
||||
]
|
||||
};
|
|
@ -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; }
|
||||
};
|
|
@ -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,
|
||||
};
|
|
@ -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>
|
||||
);
|
36
x-pack/plugins/gis/public/shared/icons/color_gradient.js
Normal file
36
x-pack/plugins/gis/public/shared/icons/color_gradient.js
Normal 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%)`;
|
||||
}
|
10
x-pack/plugins/gis/public/shared/layers/_layers.scss
Normal file
10
x-pack/plugins/gis/public/shared/layers/_layers.scss
Normal 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;
|
||||
}
|
||||
}
|
143
x-pack/plugins/gis/public/shared/layers/heatmap_layer.js
Normal file
143
x-pack/plugins/gis/public/shared/layers/heatmap_layer.js
Normal 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'}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
200
x-pack/plugins/gis/public/shared/layers/layer.js
Normal file
200
x-pack/plugins/gis/public/shared/layers/layer.js
Normal 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');
|
||||
}
|
||||
|
||||
}
|
||||
|
113
x-pack/plugins/gis/public/shared/layers/layer.test.js
Normal file
113
x-pack/plugins/gis/public/shared/layers/layer.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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;
|
||||
}
|
||||
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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';
|
|
@ -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',
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue