From 2debe647c1b3dde173fcf3e767a91539612baa2e Mon Sep 17 00:00:00 2001 From: Stefano Ricci <1219739+SteRiccio@users.noreply.github.com> Date: Thu, 26 Sep 2024 15:04:18 +0200 Subject: [PATCH 1/2] Added "Geospatial" (geo) attribute type and "geoPolygon" function (#3580) * support geo node def type * added node def geo component * prepare show geo attribute layer on map * fixed typo * code cleanup * draw geo attribute polygons on separate layer * fixed layer colors * code cleanup (merge GeoAttributeDataLayer into CoordinateAttributeDataLayer) * do not display centroid in polygon * fly to polygon: do not zoom to max level * allow uploading geojson file * show summary * import geo:polygon expression from Collect * hide geospatial features when EXPERIMENTAL_FEATURES env var is not set * fixed SonarCloud issues * added turf; show area and perimeter in foot too; * code cleanup * layout adjustments * layout adjustments * code cleanup * added delete button * added area and perimeter in other units * code cleanup * code cleanup --------- Co-authored-by: Stefano Ricci Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com> --- .env.template | 4 + .../db/tables/dataNodeDef/columnNodeDef.js | 1 + core/expressionParser/helpers/functions.js | 1 + core/i18n/resources/en/common.js | 16 +- core/numberUtils.js | 46 +++++ core/objectUtils.js | 8 + core/processUtils.js | 2 + core/survey/nodeDef.js | 2 + core/survey/nodeDefType.js | 1 + package.json | 5 +- .../collectExpressionConverter.js | 1 + .../_recordManager/recordValidationManager.js | 2 +- webapp/components/Map/Map.js | 6 +- .../ResizableModal/ResizableModal.js | 12 +- .../codemirrorArenaExpressionHint.js | 31 +--- .../components/expression/functionExamples.js | 56 ++++++ webapp/components/geo/GeoPolygonInfo.js | 72 ++++++++ webapp/components/geo/GeoPolygonInfo.scss | 8 + .../SurveyForm/components/addNodeDefPanel.js | 10 +- .../nodeDefs/components/types/nodeDefGeo.js | 161 ++++++++++++++++++ .../nodeDefs/components/types/nodeDefGeo.scss | 15 ++ .../SurveyForm/nodeDefs/nodeDefUIProps.js | 8 +- webapp/utils/fileUtils.js | 18 ++ webapp/utils/geoJsonUtils.js | 36 ++++ webapp/utils/gsapUtils.js | 8 - .../convertDataToPoints.js | 75 -------- .../CoordinateAttributeDataLayer/index.js | 1 - .../CoordinateAttributeMarker.js | 103 +++++++---- .../CoordinateAttributePolygon.js | 0 .../CoordinateAttributePopUp.js | 4 +- .../CoordinateAttributePopUp.scss | 2 +- .../GeoAttributeDataLayer.js} | 15 +- .../WhispMenuButton.js | 0 .../convertDataToGeoJsonPoints.js | 107 ++++++++++++ .../MapView/GeoAttributeDataLayer/index.js | 1 + .../useGeoAttributeDataLayer.js} | 57 ++----- .../useOnEditedRecordDataFetched.js | 8 +- .../views/App/views/Data/MapView/MapView.js | 69 +++++--- .../useSamplingPointDataLayer.js | 5 +- .../Data/MapView/common/useFlyToPoint.js | 26 ++- webpack.config.babel.js | 1 + yarn.lock | 79 ++++++++- 42 files changed, 823 insertions(+), 260 deletions(-) create mode 100644 webapp/components/expression/functionExamples.js create mode 100644 webapp/components/geo/GeoPolygonInfo.js create mode 100644 webapp/components/geo/GeoPolygonInfo.scss create mode 100644 webapp/components/survey/SurveyForm/nodeDefs/components/types/nodeDefGeo.js create mode 100644 webapp/components/survey/SurveyForm/nodeDefs/components/types/nodeDefGeo.scss create mode 100644 webapp/utils/geoJsonUtils.js delete mode 100644 webapp/utils/gsapUtils.js delete mode 100644 webapp/views/App/views/Data/MapView/CoordinateAttributeDataLayer/convertDataToPoints.js delete mode 100644 webapp/views/App/views/Data/MapView/CoordinateAttributeDataLayer/index.js rename webapp/views/App/views/Data/MapView/{CoordinateAttributeDataLayer => GeoAttributeDataLayer}/CoordinateAttributeMarker.js (53%) rename webapp/views/App/views/Data/MapView/{CoordinateAttributeDataLayer => GeoAttributeDataLayer}/CoordinateAttributePolygon.js (100%) rename webapp/views/App/views/Data/MapView/{CoordinateAttributeDataLayer => GeoAttributeDataLayer}/CoordinateAttributePopUp.js (99%) rename webapp/views/App/views/Data/MapView/{CoordinateAttributeDataLayer => GeoAttributeDataLayer}/CoordinateAttributePopUp.scss (95%) rename webapp/views/App/views/Data/MapView/{CoordinateAttributeDataLayer/CoordinateAttributeDataLayer.js => GeoAttributeDataLayer/GeoAttributeDataLayer.js} (86%) rename webapp/views/App/views/Data/MapView/{CoordinateAttributeDataLayer => GeoAttributeDataLayer}/WhispMenuButton.js (100%) create mode 100644 webapp/views/App/views/Data/MapView/GeoAttributeDataLayer/convertDataToGeoJsonPoints.js create mode 100644 webapp/views/App/views/Data/MapView/GeoAttributeDataLayer/index.js rename webapp/views/App/views/Data/MapView/{CoordinateAttributeDataLayer/useCoordinateAttributeDataLayer.js => GeoAttributeDataLayer/useGeoAttributeDataLayer.js} (68%) rename webapp/views/App/views/Data/MapView/{CoordinateAttributeDataLayer => GeoAttributeDataLayer}/useOnEditedRecordDataFetched.js (90%) diff --git a/.env.template b/.env.template index cd8df88fe0..9be2290499 100644 --- a/.env.template +++ b/.env.template @@ -78,3 +78,7 @@ ADMIN_EMAIL= # Admin user password: used only when default system admin user is created the first time # it MUST BE DELETED after the first startup ADMIN_PASSWORD= + +# Experimental features +# if set to true, experimental features will be enabled in the UI +EXPERIMENTAL_FEATURES=false diff --git a/common/model/db/tables/dataNodeDef/columnNodeDef.js b/common/model/db/tables/dataNodeDef/columnNodeDef.js index 2e94dfd813..a4d8c4b6d6 100644 --- a/common/model/db/tables/dataNodeDef/columnNodeDef.js +++ b/common/model/db/tables/dataNodeDef/columnNodeDef.js @@ -35,6 +35,7 @@ const colTypesGetterByType = { }, [nodeDefType.date]: () => [SQL.types.date], [nodeDefType.decimal]: () => [SQL.types.decimal], + [nodeDefType.geo]: () => [SQL.types.varchar], [nodeDefType.entity]: () => [SQL.types.uuid], [nodeDefType.file]: () => [SQL.types.uuid, SQL.types.varchar], [nodeDefType.integer]: () => [SQL.types.bigint], diff --git a/core/expressionParser/helpers/functions.js b/core/expressionParser/helpers/functions.js index 3b32147506..355e412de3 100644 --- a/core/expressionParser/helpers/functions.js +++ b/core/expressionParser/helpers/functions.js @@ -4,6 +4,7 @@ export const functionNames = { count: 'count', distance: 'distance', first: 'first', + geoPolygon: 'geoPolygon', includes: 'includes', index: 'index', isEmpty: 'isEmpty', diff --git a/core/i18n/resources/en/common.js b/core/i18n/resources/en/common.js index fac02e4661..55a73726f8 100644 --- a/core/i18n/resources/en/common.js +++ b/core/i18n/resources/en/common.js @@ -223,6 +223,12 @@ Do you want to proceed?`, pageNotFound: 'Page not found', }, + geo: { + area: 'Area', + vertices: 'Vertices', + perimeter: 'Perimeter', + }, + files: { header: 'Files', missing: ' Missing files: {{count}}', @@ -1153,8 +1159,8 @@ $t(common.cantUndoWarning)`, invalid: 'Invalid expression: {{details}}', missingFunctionParameters: 'Missing function parameters', undefinedFunction: 'Undefined function: {{name}}', - functionHasTooFewArguments: 'Function {{fnName}} requires at least {{minArity}} (got {{numArgs}})', - functionHasTooManyArguments: 'Function {{fnName}} only accepts at most {{maxArity}} (got {{numArgs}})', + functionHasTooFewArguments: 'Function {{fnName}} requires at least {{minArity}} arguments (got {{numArgs}})', + functionHasTooManyArguments: 'Function {{fnName}} only accepts at most {{maxArity}} arguments (got {{numArgs}})', }, // ====== Help views @@ -1355,6 +1361,7 @@ E.g. this.region = region_attribute_name coordinate: 'Coordinate', date: 'Date', decimal: 'Decimal', + geo: 'Geospatial', entity: 'Table or form', file: 'File', integer: 'Integer', @@ -1423,6 +1430,11 @@ $t(surveyForm.formEntryActions.confirmPromote)`, altitude: 'Altitude', altitudeAccuracy: 'Altitude accuracy', }, + nodeDefGeo: { + confirmDelete: 'Delete this Geospatial value?', + geoJSON: 'GeoJSON', + invalidGeoJsonFileUploaded: 'Invalid GeoJSON file uploaded', + }, nodeDefEntityForm: { addNewEntity: 'Add new {{name}}', confirmDelete: 'Are you sure you want to delete this entity?', diff --git a/core/numberUtils.js b/core/numberUtils.js index 25991f975d..2e467d3b64 100644 --- a/core/numberUtils.js +++ b/core/numberUtils.js @@ -12,6 +12,39 @@ BigNumber.config({ }, }) +export const areaUnits = { + squareMeter: 'squareMeter', + squareFoot: 'squareFoot', + acre: 'acre', + hectare: 'hectare', +} + +const areaUnitToSquareMetersConversionFactor = { + [areaUnits.acre]: 4046.85642199999983859016, + [areaUnits.hectare]: 10000, + [areaUnits.squareMeter]: 1, + [areaUnits.squareFoot]: 0.09290304, +} + +export const lengthUnits = { + meter: 'meter', + foot: 'foot', +} + +const lengthUnitToMetersConversionFactor = { + [lengthUnits.meter]: 1, + [lengthUnits.foot]: 0.3048, +} + +export const abbreviationByUnit = { + [areaUnits.squareMeter]: 'm²', + [areaUnits.squareFoot]: 'ft²', + [areaUnits.acre]: 'ac', + [areaUnits.hectare]: 'ha', + [lengthUnits.meter]: 'm', + [lengthUnits.foot]: 'ft', +} + export const toNumber = (num) => (Objects.isEmpty(num) ? NaN : Number(num)) export const isInteger = A.pipe(toNumber, Number.isInteger) @@ -51,3 +84,16 @@ export const roundToPrecision = (value, precision = NaN) => { * @returns {string} - The formatted value or null if the value was null. */ export const formatInteger = (value) => formatDecimal(value, 0) + +/** + * Returns the modulus of the specified value. The result will always be a positive number. + * @param {!number} modulus - The modulus to apply. + * @returns {number} - The result of the modulus (always positive or 0). + */ +export const mod = (modulus) => (value) => ((value % modulus) + modulus) % modulus + +export const squareMetersToUnit = (unit) => (value) => + Objects.isNil(value) ? NaN : value / areaUnitToSquareMetersConversionFactor[unit] + +export const metersToUnit = (unit) => (value) => + Objects.isNil(value) ? NaN : value / lengthUnitToMetersConversionFactor[unit] diff --git a/core/objectUtils.js b/core/objectUtils.js index 45a21e1ccd..4665f76794 100644 --- a/core/objectUtils.js +++ b/core/objectUtils.js @@ -104,6 +104,14 @@ export const setInPath = export const dissocTemporary = R.unless(R.isNil, R.dissoc(keys.temporary)) +export const keepNonEmptyProps = (obj) => + Object.entries(obj).reduce((acc, [key, value]) => { + if (!isBlank(value)) { + acc[key] = value + } + return acc + }, {}) + // ====== UTILS / uuid const _getProp = (propNameOrExtractor) => (item) => typeof propNameOrExtractor === 'string' ? R.path(propNameOrExtractor.split('.'))(item) : propNameOrExtractor(item) diff --git a/core/processUtils.js b/core/processUtils.js index 7677e48f1f..8498f96e91 100644 --- a/core/processUtils.js +++ b/core/processUtils.js @@ -74,6 +74,8 @@ const ENV = { fileStorageAwsSecretAccessKey: process.env.FILE_STORAGE_AWS_SECRET_ACCESS_KEY, fileStorageAwsS3BucketName: process.env.FILE_STORAGE_AWS_S3_BUCKET_NAME, fileStorageAwsS3BucketRegion: process.env.FILE_STORAGE_AWS_S3_BUCKET_REGION, + // Experimental features + experimentalFeatures: isTrue(process.env.EXPERIMENTAL_FEATURES), } module.exports = { diff --git a/core/survey/nodeDef.js b/core/survey/nodeDef.js index e7e7ea474f..9d746837e7 100644 --- a/core/survey/nodeDef.js +++ b/core/survey/nodeDef.js @@ -234,6 +234,7 @@ export const isCode = isType(nodeDefType.code) export const isCoordinate = isType(nodeDefType.coordinate) export const isDate = isType(nodeDefType.date) export const isDecimal = isType(nodeDefType.decimal) +export const isGeo = isType(nodeDefType.geo) export const isFile = isType(nodeDefType.file) export const isInteger = isType(nodeDefType.integer) export const isTaxon = isType(nodeDefType.taxon) @@ -618,6 +619,7 @@ export const canHaveDefaultValue = (nodeDef) => nodeDefType.coordinate, nodeDefType.date, nodeDefType.decimal, + nodeDefType.geo, nodeDefType.integer, nodeDefType.taxon, nodeDefType.text, diff --git a/core/survey/nodeDefType.js b/core/survey/nodeDefType.js index 631625d827..432bd82c3b 100644 --- a/core/survey/nodeDefType.js +++ b/core/survey/nodeDefType.js @@ -7,6 +7,7 @@ export const nodeDefType = { boolean: 'boolean', code: 'code', coordinate: 'coordinate', + geo: 'geo', taxon: 'taxon', file: 'file', entity: 'entity', diff --git a/package.json b/package.json index 69f75a0196..a3721b7386 100644 --- a/package.json +++ b/package.json @@ -106,11 +106,14 @@ "@mui/x-data-grid": "^7.14.0", "@mui/x-date-pickers": "^7.14.0", "@mui/x-tree-view": "^7.7.1", - "@openforis/arena-core": "^0.0.203", + "@openforis/arena-core": "^0.0.204", "@openforis/arena-server": "^0.1.34", "@reduxjs/toolkit": "^2.2.5", "@sendgrid/mail": "^8.1.3", "@shopify/draggable": "^1.1.3", + "@turf/area": "^7.1.0", + "@turf/centroid": "^7.1.0", + "@turf/length": "^7.1.0", "ace-builds": "^1.32.6", "adm-zip": "^0.5.10", "archiver": "^7.0.1", diff --git a/server/modules/collectImport/service/collectImport/metaImportJobs/nodeDefsImportJob/collectExpressionConverter.js b/server/modules/collectImport/service/collectImport/metaImportJobs/nodeDefsImportJob/collectExpressionConverter.js index b1d9403970..d24f664dbd 100644 --- a/server/modules/collectImport/service/collectImport/metaImportJobs/nodeDefsImportJob/collectExpressionConverter.js +++ b/server/modules/collectImport/service/collectImport/metaImportJobs/nodeDefsImportJob/collectExpressionConverter.js @@ -109,6 +109,7 @@ const convert = ({ survey, nodeDefCurrent, expression, advancedExpressionEditor // geo namespace // idm:distance is deprecated in Collect { pattern: '(geo|idm):distance', replace: 'distance' }, + { pattern: 'geo:polygon', replace: 'geoPolygon' }, // math namespace { pattern: /math:PI\(\)/, replace: 'Math.PI' }, // convert directly some functions from math:fnName to Math.fnName diff --git a/server/modules/record/manager/_recordManager/recordValidationManager.js b/server/modules/record/manager/_recordManager/recordValidationManager.js index f04f7417b2..1479f4a529 100644 --- a/server/modules/record/manager/_recordManager/recordValidationManager.js +++ b/server/modules/record/manager/_recordManager/recordValidationManager.js @@ -46,7 +46,7 @@ export const validateNodesAndPersistValidation = async (survey, record, nodes, v // 1. validate node values const nodesValueValidation = await RecordValidator.validateNodes({ survey, record, nodes }) const nodesValueValidationsByUuid = Validation.getFieldValidations(nodesValueValidation) - // 1.a. workaraound: always define value field validation even when validation is valid to allow cleaning up errors later + // 1.a. workaround: always define value field validation even when validation is valid to allow cleaning up errors later Object.entries(nodesValueValidationsByUuid).forEach(([nodeUuid, nodeValueValidation]) => { if (Validation.isValid(nodeValueValidation)) { const nodeValueValidationUpdated = Validation.setField('value', Validation.newInstance())(nodeValueValidation) diff --git a/webapp/components/Map/Map.js b/webapp/components/Map/Map.js index d7a720e0b7..0dfbab0323 100644 --- a/webapp/components/Map/Map.js +++ b/webapp/components/Map/Map.js @@ -1,7 +1,7 @@ import './Map.scss' import React from 'react' -import { MapContainer, ScaleControl } from 'react-leaflet' +import { GeoJSON, MapContainer, ScaleControl } from 'react-leaflet' import PropTypes from 'prop-types' import { ButtonSave } from '@webapp/components' @@ -32,7 +32,7 @@ L.Marker.prototype.options.icon = L.icon({ const INITIAL_ZOOM_LEVEL = 3 export const Map = (props) => { - const { editable = false, layers = [], markerPoint, markerTitle, showOptions = true } = props + const { editable = false, geoJson = null, layers = [], markerPoint, markerTitle, showOptions = true } = props const { centerPositionLatLon, markerPointUpdated, markerPointUpdatedToString, onMarkerPointUpdated, onSaveClick } = useMap(props) @@ -54,6 +54,7 @@ export const Map = (props) => { onPointUpdated={onMarkerPointUpdated} title={markerTitle} /> + {geoJson && } {showOptions && ( <> @@ -85,6 +86,7 @@ export const Map = (props) => { Map.propTypes = { centerPoint: PropTypes.object, editable: PropTypes.bool, + geoJson: PropTypes.object, layers: PropTypes.array, markerPoint: PropTypes.object, markerTitle: PropTypes.string, diff --git a/webapp/components/ResizableModal/ResizableModal.js b/webapp/components/ResizableModal/ResizableModal.js index 4c37320365..af8a0ae10d 100644 --- a/webapp/components/ResizableModal/ResizableModal.js +++ b/webapp/components/ResizableModal/ResizableModal.js @@ -39,7 +39,15 @@ export const ResizableModal = (props) => { return (
- +

{header}

{onDetach && ( @@ -68,6 +76,6 @@ ResizableModal.propTypes = { left: PropTypes.number, onClose: PropTypes.func, onDetach: PropTypes.func, - onRequestClose: PropTypes.func, + onRequestClose: PropTypes.func.isRequired, top: PropTypes.number, } diff --git a/webapp/components/expression/codemirrorArenaExpressionHint.js b/webapp/components/expression/codemirrorArenaExpressionHint.js index c29b88ca92..36fa7964cf 100644 --- a/webapp/components/expression/codemirrorArenaExpressionHint.js +++ b/webapp/components/expression/codemirrorArenaExpressionHint.js @@ -1,39 +1,10 @@ import CodeMirror from 'codemirror/lib/codemirror' import * as Survey from '@core/survey/survey' -import * as Expression from '@core/expressionParser/expression' import * as NodeDefExpressionValidator from '@core/survey/nodeDefExpressionValidator' import * as ExpressionVariables from './expressionVariables' - -const functionExamples = { - [Expression.modes.json]: { - [Expression.functionNames.categoryItemProp]: - `cateoryItemProp('category_name', 'prop_name', 'codeLevel1', 'codeLevel2', ...)`, - [Expression.functionNames.distance]: 'distance(coordinate_attribute_1, coordinate_attribute_2)', - [Expression.functionNames.first]: - 'first(multiple_attribute_name), first(multiple_entity_name).entity_attribute_name, ...', - [Expression.functionNames.includes]: `includes(multiple_attribute_name, 'value') = true/false`, - [Expression.functionNames.index]: `index(node_name), index(this), index($context), index(parent(this))`, - [Expression.functionNames.isEmpty]: `isEmpty(attribute_name) = true/false`, - [Expression.functionNames.last]: - 'last(multiple_entity_name).entity_attribute_name, last(multiple_attribute_name), ...', - [Expression.functionNames.ln]: 'ln(10) = 2.302…', - [Expression.functionNames.log10]: 'log10(100) = 2', - [Expression.functionNames.max]: 'max(3,1,2) = 3', - [Expression.functionNames.min]: 'min(3,1) = 1', - [Expression.functionNames.now]: 'now()', - [Expression.functionNames.parent]: `parent(this), parent($context), parent(node_name)`, - [Expression.functionNames.pow]: 'pow(2,3) = 2³ = 8', - [Expression.functionNames.taxonProp]: `taxonProp('taxonomy_name', 'extra_prop', 'taxon_code')`, - [Expression.functionNames.uuid]: 'uuid()', - }, - [Expression.modes.sql]: { - [Expression.functionNames.avg]: 'avg(variable_name)', - [Expression.functionNames.count]: 'count(variable_name)', - [Expression.functionNames.sum]: 'sum(variable_name)', - }, -} +import functionExamples from './functionExamples' const _findCharIndex = ({ value, end, matchingRegEx }) => { for (let i = end; i >= 0; i -= 1) { diff --git a/webapp/components/expression/functionExamples.js b/webapp/components/expression/functionExamples.js new file mode 100644 index 0000000000..5c2489e11f --- /dev/null +++ b/webapp/components/expression/functionExamples.js @@ -0,0 +1,56 @@ +import * as ProcessUtils from '@core/processUtils' +import * as Expression from '@core/expressionParser/expression' + +const functionExamples = { + [Expression.modes.json]: { + [Expression.functionNames.categoryItemProp]: + `cateoryItemProp('category_name', 'prop_name', 'codeLevel1', 'codeLevel2', ...)`, + [Expression.functionNames.distance]: 'distance(coordinate_attribute_1, coordinate_attribute_2)', + [Expression.functionNames.first]: + 'first(multiple_attribute_name), first(multiple_entity_name).entity_attribute_name, ...', + [Expression.functionNames.geoPolygon]: + 'geoPolygon(coordinate_attribute_1, coordinate_attribute_2, ...), geoPolygon(multiple_entity_name.coordinate_attribute), ...', + [Expression.functionNames.includes]: `includes(multiple_attribute_name, 'value') = true/false`, + [Expression.functionNames.index]: `index(node_name), index(this), index($context), index(parent(this))`, + [Expression.functionNames.isEmpty]: `isEmpty(attribute_name) = true/false`, + [Expression.functionNames.last]: + 'last(multiple_entity_name).entity_attribute_name, last(multiple_attribute_name), ...', + [Expression.functionNames.ln]: 'ln(10) = 2.302…', + [Expression.functionNames.log10]: 'log10(100) = 2', + [Expression.functionNames.max]: 'max(3,1,2) = 3', + [Expression.functionNames.min]: 'min(3,1) = 1', + [Expression.functionNames.now]: 'now()', + [Expression.functionNames.parent]: `parent(this), parent($context), parent(node_name)`, + [Expression.functionNames.pow]: 'pow(2,3) = 2³ = 8', + [Expression.functionNames.taxonProp]: `taxonProp('taxonomy_name', 'extra_prop', 'taxon_code')`, + [Expression.functionNames.uuid]: 'uuid()', + }, + [Expression.modes.sql]: { + [Expression.functionNames.avg]: 'avg(variable_name)', + [Expression.functionNames.count]: 'count(variable_name)', + [Expression.functionNames.sum]: 'sum(variable_name)', + }, +} + +const experimentalFunctions = [Expression.functionNames.geoPolygon] +const isFunctionAvailable = (functionName) => + ProcessUtils.ENV.experimentalFeatures || !experimentalFunctions.includes(functionName) + +const availableFunctionExamples = Object.entries(functionExamples).reduce( + (accFunctionsByMode, [mode, functionsInMode]) => { + const availableFunctionInMode = Object.entries(functionsInMode).reduce( + (accFunctionsByName, [functionName, value]) => { + if (isFunctionAvailable(functionName)) { + accFunctionsByName[functionName] = value + } + return accFunctionsByName + }, + {} + ) + accFunctionsByMode[mode] = availableFunctionInMode + return accFunctionsByMode + }, + {} +) + +export default availableFunctionExamples diff --git a/webapp/components/geo/GeoPolygonInfo.js b/webapp/components/geo/GeoPolygonInfo.js new file mode 100644 index 0000000000..c83516ac2b --- /dev/null +++ b/webapp/components/geo/GeoPolygonInfo.js @@ -0,0 +1,72 @@ +import './GeoPolygonInfo.scss' + +import React, { useCallback, useMemo } from 'react' +import PropTypes from 'prop-types' + +import * as NumberUtils from '@core/numberUtils' + +import { useI18n } from '@webapp/store/system' +import { GeoJsonUtils } from '@webapp/utils/geoJsonUtils' + +import { FormItem } from '../form/Input' +import { ButtonIconInfo } from '../buttons' + +const { + abbreviationByUnit, + areaUnits, + formatDecimal, + lengthUnits, + metersToUnit, + roundToPrecision, + squareMetersToUnit, +} = NumberUtils + +const formatNumber = (value) => formatDecimal(roundToPrecision(value, 2)) + +export const GeoPolygonInfo = (props) => { + const { geoJson } = props + + const i18n = useI18n() + + const areaInSquareMeters = GeoJsonUtils.area(geoJson) + const perimeterInMeters = GeoJsonUtils.perimeter(geoJson) + + const areaInUnit = useCallback( + (unit) => `${formatNumber(squareMetersToUnit(unit)(areaInSquareMeters))} ${abbreviationByUnit[unit]}`, + [areaInSquareMeters] + ) + + const areaTooltipContent = useMemo( + () => [areaUnits.squareMeter, areaUnits.squareFoot, areaUnits.acre].map(areaInUnit).join('
'), + [areaInUnit] + ) + + const perimeterInUnit = useCallback( + (unit) => `${formatNumber(metersToUnit(unit)(perimeterInMeters))} ${abbreviationByUnit[unit]}`, + [perimeterInMeters] + ) + + const perimeterTooltipContent = useMemo(() => [lengthUnits.foot].map(perimeterInUnit).join('
'), [perimeterInUnit]) + + return ( +
+ {GeoJsonUtils.countVertices(geoJson)} + +
+ {areaInUnit(areaUnits.hectare)} + +
+
+ +
+ {perimeterInUnit(lengthUnits.meter)} + +
+
+
+ ) +} + +GeoPolygonInfo.propTypes = { + geoJson: PropTypes.object.isRequired, +} diff --git a/webapp/components/geo/GeoPolygonInfo.scss b/webapp/components/geo/GeoPolygonInfo.scss new file mode 100644 index 0000000000..0a19c412b6 --- /dev/null +++ b/webapp/components/geo/GeoPolygonInfo.scss @@ -0,0 +1,8 @@ +.geo-polygon-info { + display: flex; + flex-direction: column; + + .form-item { + grid-template-columns: 6rem 1fr; + } +} diff --git a/webapp/components/survey/SurveyForm/components/addNodeDefPanel.js b/webapp/components/survey/SurveyForm/components/addNodeDefPanel.js index cf6e717dd6..d2ca2dcd1d 100644 --- a/webapp/components/survey/SurveyForm/components/addNodeDefPanel.js +++ b/webapp/components/survey/SurveyForm/components/addNodeDefPanel.js @@ -2,11 +2,11 @@ import './addNodeDefPanel.scss' import React from 'react' import { connect } from 'react-redux' -import * as R from 'ramda' import { useNavigate } from 'react-router' import { useI18n } from '@webapp/store/system' +import * as ProcessUtils from '@core/processUtils' import * as NodeDef from '@core/survey/nodeDef' import * as NodeDefLayout from '@core/survey/nodeDefLayout' import { SurveyState, NodeDefsActions } from '@webapp/store/survey' @@ -15,14 +15,20 @@ import { TestId } from '@webapp/utils/testId' import * as NodeDefUIProps from '../nodeDefs/nodeDefUIProps' +const experimentalNodeDefTypes = [NodeDef.nodeDefType.geo] + const AddNodeDefButtons = (props) => { const { surveyCycleKey, nodeDef, addNodeDef } = props const i18n = useI18n() + const availableNodeDefTypes = Object.values(NodeDef.nodeDefType).filter( + (type) => ProcessUtils.ENV.experimentalFeatures || !experimentalNodeDefTypes.includes(type) + ) + return ( <> - {R.values(NodeDef.nodeDefType).map((type) => { + {availableNodeDefTypes.map((type) => { const nodeDefProps = NodeDefUIProps.getDefaultPropsByType(type, surveyCycleKey) // Cannot add entities when entity is rendered as table diff --git a/webapp/components/survey/SurveyForm/nodeDefs/components/types/nodeDefGeo.js b/webapp/components/survey/SurveyForm/nodeDefs/components/types/nodeDefGeo.js new file mode 100644 index 0000000000..5bc08a3204 --- /dev/null +++ b/webapp/components/survey/SurveyForm/nodeDefs/components/types/nodeDefGeo.js @@ -0,0 +1,161 @@ +import './nodeDefGeo.scss' + +import classNames from 'classnames' +import PropTypes from 'prop-types' +import React, { useCallback, useState } from 'react' +import { useDispatch, useSelector } from 'react-redux' + +import * as Node from '@core/record/node' +import * as NodeDef from '@core/survey/nodeDef' +import * as NodeDefLayout from '@core/survey/nodeDefLayout' + +import { RecordState } from '@webapp/store/ui/record' + +import { Button, ButtonIconDelete, ExpansionPanel, Map, PanelRight } from '@webapp/components' +import { UploadButton } from '@webapp/components/form' +import { Input } from '@webapp/components/form/Input' +import { GeoPolygonInfo } from '@webapp/components/geo/GeoPolygonInfo' +import { useConfirmAsync } from '@webapp/components/hooks' + +import { useSurveyPreferredLang } from '@webapp/store/survey' +import { NotificationActions } from '@webapp/store/ui' +import { useAuthCanUseMap } from '@webapp/store/user/hooks' + +import { FileUtils } from '@webapp/utils/fileUtils' +import { GeoJsonUtils } from '@webapp/utils/geoJsonUtils' + +import * as NodeDefUiProps from '../../nodeDefUIProps' + +const maxFileSize = 1 + +const NodeDefGeo = (props) => { + const { + canEditRecord, + edit, + entry, + insideTable = false, + nodeDef, + nodes, + readOnly = false, + renderType, + updateNode, + } = props + + const dispatch = useDispatch() + const lang = useSurveyPreferredLang() + const canUseMap = useAuthCanUseMap() + const noHeader = useSelector(RecordState.hasNoHeader) + const canShowMap = canUseMap && !noHeader + + const [showMap, setShowMap] = useState(false) + const confirm = useConfirmAsync() + + const entryDisabled = edit || !canEditRecord || readOnly + + const node = entry ? nodes[0] : null + const value = Node.getValue(node, NodeDefUiProps.getDefaultValue(nodeDef)) + const valueText = value ? JSON.stringify(value) : '' + + const nodeDefLabel = NodeDef.getLabel(nodeDef, lang) + + const toggleShowMap = useCallback(() => setShowMap(!showMap), [showMap]) + + const onFilesChange = useCallback( + async (files) => { + const file = files[0] + const text = await FileUtils.readAsText(file) + const geoJson = GeoJsonUtils.parse(text) + if (geoJson) { + dispatch(NotificationActions.hideNotification()) + updateNode(nodeDef, node, geoJson) + } else { + dispatch(NotificationActions.notifyWarning({ key: 'surveyForm.nodeDefGeo.invalidGeoJsonFileUploaded' })) + } + }, + [dispatch, node, nodeDef, updateNode] + ) + + const onClearValueClick = useCallback(async () => { + if (await confirm({ key: 'surveyForm.nodeDefGeo.confirmDelete' })) { + updateNode(nodeDef, node, null) + } + }, [confirm, node, nodeDef, updateNode]) + + const mapPanelRight = showMap ? ( + + + + ) : null + + const mapTriggerButton = canShowMap ? ( +