diff --git a/package.json b/package.json index 9023d18b32f8cf..af6ab44fa76b39 100644 --- a/package.json +++ b/package.json @@ -412,6 +412,7 @@ "unified": "^9.2.1", "unstated": "^2.1.1", "use-resize-observer": "^6.0.0", + "usng.js": "^0.4.5", "utility-types": "^3.10.0", "uuid": "3.3.2", "vega": "^5.19.1", diff --git a/x-pack/plugins/maps/public/connected_components/toolbar_overlay/set_view_control/set_view_control.tsx b/x-pack/plugins/maps/public/connected_components/toolbar_overlay/set_view_control/set_view_control.tsx index de37ec5e00877d..2840b23c709e6d 100644 --- a/x-pack/plugins/maps/public/connected_components/toolbar_overlay/set_view_control/set_view_control.tsx +++ b/x-pack/plugins/maps/public/connected_components/toolbar_overlay/set_view_control/set_view_control.tsx @@ -5,23 +5,52 @@ * 2.0. */ -import React, { ChangeEvent, Component } from 'react'; +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 { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import { MapCenter } from '../../../../common/descriptor_types'; import { MapSettings } from '../../../reducers/map'; +import * as usng from 'usng.js'; +import { isNull } from 'lodash'; + +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; + +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' + } +]; + export interface Props { settings: MapSettings; zoom: number; @@ -34,6 +63,9 @@ interface State { lat: number | string; lon: number | string; zoom: number | string; + coord: string; + mgrs: string; + utm: object } export class SetViewControl extends Component { @@ -42,8 +74,91 @@ export class SetViewControl extends Component { lat: 0, lon: 0, zoom: 0, + coord: DEFAULT_SET_VIEW_COORDINATE_SYSTEM, + mgrs: "", + utm: {} }; + static convertLatLonToUTM(lat, lon) { + const utmCoord = converter.LLtoUTM( + lat, + lon + ); + + let eastwest = 'E'; + if (utmCoord.easting < 0) { + eastwest = 'W'; + } + let norwest = 'N'; + if (utmCoord.northing < 0) { + norwest = 'S'; + } + + 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; + } + + static convertLatLonToMGRS(lat, lon) { + const mgrsCoord = converter.LLtoMGRS(lat,lon, 5); + return mgrsCoord; + } + + static getViewString(lat, lon, zoom) { + return `${lat},${lon},${zoom}`; + } + + static convertMGRStoUSNG(mgrs){ + let eastNorthSpace, squareIdEastSpace, gridZoneSquareIdSpace + 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; + }; + } + gridZoneSquareIdSpace = squareIdEastSpace - 2 + let numPartLength = mgrs.substr(squareIdEastSpace).length / 2; + // add the number split space + eastNorthSpace = squareIdEastSpace + numPartLength; + let stringArray = mgrs.split(""); + + stringArray.splice(eastNorthSpace, 0, " "); + stringArray.splice(squareIdEastSpace, 0, " "); + stringArray.splice(gridZoneSquareIdSpace, 0, " "); + + let rejoinedArray = stringArray.join(""); + return rejoinedArray; +} + +static convertMGRStoLL(mgrs){ + return mgrs ? converter.USNGtoLL(SetViewControl.convertMGRStoUSNG(mgrs)) : ''; +} + + static getDerivedStateFromProps(nextProps, prevState) { + const nextView = SetViewControl.getViewString(nextProps.center.lat, nextProps.center.lon, nextProps.zoom); + + const utm = SetViewControl.convertLatLonToUTM(nextProps.center.lat, nextProps.center.lon); + const mgrs = SetViewControl.convertLatLonToMGRS(nextProps.center.lat, nextProps.center.lon); + + if (nextView !== prevState.prevView) { + return { + lat: nextProps.center.lat, + lon: nextProps.center.lon, + zoom: nextProps.zoom, + utm: utm, + mgrs: mgrs, + prevView: nextView, + }; + } + + return null; + } + _togglePopover = () => { if (this.state.isPopoverOpen) { this._closePopover(); @@ -64,6 +179,12 @@ export class SetViewControl extends Component { }); }; + _onCoordinateSystemChange = coordId => { + this.setState({ + coord: coordId, + }); + }; + _onLatChange = (evt: ChangeEvent) => { this._onChange('lat', evt); }; @@ -73,17 +194,125 @@ export class SetViewControl extends Component { }; _onZoomChange = (evt: ChangeEvent) => { - this._onChange('zoom', evt); + const sanitizedValue = parseFloat(evt.target.value); + this.setState({ + ['zoom']: isNaN(sanitizedValue) ? '' : sanitizedValue, + }); + }; + + _onUTMZoneChange = (evt: ChangeEvent) => { + this._onUTMChange('zone', evt) + }; + + _onUTMEastingChange = (evt: ChangeEvent) => { + this._onUTMChange('easting', evt) }; - _onChange = (name: 'lat' | 'lon' | 'zoom', evt: ChangeEvent) => { + _onUTMNorthingChange = (evt: ChangeEvent) => { + this._onUTMChange('northing', evt) + }; + + _onMGRSChange = (evt: ChangeEvent) => { + this.setState({ + ['mgrs']: isNull(evt.target.value) ? '' : evt.target.value + }, this._syncToMGRS) + }; + + _onUTMChange = (name: 'easting' | 'northing' | 'zone', evt: ChangeEvent) => { + let updateObj = {...this.state.utm}; + updateObj[name] = isNull(evt.target.value) ? '' : evt.target.value; + this.setState({ + ['utm']: updateObj + }, this._syncToUTM) + }; + + _onChange = (name: 'lat' | 'lon' , evt: ChangeEvent) => { const sanitizedValue = parseFloat(evt.target.value); // @ts-expect-error this.setState({ [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 = SetViewControl.convertLatLonToUTM(this.state.lat, this.state.lon); + const mgrs = SetViewControl.convertLatLonToMGRS(this.state.lat, this.state.lon); + + this.setState({mgrs: mgrs, utm: utm}); + } else { + this.setState({mgrs: '', utm: {}}); + } + } + + /** + * Sync the current lat/lon to MGRS that is set + */ + _syncToMGRS = () => { + if (this.state.mgrs !== '') { + let lon, lat; + + try { + const { north, east } = SetViewControl.convertMGRStoLL(this.state.mgrs); + lat = north; + lon = east; + } catch(err) { + console.log("error converting MGRS:" + this.state.mgrs, err); + return; + } + + const utm = SetViewControl.convertLatLonToUTM(lat, lon); + + this.setState({ + lat: isNaN(lat) ? '' : lat, + lon: isNaN(lon) ? '' : lon, + utm: utm + }); + + } else { + this.setState({ + lat: '', + lon: '', + utm: {} + }); + } + } + + /** + * Sync the current lat/lon to UTM that is set + */ + _syncToUTM = () => { + + if (this.state.utm) { + let lat, lon; + try { + ({ lat, lon } = converter.UTMtoLL(this.state.utm.northing, this.state.utm.easting, this.state.zoneNumber)); + } catch(err) { + console.log("error converting UTM"); + return; + } + + const mgrs = SetViewControl.convertLatLonToMGRS(lat, lon); + + this.setState({ + lat: isNaN(lat) ? '' : lat, + lon: isNaN(lon) ? '' : lon, + mgrs: mgrs + }); + + } else { + this.setState({ + lat: '', + lon: '', + mgrs: '' + }); + } + } + _renderNumberFormRow = ({ value, min, @@ -117,6 +346,124 @@ export class SetViewControl extends Component { }; }; + _renderMGRSFormRow = ({ value, onChange, label, dataTestSubj }) => { + let point; + try { + point = SetViewControl.convertMGRStoLL(value); + } catch(err) { + point = undefined; + console.log("error converting MGRS", err); + } + + const isInvalid = value === '' || point === undefined; + const error = isInvalid ? `MGRS is invalid` : null; + return { + isInvalid, + component: ( + + + + ), + }; + }; + + _renderUTMZoneRow = ({ value, onChange, label, dataTestSubj }) => { + // const zoneNum = ( value ) ? parseInt(value.substring(0, value.length - 1)) : ''; + // const zoneLetter = ( value ) ? value.substring(value.length - 1, value.length) : ''; + + 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 ? `UTM Zone is invalid` : null; + return { + isInvalid, + component: ( + + + + ), + }; + }; + + _renderUTMEastingRow = ({ value, onChange, label, dataTestSubj }) => { + let point; + try { + point = converter.UTMtoLL( + this.state.utm.northing, + parseFloat(value), + this.state.utm.zoneNumber + ); + } catch { + point = undefined; + } + const isInvalid = value === '' || point === undefined; + const error = isInvalid ? `UTM Easting is invalid` : null; + return { + isInvalid, + component: ( + + + + ), + }; + }; + + _renderUTMNorthingRow = ({ value, onChange, label, dataTestSubj }) => { + let point; + try { + point = converter.UTMtoLL( + parseFloat(value), + this.state.utm.easting, + this.state.utm.zoneNumber + ); + } catch { + point = undefined; + } + const isInvalid = value === '' || point === undefined; + const error = isInvalid ? `UTM Northing is invalid` : null; + return { + isInvalid, + component: ( + + + + ), + }; + }; + _onSubmit = () => { const { lat, lon, zoom } = this.state; this._closePopover(); @@ -146,6 +493,42 @@ export class SetViewControl extends Component { dataTestSubj: 'longitudeInput', }); + const { isInvalid: isMGRSInvalid, component: mgrsFormRow } = this._renderMGRSFormRow({ + value: this.state.mgrs, + onChange: this._onMGRSChange, + label: i18n.translate('xpack.maps.setViewControl.mgrsLabel', { + defaultMessage: 'MGRS', + }), + dataTestSubj: 'mgrsInput', + }); + + const { isInvalid: isUTMZoneInvalid, component: utmZoneRow } = 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', + }); + + const { isInvalid: isUTMEastingInvalid, component: utmEastingRow } = 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', + }); + + const { isInvalid: isUTMNorthingInvalid, component: utmNorthingRow } = 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', + }); + const { isInvalid: isZoomInvalid, component: zoomFormRow } = this._renderNumberFormRow({ value: this.state.zoom, min: this.props.settings.minZoom, @@ -157,13 +540,68 @@ export class SetViewControl extends Component { dataTestSubj: 'zoomInput', }); + let coordinateInputs; + if (this.state.coord === "dd") { + coordinateInputs = ( + + {latFormRow} + {lonFormRow} + {zoomFormRow} + + ); + } else if (this.state.coord === "dms") { + coordinateInputs = ( + + {latFormRow} + {lonFormRow} + {zoomFormRow} + + ); + } else if (this.state.coord === "utm") { + coordinateInputs = ( + + {utmZoneRow} + {utmEastingRow} + {utmNorthingRow} + {zoomFormRow} + + ); + } else if (this.state.coord === "mgrs") { + coordinateInputs = ( + + {mgrsFormRow} + {zoomFormRow} + + ); + } + return ( - {latFormRow} - - {lonFormRow} + { + this.setState({ isCoordPopoverOpen: false }); + }} + button={ + { + this.setState({ isCoordPopoverOpen: !this.state.isCoordPopoverOpen }); + }}> + Coordinate System + + } + > + + - {zoomFormRow} + {coordinateInputs} diff --git a/yarn.lock b/yarn.lock index ec9e65305e2a07..f7ce70b09833e1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -28208,6 +28208,11 @@ use@^2.0.0: isobject "^3.0.0" lazy-cache "^2.0.2" +usng.js@^0.4.5: + version "0.4.5" + resolved "https://registry.yarnpkg.com/usng.js/-/usng.js-0.4.5.tgz#49301e131baa9f7f7ab36c0539472c1aa1ecdae7" + integrity sha512-JTJcFFDy/JqA5iiU8DqMOV5gJiL3ZdXH0hCKYaRMDbbredhFna5Ok4C1YDFU07mTGAgm+5FzHaaOzQnO/3BzWQ== + utif@^2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/utif/-/utif-2.0.1.tgz#9e1582d9bbd20011a6588548ed3266298e711759"