[Maps] fix Go To - lat/long values outside expected range cause blank Maps app (#141873)

* [Maps] fix Go To - lat/long values outside expected range cause blank Maps app

* UTM form

* wire UTM form

* add unit tests

* fix test names

* fix expects

* fix functional tests

* review feedback
This commit is contained in:
Nathan Reese 2022-09-27 17:53:37 -06:00 committed by GitHub
parent e4080d5b64
commit cc9f1c6409
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 804 additions and 719 deletions

View file

@ -0,0 +1,149 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { ChangeEvent, Component } from 'react';
import {
EuiForm,
EuiFormRow,
EuiButton,
EuiFieldNumber,
EuiTextAlign,
EuiSpacer,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
import { MapCenter, MapSettings } from '../../../../common/descriptor_types';
import { withinRange } from './utils';
interface Props {
settings: MapSettings;
zoom: number;
center: MapCenter;
onSubmit: (lat: number, lon: number, zoom: number) => void;
}
interface State {
lat: number | string;
lon: number | string;
zoom: number | string;
}
export class DecimalDegreesForm extends Component<Props, State> {
state: State = {
lat: this.props.center.lat,
lon: this.props.center.lon,
zoom: this.props.zoom,
};
_onLatChange = (evt: ChangeEvent<HTMLInputElement>) => {
const sanitizedValue = parseFloat(evt.target.value);
this.setState({
lat: isNaN(sanitizedValue) ? '' : sanitizedValue,
});
};
_onLonChange = (evt: ChangeEvent<HTMLInputElement>) => {
const sanitizedValue = parseFloat(evt.target.value);
this.setState({
lon: isNaN(sanitizedValue) ? '' : sanitizedValue,
});
};
_onZoomChange = (evt: ChangeEvent<HTMLInputElement>) => {
const sanitizedValue = parseFloat(evt.target.value);
this.setState({
zoom: isNaN(sanitizedValue) ? '' : sanitizedValue,
});
};
_onSubmit = () => {
const { lat, lon, zoom } = this.state;
this.props.onSubmit(lat as number, lon as number, zoom as number);
};
render() {
const { isInvalid: isLatInvalid, error: latError } = withinRange(this.state.lat, -90, 90);
const { isInvalid: isLonInvalid, error: lonError } = withinRange(this.state.lon, -180, 180);
const { isInvalid: isZoomInvalid, error: zoomError } = withinRange(
this.state.zoom,
this.props.settings.minZoom,
this.props.settings.maxZoom
);
return (
<EuiForm>
<EuiFormRow
label={i18n.translate('xpack.maps.setViewControl.latitudeLabel', {
defaultMessage: 'Latitude',
})}
isInvalid={isLatInvalid}
error={latError}
display="columnCompressed"
>
<EuiFieldNumber
compressed
value={this.state.lat}
onChange={this._onLatChange}
isInvalid={isLatInvalid}
data-test-subj="latitudeInput"
/>
</EuiFormRow>
<EuiFormRow
label={i18n.translate('xpack.maps.setViewControl.longitudeLabel', {
defaultMessage: 'Longitude',
})}
isInvalid={isLonInvalid}
error={lonError}
display="columnCompressed"
>
<EuiFieldNumber
compressed
value={this.state.lon}
onChange={this._onLonChange}
isInvalid={isLonInvalid}
data-test-subj="longitudeInput"
/>
</EuiFormRow>
<EuiFormRow
label={i18n.translate('xpack.maps.setViewControl.zoomLabel', {
defaultMessage: 'Zoom',
})}
isInvalid={isZoomInvalid}
error={zoomError}
display="columnCompressed"
>
<EuiFieldNumber
compressed
value={this.state.zoom}
onChange={this._onZoomChange}
isInvalid={isZoomInvalid}
data-test-subj="zoomInput"
/>
</EuiFormRow>
<EuiSpacer size="s" />
<EuiTextAlign textAlign="right">
<EuiButton
size="s"
fill
disabled={isLatInvalid || isLonInvalid || isZoomInvalid}
onClick={this._onSubmit}
data-test-subj="submitViewButton"
>
<FormattedMessage
id="xpack.maps.setViewControl.submitButtonLabel"
defaultMessage="Go"
/>
</EuiButton>
</EuiTextAlign>
</EuiForm>
);
}
}

View file

@ -0,0 +1,149 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import _ from 'lodash';
import React, { ChangeEvent, Component } from 'react';
import {
EuiForm,
EuiFormRow,
EuiButton,
EuiFieldNumber,
EuiFieldText,
EuiTextAlign,
EuiSpacer,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
import { MapCenter, MapSettings } from '../../../../common/descriptor_types';
import { ddToMGRS, mgrsToDD, withinRange } from './utils';
interface Props {
settings: MapSettings;
zoom: number;
center: MapCenter;
onSubmit: (lat: number, lon: number, zoom: number) => void;
}
interface State {
mgrs: string;
zoom: number | string;
}
export class MgrsForm extends Component<Props, State> {
state: State = {
mgrs: ddToMGRS(this.props.center.lat, this.props.center.lon),
zoom: this.props.zoom,
};
_toPoint() {
return this.state.mgrs === '' ? undefined : mgrsToDD(this.state.mgrs);
}
_isMgrsInvalid() {
const point = this._toPoint();
return (
point === undefined ||
!point.north ||
_.isNaN(point.north) ||
!point.south ||
_.isNaN(point.south) ||
!point.east ||
_.isNaN(point.east) ||
!point.west ||
_.isNaN(point.west)
);
}
_onMGRSChange = (evt: ChangeEvent<HTMLInputElement>) => {
this.setState({
mgrs: _.isNull(evt.target.value) ? '' : evt.target.value,
});
};
_onZoomChange = (evt: ChangeEvent<HTMLInputElement>) => {
const sanitizedValue = parseFloat(evt.target.value);
this.setState({
zoom: isNaN(sanitizedValue) ? '' : sanitizedValue,
});
};
_onSubmit = () => {
const point = this._toPoint();
if (point) {
this.props.onSubmit(point.north, point.east, this.state.zoom as number);
}
};
render() {
const isMgrsInvalid = this._isMgrsInvalid();
const mgrsError = isMgrsInvalid
? i18n.translate('xpack.maps.setViewControl.mgrsInvalid', {
defaultMessage: 'MGRS is invalid',
})
: null;
const { isInvalid: isZoomInvalid, error: zoomError } = withinRange(
this.state.zoom,
this.props.settings.minZoom,
this.props.settings.maxZoom
);
return (
<EuiForm>
<EuiFormRow
label={i18n.translate('xpack.maps.setViewControl.mgrsLabel', {
defaultMessage: 'MGRS',
})}
isInvalid={isMgrsInvalid}
error={mgrsError}
display="columnCompressed"
>
<EuiFieldText
compressed
value={this.state.mgrs}
onChange={this._onMGRSChange}
isInvalid={isMgrsInvalid}
data-test-subj="mgrsInput"
/>
</EuiFormRow>
<EuiFormRow
label={i18n.translate('xpack.maps.setViewControl.zoomLabel', {
defaultMessage: 'Zoom',
})}
isInvalid={isZoomInvalid}
error={zoomError}
display="columnCompressed"
>
<EuiFieldNumber
compressed
value={this.state.zoom}
onChange={this._onZoomChange}
isInvalid={isZoomInvalid}
data-test-subj="zoomInput"
/>
</EuiFormRow>
<EuiSpacer size="s" />
<EuiTextAlign textAlign="right">
<EuiButton
size="s"
fill
disabled={isMgrsInvalid || isZoomInvalid}
onClick={this._onSubmit}
data-test-subj="submitViewButton"
>
<FormattedMessage
id="xpack.maps.setViewControl.submitButtonLabel"
defaultMessage="Go"
/>
</EuiButton>
</EuiTextAlign>
</EuiForm>
);
}
}

View file

@ -0,0 +1,6 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/

View file

@ -5,50 +5,11 @@
* 2.0.
*/
import React, { ChangeEvent, Component, Fragment } from 'react';
import {
EuiForm,
EuiFormRow,
EuiButton,
EuiFieldNumber,
EuiFieldText,
EuiButtonIcon,
EuiPopover,
EuiTextAlign,
EuiSpacer,
EuiPanel,
} from '@elastic/eui';
import { EuiButtonEmpty } from '@elastic/eui';
import { EuiRadioGroup } from '@elastic/eui';
import React, { Component } from 'react';
import { EuiButtonIcon, EuiPopover, EuiPanel } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
import * as usng from 'usng.js';
import { isNaN, isNull } from 'lodash';
import { MapCenter, MapSettings } from '../../../../common/descriptor_types';
export const COORDINATE_SYSTEM_DEGREES_DECIMAL = 'dd';
export const COORDINATE_SYSTEM_MGRS = 'mgrs';
export const COORDINATE_SYSTEM_UTM = 'utm';
export const DEFAULT_SET_VIEW_COORDINATE_SYSTEM = COORDINATE_SYSTEM_DEGREES_DECIMAL;
// @ts-ignore
const converter = new usng.Converter();
const COORDINATE_SYSTEMS = [
{
id: COORDINATE_SYSTEM_DEGREES_DECIMAL,
label: 'Degrees Decimal',
},
{
id: COORDINATE_SYSTEM_UTM,
label: 'UTM',
},
{
id: COORDINATE_SYSTEM_MGRS,
label: 'MGRS',
},
];
import { SetViewForm } from './set_view_form';
export interface Props {
settings: MapSettings;
@ -59,73 +20,17 @@ export interface Props {
interface State {
isPopoverOpen: boolean;
lat: number | string;
lon: number | string;
zoom: number | string;
coord: string;
mgrs: string;
utm: {
northing: string;
easting: string;
zoneNumber: string;
zoneLetter: string | undefined;
zone: string;
};
isCoordPopoverOpen: boolean;
prevView: string | undefined;
}
export class SetViewControl extends Component<Props, State> {
state: State = {
isPopoverOpen: false,
lat: 0,
lon: 0,
zoom: 0,
coord: DEFAULT_SET_VIEW_COORDINATE_SYSTEM,
mgrs: '',
utm: {
northing: '',
easting: '',
zoneNumber: '',
zoneLetter: '',
zone: '',
},
isCoordPopoverOpen: false,
prevView: '',
};
static getDerivedStateFromProps(nextProps: Props, prevState: State) {
const nextView = getViewString(nextProps.center.lat, nextProps.center.lon, nextProps.zoom);
const utm = convertLatLonToUTM(nextProps.center.lat, nextProps.center.lon);
const mgrs = convertLatLonToMGRS(nextProps.center.lat, nextProps.center.lon);
if (nextView !== prevState.prevView) {
return {
lat: nextProps.center.lat,
lon: nextProps.center.lon,
zoom: nextProps.zoom,
utm,
mgrs,
prevView: nextView,
};
}
return null;
}
_togglePopover = () => {
if (this.state.isPopoverOpen) {
this._closePopover();
return;
}
this.setState({
lat: this.props.center.lat,
lon: this.props.center.lon,
zoom: this.props.zoom,
isPopoverOpen: true,
});
this.setState((prevState) => ({
isPopoverOpen: !prevState.isPopoverOpen,
}));
};
_closePopover = () => {
@ -134,567 +39,11 @@ export class SetViewControl extends Component<Props, State> {
});
};
_onCoordinateSystemChange = (coordId: string) => {
this.setState({
coord: coordId,
});
};
_onLatChange = (evt: ChangeEvent<HTMLInputElement>) => {
this._onChange('lat', evt);
};
_onLonChange = (evt: ChangeEvent<HTMLInputElement>) => {
this._onChange('lon', evt);
};
_onZoomChange = (evt: ChangeEvent<HTMLInputElement>) => {
const sanitizedValue = parseFloat(evt.target.value);
this.setState({
['zoom']: isNaN(sanitizedValue) ? '' : sanitizedValue,
});
};
_onUTMZoneChange = (evt: ChangeEvent<HTMLInputElement>) => {
this._onUTMChange('zone', evt);
};
_onUTMEastingChange = (evt: ChangeEvent<HTMLInputElement>) => {
this._onUTMChange('easting', evt);
};
_onUTMNorthingChange = (evt: ChangeEvent<HTMLInputElement>) => {
this._onUTMChange('northing', evt);
};
_onMGRSChange = (evt: ChangeEvent<HTMLInputElement>) => {
this.setState(
{
['mgrs']: isNull(evt.target.value) ? '' : evt.target.value,
},
this._syncToMGRS
);
};
_onUTMChange = (name: 'easting' | 'northing' | 'zone', evt: ChangeEvent<HTMLInputElement>) => {
const value = evt.target.value;
const updateObj = { ...this.state.utm };
updateObj[name] = isNull(value) ? '' : value;
if (name === 'zone' && value.length > 0) {
const zoneLetter = value.substring(value.length - 1);
const zoneNumber = value.substring(0, value.length - 1);
updateObj.zoneLetter = isNaN(zoneLetter) ? zoneLetter : '';
updateObj.zoneNumber = isNaN(zoneNumber) ? '' : zoneNumber;
}
this.setState(
{
// @ts-ignore
['utm']: updateObj,
},
this._syncToUTM
);
};
_onChange = (name: 'lat' | 'lon', evt: ChangeEvent<HTMLInputElement>) => {
const sanitizedValue = parseFloat(evt.target.value);
this.setState(
// @ts-ignore
{
[name]: isNaN(sanitizedValue) ? '' : sanitizedValue,
},
this._syncToLatLon
);
};
/**
* Sync all coordinates to the lat/lon that is set
*/
_syncToLatLon = () => {
if (this.state.lat !== '' && this.state.lon !== '') {
const utm = convertLatLonToUTM(this.state.lat, this.state.lon);
const mgrs = convertLatLonToMGRS(this.state.lat, this.state.lon);
this.setState({ mgrs, utm });
} else {
this.setState({
mgrs: '',
utm: { northing: '', easting: '', zoneNumber: '', zoneLetter: '', zone: '' },
});
}
};
/**
* Sync the current lat/lon to MGRS that is set
*/
_syncToMGRS = () => {
if (this.state.mgrs !== '') {
let lon;
let lat;
try {
const { north, east } = convertMGRStoLL(this.state.mgrs);
lat = north;
lon = east;
} catch (err) {
return;
}
const utm = convertLatLonToUTM(lat, lon);
this.setState({
lat: isNaN(lat) ? '' : lat,
lon: isNaN(lon) ? '' : lon,
utm,
});
} else {
this.setState({
lat: '',
lon: '',
utm: { northing: '', easting: '', zoneNumber: '', zoneLetter: '', zone: '' },
});
}
};
/**
* Sync the current lat/lon to UTM that is set
*/
_syncToUTM = () => {
if (this.state.utm) {
let lat;
let lon;
try {
({ lat, lon } = converter.UTMtoLL(
this.state.utm.northing,
this.state.utm.easting,
this.state.utm.zoneNumber
));
} catch (err) {
return;
}
const mgrs = convertLatLonToMGRS(lat, lon);
this.setState({
lat: isNaN(lat) ? '' : lat,
lon: isNaN(lon) ? '' : lon,
mgrs,
});
} else {
this.setState({
lat: '',
lon: '',
mgrs: '',
});
}
};
_renderNumberFormRow = ({
value,
min,
max,
onChange,
label,
dataTestSubj,
}: {
value: string | number;
min: number;
max: number;
onChange: (evt: ChangeEvent<HTMLInputElement>) => void;
label: string;
dataTestSubj: string;
}) => {
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} display="columnCompressed">
<EuiFieldNumber
compressed
value={value}
onChange={onChange}
isInvalid={isInvalid}
data-test-subj={dataTestSubj}
/>
</EuiFormRow>
),
};
};
_renderMGRSFormRow = ({
value,
onChange,
label,
dataTestSubj,
}: {
value: string;
onChange: (evt: ChangeEvent<HTMLInputElement>) => void;
label: string;
dataTestSubj: string;
}) => {
let point;
try {
point = convertMGRStoLL(value);
} catch (err) {
point = undefined;
}
const isInvalid =
value === '' ||
point === undefined ||
!point.north ||
isNaN(point.north) ||
!point.south ||
isNaN(point.south) ||
!point.east ||
isNaN(point.east) ||
!point.west ||
isNaN(point.west);
const error = isInvalid
? i18n.translate('xpack.maps.setViewControl.mgrsInvalid', {
defaultMessage: 'MGRS is invalid',
})
: null;
return {
isInvalid,
component: (
<EuiFormRow label={label} isInvalid={isInvalid} error={error} display="columnCompressed">
<EuiFieldText
compressed
value={value}
onChange={onChange}
isInvalid={isInvalid}
data-test-subj={dataTestSubj}
/>
</EuiFormRow>
),
};
};
_renderUTMZoneRow = ({
value,
onChange,
label,
dataTestSubj,
}: {
value: string | number;
onChange: (evt: ChangeEvent<HTMLInputElement>) => void;
label: string;
dataTestSubj: string;
}) => {
let point;
try {
point = converter.UTMtoLL(
this.state.utm.northing,
this.state.utm.easting,
this.state.utm.zoneNumber
);
} catch {
point = undefined;
}
const isInvalid = value === '' || point === undefined;
const error = isInvalid
? i18n.translate('xpack.maps.setViewControl.utmInvalidZone', {
defaultMessage: 'UTM Zone is invalid',
})
: null;
return {
isInvalid,
component: (
<EuiFormRow label={label} isInvalid={isInvalid} error={error} display="columnCompressed">
<EuiFieldText
compressed
value={value}
onChange={onChange}
isInvalid={isInvalid}
data-test-subj={dataTestSubj}
/>
</EuiFormRow>
),
};
};
_renderUTMEastingRow = ({
value,
onChange,
label,
dataTestSubj,
}: {
value: string | number;
onChange: (evt: ChangeEvent<HTMLInputElement>) => void;
label: string;
dataTestSubj: string;
}) => {
let point;
try {
point = converter.UTMtoLL(this.state.utm.northing, value, this.state.utm.zoneNumber);
} catch {
point = undefined;
}
const isInvalid = value === '' || point === undefined;
const error = isInvalid
? i18n.translate('xpack.maps.setViewControl.utmInvalidEasting', {
defaultMessage: 'UTM Easting is invalid',
})
: null;
return {
isInvalid,
component: (
<EuiFormRow label={label} isInvalid={isInvalid} error={error} display="columnCompressed">
<EuiFieldNumber
compressed
value={value}
onChange={onChange}
isInvalid={isInvalid}
data-test-subj={dataTestSubj}
/>
</EuiFormRow>
),
};
};
_renderUTMNorthingRow = ({
value,
onChange,
label,
dataTestSubj,
}: {
value: string | number;
onChange: (evt: ChangeEvent<HTMLInputElement>) => void;
label: string;
dataTestSubj: string;
}) => {
let point;
try {
point = converter.UTMtoLL(value, this.state.utm.easting, this.state.utm.zoneNumber);
} catch {
point = undefined;
}
const isInvalid = value === '' || point === undefined;
const error = isInvalid
? i18n.translate('xpack.maps.setViewControl.utmInvalidNorthing', {
defaultMessage: 'UTM Northing is invalid',
})
: null;
return {
isInvalid,
component: (
<EuiFormRow label={label} isInvalid={isInvalid} error={error} display="columnCompressed">
<EuiFieldNumber
compressed
value={value}
onChange={onChange}
isInvalid={isInvalid}
data-test-subj={dataTestSubj}
/>
</EuiFormRow>
),
};
};
_onSubmit = () => {
const { lat, lon, zoom } = this.state;
_onSubmit = (lat: number, lon: number, zoom: number) => {
this._closePopover();
this.props.onSubmit({ lat: lat as number, lon: lon as number, zoom: zoom as number });
this.props.onSubmit({ lat, lon, zoom });
};
_renderSetViewForm() {
let isLatInvalid;
let latFormRow;
let isLonInvalid;
let lonFormRow;
let isMGRSInvalid;
let mgrsFormRow;
let isUtmZoneInvalid;
let utmZoneRow;
let isUtmEastingInvalid;
let utmEastingRow;
let isUtmNorthingInvalid;
let utmNorthingRow;
if (this.state.coord === COORDINATE_SYSTEM_DEGREES_DECIMAL) {
const latRenderObject = this._renderNumberFormRow({
value: this.state.lat,
min: -90,
max: 90,
onChange: this._onLatChange,
label: i18n.translate('xpack.maps.setViewControl.latitudeLabel', {
defaultMessage: 'Latitude',
}),
dataTestSubj: 'latitudeInput',
});
isLatInvalid = latRenderObject.isInvalid;
latFormRow = latRenderObject.component;
const lonRenderObject = this._renderNumberFormRow({
value: this.state.lon,
min: -180,
max: 180,
onChange: this._onLonChange,
label: i18n.translate('xpack.maps.setViewControl.longitudeLabel', {
defaultMessage: 'Longitude',
}),
dataTestSubj: 'longitudeInput',
});
isLonInvalid = lonRenderObject.isInvalid;
lonFormRow = lonRenderObject.component;
} else if (this.state.coord === COORDINATE_SYSTEM_MGRS) {
const mgrsRenderObject = this._renderMGRSFormRow({
value: this.state.mgrs,
onChange: this._onMGRSChange,
label: i18n.translate('xpack.maps.setViewControl.mgrsLabel', {
defaultMessage: 'MGRS',
}),
dataTestSubj: 'mgrsInput',
});
isMGRSInvalid = mgrsRenderObject.isInvalid;
mgrsFormRow = mgrsRenderObject.component;
} else if (this.state.coord === COORDINATE_SYSTEM_UTM) {
const utmZoneRenderObject = this._renderUTMZoneRow({
value: this.state.utm !== undefined ? this.state.utm.zone : '',
onChange: this._onUTMZoneChange,
label: i18n.translate('xpack.maps.setViewControl.utmZoneLabel', {
defaultMessage: 'UTM Zone',
}),
dataTestSubj: 'utmZoneInput',
});
isUtmZoneInvalid = utmZoneRenderObject.isInvalid;
utmZoneRow = utmZoneRenderObject.component;
const utmEastingRenderObject = this._renderUTMEastingRow({
value: this.state.utm !== undefined ? this.state.utm.easting : '',
onChange: this._onUTMEastingChange,
label: i18n.translate('xpack.maps.setViewControl.utmEastingLabel', {
defaultMessage: 'UTM Easting',
}),
dataTestSubj: 'utmEastingInput',
});
isUtmEastingInvalid = utmEastingRenderObject.isInvalid;
utmEastingRow = utmEastingRenderObject.component;
const utmNorthingRenderObject = this._renderUTMNorthingRow({
value: this.state.utm !== undefined ? this.state.utm.northing : '',
onChange: this._onUTMNorthingChange,
label: i18n.translate('xpack.maps.setViewControl.utmNorthingLabel', {
defaultMessage: 'UTM Northing',
}),
dataTestSubj: 'utmNorthingInput',
});
isUtmNorthingInvalid = utmNorthingRenderObject.isInvalid;
utmNorthingRow = utmNorthingRenderObject.component;
}
const { isInvalid: isZoomInvalid, component: zoomFormRow } = this._renderNumberFormRow({
value: this.state.zoom,
min: this.props.settings.minZoom,
max: this.props.settings.maxZoom,
onChange: this._onZoomChange,
label: i18n.translate('xpack.maps.setViewControl.zoomLabel', {
defaultMessage: 'Zoom',
}),
dataTestSubj: 'zoomInput',
});
let coordinateInputs;
if (this.state.coord === 'dd') {
coordinateInputs = (
<Fragment>
{latFormRow}
{lonFormRow}
{zoomFormRow}
</Fragment>
);
} else if (this.state.coord === 'dms') {
coordinateInputs = (
<Fragment>
{latFormRow}
{lonFormRow}
{zoomFormRow}
</Fragment>
);
} else if (this.state.coord === 'utm') {
coordinateInputs = (
<Fragment>
{utmZoneRow}
{utmEastingRow}
{utmNorthingRow}
{zoomFormRow}
</Fragment>
);
} else if (this.state.coord === 'mgrs') {
coordinateInputs = (
<Fragment>
{mgrsFormRow}
{zoomFormRow}
</Fragment>
);
}
return (
<EuiForm data-test-subj="mapSetViewForm" style={{ width: 240 }}>
<EuiPopover
panelPaddingSize="s"
isOpen={this.state.isCoordPopoverOpen}
closePopover={() => {
this.setState({ isCoordPopoverOpen: false });
}}
button={
<EuiButtonEmpty
iconType="controlsHorizontal"
size="xs"
onClick={() => {
this.setState({ isCoordPopoverOpen: !this.state.isCoordPopoverOpen });
}}
>
Coordinate System
</EuiButtonEmpty>
}
>
<EuiRadioGroup
options={COORDINATE_SYSTEMS}
idSelected={this.state.coord}
onChange={this._onCoordinateSystemChange}
/>
</EuiPopover>
{coordinateInputs}
<EuiSpacer size="s" />
<EuiTextAlign textAlign="right">
<EuiButton
size="s"
fill
disabled={
isLatInvalid ||
isLonInvalid ||
isZoomInvalid ||
isMGRSInvalid ||
isUtmZoneInvalid ||
isUtmEastingInvalid ||
isUtmNorthingInvalid
}
onClick={this._onSubmit}
data-test-subj="submitViewButton"
>
<FormattedMessage
id="xpack.maps.setViewControl.submitButtonLabel"
defaultMessage="Go"
/>
</EuiButton>
</EuiTextAlign>
</EuiForm>
);
}
render() {
return (
<EuiPopover
@ -720,67 +69,13 @@ export class SetViewControl extends Component<Props, State> {
isOpen={this.state.isPopoverOpen}
closePopover={this._closePopover}
>
{this._renderSetViewForm()}
<SetViewForm
settings={this.props.settings}
zoom={this.props.zoom}
center={this.props.center}
onSubmit={this._onSubmit}
/>
</EuiPopover>
);
}
}
function convertLatLonToUTM(lat: string | number, lon: string | number) {
const utmCoord = converter.LLtoUTM(lat, lon);
let eastwest = 'E';
if (utmCoord.easting < 0) {
eastwest = 'W';
}
let norwest = 'N';
if (utmCoord.northing < 0) {
norwest = 'S';
}
if (utmCoord !== 'undefined') {
utmCoord.zoneLetter = isNaN(lat) ? '' : converter.UTMLetterDesignator(lat);
utmCoord.zone = `${utmCoord.zoneNumber}${utmCoord.zoneLetter}`;
utmCoord.easting = Math.round(utmCoord.easting);
utmCoord.northing = Math.round(utmCoord.northing);
utmCoord.str = `${utmCoord.zoneNumber}${utmCoord.zoneLetter} ${utmCoord.easting}${eastwest} ${utmCoord.northing}${norwest}`;
}
return utmCoord;
}
function convertLatLonToMGRS(lat: string | number, lon: string | number) {
const mgrsCoord = converter.LLtoMGRS(lat, lon, 5);
return mgrsCoord;
}
function getViewString(lat: number, lon: number, zoom: number) {
return `${lat},${lon},${zoom}`;
}
function convertMGRStoUSNG(mgrs: string) {
let squareIdEastSpace = 0;
for (let i = mgrs.length - 1; i > -1; i--) {
// check if we have hit letters yet
if (isNaN(mgrs.substr(i, 1))) {
squareIdEastSpace = i + 1;
break;
}
}
const gridZoneSquareIdSpace = squareIdEastSpace ? squareIdEastSpace - 2 : -1;
const numPartLength = mgrs.substr(squareIdEastSpace).length / 2;
// add the number split space
const eastNorthSpace = squareIdEastSpace ? squareIdEastSpace + numPartLength : -1;
const stringArray = mgrs.split('');
stringArray.splice(eastNorthSpace, 0, ' ');
stringArray.splice(squareIdEastSpace, 0, ' ');
stringArray.splice(gridZoneSquareIdSpace, 0, ' ');
const rejoinedArray = stringArray.join('');
return rejoinedArray;
}
function convertMGRStoLL(mgrs: string) {
return mgrs ? converter.USNGtoLL(convertMGRStoUSNG(mgrs)) : '';
}

View file

@ -0,0 +1,134 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { Component } from 'react';
import { EuiButtonEmpty, EuiPopover, EuiRadioGroup } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
import { MapCenter, MapSettings } from '../../../../common/descriptor_types';
import { DecimalDegreesForm } from './decimal_degrees_form';
import { MgrsForm } from './mgrs_form';
import { UtmForm } from './utm_form';
const DEGREES_DECIMAL = 'dd';
const MGRS = 'mgrs';
const UTM = 'utm';
const COORDINATE_SYSTEM_OPTIONS = [
{
id: DEGREES_DECIMAL,
label: i18n.translate('xpack.maps.setViewControl.decimalDegreesLabel', {
defaultMessage: 'Decimal degrees',
}),
},
{
id: UTM,
label: 'UTM',
},
{
id: MGRS,
label: 'MGRS',
},
];
interface Props {
settings: MapSettings;
zoom: number;
center: MapCenter;
onSubmit: (lat: number, lon: number, zoom: number) => void;
}
interface State {
isPopoverOpen: boolean;
coordinateSystem: string;
}
export class SetViewForm extends Component<Props, State> {
state: State = {
coordinateSystem: DEGREES_DECIMAL,
isPopoverOpen: false,
};
_togglePopover = () => {
this.setState((prevState) => ({
isPopoverOpen: !prevState.isPopoverOpen,
}));
};
_closePopover = () => {
this.setState({
isPopoverOpen: false,
});
};
_onCoordinateSystemChange = (optionId: string) => {
this._closePopover();
this.setState({
coordinateSystem: optionId,
});
};
_renderForm() {
if (this.state.coordinateSystem === MGRS) {
return (
<MgrsForm
settings={this.props.settings}
zoom={this.props.zoom}
center={this.props.center}
onSubmit={this.props.onSubmit}
/>
);
}
if (this.state.coordinateSystem === UTM) {
return (
<UtmForm
settings={this.props.settings}
zoom={this.props.zoom}
center={this.props.center}
onSubmit={this.props.onSubmit}
/>
);
}
return (
<DecimalDegreesForm
settings={this.props.settings}
zoom={this.props.zoom}
center={this.props.center}
onSubmit={this.props.onSubmit}
/>
);
}
render() {
return (
<div data-test-subj="mapSetViewForm" style={{ width: 240 }}>
<EuiPopover
panelPaddingSize="s"
isOpen={this.state.isPopoverOpen}
closePopover={this._closePopover}
button={
<EuiButtonEmpty iconType="controlsHorizontal" size="xs" onClick={this._togglePopover}>
<FormattedMessage
id="xpack.maps.setViewControl.changeCoordinateSystemButtonLabel"
defaultMessage="Coordinate system"
/>
</EuiButtonEmpty>
}
>
<EuiRadioGroup
options={COORDINATE_SYSTEM_OPTIONS}
idSelected={this.state.coordinateSystem}
onChange={this._onCoordinateSystemChange}
/>
</EuiPopover>
{this._renderForm()}
</div>
);
}
}

View file

@ -0,0 +1,52 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { ddToMGRS, mgrsToDD, ddToUTM, utmToDD } from './utils';
describe('MGRS', () => {
test('ddToMGRS should convert lat lon to MGRS', () => {
expect(ddToMGRS(29.29926, 32.05495)).toEqual('36RVT08214151');
});
test('ddToMGRS should return empty string for lat lon that does not translate to MGRS grid', () => {
expect(ddToMGRS(90, 32.05495)).toEqual('');
});
test('mgrsToDD should convert MGRS to lat lon', () => {
expect(mgrsToDD('36RVT08214151')).toEqual({
east: 32.05498649594143,
north: 29.299330195900975,
south: 29.299239224067065,
west: 32.054884373627345,
});
});
});
describe('UTM', () => {
test('ddToUTM should convert lat lon to UTM', () => {
expect(ddToUTM(29.29926, 32.05495)).toEqual({
easting: '408216',
northing: '3241512',
zone: '36R',
});
});
test('ddToUTM should return empty strings for lat lon that does not translate to UTM grid', () => {
expect(ddToUTM(90, 32.05495)).toEqual({
northing: '',
easting: '',
zone: '',
});
});
test('utmToDD should convert UTM to lat lon', () => {
expect(utmToDD('3241512', '408216', '36R')).toEqual({
lat: 29.29925770984472,
lon: 32.05494597943409,
});
});
});

View file

@ -0,0 +1,91 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { i18n } from '@kbn/i18n';
import * as usng from 'usng.js';
// @ts-ignore
const converter = new usng.Converter();
export function withinRange(value: string | number, min: number, max: number) {
const isInvalid = value === '' || value > max || value < min;
const error = isInvalid
? i18n.translate('xpack.maps.setViewControl.outOfRangeErrorMsg', {
defaultMessage: `Must be between {min} and {max}`,
values: { min, max },
})
: null;
return { isInvalid, error };
}
export function ddToUTM(lat: number, lon: number) {
try {
const utm = converter.LLtoUTM(lat, lon);
return {
northing: utm === converter.UNDEFINED_STR ? '' : String(Math.round(utm.northing)),
easting: utm === converter.UNDEFINED_STR ? '' : String(Math.round(utm.easting)),
zone:
utm === converter.UNDEFINED_STR
? ''
: `${utm.zoneNumber}${converter.UTMLetterDesignator(lat)}`,
};
} catch (e) {
return {
northing: '',
easting: '',
zone: '',
};
}
}
export function utmToDD(northing: string, easting: string, zoneNumber: string) {
try {
return converter.UTMtoLL(northing, easting, zoneNumber);
} catch (e) {
return undefined;
}
}
export function ddToMGRS(lat: number, lon: number) {
try {
const mgrsCoord = converter.LLtoMGRS(lat, lon, 5);
return mgrsCoord;
} catch (e) {
return '';
}
}
function mgrstoUSNG(mgrs: string) {
let squareIdEastSpace = 0;
for (let i = mgrs.length - 1; i > -1; i--) {
// check if we have hit letters yet
if (isNaN(parseInt(mgrs.substr(i, 1), 10))) {
squareIdEastSpace = i + 1;
break;
}
}
const gridZoneSquareIdSpace = squareIdEastSpace ? squareIdEastSpace - 2 : -1;
const numPartLength = mgrs.substr(squareIdEastSpace).length / 2;
// add the number split space
const eastNorthSpace = squareIdEastSpace ? squareIdEastSpace + numPartLength : -1;
const stringArray = mgrs.split('');
stringArray.splice(eastNorthSpace, 0, ' ');
stringArray.splice(squareIdEastSpace, 0, ' ');
stringArray.splice(gridZoneSquareIdSpace, 0, ' ');
const rejoinedArray = stringArray.join('');
return rejoinedArray;
}
export function mgrsToDD(mgrs: string) {
try {
return converter.USNGtoLL(mgrstoUSNG(mgrs));
} catch (e) {
return undefined;
}
}

View file

@ -0,0 +1,209 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import _ from 'lodash';
import React, { ChangeEvent, Component } from 'react';
import {
EuiForm,
EuiFormRow,
EuiButton,
EuiFieldNumber,
EuiFieldText,
EuiTextAlign,
EuiSpacer,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
import { MapCenter, MapSettings } from '../../../../common/descriptor_types';
import { ddToUTM, utmToDD, withinRange } from './utils';
interface Props {
settings: MapSettings;
zoom: number;
center: MapCenter;
onSubmit: (lat: number, lon: number, zoom: number) => void;
}
interface State {
northing: string;
easting: string;
zone: string;
zoom: number | string;
}
export class UtmForm extends Component<Props, State> {
constructor(props: Props) {
super(props);
const utm = ddToUTM(this.props.center.lat, this.props.center.lon);
this.state = {
northing: utm.northing,
easting: utm.easting,
zone: utm.zone,
zoom: this.props.zoom,
};
}
_toPoint() {
const { northing, easting, zone } = this.state;
return northing === '' || easting === '' || zone.length < 2
? undefined
: utmToDD(northing, easting, zone.substring(0, zone.length - 1));
}
_isUtmInvalid() {
const point = this._toPoint();
return point === undefined;
}
_onZoneChange = (evt: ChangeEvent<HTMLInputElement>) => {
this.setState({
zone: _.isNull(evt.target.value) ? '' : evt.target.value,
});
};
_onEastingChange = (evt: ChangeEvent<HTMLInputElement>) => {
this.setState({
easting: _.isNull(evt.target.value) ? '' : evt.target.value,
});
};
_onNorthingChange = (evt: ChangeEvent<HTMLInputElement>) => {
this.setState({
northing: _.isNull(evt.target.value) ? '' : evt.target.value,
});
};
_onZoomChange = (evt: ChangeEvent<HTMLInputElement>) => {
const sanitizedValue = parseFloat(evt.target.value);
this.setState({
zoom: isNaN(sanitizedValue) ? '' : sanitizedValue,
});
};
_onSubmit = () => {
const point = this._toPoint();
if (point) {
this.props.onSubmit(point.lat, point.lon, this.state.zoom as number);
}
};
render() {
const isUtmInvalid = this._isUtmInvalid();
const northingError =
isUtmInvalid || this.state.northing === ''
? i18n.translate('xpack.maps.setViewControl.utmInvalidNorthing', {
defaultMessage: 'UTM Northing is invalid',
})
: null;
const eastingError =
isUtmInvalid || this.state.northing === ''
? i18n.translate('xpack.maps.setViewControl.utmInvalidEasting', {
defaultMessage: 'UTM Easting is invalid',
})
: null;
const zoneError =
isUtmInvalid || this.state.northing === ''
? i18n.translate('xpack.maps.setViewControl.utmInvalidZone', {
defaultMessage: 'UTM Zone is invalid',
})
: null;
const { isInvalid: isZoomInvalid, error: zoomError } = withinRange(
this.state.zoom,
this.props.settings.minZoom,
this.props.settings.maxZoom
);
return (
<EuiForm>
<EuiFormRow
label={i18n.translate('xpack.maps.setViewControl.utmZoneLabel', {
defaultMessage: 'UTM Zone',
})}
isInvalid={isUtmInvalid}
error={zoneError}
display="columnCompressed"
>
<EuiFieldText
compressed
value={this.state.zone}
onChange={this._onZoneChange}
isInvalid={isUtmInvalid}
data-test-subj="utmZoneInput"
/>
</EuiFormRow>
<EuiFormRow
label={i18n.translate('xpack.maps.setViewControl.utmEastingLabel', {
defaultMessage: 'UTM Easting',
})}
isInvalid={isUtmInvalid}
error={eastingError}
display="columnCompressed"
>
<EuiFieldNumber
compressed
value={this.state.easting}
onChange={this._onEastingChange}
isInvalid={isUtmInvalid}
data-test-subj="utmEastingInput"
/>
</EuiFormRow>
<EuiFormRow
label={i18n.translate('xpack.maps.setViewControl.utmNorthingLabel', {
defaultMessage: 'UTM Northing',
})}
isInvalid={isUtmInvalid}
error={northingError}
display="columnCompressed"
>
<EuiFieldNumber
compressed
value={this.state.northing}
onChange={this._onNorthingChange}
isInvalid={isUtmInvalid}
data-test-subj="utmNorthingInput"
/>
</EuiFormRow>
<EuiFormRow
label={i18n.translate('xpack.maps.setViewControl.zoomLabel', {
defaultMessage: 'Zoom',
})}
isInvalid={isZoomInvalid}
error={zoomError}
display="columnCompressed"
>
<EuiFieldNumber
compressed
value={this.state.zoom}
onChange={this._onZoomChange}
isInvalid={isZoomInvalid}
data-test-subj="zoomInput"
/>
</EuiFormRow>
<EuiSpacer size="s" />
<EuiTextAlign textAlign="right">
<EuiButton
size="s"
fill
disabled={isUtmInvalid || isZoomInvalid}
onClick={this._onSubmit}
data-test-subj="submitViewButton"
>
<FormattedMessage
id="xpack.maps.setViewControl.submitButtonLabel"
defaultMessage="Go"
/>
</EuiButton>
</EuiTextAlign>
</EuiForm>
);
}
}