diff --git a/anet.yml b/anet.yml index 5a7e20dd58..30b098318d 100644 --- a/anet.yml +++ b/anet.yml @@ -641,6 +641,9 @@ dictionary: helpText: Help text for object date field visibleWhen: $[?(@.colourOptions === 'GREEN')] + location: + format: LAT_LON + position: name: 'Position Name' diff --git a/client/package.json b/client/package.json index 5af04b36b5..5511e97031 100644 --- a/client/package.json +++ b/client/package.json @@ -138,6 +138,7 @@ "locale-compare-polyfill": "0.0.2", "lodash": "4.17.20", "mathjs": "7.2.0", + "mgrs": "1.0.0", "milsymbol": "2.0.0", "ml-matrix": "6.5.1", "moment": "2.27.0", diff --git a/client/src/geoUtils.js b/client/src/geoUtils.js new file mode 100644 index 0000000000..0f50fe5f25 --- /dev/null +++ b/client/src/geoUtils.js @@ -0,0 +1,54 @@ +import { forward, toPoint } from "mgrs" + +export function parseCoordinate(latLng) { + const value = parseFloat(latLng) + if (!value && value !== 0) { + return null + } + + /* + * We use 5 decimal point (~110cm) precision because MGRS has + * a minimum of 1 meter precision. + * Please see; + * https://stackoverflow.com/a/16743805/1209097 + * https://en.wikipedia.org/wiki/Military_Grid_Reference_System + */ + const precision = 5 + /* + * for the purpose of rounding below please see: + * https://stackoverflow.com/questions/1458633/how-to-deal-with-floating-point-number-precision-in-javascript + * https://floating-point-gui.de/ + */ + const safeRoundedValue = Math.round(value * 10 ** precision * 10) / 10 + /* + * Also, coordinates are truncated instead of rounding when changing + * precision level in order to aviod inconsistencies during (MGRS <--> Lat/Lon) conversion. + */ + return Math.trunc(safeRoundedValue) / 10 ** precision +} + +export function convertLatLngToMGRS(lat, lng) { + const parsedLat = parseCoordinate(lat) + const parsedLng = parseCoordinate(lng) + + let mgrs = "" + try { + if ((parsedLat || parsedLat === 0) && (parsedLng || parsedLng === 0)) { + mgrs = forward([parsedLng, parsedLat]) + } + } catch (e) { + mgrs = "" + } + return mgrs +} + +export function convertMGRSToLatLng(mgrs) { + let latLng + try { + // toPoint returns an array of [lon, lat] + latLng = mgrs ? toPoint(mgrs) : ["", ""] + } catch (e) { + latLng = ["", ""] + } + return [parseCoordinate(latLng[1]), parseCoordinate(latLng[0])] +} diff --git a/client/src/models/Location.js b/client/src/models/Location.js index ff9203a841..dcf0eb05e4 100644 --- a/client/src/models/Location.js +++ b/client/src/models/Location.js @@ -1,5 +1,8 @@ import Model from "components/Model" +import { convertMGRSToLatLng } from "geoUtils" +import _isEmpty from "lodash/isEmpty" import LOCATIONS_ICON from "resources/locations.png" +import Settings from "settings" import utils from "utils" import * as yup from "yup" @@ -53,6 +56,29 @@ export default class Location extends Model { return true }) .default(null), + // not actually in the database, but used for validation + displayedCoordinate: yup + .string() + .nullable() + .test({ + name: "displayedCoordinate", + test: function(displayedCoordinate) { + if (_isEmpty(displayedCoordinate)) { + return true + } + if (Settings?.fields?.location?.format === "MGRS") { + const latLngValue = convertMGRSToLatLng(displayedCoordinate) + return !latLngValue[0] || !latLngValue[1] + ? this.createError({ + message: "Please enter a valid MGRS coordinate", + path: "displayedCoordinate" + }) + : true + } + return true + } + }) + .default(null), // FIXME: resolve code duplication in yup schema for approval steps planningApprovalSteps: yup .array() @@ -99,15 +125,6 @@ export default class Location extends Model { static autocompleteQuery = "uuid, name" - static parseCoordinate(latLng) { - const value = parseFloat(latLng) - if (!value && value !== 0) { - return null - } - // 6 decimal point (~10cm) precision https://stackoverflow.com/a/16743805/1209097 - return parseFloat(value.toFixed(6)) - } - static hasCoordinates(location) { return ( location && diff --git a/client/src/pages/locations/Form.js b/client/src/pages/locations/Form.js index 65e6f4e2dc..7dae368ad1 100644 --- a/client/src/pages/locations/Form.js +++ b/client/src/pages/locations/Form.js @@ -9,6 +9,7 @@ import Messages from "components/Messages" import NavigationWarning from "components/NavigationWarning" import { jumpToTop } from "components/Page" import { FastField, Form, Formik } from "formik" +import { parseCoordinate } from "geoUtils" import _escape from "lodash/escape" import { Location, Position } from "models" import PropTypes from "prop-types" @@ -80,12 +81,11 @@ const LocationForm = ({ edit, title, initialValues }) => { initialValues={initialValues} > {({ - handleSubmit, isSubmitting, dirty, - errors, setFieldTouched, setFieldValue, + setValues, values, submitForm }) => { @@ -94,7 +94,14 @@ const LocationForm = ({ edit, title, initialValues }) => { name: _escape(values.name) || "", // escape HTML in location name! draggable: true, autoPan: true, - onMove: (event, map) => onMarkerMove(event, map, setFieldValue) + onMove: (event, map) => { + const latLng = map.wrapLatLng(event.target.getLatLng()) + setValues({ + ...values, + lat: parseCoordinate(latLng.lat), + lng: parseCoordinate(latLng.lng) + }) + } } if (Location.hasCoordinates(values)) { Object.assign(marker, { @@ -140,7 +147,7 @@ const LocationForm = ({ edit, title, initialValues }) => { lat={values.lat} lng={values.lng} isSubmitting={isSubmitting} - setFieldValue={setFieldValue} + setValues={vals => setValues({ ...values, ...vals })} setFieldTouched={setFieldTouched} /> @@ -149,7 +156,12 @@ const LocationForm = ({ edit, title, initialValues }) => { { - onMarkerMapClick(event, map, setFieldValue) + const latLng = map.wrapLatLng(event.latlng) + setValues({ + ...values, + lat: parseCoordinate(latLng.lat), + lng: parseCoordinate(latLng.lng) + }) }} /> @@ -196,24 +208,12 @@ const LocationForm = ({ edit, title, initialValues }) => { ) - function onMarkerMove(event, map, setFieldValue) { - const latLng = map.wrapLatLng(event.target.getLatLng()) - setFieldValue("lat", Location.parseCoordinate(latLng.lat)) - setFieldValue("lng", Location.parseCoordinate(latLng.lng)) - } - - function onMarkerMapClick(event, map, setFieldValue) { - const latLng = map.wrapLatLng(event.latlng) - setFieldValue("lat", Location.parseCoordinate(latLng.lat)) - setFieldValue("lng", Location.parseCoordinate(latLng.lng)) - } - function onCancel() { history.goBack() } function onSubmit(values, form) { - return save(values, form) + return save(values) .then(response => onSubmitSuccess(response, values, form)) .catch(error => { setError(error) @@ -240,8 +240,12 @@ const LocationForm = ({ edit, title, initialValues }) => { }) } - function save(values, form) { - const location = Object.without(new Location(values), "notes") + function save(values) { + const location = Object.without( + new Location(values), + "notes", + "displayedCoordinate" + ) return API.mutation(edit ? GQL_UPDATE_LOCATION : GQL_CREATE_LOCATION, { location }) diff --git a/client/src/pages/locations/GeoLocation.js b/client/src/pages/locations/GeoLocation.js index b98492ed78..e1138b2eb9 100644 --- a/client/src/pages/locations/GeoLocation.js +++ b/client/src/pages/locations/GeoLocation.js @@ -1,41 +1,62 @@ -import RemoveButton from "components/RemoveButton" -import React from "react" -import PropTypes from "prop-types" -import { Col, ControlLabel, FormGroup } from "react-bootstrap" -import { Field } from "formik" -import { Location } from "models" +import { + AnchorButton, + Popover, + PopoverInteractionKind, + Position, + Tooltip +} from "@blueprintjs/core" import * as FieldHelper from "components/FieldHelper" +import { Field } from "formik" +import { + convertLatLngToMGRS, + convertMGRSToLatLng, + parseCoordinate +} from "geoUtils" +import PropTypes from "prop-types" +import React, { useEffect, useState } from "react" +import { Col, ControlLabel, FormGroup, Table } from "react-bootstrap" +import Settings from "settings" export const GEO_LOCATION_DISPLAY_TYPE = { FORM_FIELD: "FORM_FIELD", GENERIC: "GENERIC" } +const MGRS_LABEL = "MGRS Coordinate" +const LAT_LON_LABEL = "Latitude, Longitude" + const GeoLocation = ({ lat, lng, + editable, + setValues, setFieldTouched, - setFieldValue, isSubmitting, - editable, displayType }) => { + let label = LAT_LON_LABEL + let CoordinatesFormField = LatLonFormField + if (Settings?.fields?.location?.format === "MGRS") { + label = MGRS_LABEL + CoordinatesFormField = MGRSFormField + } + if (!editable) { const humanValue = ( - <> - {Location.parseCoordinate(lat) || "?"} - - {Location.parseCoordinate(lng) || "?"} - +
+ + +
) if (displayType === GEO_LOCATION_DISPLAY_TYPE.FORM_FIELD) { return ( ) } @@ -43,15 +64,160 @@ const GeoLocation = ({ return humanValue } - const setTouched = touched => { - setFieldTouched("lat", touched, false) - setFieldTouched("lng", touched, false) + return ( + + ) +} + +function fnRequiredWhenEditable(props, propName, componentName) { + if (props.editable === true && typeof props[propName] !== "function") { + return new Error( + `Invalid prop '${propName}' is supplied to ${componentName}. '${propName}' isRequired when ${componentName} is editable and '${propName}' must be a function!` + ) + } +} + +GeoLocation.propTypes = { + lat: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), + lng: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), + editable: PropTypes.bool, + setValues: fnRequiredWhenEditable, + setFieldTouched: fnRequiredWhenEditable, + isSubmitting: PropTypes.bool, + displayType: PropTypes.oneOf([ + GEO_LOCATION_DISPLAY_TYPE.FORM_FIELD, + GEO_LOCATION_DISPLAY_TYPE.GENERIC + ]) +} + +GeoLocation.defaultProps = { + lat: null, + lng: null, + editable: false, + isSubmitting: false, + displayType: GEO_LOCATION_DISPLAY_TYPE.GENERIC +} + +export default GeoLocation + +/* =========================== MGRSFormField ================================ */ + +const MGRSFormField = ({ + lat, + lng, + editable, + setValues, + setFieldTouched, + isSubmitting +}) => { + const [mgrs, setMgrs] = useState("") + + useEffect(() => { + if (!editable && lat === null && lng === null) { + setMgrs("") + } else { + const mgrsValue = convertLatLngToMGRS(lat, lng) + if (mgrsValue) { + setMgrs(mgrsValue) + if (editable) { + setValues({ displayedCoordinate: mgrsValue, lat: lat, lng: lng }) + } + } + } + }, [editable, lat, lng, setValues]) + + if (!editable) { + return {mgrs || "?"} + } + + return ( + + + {MGRS_LABEL} + + + + + setMgrs(e.target.value)} + onBlur={e => { + const newLatLng = convertMGRSToLatLng(mgrs) + setValues({ + displayedCoordinate: e.target.value, + lat: newLatLng[0], + lng: newLatLng[1] + }) + setFieldTouched("displayedCoordinate", true, false) + }} + /> + + { + setValues({ lat: null, lng: null, displayedCoordinate: null }) + setMgrs("") + }} + /> + + + ) +} + +MGRSFormField.propTypes = { + lat: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), + lng: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), + editable: PropTypes.bool, + setValues: fnRequiredWhenEditable, + setFieldTouched: fnRequiredWhenEditable, + isSubmitting: PropTypes.bool +} + +MGRSFormField.defaultProps = { + lat: null, + lng: null, + editable: false, + isSubmitting: false +} + +/* ========================= LatLonFormField ================================ */ + +const LatLonFormField = ({ + lat, + lng, + editable, + setValues, + setFieldTouched, + isSubmitting +}) => { + if (!editable) { + const lt = parseCoordinate(lat) + const ln = parseCoordinate(lng) + return ( + <> + {lt || lt === 0 ? lt : "?"} + + {ln || ln === 0 ? ln : "?"} + + ) } return ( - Latitude, Longitude + {LAT_LON_LABEL} @@ -60,8 +226,12 @@ const GeoLocation = ({ name="lat" component={FieldHelper.InputFieldNoLabel} onBlur={() => { - setTouched(true) - setFieldValue("lat", Location.parseCoordinate(lat)) + setFieldTouched("lat", true, false) + setFieldTouched("lng", true, false) + setValues({ + lat: parseCoordinate(lat), + lng: parseCoordinate(lng) + }) }} /> @@ -70,58 +240,150 @@ const GeoLocation = ({ name="lng" component={FieldHelper.InputFieldNoLabel} onBlur={() => { - setTouched(true) - setFieldValue("lng", Location.parseCoordinate(lng)) + setFieldTouched("lat", true, false) + setFieldTouched("lng", true, false) + setValues({ + lat: parseCoordinate(lat), + lng: parseCoordinate(lng) + }) }} /> - {(lat || lng) && ( - - { - setTouched(false) // prevent validation since lat, lng can be null together - setFieldValue("lat", null) - setFieldValue("lng", null) - }} - disabled={isSubmitting} - /> - - )} + { + // setting second param to false prevents validation since lat, lng can be null together + setFieldTouched("lat", false, false) + setFieldTouched("lng", false, false) + setValues({ lat: null, lng: null }) + }} + /> ) } -function fnRequiredWhenEditable(props, propName, componentName) { - if (props.editable === true && typeof props[propName] !== "function") { - return new Error( - `Invalid prop '${propName}' is supplied to ${componentName}. '${propName}' isRequired when ${componentName} is editable and '${propName}' must be a function!` - ) - } -} - -GeoLocation.propTypes = { +LatLonFormField.propTypes = { lat: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), lng: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), - setFieldTouched: fnRequiredWhenEditable, - setFieldValue: fnRequiredWhenEditable, - isSubmitting: PropTypes.bool, editable: PropTypes.bool, - displayType: PropTypes.oneOf([ - GEO_LOCATION_DISPLAY_TYPE.FORM_FIELD, - GEO_LOCATION_DISPLAY_TYPE.GENERIC - ]) + setValues: fnRequiredWhenEditable, + setFieldTouched: fnRequiredWhenEditable, + isSubmitting: PropTypes.bool } -GeoLocation.defaultProps = { +LatLonFormField.defaultProps = { lat: null, lng: null, - isSubmitting: false, editable: false, - displayType: GEO_LOCATION_DISPLAY_TYPE.GENERIC + isSubmitting: false } -export default GeoLocation +/* ======================= CoordinateActionButtons ============================ */ + +const CoordinateActionButtons = ({ + lat, + lng, + onClear, + isSubmitting, + disabled +}) => { + return ( + + + + + + + ) +} + +CoordinateActionButtons.propTypes = { + lat: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), + lng: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), + onClear: PropTypes.func.isRequired, + isSubmitting: PropTypes.bool.isRequired, + disabled: PropTypes.bool +} + +CoordinateActionButtons.defaultProps = { + lat: null, + lng: null, + disabled: true +} + +/* ======================= AllFormatsInfo ============================ */ + +const AllFormatsInfo = ({ lat, lng, inForm }) => { + if (!inForm && ((!lat && lat !== 0) || (!lng && lng !== 0))) { + return null + } + return ( + + + + + + + + + + + + + + + + + +
+ All Coordinate Formats +
{LAT_LON_LABEL} + +
{MGRS_LABEL} + +
+ + } + target={ + + + + } + position={Position.RIGHT} + interactionKind={PopoverInteractionKind.CLICK_TARGET_ONLY} + usePortal={false} + /> + ) +} + +AllFormatsInfo.propTypes = { + lat: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), + lng: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), + inForm: PropTypes.bool +} + +AllFormatsInfo.defaultProps = { + lat: null, + lng: null, + inForm: false +} diff --git a/client/yarn.lock b/client/yarn.lock index 03e4d88576..c1241cf573 100644 --- a/client/yarn.lock +++ b/client/yarn.lock @@ -10834,6 +10834,11 @@ methods@~1.1.2: resolved "https://registry.yarnpkg.com/methods/-/methods-1.1.2.tgz#5529a4d67654134edcc5266656835b0f851afcee" integrity sha1-VSmk1nZUE07cxSZmVoNbD4Ua/O4= +mgrs@1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/mgrs/-/mgrs-1.0.0.tgz#fb91588e78c90025672395cb40b25f7cd6ad1829" + integrity sha1-+5FYjnjJACVnI5XLQLJffNatGCk= + microevent.ts@~0.1.1: version "0.1.1" resolved "https://registry.yarnpkg.com/microevent.ts/-/microevent.ts-0.1.1.tgz#70b09b83f43df5172d0205a63025bce0f7357fa0" diff --git a/src/main/resources/anet-schema.yml b/src/main/resources/anet-schema.yml index 4d98a12a3d..e453dd6523 100644 --- a/src/main/resources/anet-schema.yml +++ b/src/main/resources/anet-schema.yml @@ -516,6 +516,17 @@ properties: additionalProperties: "$ref": "#/$defs/customField" + location: + type: object + additionalProperties: false + required: [format] + properties: + format: + type: string + enum: [LAT_LON, MGRS] + title: Coordinate format for location + description: Used in the UI where a location's coordinate is shown. Defaults to LAT_LON. + position: type: object additionalProperties: false