diff --git a/i18n/en.pot b/i18n/en.pot index 4ae87b4de..25ae1d8ad 100644 --- a/i18n/en.pot +++ b/i18n/en.pot @@ -5,8 +5,8 @@ msgstr "" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1)\n" -"POT-Creation-Date: 2024-01-11T09:20:59.829Z\n" -"PO-Revision-Date: 2024-01-11T09:20:59.829Z\n" +"POT-Creation-Date: 2024-01-29T20:53:51.377Z\n" +"PO-Revision-Date: 2024-01-29T20:53:51.377Z\n" msgid "Untitled map, {{date}}" msgstr "Untitled map, {{date}}" @@ -212,6 +212,22 @@ msgstr "Cancel" msgid "Close" msgstr "Close" +msgid "Source" +msgstr "Source" + +msgid "Configure available layers" +msgstr "Configure available layers" + +msgid "" +"Choose which layers are available to add to maps. This setting applies to " +"all users." +msgstr "" +"Choose which layers are available to add to maps. This setting applies to " +"all users." + +msgid "Manage available layers" +msgstr "Manage available layers" + msgid "No organisation units are selected" msgstr "No organisation units are selected" @@ -227,29 +243,20 @@ msgstr "Point color" msgid "Point radius" msgstr "Point radius" -msgid "event" -msgstr "event" - -msgid "tracked entity" -msgstr "tracked entity" - -msgid "facility" -msgstr "facility" - -msgid "thematic" -msgstr "thematic" +msgid "Event" +msgstr "Event" -msgid "org unit" -msgstr "org unit" +msgid "Tracked entity" +msgstr "Tracked entity" -msgid "Earth Engine" -msgstr "Earth Engine" +msgid "Facility" +msgstr "Facility" -msgid "Edit {{name}} layer" -msgstr "Edit {{name}} layer" +msgid "Thematic" +msgstr "Thematic" -msgid "Add new {{name}} layer" -msgstr "Add new {{name}} layer" +msgid "{{name}} layer" +msgstr "{{name}} layer" msgid "Update layer" msgstr "Update layer" @@ -292,20 +299,50 @@ msgstr "" msgid "Unit" msgstr "Unit" -msgid "Source" -msgstr "Source" - msgid "Legend preview" msgstr "Legend preview" +msgid "Mean" +msgstr "Mean" + +msgid "Sum" +msgstr "Sum" + +msgid "Min" +msgstr "Min" + +msgid "Max" +msgstr "Max" + +msgid "Median" +msgstr "Median" + +msgid "Count" +msgstr "Count" + +msgid "Mode" +msgstr "Mode" + +msgid "Product" +msgstr "Product" + +msgid "Period aggregation method" +msgstr "Period aggregation method" + +msgid "Loading periods" +msgstr "Loading periods" + msgid "Year" msgstr "Year" msgid "Available periods are set by the source data" msgstr "Available periods are set by the source data" -msgid "Loading periods" -msgstr "Loading periods" +msgid "Start date" +msgstr "Start date" + +msgid "End date" +msgstr "End date" msgid "Min value is required" msgstr "Min value is required" @@ -319,18 +356,12 @@ msgstr "Max should be greater than min" msgid "Valid steps are {{minSteps}} to {{maxSteps}}" msgstr "Valid steps are {{minSteps}} to {{maxSteps}}" -msgid "Unit: {{ unit }}" -msgstr "Unit: {{ unit }}" - -msgid "Min" -msgstr "Min" - -msgid "Max" -msgstr "Max" - msgid "Steps" msgstr "Steps" +msgid "Facility buffer" +msgstr "Facility buffer" + msgid "Org Units" msgstr "Org Units" @@ -802,9 +833,6 @@ msgstr "Previous year" msgid "Next year" msgstr "Next year" -msgid "Relative" -msgstr "Relative" - msgid "Period type" msgstr "Period type" @@ -829,12 +857,6 @@ msgstr "Timeline" msgid "Split map views" msgstr "Split map views" -msgid "Start date" -msgstr "Start date" - -msgid "End date" -msgstr "End date" - msgid "Not available offline" msgstr "Not available offline" @@ -856,15 +878,9 @@ msgstr "Tracked Entity Type" msgid "By data element" msgstr "By data element" -msgid "Count" -msgstr "Count" - msgid "Average" msgstr "Average" -msgid "Sum" -msgstr "Sum" - msgid "Standard deviation" msgstr "Standard deviation" @@ -880,12 +896,6 @@ msgstr "Hectares" msgid "Acres" msgstr "Acres" -msgid "Mean" -msgstr "Mean" - -msgid "Median" -msgstr "Median" - msgid "Std dev" msgstr "Std dev" @@ -907,15 +917,223 @@ msgstr "Bing Aerial" msgid "Bing Aerial Labels" msgstr "Bing Aerial Labels" +msgid "Building footprints" +msgstr "Building footprints" + +msgid "" +"The outlines of buildings derived from high-resolution satellite imagery. " +"Only for the continent of Africa." +msgstr "" +"The outlines of buildings derived from high-resolution satellite imagery. " +"Only for the continent of Africa." + +msgid "Building counts are only available for smaller organisation unit areas." +msgstr "Building counts are only available for smaller organisation unit areas." + +msgid "" +"Select a smaller area or single organization unit to see the count of " +"buildings." +msgstr "" +"Select a smaller area or single organization unit to see the count of " +"buildings." + +msgid "Number of buildings" +msgstr "Number of buildings" + +msgid "Elevation" +msgstr "Elevation" + +msgid "meters" +msgstr "meters" + +msgid "Elevation above sea-level." +msgstr "Elevation above sea-level." + +msgid "Landcover Copernicus" +msgstr "Landcover Copernicus" + +msgid "Distinct landcover types collected from satellites." +msgstr "Distinct landcover types collected from satellites." + +msgid "Unknown" +msgstr "Unknown" + +msgid "Shrubs" +msgstr "Shrubs" + +msgid "Herbaceous vegetation" +msgstr "Herbaceous vegetation" + +msgid "Agriculture" +msgstr "Agriculture" + +msgid "Urban / built up" +msgstr "Urban / built up" + +msgid "Bare / sparse vegetation" +msgstr "Bare / sparse vegetation" + +msgid "Snow and ice" +msgstr "Snow and ice" + +msgid "Permanent water bodies" +msgstr "Permanent water bodies" + +msgid "Herbaceous wetland" +msgstr "Herbaceous wetland" + +msgid "Moss and lichen" +msgstr "Moss and lichen" + +msgid "Closed forest, evergreen needle leaf" +msgstr "Closed forest, evergreen needle leaf" + +msgid "Closed forest, evergreen broad leaf" +msgstr "Closed forest, evergreen broad leaf" + +msgid "Closed forest, deciduous needle leaf" +msgstr "Closed forest, deciduous needle leaf" + +msgid "Closed forest, deciduous broad leaf" +msgstr "Closed forest, deciduous broad leaf" + +msgid "Closed forest, mixed" +msgstr "Closed forest, mixed" + +msgid "Closed forest, other" +msgstr "Closed forest, other" + +msgid "Open forest, evergreen needle leaf" +msgstr "Open forest, evergreen needle leaf" + +msgid "Open forest, evergreen broad leaf" +msgstr "Open forest, evergreen broad leaf" + +msgid "Open forest, deciduous needle leaf" +msgstr "Open forest, deciduous needle leaf" + +msgid "Open forest, deciduous broad leaf" +msgstr "Open forest, deciduous broad leaf" + +msgid "Open forest, mixed" +msgstr "Open forest, mixed" + +msgid "Open forest, other" +msgstr "Open forest, other" + +msgid "Water" +msgstr "Water" + +msgid "Landcover" +msgstr "Landcover" + +msgid "Evergreen Needleleaf forest" +msgstr "Evergreen Needleleaf forest" + +msgid "Evergreen Broadleaf forest" +msgstr "Evergreen Broadleaf forest" + +msgid "Deciduous Needleleaf forest" +msgstr "Deciduous Needleleaf forest" + +msgid "Deciduous Broadleaf forest" +msgstr "Deciduous Broadleaf forest" + +msgid "Mixed forest" +msgstr "Mixed forest" + +msgid "Closed shrublands" +msgstr "Closed shrublands" + +msgid "Open shrublands" +msgstr "Open shrublands" + +msgid "Woody savannas" +msgstr "Woody savannas" + +msgid "Savannas" +msgstr "Savannas" + +msgid "Grasslands" +msgstr "Grasslands" + +msgid "Permanent wetlands" +msgstr "Permanent wetlands" + +msgid "Croplands" +msgstr "Croplands" + +msgid "Urban and built-up" +msgstr "Urban and built-up" + +msgid "Cropland/Natural vegetation mosaic" +msgstr "Cropland/Natural vegetation mosaic" + +msgid "Barren or sparsely vegetated" +msgstr "Barren or sparsely vegetated" + +msgid "Nighttime lights" +msgstr "Nighttime lights" + +msgid "light intensity" +msgstr "light intensity" + +msgid "" +"Light intensity from cities, towns, and other sites with persistent " +"lighting, including gas flares." +msgstr "" +"Light intensity from cities, towns, and other sites with persistent " +"lighting, including gas flares." + msgid "Population" msgstr "Population" -msgid "people per hectare" -msgstr "people per hectare" +msgid "people per km²" +msgstr "people per km²" msgid "Estimated number of people living in an area." msgstr "Estimated number of people living in an area." +msgid "people per hectare" +msgstr "people per hectare" + +msgid "Precipitation" +msgstr "Precipitation" + +msgid "millimeter" +msgstr "millimeter" + +msgid "" +"Precipitation collected from satellite and weather stations on the ground. " +"The values are in millimeters within 5 days periods. Updated monthly, " +"during the 3rd week of the following month." +msgstr "" +"Precipitation collected from satellite and weather stations on the ground. " +"The values are in millimeters within 5 days periods. Updated monthly, " +"during the 3rd week of the following month." + +msgid "Temperature MODIS" +msgstr "Temperature MODIS" + +msgid "°C during daytime" +msgstr "°C during daytime" + +msgid "" +"Land surface temperatures collected from satellite. Blank spots will appear " +"in areas with a persistent cloud cover." +msgstr "" +"Land surface temperatures collected from satellite. Blank spots will appear " +"in areas with a persistent cloud cover." + +msgid "Nitrogen dioxide (NO2)" +msgstr "Nitrogen dioxide (NO2)" + +msgid "Ozone" +msgstr "Ozone" + +msgid "Particle pollution" +msgstr "Particle pollution" + msgid "Population age groups" msgstr "Population age groups" @@ -1030,138 +1248,62 @@ msgstr "Female 75 - 79 years" msgid "Female 80 years and above" msgstr "Female 80 years and above" -msgid "Building footprints" -msgstr "Building footprints" +msgid "Precipitation CHIRPS" +msgstr "Precipitation CHIRPS" -msgid "Number of buildings" -msgstr "Number of buildings" +msgid "Precipitation collected from satellite and weather stations on the ground." +msgstr "Precipitation collected from satellite and weather stations on the ground." -msgid "" -"The outlines of buildings derived from high-resolution satellite imagery. " -"Only for the continent of Africa." -msgstr "" -"The outlines of buildings derived from high-resolution satellite imagery. " -"Only for the continent of Africa." - -msgid "Building counts are only available for smaller organisation unit areas." -msgstr "Building counts are only available for smaller organisation unit areas." +msgid "Precipitation ERA5" +msgstr "Precipitation ERA5" msgid "" -"Select a smaller area or single organization unit to see the count of " -"buildings." +"Accumulated liquid and frozen water, including rain and snow, that falls to " +"the surface. Combines model data with observations from across the world." msgstr "" -"Select a smaller area or single organization unit to see the count of " -"buildings." - -msgid "Elevation" -msgstr "Elevation" +"Accumulated liquid and frozen water, including rain and snow, that falls to " +"the surface. Combines model data with observations from across the world." -msgid "meters" -msgstr "meters" +msgid "Precipitation monthly" +msgstr "Precipitation monthly" -msgid "Elevation above sea-level." -msgstr "Elevation above sea-level." +msgid "Satellite imagery (Sentinel-2)" +msgstr "Satellite imagery (Sentinel-2)" -msgid "Precipitation" -msgstr "Precipitation" - -msgid "millimeter" -msgstr "millimeter" - -msgid "" -"Precipitation collected from satellite and weather stations on the ground. " -"The values are in millimeters within 5 days periods. Updated monthly, " -"during the 3rd week of the following month." -msgstr "" -"Precipitation collected from satellite and weather stations on the ground. " -"The values are in millimeters within 5 days periods. Updated monthly, " -"during the 3rd week of the following month." +msgid "Sulfur Dioxide (SO2)" +msgstr "Sulfur Dioxide (SO2)" msgid "Temperature" msgstr "Temperature" -msgid "°C during daytime" -msgstr "°C during daytime" - msgid "" -"Land surface temperatures collected from satellite. Blank spots will appear " -"in areas with a persistent cloud cover." +"Temperature at 2m above the surface. Combines model data with observations " +"from across the world." msgstr "" -"Land surface temperatures collected from satellite. Blank spots will appear " -"in areas with a persistent cloud cover." - -msgid "Landcover" -msgstr "Landcover" - -msgid "Distinct landcover types collected from satellites." -msgstr "Distinct landcover types collected from satellites." - -msgid "Evergreen Needleleaf forest" -msgstr "Evergreen Needleleaf forest" - -msgid "Evergreen Broadleaf forest" -msgstr "Evergreen Broadleaf forest" - -msgid "Deciduous Needleleaf forest" -msgstr "Deciduous Needleleaf forest" - -msgid "Deciduous Broadleaf forest" -msgstr "Deciduous Broadleaf forest" - -msgid "Mixed forest" -msgstr "Mixed forest" - -msgid "Closed shrublands" -msgstr "Closed shrublands" - -msgid "Open shrublands" -msgstr "Open shrublands" - -msgid "Woody savannas" -msgstr "Woody savannas" - -msgid "Savannas" -msgstr "Savannas" - -msgid "Grasslands" -msgstr "Grasslands" - -msgid "Permanent wetlands" -msgstr "Permanent wetlands" - -msgid "Croplands" -msgstr "Croplands" - -msgid "Urban and built-up" -msgstr "Urban and built-up" - -msgid "Cropland/Natural vegetation mosaic" -msgstr "Cropland/Natural vegetation mosaic" +"Temperature at 2m above the surface. Combines model data with observations " +"from across the world." -msgid "Snow and ice" -msgstr "Snow and ice" +msgid "Max temperature" +msgstr "Max temperature" -msgid "Barren or sparsely vegetated" -msgstr "Barren or sparsely vegetated" - -msgid "Water" -msgstr "Water" +msgid "" +"Max temperature at 2m above the surface. Combines model data with " +"observations from across the world." +msgstr "" +"Max temperature at 2m above the surface. Combines model data with " +"observations from across the world." -msgid "people per km²" -msgstr "people per km²" +msgid "Temperature monthly" +msgstr "Temperature monthly" -msgid "Nighttime lights" -msgstr "Nighttime lights" +msgid "Cropland" +msgstr "Cropland" -msgid "light intensity" -msgstr "light intensity" +msgid "Ecoregions" +msgstr "Ecoregions" -msgid "" -"Light intensity from cities, towns, and other sites with persistent " -"lighting, including gas flares." -msgstr "" -"Light intensity from cities, towns, and other sites with persistent " -"lighting, including gas flares." +msgid "Rivers" +msgstr "Rivers" msgid "All" msgstr "All" @@ -1196,6 +1338,9 @@ msgstr "Equal counts" msgid "Symbol" msgstr "Symbol" +msgid "Relative" +msgstr "Relative" + msgid "Daily" msgstr "Daily" @@ -1274,9 +1419,6 @@ msgstr "Displaying first {{pageSize}} events out of {{total}}" msgid "No data found" msgstr "No data found" -msgid "Event" -msgstr "Event" - msgid "Facilities" msgstr "Facilities" @@ -1295,9 +1437,6 @@ msgstr "Thematic layer" msgid "No data" msgstr "No data" -msgid "Tracked entity" -msgstr "Tracked entity" - msgid "No tracked entities found" msgstr "No tracked entities found" @@ -1329,9 +1468,6 @@ msgstr "" "This layer requires a Google Earth Engine account. Check the DHIS2 " "documentation for more information." -msgid "Thematic" -msgstr "Thematic" - msgid "Events" msgstr "Events" @@ -1341,9 +1477,6 @@ msgstr "Tracked entities" msgid "Org units" msgstr "Org units" -msgid "Facility" -msgstr "Facility" - msgid "Start date is invalid" msgstr "Start date is invalid" diff --git a/public/images/landcover-copernicus.jpeg b/public/images/landcover-copernicus.jpeg new file mode 100644 index 000000000..7cc5b6aa9 Binary files /dev/null and b/public/images/landcover-copernicus.jpeg differ diff --git a/public/images/nitrogen-dioxide.png b/public/images/nitrogen-dioxide.png new file mode 100644 index 000000000..4d518cfd8 Binary files /dev/null and b/public/images/nitrogen-dioxide.png differ diff --git a/public/images/ozone.png b/public/images/ozone.png new file mode 100644 index 000000000..a8d8c0587 Binary files /dev/null and b/public/images/ozone.png differ diff --git a/public/images/particle-pollution.png b/public/images/particle-pollution.png new file mode 100644 index 000000000..0ab0e7e59 Binary files /dev/null and b/public/images/particle-pollution.png differ diff --git a/public/images/satellite-imagery.png b/public/images/satellite-imagery.png new file mode 100644 index 000000000..219560a2e Binary files /dev/null and b/public/images/satellite-imagery.png differ diff --git a/public/images/sulfur-dioxide.png b/public/images/sulfur-dioxide.png new file mode 100644 index 000000000..ad04c3106 Binary files /dev/null and b/public/images/sulfur-dioxide.png differ diff --git a/src/actions/layerEdit.js b/src/actions/layerEdit.js index 80037f548..f49686958 100644 --- a/src/actions/layerEdit.js +++ b/src/actions/layerEdit.js @@ -179,10 +179,15 @@ export const setPeriodName = (periodName) => ({ }) // Set period type (thematic) -export const setPeriodType = (periodType, clearPeriod = true) => ({ +export const setPeriodType = (periodType, keepPeriod) => ({ type: types.LAYER_EDIT_PERIOD_TYPE_SET, periodType, - clearPeriod, + keepPeriod, +}) + +export const setPeriodReducer = (payload) => ({ + type: types.LAYER_EDIT_PERIOD_REDUCER_SET, + payload, }) // Set period (event & thematic) @@ -234,10 +239,10 @@ export const setOrgUnitMode = (mode) => ({ payload: mode, }) -// Set layer params (EE) -export const setParams = (params) => ({ - type: types.LAYER_EDIT_PARAMS_SET, - payload: params, +// Set layer style (EE) +export const setStyle = (payload) => ({ + type: types.LAYER_EDIT_STYLE_SET, + payload, }) // Set collection filter (EE) @@ -358,3 +363,9 @@ export const setNoDataColor = (color) => ({ type: types.LAYER_EDIT_NO_DATA_COLOR_SET, payload: color, }) + +// Set period for EE layer +export const setEarthEnginePeriod = (payload) => ({ + type: types.LAYER_EDIT_EARTH_ENGINE_PERIOD_SET, + payload, +}) diff --git a/src/components/classification/Classification.js b/src/components/classification/Classification.js index 1b9031abe..5227c6168 100644 --- a/src/components/classification/Classification.js +++ b/src/components/classification/Classification.js @@ -68,7 +68,7 @@ Classification.propTypes = { setClassification: PropTypes.func.isRequired, setColorScale: PropTypes.func.isRequired, classes: PropTypes.number, - colorScale: PropTypes.string, + colorScale: PropTypes.array, method: PropTypes.number, } diff --git a/src/components/core/ColorScaleSelect.js b/src/components/core/ColorScaleSelect.js index ee3d735ac..7498ce974 100644 --- a/src/components/core/ColorScaleSelect.js +++ b/src/components/core/ColorScaleSelect.js @@ -14,12 +14,11 @@ const ColorScaleSelect = ({ palette, width, onChange, className }) => { const [isOpen, setIsOpen] = useState(false) const anchorRef = useRef() - const bins = palette.split(',').length + const bins = palette.length const scale = getColorScale(palette) const onColorScaleSelect = (scale) => { - const classes = palette.split(',').length - onChange(getColorPalette(scale, classes)) + onChange(getColorPalette(scale, bins)) setIsOpen(false) } @@ -61,7 +60,7 @@ const ColorScaleSelect = ({ palette, width, onChange, className }) => { } ColorScaleSelect.propTypes = { - palette: PropTypes.string.isRequired, + palette: PropTypes.array.isRequired, onChange: PropTypes.func.isRequired, className: PropTypes.string, width: PropTypes.number, diff --git a/src/components/core/NumberField.js b/src/components/core/NumberField.js index b0d284e03..40d7e24cf 100644 --- a/src/components/core/NumberField.js +++ b/src/components/core/NumberField.js @@ -25,7 +25,7 @@ const NumberField = ({ label={label} value={Number.isNaN(value) ? '' : String(value)} disabled={disabled} - onChange={({ value }) => onChange(value)} + onChange={({ value }) => onChange(Number(value))} /> ) diff --git a/src/components/datatable/DataTable.js b/src/components/datatable/DataTable.js index 27c8f7a99..cc7fd55c5 100644 --- a/src/components/datatable/DataTable.js +++ b/src/components/datatable/DataTable.js @@ -124,7 +124,13 @@ class DataTable extends Component { return sortDirection === 'ASC' ? a - b : b - a } - if (a !== undefined) { + // Some values are null + if ( + a !== undefined && + a !== null && + b !== undefined && + b !== null + ) { return sortDirection === 'ASC' ? a.localeCompare(b) : b.localeCompare(a) @@ -192,7 +198,6 @@ class DataTable extends Component { aggregationType, legend, } = layer - const isThematic = layerType === THEMATIC_LAYER const isOrgUnit = layerType === ORG_UNIT_LAYER const isEvent = layerType === EVENT_LAYER diff --git a/src/components/datatable/EarthEngineColumns.js b/src/components/datatable/EarthEngineColumns.js index 192127fec..bb9039857 100644 --- a/src/components/datatable/EarthEngineColumns.js +++ b/src/components/datatable/EarthEngineColumns.js @@ -11,10 +11,10 @@ const EarthEngineColumns = ({ aggregationType, legend, data }) => { if (hasClasses(aggregationType) && items) { const valueFormat = numberPrecision(2) - return items.map(({ id, name }) => ( + return items.map(({ value, name }) => ( { )} cellRenderer={(d) => - d.cellData !== undefined ? valueFormat(d.cellData) : '' + d.cellData !== undefined && d.cellData !== null + ? valueFormat(d.cellData) + : '' } /> ) diff --git a/src/components/earthEngine/EarthEngineLayer.js b/src/components/earthEngine/EarthEngineLayer.js new file mode 100644 index 000000000..ef89046bb --- /dev/null +++ b/src/components/earthEngine/EarthEngineLayer.js @@ -0,0 +1,35 @@ +import i18n from '@dhis2/d2-i18n' +import PropTypes from 'prop-types' +import React from 'react' +import { Checkbox } from '../core/index.js' +import styles from './styles/EarthEngineLayer.module.css' + +const EarthEngineLayer = ({ layer, isAdded, onShow, onHide }) => { + const { layerId, name, img, description, source } = layer + + return ( +
(isAdded ? onHide(layerId) : onShow(layerId))} + > + {}} /> + +
+

{name}

+

{description}

+
+ {i18n.t('Source')}: {source} +
+
+
+ ) +} + +EarthEngineLayer.propTypes = { + isAdded: PropTypes.bool.isRequired, + layer: PropTypes.object.isRequired, + onHide: PropTypes.func.isRequired, + onShow: PropTypes.func.isRequired, +} + +export default EarthEngineLayer diff --git a/src/components/earthEngine/EarthEngineModal.js b/src/components/earthEngine/EarthEngineModal.js new file mode 100644 index 000000000..7f55b324d --- /dev/null +++ b/src/components/earthEngine/EarthEngineModal.js @@ -0,0 +1,58 @@ +import i18n from '@dhis2/d2-i18n' +import { + Modal, + ModalTitle, + ModalContent, + ModalActions, + Button, + ButtonStrip, +} from '@dhis2/ui' +import PropTypes from 'prop-types' +import React from 'react' +import useEarthEngineLayers from '../../hooks/useEarthEngineLayersStore.js' +import EarthEngineLayer from './EarthEngineLayer.js' +import earthEngineLayers from '../../constants/earthEngineLayers/index.js' +import styles from './styles/EarthEngineModal.module.css' + +const layers = earthEngineLayers + .filter((l) => !l.legacy) + .sort((a, b) => a.name.localeCompare(b.name)) + +const EarthEngineModal = ({ onClose }) => { + const { addedLayers, addLayer, removeLayer } = useEarthEngineLayers() + + return ( + + {i18n.t('Configure available layers')} + +
+ {i18n.t( + 'Choose which layers are available to add to maps. This setting applies to all users.' + )} +
+ {layers.map((layer) => ( + + ))} +
+ + + + + +
+ ) +} + +EarthEngineModal.propTypes = { + onClose: PropTypes.func.isRequired, +} + +export default EarthEngineModal diff --git a/src/components/earthEngine/ManageLayersButton.js b/src/components/earthEngine/ManageLayersButton.js new file mode 100644 index 000000000..aab529b0d --- /dev/null +++ b/src/components/earthEngine/ManageLayersButton.js @@ -0,0 +1,30 @@ +import { useCachedDataQuery } from '@dhis2/analytics' +import i18n from '@dhis2/d2-i18n' +import { Button } from '@dhis2/ui' +import PropTypes from 'prop-types' +import React from 'react' +import { MAPS_ADMIN_AUTHORITY_ID } from '../../constants/settings.js' +import styles from './styles/ManageLayersButton.module.css' + +const ManageLayersButton = ({ onClick }) => { + const { currentUser } = useCachedDataQuery() + const isMapsAdmin = currentUser.authorities.has(MAPS_ADMIN_AUTHORITY_ID) + + if (!isMapsAdmin) { + return null + } + + return ( +
+ +
+ ) +} + +ManageLayersButton.propTypes = { + onClick: PropTypes.func.isRequired, +} + +export default ManageLayersButton diff --git a/src/components/earthEngine/styles/EarthEngineLayer.module.css b/src/components/earthEngine/styles/EarthEngineLayer.module.css new file mode 100644 index 000000000..0f0da0989 --- /dev/null +++ b/src/components/earthEngine/styles/EarthEngineLayer.module.css @@ -0,0 +1,52 @@ +.layer { + display: flex; + align-items: center; + color: var(--colors-grey800); + font-size: 0.9rem; + border: 1px solid var(--colors-grey300); + border-radius: 4px; + padding: var(--spacers-dp16) var(--spacers-dp8); + margin-bottom: var(--spacers-dp8); + margin-right: var(--spacers-dp8); + cursor: pointer; +} + +.layer:hover { + border-color: var(--colors-grey400); +} + +.layer input { + cursor: pointer; +} + +.layer img { + display: block; + box-sizing: border-box; + border: 1px solid var(--colors-grey400); + width: 120px; + height: 120px; + margin-left: var(--spacers-dp16); + margin-right: var(--spacers-dp16); +} + +.layerInfo { + align-self: start; +} + +.layerInfo h2 { + margin: 0; + padding-top: 2px; + font-size: 0.9rem; + font-weight: 500; +} + +.layerInfo p { + color: var(--colors-grey700); + margin: var(--spacers-dp8) 0; + line-height: 1.2rem; +} + +.layerInfo .source { + color: var(--colors-grey600); + padding-top: var(--spacers-dp8); +} diff --git a/src/components/earthEngine/styles/EarthEngineModal.module.css b/src/components/earthEngine/styles/EarthEngineModal.module.css new file mode 100644 index 000000000..b3f2cd5e2 --- /dev/null +++ b/src/components/earthEngine/styles/EarthEngineModal.module.css @@ -0,0 +1,10 @@ +.description { + font-size: 0.9rem; + line-height: 1.5rem; + padding-bottom: var(--spacers-dp12); +} + +.layersTable { + border-collapse: collapse; + width: 100%; +} diff --git a/src/components/earthEngine/styles/ManageLayersButton.module.css b/src/components/earthEngine/styles/ManageLayersButton.module.css new file mode 100644 index 000000000..ffea82b56 --- /dev/null +++ b/src/components/earthEngine/styles/ManageLayersButton.module.css @@ -0,0 +1,4 @@ +.button { + margin-left: var(--spacers-dp16); + margin-bottom: var(--spacers-dp12); +} diff --git a/src/components/edit/LayerEdit.js b/src/components/edit/LayerEdit.js index 9c2002a4d..ffc1d9162 100644 --- a/src/components/edit/LayerEdit.js +++ b/src/components/edit/LayerEdit.js @@ -32,12 +32,11 @@ const layerType = { } const layerName = () => ({ - event: i18n.t('event'), - trackedEntity: i18n.t('tracked entity'), - facility: i18n.t('facility'), - thematic: i18n.t('thematic'), - orgUnit: i18n.t('org unit'), - earthEngine: i18n.t('Earth Engine'), + event: i18n.t('Event'), + trackedEntity: i18n.t('Tracked entity'), + facility: i18n.t('Facility'), + thematic: i18n.t('Thematic'), + orgUnit: i18n.t('Org unit'), }) const LayerEdit = ({ layer, addLayer, updateLayer, cancelLayer }) => { @@ -80,19 +79,11 @@ const LayerEdit = ({ layer, addLayer, updateLayer, cancelLayer }) => { return null } - let name = layerName()[type] - - if (type === EARTH_ENGINE_LAYER) { - name = layer.name.toLowerCase() - } - - const title = layer.id - ? i18n.t('Edit {{name}} layer', { name }) - : i18n.t('Add new {{name}} layer', { name }) + const name = type === EARTH_ENGINE_LAYER ? layer.name : layerName()[type] return ( - {title} + {i18n.t('{{name}} layer', { name })}
{ const [tab, setTab] = useState('data') - const [periods, setPeriods] = useState() const [error, setError] = useState() const { - layerId, - datasetId, - band, - rows, - params, - filter, + aggregations, areaRadius, + band, + bands, + datasetId, + defaultAggregations, + description, + filters, + maskOperator, + notice, + orgUnitField, orgUnits, + rows, setOrgUnits, - orgUnitField, - setFilter, + source, + sourceUrl, + style, + period, + periodType, setBufferRadius, + setEarthEnginePeriod, + unit, validateLayer, onLayerValidation, } = props - const dataset = getEarthEngineLayer(layerId) - - const { - description, - notice, - periodType, - bands, - filters = defaultFilters, - unit, - source, - sourceUrl, - aggregations, - } = dataset - - const period = getPeriodFromFilter(filter) - - const setPeriod = useCallback( - (period) => setFilter(period ? filters(period) : null), - [filters, setFilter] - ) - const noBandSelected = Array.isArray(bands) && (!band || !band.length) + const hasAggregations = !!(aggregations || defaultAggregations) const hasMultipleAggregations = !aggregations || aggregations.length > 1 const hasOrgUnitField = !!orgUnitField && orgUnitField !== NONE - // Load all available periods - useEffect(() => { - let isCancelled = false - - if (periodType) { - getPeriods(datasetId, periodType) - .then((periods) => { - if (!isCancelled) { - setPeriods(periods) - } - }) - .catch((error) => - setError({ - type: 'engine', - message: error.message, - }) - ) - } - - return () => (isCancelled = true) - }, [datasetId, periodType]) - - // Set most recent period by default - useEffect(() => { - if (filter === undefined) { - if (Array.isArray(periods) && periods.length) { - setPeriod(periods[0]) - } - } - }, [periods, filter, setPeriod]) - // Set default org unit level useEffect(() => { if (!rows) { @@ -131,7 +82,9 @@ const EarthEngineDialog = (props) => { useEffect(() => { if (validateLayer) { - const isValid = !noBandSelected && (!periodType || period) + const isValid = + !noBandSelected && + (!periodType || periodType === 'range' || period) if (!isValid) { if (noBandSelected) { @@ -205,7 +158,7 @@ const EarthEngineDialog = (props) => { } /> )} - + {hasAggregations && } {unit && (
{i18n.t('Unit')}: {unit} @@ -225,11 +178,12 @@ const EarthEngineDialog = (props) => { )} {tab === 'period' && ( { {tab === 'style' && ( )} @@ -251,29 +207,46 @@ const EarthEngineDialog = (props) => { EarthEngineDialog.propTypes = { datasetId: PropTypes.string.isRequired, - layerId: PropTypes.string.isRequired, setBufferRadius: PropTypes.func.isRequired, - setFilter: PropTypes.func.isRequired, + setEarthEnginePeriod: PropTypes.func.isRequired, setOrgUnits: PropTypes.func.isRequired, validateLayer: PropTypes.bool.isRequired, onLayerValidation: PropTypes.func.isRequired, + aggregations: PropTypes.array, areaRadius: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), band: PropTypes.oneOfType([PropTypes.string, PropTypes.array]), - filter: PropTypes.array, + bands: PropTypes.array, + defaultAggregations: PropTypes.oneOfType([ + PropTypes.array, + PropTypes.string, + ]), + description: PropTypes.string, + filters: PropTypes.array, legend: PropTypes.object, + maskOperator: PropTypes.string, + notice: PropTypes.string, orgUnitField: PropTypes.string, orgUnits: PropTypes.object, - params: PropTypes.shape({ - max: PropTypes.number.isRequired, - min: PropTypes.number.isRequired, - palette: PropTypes.string.isRequired, - }), + period: PropTypes.object, + periodType: PropTypes.string, + precision: PropTypes.number, rows: PropTypes.array, + source: PropTypes.string, + sourceUrl: PropTypes.string, + style: PropTypes.oneOfType([ + PropTypes.array, + PropTypes.shape({ + max: PropTypes.number, + min: PropTypes.number, + palette: PropTypes.array, + }), + ]), + unit: PropTypes.string, } export default connect( null, - { setOrgUnits, setFilter, setBufferRadius }, + { setOrgUnits, setEarthEnginePeriod, setBufferRadius }, null, { forwardRef: true, diff --git a/src/components/edit/earthEngine/LegendPreview.js b/src/components/edit/earthEngine/LegendPreview.js index f84c04baa..aef7146f0 100644 --- a/src/components/edit/earthEngine/LegendPreview.js +++ b/src/components/edit/earthEngine/LegendPreview.js @@ -5,11 +5,12 @@ import { createLegend } from '../../../loaders/earthEngineLoader.js' import LegendItem from '../../legend/LegendItem.js' import styles from '../styles/LayerDialog.module.css' -const paramsAreValid = ({ min, max }) => +const styleIsValid = ({ min, max }) => !Number.isNaN(min) && !Number.isNaN(max) && max > min -const LegendPreview = ({ params }) => { - const legend = paramsAreValid(params) && createLegend(params) +const LegendPreview = ({ style, showBelowMin, precision }) => { + const legend = + styleIsValid(style) && createLegend(style, showBelowMin, precision) return legend ? (
@@ -28,10 +29,12 @@ const LegendPreview = ({ params }) => { } LegendPreview.propTypes = { - params: PropTypes.shape({ + precision: PropTypes.number, + showBelowMin: PropTypes.bool, + style: PropTypes.shape({ max: PropTypes.number.isRequired, min: PropTypes.number.isRequired, - palette: PropTypes.string.isRequired, + palette: PropTypes.array.isRequired, }), } diff --git a/src/components/edit/earthEngine/PeriodReducer.js b/src/components/edit/earthEngine/PeriodReducer.js new file mode 100644 index 000000000..d0f48f67d --- /dev/null +++ b/src/components/edit/earthEngine/PeriodReducer.js @@ -0,0 +1,154 @@ +import i18n from '@dhis2/d2-i18n' +import { CircularLoader } from '@dhis2/ui' +import PropTypes from 'prop-types' +import React, { useState, useCallback, useEffect } from 'react' +import { useSelector, useDispatch } from 'react-redux' +import { setPeriodReducer } from '../../../actions/layerEdit.js' +import { START_END_DATES } from '../../../constants/periods.js' +import { getTimeRange, createTimeRange } from '../../../util/earthEngine.js' +import { SelectField } from '../../core/index.js' +import PeriodSelect from '../../periods/PeriodSelect.js' +import PeriodTypeSelect from '../../periods/PeriodTypeSelect.js' +import StartEndDates from './StartEndDates.js' +import styles from './styles/PeriodReducer.module.css' + +// TOOD: Remove reducers that are less relevant +const periodReducers = [ + { + id: 'mean', + name: i18n.t('Mean'), + }, + { + id: 'sum', + name: i18n.t('Sum'), + }, + { + id: 'min', + name: i18n.t('Min'), + }, + { + id: 'max', + name: i18n.t('Max'), + }, + { + id: 'median', + name: i18n.t('Median'), + }, + { + id: 'count', + name: i18n.t('Count'), + }, + { + id: 'mode', + name: i18n.t('Mode'), + }, + { + id: 'product', + name: i18n.t('Product'), + }, +] + +const EarthEnginePeriodReducer = ({ + datasetId, + period, + defaultPeriodType, + range, + // reducer, + onChange, + errorText, + className, +}) => { + const [periodType, setPeriodType] = useState(defaultPeriodType) + const [dateRange, setDateRange] = useState() + const reducer = useSelector((state) => state.layerEdit.periodReducer) + const dispatch = useDispatch() + + const onPeriodChange = useCallback( + (period) => onChange(period ? { ...period, periodType } : null), + [periodType, onChange] + ) + + const onPeriodReducerChange = useCallback( + (reducer) => { + dispatch(setPeriodReducer(reducer.id)) + }, + [dispatch] + ) + + useEffect(() => { + if (range) { + setDateRange(createTimeRange(range)) + } else { + getTimeRange(datasetId).then(setDateRange) + } + }, [datasetId, range]) + + // Clear period when periodType is changed + useEffect(() => { + onChange() + }, [periodType, onChange]) + + return ( +
+ setPeriodType(period.id)} + /> + {dateRange && periodType ? ( + <> + {periodType === START_END_DATES ? ( + + ) : ( + + )} + {periodType !== 'DAILY' && ( + + )} + + ) : ( +
+ + {i18n.t('Loading periods')} +
+ )} +
+ ) +} + +EarthEnginePeriodReducer.propTypes = { + datasetId: PropTypes.string.isRequired, + defaultPeriodType: PropTypes.string.isRequired, + onChange: PropTypes.func.isRequired, + className: PropTypes.string, + errorText: PropTypes.string, + period: PropTypes.object, + range: PropTypes.shape({ + firstDate: PropTypes.string.isRequired, + lastDate: PropTypes.number.isRequired, // relative to today + }), +} + +export default EarthEnginePeriodReducer diff --git a/src/components/edit/earthEngine/PeriodSelect.js b/src/components/edit/earthEngine/PeriodSelect.js index 63b3efc1a..e5f936b95 100644 --- a/src/components/edit/earthEngine/PeriodSelect.js +++ b/src/components/edit/earthEngine/PeriodSelect.js @@ -2,20 +2,24 @@ import i18n from '@dhis2/d2-i18n' import { CircularLoader } from '@dhis2/ui' import PropTypes from 'prop-types' import React, { useState, useMemo, useCallback, useEffect } from 'react' +import { getPeriods } from '../../../util/earthEngine.js' import { SelectField } from '../../core/index.js' import styles from './styles/PeriodSelect.module.css' -// http://localhost:8080/api/periodTypes.json const EarthEnginePeriodSelect = ({ periodType, period, - periods, + datasetId, + filters, onChange, + onError, errorText, className, }) => { + const [periods, setPeriods] = useState() + const [yearPeriods, setYearPeriods] = useState() const [year, setYear] = useState() - const byYear = periodType === 'Custom' + const byYear = periodType === 'BY_YEAR' || periodType === 'EE_MONTHLY' const years = useMemo( () => @@ -28,29 +32,65 @@ const EarthEnginePeriodSelect = ({ [byYear, periods] ) - const byYearPeriods = useMemo( - () => - byYear && year && periods - ? periods.filter((p) => p.year === year) - : null, - [byYear, year, periods] - ) + const onYearChange = useCallback(({ id }) => { + setYear(id) + }, []) - const onYearChange = useCallback( - ({ id }) => { - onChange(null) - setYear(id) - }, - [onChange] - ) + useEffect(() => { + let isCancelled = false + if (periodType) { + getPeriods(datasetId, periodType, filters) + .then((periods) => { + if (!isCancelled) { + setPeriods(periods) + } + }) + .catch((error) => + onError({ + type: 'engine', + message: error.message, + }) + ) + } + + return () => (isCancelled = true) + }, [datasetId, periodType, filters, onError]) + + // Set year from period useEffect(() => { - if (byYear && period) { + if (!year && byYear && period) { setYear(period.year) } - }, [byYear, period]) + }, [year, byYear, period]) + + // Set most recent period by default + useEffect(() => { + if (!period && Array.isArray(periods) && periods.length) { + onChange(periods[0]) + } + }, [period, periods, onChange]) + + // Set avaiable periods for one year + useEffect(() => { + if (byYear && year && periods) { + setYearPeriods(periods.filter((p) => p.year === year)) + } + }, [year, byYear, periods]) + + // If year is changed, set most recent period for one year + useEffect(() => { + if ( + byYear && + period && + yearPeriods && + !yearPeriods.find(({ id }) => id === period.id) + ) { + onChange(yearPeriods[0]) + } + }, [byYear, period, yearPeriods]) - const items = byYear ? byYearPeriods : periods + const items = yearPeriods || periods return items ? (
@@ -67,7 +107,12 @@ const EarthEnginePeriodSelect = ({ label={i18n.t('Period')} loading={!periods} items={items} - value={items && period && period.id} + value={ + items && + period && + items.find(({ id }) => id === period.id) && + period.id + } onChange={onChange} helpText={i18n.t( 'Available periods are set by the source data' @@ -85,12 +130,14 @@ const EarthEnginePeriodSelect = ({ } EarthEnginePeriodSelect.propTypes = { + datasetId: PropTypes.string.isRequired, periodType: PropTypes.string.isRequired, onChange: PropTypes.func.isRequired, + onError: PropTypes.func.isRequired, className: PropTypes.string, errorText: PropTypes.string, + filters: PropTypes.array, period: PropTypes.object, - periods: PropTypes.oneOfType([PropTypes.array, PropTypes.object]), } export default EarthEnginePeriodSelect diff --git a/src/components/edit/earthEngine/PeriodTab.js b/src/components/edit/earthEngine/PeriodTab.js new file mode 100644 index 000000000..972dcfa97 --- /dev/null +++ b/src/components/edit/earthEngine/PeriodTab.js @@ -0,0 +1,64 @@ +import { DAILY, WEEKLY } from '@dhis2/analytics' +import PropTypes from 'prop-types' +import React from 'react' +import PeriodReducer from './PeriodReducer.js' +import PeriodSelect from './PeriodSelect.js' + +const reducerPeriods = [DAILY, WEEKLY] + +const EarthEnginePeriodTab = ({ + datasetId, + filters, + periodType, + period, + periodRange, + periodReducer, + onChange, + onError, + errorText, + className, +}) => { + return ( +
+ {reducerPeriods.includes(periodType) ? ( + + ) : ( + + )} +
+ ) +} + +EarthEnginePeriodTab.propTypes = { + datasetId: PropTypes.string.isRequired, + periodType: PropTypes.string.isRequired, + onChange: PropTypes.func.isRequired, + onError: PropTypes.func.isRequired, + className: PropTypes.string, + errorText: PropTypes.string, + filters: PropTypes.array, + period: PropTypes.object, + periodRange: PropTypes.shape({ + firstDate: PropTypes.string.isRequired, + lastDate: PropTypes.number.isRequired, // relative to today + }), + periodReducer: PropTypes.string, +} + +export default EarthEnginePeriodTab diff --git a/src/components/edit/earthEngine/StartEndDates.js b/src/components/edit/earthEngine/StartEndDates.js new file mode 100644 index 000000000..6dbf527e6 --- /dev/null +++ b/src/components/edit/earthEngine/StartEndDates.js @@ -0,0 +1,68 @@ +import i18n from '@dhis2/d2-i18n' +import PropTypes from 'prop-types' +import React, { useEffect } from 'react' +import { DatePicker } from '../../core/index.js' +import styles from '../styles/LayerDialog.module.css' + +const StartEndDates = ({ + dateRange, + period, + onChange, + errorText, + className, +}) => { + useEffect(() => { + const { firstDate, lastDate } = dateRange + + if (!period) { + onChange({ + startDate: firstDate, // TODO: End date minus days + endDate: lastDate, + }) + } + }, [dateRange, period, onChange]) + + if (!period) { + return null + } + + const { startDate, endDate } = period + + const setStartDate = (startDate) => onChange({ startDate, endDate }) + const setEndDate = (endDate) => onChange({ startDate, endDate }) + + return ( + <> + + + {errorText && ( +
+ {errorText} +
+ )} + + ) +} + +StartEndDates.propTypes = { + dateRange: PropTypes.object.isRequired, + onChange: PropTypes.func.isRequired, + className: PropTypes.string, + errorText: PropTypes.string, + period: PropTypes.shape({ + endDate: PropTypes.string.isRequired, + startDate: PropTypes.string.isRequired, + }), +} + +export default StartEndDates diff --git a/src/components/edit/earthEngine/StyleSelect.js b/src/components/edit/earthEngine/StyleSelect.js index 383f959ad..ef6bef5c9 100644 --- a/src/components/edit/earthEngine/StyleSelect.js +++ b/src/components/edit/earthEngine/StyleSelect.js @@ -2,7 +2,7 @@ import i18n from '@dhis2/d2-i18n' import PropTypes from 'prop-types' import React, { useState, useCallback } from 'react' import { connect } from 'react-redux' -import { setParams } from '../../../actions/layerEdit.js' +import { setStyle } from '../../../actions/layerEdit.js' import { getColorScale, getColorPalette } from '../../../util/colors.js' import { NumberField, ColorScaleSelect } from '../../core/index.js' import styles from '../styles/LayerDialog.module.css' @@ -10,9 +10,25 @@ import styles from '../styles/LayerDialog.module.css' const minSteps = 3 const maxSteps = 9 -const StyleSelect = ({ unit, params, setParams }) => { - const { min, max, palette } = params - const [steps, setSteps] = useState(palette.split(',').length) +const countDecimals = (number) => { + if (Math.floor(number.valueOf()) === number.valueOf()) { + return 0 + } + + var str = number.toString() + if (str.indexOf('.') !== -1 && str.indexOf('-') !== -1) { + return str.split('-')[1] || 0 + } else if (str.indexOf('.') !== -1) { + return str.split('.')[1].length || 0 + } + return str.split('-')[1] || 0 +} + +const getStep = (number) => 1 / Math.pow(10, countDecimals(number)) + +const StyleSelect = ({ unit, style, setStyle }) => { + const { min, max, palette } = style + const [steps, setSteps] = useState(palette.length) const onStepsChange = useCallback( (steps) => { @@ -21,15 +37,16 @@ const StyleSelect = ({ unit, params, setParams }) => { const newPalette = getColorPalette(scale, steps) if (newPalette) { - setParams({ palette: newPalette }) + setStyle({ palette: newPalette }) } } setSteps(steps) }, - [palette, setParams] + [palette, setStyle] ) + const numberFieldStep = Math.min(getStep(min), getStep(max)) let warningText if (Number.isNaN(min)) { @@ -48,22 +65,21 @@ const StyleSelect = ({ unit, params, setParams }) => { return (

- {i18n.t('Unit: {{ unit }}', { - unit, - nsSeparator: '|', // https://github.com/i18next/i18next/issues/361 - })} + {i18n.t('Unit')}: {unit}

setParams({ min: parseInt(min) })} + step={numberFieldStep} + onChange={(min) => setStyle({ min })} className={styles.flexInnerColumn} /> setParams({ max: parseInt(max) })} + step={numberFieldStep} + onChange={(max) => setStyle({ max })} className={styles.flexInnerColumn} /> { min={minSteps} max={maxSteps} onChange={onStepsChange} - className={styles.flexInnerColumn} + className={styles.stepField} /> {warningText && (
{warningText}
)}
setParams({ palette })} + palette={style.palette} + onChange={(palette) => setStyle({ palette })} width={260} />
@@ -90,15 +106,15 @@ const StyleSelect = ({ unit, params, setParams }) => { } StyleSelect.propTypes = { - setParams: PropTypes.func.isRequired, + setStyle: PropTypes.func.isRequired, unit: PropTypes.string.isRequired, - params: PropTypes.shape({ + style: PropTypes.shape({ max: PropTypes.number.isRequired, min: PropTypes.number.isRequired, - palette: PropTypes.string.isRequired, + palette: PropTypes.array.isRequired, }), } export default connect(null, { - setParams, + setStyle, })(StyleSelect) diff --git a/src/components/edit/earthEngine/StyleTab.js b/src/components/edit/earthEngine/StyleTab.js index c065162e8..849280a99 100644 --- a/src/components/edit/earthEngine/StyleTab.js +++ b/src/components/edit/earthEngine/StyleTab.js @@ -1,3 +1,4 @@ +import i18n from '@dhis2/d2-i18n' import PropTypes from 'prop-types' import React from 'react' import { EE_BUFFER } from '../../../constants/layers.js' @@ -6,27 +7,50 @@ import styles from '../styles/LayerDialog.module.css' import LegendPreview from './LegendPreview.js' import StyleSelect from './StyleSelect.js' -const StyleTab = ({ unit, params, hasOrgUnitField }) => ( -
-
- {params && } - +const StyleTab = ({ unit, style, showBelowMin, hasOrgUnitField }) => { + const { min, max, palette } = style + const isClassStyle = + min !== undefined && + max !== undefined && + palette !== undefined && + palette.length < 10 + + return ( +
+
+ {isClassStyle && } + +
+ {isClassStyle && ( + + )}
- {params && } -
-) + ) +} StyleTab.propTypes = { hasOrgUnitField: PropTypes.bool.isRequired, - unit: PropTypes.string.isRequired, - params: PropTypes.shape({ - max: PropTypes.number.isRequired, - min: PropTypes.number.isRequired, - palette: PropTypes.string.isRequired, - }), + precision: PropTypes.number, + showBelowMin: PropTypes.bool, + style: PropTypes.oneOfType([ + PropTypes.array, + PropTypes.shape({ + color: PropTypes.string, + max: PropTypes.number, + min: PropTypes.number, + palette: PropTypes.array, + strokeWidth: PropTypes.number, + }), + ]), + unit: PropTypes.string, } export default StyleTab diff --git a/src/components/edit/earthEngine/styles/PeriodReducer.module.css b/src/components/edit/earthEngine/styles/PeriodReducer.module.css new file mode 100644 index 000000000..8a5cad7e3 --- /dev/null +++ b/src/components/edit/earthEngine/styles/PeriodReducer.module.css @@ -0,0 +1,20 @@ +.periodSelect { + width: auto; + max-width: 360px; +} + +.reducer { + max-width: 280px; +} + +.loading { + display: flex; + align-items: center; + font-style: italic; + padding-top: var(--spacers-dp12); +} + +.loading div { + display: inline-block; + margin-right: var(--spacers-dp24); +} diff --git a/src/components/edit/shared/BufferRadius.js b/src/components/edit/shared/BufferRadius.js index 68d347621..82776e585 100644 --- a/src/components/edit/shared/BufferRadius.js +++ b/src/components/edit/shared/BufferRadius.js @@ -16,6 +16,7 @@ import styles from './styles/BufferRadius.module.css' const BufferRadius = ({ radius, defaultRadius = 1000, + label, hasOrgUnitField, disabled, className, @@ -27,7 +28,7 @@ const BufferRadius = ({ return (
@@ -63,6 +64,7 @@ BufferRadius.propTypes = { defaultRadius: PropTypes.number, disabled: PropTypes.bool, hasOrgUnitField: PropTypes.bool, + label: PropTypes.string, radius: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), } diff --git a/src/components/edit/styles/LayerDialog.module.css b/src/components/edit/styles/LayerDialog.module.css index 26111ae2e..7abe8a9e8 100644 --- a/src/components/edit/styles/LayerDialog.module.css +++ b/src/components/edit/styles/LayerDialog.module.css @@ -105,6 +105,14 @@ max-width: 140px; } +.stepField { + max-width: 50px; +} + +.stepField input { + width: 50px; +} + .checkbox { margin: 8px 0 0 -8px; } diff --git a/src/components/edit/thematic/ThematicDialog.js b/src/components/edit/thematic/ThematicDialog.js index 73873206f..80912a0a2 100644 --- a/src/components/edit/thematic/ThematicDialog.js +++ b/src/components/edit/thematic/ThematicDialog.js @@ -420,6 +420,7 @@ class ThematicDialog extends Component { { const [isOpen, setIsOpen] = useState(false) + const [isManaging, setIsManaging] = useState(false) const buttonRef = useRef() + const toggleDialog = () => setIsOpen(!isOpen) + const onManaging = () => { + setIsManaging(true) + setIsOpen(false) + } + return ( <>
@@ -24,7 +32,14 @@ const AddLayerButton = () => {
{isOpen && ( - + + )} + {isManaging && ( + setIsManaging(false)} /> )} ) diff --git a/src/components/layers/overlays/AddLayerPopover.js b/src/components/layers/overlays/AddLayerPopover.js index dca19f96d..56b3c79e2 100644 --- a/src/components/layers/overlays/AddLayerPopover.js +++ b/src/components/layers/overlays/AddLayerPopover.js @@ -2,23 +2,47 @@ import { useCachedDataQuery } from '@dhis2/analytics' import { Popover } from '@dhis2/ui' import PropTypes from 'prop-types' import React from 'react' -import { connect } from 'react-redux' +import { useSelector, useDispatch } from 'react-redux' import { addLayer, editLayer } from '../../../actions/layers.js' import { EXTERNAL_LAYER } from '../../../constants/layers.js' +import useEarthEngineLayers from '../../../hooks/useEarthEngineLayersStore.js' import { isSplitViewMap } from '../../../util/helpers.js' +import earthEngineLayers from '../../../constants/earthEngineLayers/index.js' +import ManageLayersButton from '../../earthEngine/ManageLayersButton.js' import LayerList from './LayerList.js' -const AddLayerPopover = ({ - anchorEl, - isSplitView, - addLayer, - editLayer, - onClose, -}) => { +const includeEarthEngineLayers = (layerTypes, addedLayers) => { + // Earth Engine layers that are added to this DHIS2 instance + const eeLayers = earthEngineLayers + .filter((l) => !l.legacy && addedLayers.includes(l.layerId)) + .sort((a, b) => a.name.localeCompare(b.name)) + + // Make copy before slicing below + const layers = [...layerTypes] + + // Insert Earth Engine layers before external layers + layers.splice(5, 0, ...eeLayers) + + return layers +} + +const AddLayerPopover = ({ anchorEl, onClose, onManaging }) => { + const isSplitView = useSelector((state) => + isSplitViewMap(state.map.mapViews) + ) + const dispatch = useDispatch() const { layerTypes } = useCachedDataQuery() + const { addedLayers } = useEarthEngineLayers() + const layers = includeEarthEngineLayers(layerTypes, addedLayers) + const onLayerSelect = (layer) => { const config = { ...layer } - layer.layer === EXTERNAL_LAYER ? addLayer(config) : editLayer(config) + const layerType = layer.layer + + dispatch( + layerType === EXTERNAL_LAYER ? addLayer(config) : editLayer(config) + ) + onClose() } @@ -32,25 +56,19 @@ const AddLayerPopover = ({ dataTest="addlayerpopover" > + ) } AddLayerPopover.propTypes = { - addLayer: PropTypes.func.isRequired, - editLayer: PropTypes.func.isRequired, onClose: PropTypes.func.isRequired, + onManaging: PropTypes.func.isRequired, anchorEl: PropTypes.object, - isSplitView: PropTypes.bool, } -export default connect( - ({ map }) => ({ - isSplitView: isSplitViewMap(map.mapViews), - }), - { addLayer, editLayer } -)(AddLayerPopover) +export default AddLayerPopover diff --git a/src/components/layers/overlays/OverlayCard.js b/src/components/layers/overlays/OverlayCard.js index 7c1405dff..4f0804f4e 100644 --- a/src/components/layers/overlays/OverlayCard.js +++ b/src/components/layers/overlays/OverlayCard.js @@ -54,8 +54,8 @@ const OverlayCard = ({ isExpanded = true, opacity, isVisible, - layer: layerType, isLoaded, + layer: layerType, } = layer const canEdit = layerType !== EXTERNAL_LAYER diff --git a/src/components/loaders/LayerLoader.js b/src/components/loaders/LayerLoader.js index 41192f77a..bcc425aa6 100644 --- a/src/components/loaders/LayerLoader.js +++ b/src/components/loaders/LayerLoader.js @@ -8,7 +8,7 @@ import OrgUnitLoader from './OrgUnitLoader.js' import ThematicLoader from './ThematicLoader.js' import TrackedEntityLoader from './TrackedEntityLoader.js' -const layerType = { +const layerTypes = { earthEngine: EarthEngineLoader, event: EventLoader, external: ExternalLoader, diff --git a/src/components/map/ContextMenu.js b/src/components/map/ContextMenu.js index f938129dd..cf2f5dcaa 100644 --- a/src/components/map/ContextMenu.js +++ b/src/components/map/ContextMenu.js @@ -8,9 +8,8 @@ import { IconInfo16, IconLocation16, } from '@dhis2/ui' -import PropTypes from 'prop-types' -import React, { Fragment, useRef } from 'react' -import { connect } from 'react-redux' +import React, { useRef, useMemo, useCallback } from 'react' +import { useSelector, useDispatch } from 'react-redux' import { updateLayer } from '../../actions/layers.js' import { closeContextMenu, @@ -23,26 +22,67 @@ import { EARTH_ENGINE_LAYER, RENDERING_STRATEGY_SPLIT_BY_PERIOD, } from '../../constants/layers.js' +import { lowercaseFirstLetter } from '../../util/helpers.js' import { drillUpDown } from '../../util/map.js' import styles from './styles/ContextMenu.module.css' -const ContextMenu = (props) => { +const ContextMenu = () => { + const contextMenu = useSelector((state) => state.contextMenu || {}) + const earthEngineLayers = useSelector((state) => + state.map.mapViews.filter((view) => view.layer === EARTH_ENGINE_LAYER) + ) + const dispatch = useDispatch() const anchorRef = useRef() - const { - feature, - layerType, - layerConfig, - coordinates, - earthEngineLayers, - position, - offset, - closeContextMenu, - openCoordinatePopup, - showEarthEngineValue, - setOrgUnitProfile, - updateLayer, - } = props + const { feature, layerType, layerConfig, coordinates, position, offset } = + contextMenu + + const attr = useMemo(() => feature?.properties || {}, [feature]) + + const onClick = useCallback( + (item, id) => { + dispatch(closeContextMenu()) + + switch (item) { + case 'drill_up': + dispatch( + updateLayer( + drillUpDown( + layerConfig, + attr.grandParentId, + attr.grandParentParentGraph, + parseInt(attr.level) - 1 + ) + ) + ) + break + case 'drill_down': + dispatch( + updateLayer( + drillUpDown( + layerConfig, + attr.id, + attr.parentGraph, + parseInt(attr.level) + 1 + ) + ) + ) + break + case 'show_info': + dispatch(setOrgUnitProfile(attr.id)) + break + case 'show_coordinate': + dispatch(openCoordinatePopup(coordinates)) + break + case 'show_ee_value': + dispatch(showEarthEngineValue(id, coordinates)) + break + + default: + } + }, + [layerConfig, attr, coordinates, dispatch] + ) if (!position) { return null @@ -54,48 +94,8 @@ const ContextMenu = (props) => { const left = offset[0] + position[0] const top = offset[1] + position[1] - const attr = feature?.properties || {} - - const onClick = (item, id) => { - closeContextMenu() - - switch (item) { - case 'drill_up': - updateLayer( - drillUpDown( - layerConfig, - attr.grandParentId, - attr.grandParentParentGraph, - parseInt(attr.level) - 1 - ) - ) - break - case 'drill_down': - updateLayer( - drillUpDown( - layerConfig, - attr.id, - attr.parentGraph, - parseInt(attr.level) + 1 - ) - ) - break - case 'show_info': - setOrgUnitProfile(attr.id) - break - case 'show_coordinate': - openCoordinatePopup(coordinates) - break - case 'show_ee_value': - showEarthEngineValue(id, coordinates) - break - - default: - } - } - return ( - + <>
{ dataTest="context-menu-show-ee-value" key={layer.id} label={i18n.t('Show {{name}}', { - name: layer.name.toLowerCase(), + name: lowercaseFirstLetter(layer.name), })} icon={} onClick={() => @@ -163,38 +163,8 @@ const ContextMenu = (props) => {
-
+ ) } -ContextMenu.propTypes = { - closeContextMenu: PropTypes.func.isRequired, - openCoordinatePopup: PropTypes.func.isRequired, - setOrgUnitProfile: PropTypes.func.isRequired, - showEarthEngineValue: PropTypes.func.isRequired, - updateLayer: PropTypes.func.isRequired, - coordinates: PropTypes.array, - earthEngineLayers: PropTypes.array, - feature: PropTypes.object, - layerConfig: PropTypes.object, - layerType: PropTypes.string, - map: PropTypes.object, - offset: PropTypes.array, - position: PropTypes.array, -} - -export default connect( - ({ contextMenu, map }) => ({ - ...contextMenu, - earthEngineLayers: map.mapViews.filter( - (view) => view.layer === EARTH_ENGINE_LAYER - ), - }), - { - closeContextMenu, - openCoordinatePopup, - showEarthEngineValue, - setOrgUnitProfile, - updateLayer, - } -)(ContextMenu) +export default ContextMenu diff --git a/src/components/map/layers/earthEngine/EarthEngineLayer.js b/src/components/map/layers/earthEngine/EarthEngineLayer.js index 214f7ac13..bb5057724 100644 --- a/src/components/map/layers/earthEngine/EarthEngineLayer.js +++ b/src/components/map/layers/earthEngine/EarthEngineLayer.js @@ -19,14 +19,17 @@ export default class EarthEngineLayer extends Layer { componentDidUpdate(prev) { super.componentDidUpdate(prev) - const { coordinate } = this.props + const { coordinate, precision } = this.props if (coordinate && coordinate !== prev.coordinate) { try { - this.layer.showValue({ - lng: coordinate[0], - lat: coordinate[1], - }) + this.layer.showValue( + { + lng: coordinate[0], + lat: coordinate[1], + }, + precision + ) } catch (error) { this.setState({ error: i18n.t( @@ -57,7 +60,7 @@ export default class EarthEngineLayer extends Layer { isVisible, datasetId, band, - mask, + maskOperator, attribution, filter, methods, @@ -68,12 +71,15 @@ export default class EarthEngineLayer extends Layer { value, resolution, projection, - params, + style, popup, data, aggregationType, areaRadius, tileScale, + periodReducer, + useCentroid, + cloudScore, } = this.props const { map, isPlugin } = this.context @@ -87,9 +93,10 @@ export default class EarthEngineLayer extends Layer { isVisible, datasetId, band, - mask, + maskOperator, attribution, filter, + periodReducer, methods, mosaic, name, @@ -101,15 +108,21 @@ export default class EarthEngineLayer extends Layer { projection, data, aggregationType, + useCentroid, tileScale, + cloudScore, preload: !isPlugin && this.hasAggregations(), onClick: this.onFeatureClick.bind(this), onRightClick: this.onFeatureRightClick.bind(this), onLoad: this.onLoad.bind(this), } - if (params) { - config.params = params + if (style) { + config.style = style + } + + if (style) { + config.style = style } if (areaRadius) { diff --git a/src/components/map/layers/earthEngine/EarthEnginePopup.js b/src/components/map/layers/earthEngine/EarthEnginePopup.js index 42326ba94..2c81b7749 100644 --- a/src/components/map/layers/earthEngine/EarthEnginePopup.js +++ b/src/components/map/layers/earthEngine/EarthEnginePopup.js @@ -34,10 +34,10 @@ const EarthEnginePopup = (props) => { {items - .filter((i) => values[i.id]) - .sort((a, b) => values[b.id] - values[a.id]) - .map(({ id, name, color }) => ( - + .filter((i) => values[i.value]) + .sort((a, b) => values[b.value] - values[a.value]) + .map(({ value, name, color }) => ( + { > {name} - {valueFormat(values[id])} + {valueFormat(values[value])} {isPercentage ? '%' : ''} diff --git a/src/components/orgunits/OrgUnitData.js b/src/components/orgunits/OrgUnitData.js index bce650e02..4108d34d1 100644 --- a/src/components/orgunits/OrgUnitData.js +++ b/src/components/orgunits/OrgUnitData.js @@ -23,7 +23,7 @@ const ORGUNIT_PROFILE_QUERY = { // Only YEARLY period type is supported in first version const periodType = 'YEARLY' const currentYear = String(new Date().getFullYear()) -const periods = getFixedPeriodsByType(periodType, currentYear) +const periods = getFixedPeriodsByType({ periodType, currentYear }) const defaultPeriod = filterFuturePeriods(periods)[0] || periods[0] /* diff --git a/src/components/periods/PeriodSelect.js b/src/components/periods/PeriodSelect.js index f020e3ce4..212359ed9 100644 --- a/src/components/periods/PeriodSelect.js +++ b/src/components/periods/PeriodSelect.js @@ -7,7 +7,8 @@ import { } from '@dhis2/ui' import cx from 'classnames' import PropTypes from 'prop-types' -import React, { Component } from 'react' +import React, { useState, useMemo, useCallback, useEffect } from 'react' +import usePrevious from '../../hooks/usePrevious.js' import { getFixedPeriodsByType, filterFuturePeriods, @@ -16,124 +17,128 @@ import { getYear } from '../../util/time.js' import { SelectField } from '../core/index.js' import styles from './styles/PeriodSelect.module.css' -class PeriodSelect extends Component { - static propTypes = { - onChange: PropTypes.func.isRequired, - className: PropTypes.string, - errorText: PropTypes.string, - period: PropTypes.shape({ - id: PropTypes.string.isRequired, - startDate: PropTypes.string, - }), - periodType: PropTypes.string, - } - - state = { - year: null, - periods: null, - } - - componentDidMount() { - this.setPeriods() - } - - componentDidUpdate(prevProps, prevState) { - const { periodType, period, onChange } = this.props - const { year, periods } = this.state - - if (periodType !== prevProps.periodType) { - this.setPeriods() - } else if (periods && !period) { - onChange(filterFuturePeriods(periods)[0] || periods[0]) // Autoselect most recent period - } - - // Change period if year is changed (but keep period index) - if (period && prevState.periods && year !== prevState.year) { - const periodIndex = prevState.periods.findIndex( - (item) => item.id === period.id - ) - onChange(periods[periodIndex]) - } - } - - render() { - const { periodType, period, onChange, className, errorText } = - this.props - const { periods } = this.state - - if (!periods) { - return null +const PeriodSelect = ({ + onChange, + className, + errorText, + firstDate, + lastDate, + period, + periodType, +}) => { + const [year, setYear] = useState(getYear(period?.startDate || lastDate)) + const prevYear = usePrevious(year) + + // Set periods when periodType or year changes + /* eslint-disable react-hooks/exhaustive-deps */ + const periods = useMemo( + () => + periodType + ? getFixedPeriodsByType({ + periodType, + year, + firstDate, + lastDate, + }) + : [period], // saved map period (not included in depency array by design) + [periodType, year, firstDate, lastDate] + ) + /* eslint-enable react-hooks/exhaustive-deps */ + + const periodIndex = useMemo( + () => period && periods.findIndex((p) => p.id === period.id), + [period, periods] + ) + + const prevPeriodIndex = usePrevious(periodIndex) + + // Increment/decrement year + const changeYear = useCallback( + (change) => { + const newYear = year + change + + if ( + (!firstDate || newYear >= getYear(firstDate)) && + (!lastDate || newYear <= getYear(lastDate)) + ) { + setYear(newYear) + } + }, + [year, firstDate, lastDate] + ) + + // Autoselect most recent period + useEffect(() => { + if (!period && periods) { + onChange(filterFuturePeriods(periods)[0] || periods[0]) } + }, [period, periods, onChange]) - const value = - period && periods.some((p) => p.id === period.id) ? period.id : null - - return ( -
- - {periodType && ( -
- -
- )} -
- ) - } - - setPeriods() { - const { periodType, period } = this.props - const year = this.state.year || getYear(period && period.startDate) - let periods + // Keep the same period position when year changes + useEffect(() => { + if (year !== prevYear && prevPeriodIndex >= 0) { + const newPeriod = periods[prevPeriodIndex] - if (periodType) { - periods = getFixedPeriodsByType(periodType, year) - } else if (period) { - periods = [period] // If period is loaded in favorite + if (newPeriod) { + onChange(newPeriod) + } } + }, [year, prevYear, periods, prevPeriodIndex, onChange]) - this.setState({ periods, year }) + if (!periods) { + return null } - nextYear = () => { - this.changeYear(1) - } - - previousYear = () => { - this.changeYear(-1) - } - - changeYear = (change) => { - const { periodType } = this.props - const year = this.state.year + change + const value = + period && periods.some((p) => p.id === period.id) ? period.id : null + + // TODO: What if no periods are within firstDate and lastDate? + return ( +
+ + {periodType && ( +
+ +
+ )} +
+ ) +} - this.setState({ - year, - periods: getFixedPeriodsByType(periodType, year), - }) - } +PeriodSelect.propTypes = { + onChange: PropTypes.func.isRequired, + className: PropTypes.string, + errorText: PropTypes.string, + firstDate: PropTypes.string, + lastDate: PropTypes.string, + period: PropTypes.shape({ + id: PropTypes.string.isRequired, + startDate: PropTypes.string, + }), + periodType: PropTypes.string, } export default PeriodSelect diff --git a/src/components/periods/PeriodTypeSelect.js b/src/components/periods/PeriodTypeSelect.js index 3c04c6e98..2122545b6 100644 --- a/src/components/periods/PeriodTypeSelect.js +++ b/src/components/periods/PeriodTypeSelect.js @@ -1,7 +1,7 @@ +import { useCachedDataQuery } from '@dhis2/analytics' import i18n from '@dhis2/d2-i18n' import PropTypes from 'prop-types' -import React, { useEffect } from 'react' -import { RELATIVE_PERIODS } from '../../constants/periods.js' +import React, { useMemo, useEffect } from 'react' import { getPeriodTypes, getRelativePeriods } from '../../util/periods.js' import { SelectField } from '../core/index.js' @@ -9,31 +9,38 @@ const PeriodTypeSelect = ({ onChange, className, errorText, - hiddenPeriods, + includeRelativePeriods, period, value, }) => { + const { systemSettings } = useCachedDataQuery() + const { hiddenPeriods } = systemSettings + + const periodTypes = useMemo( + () => getPeriodTypes(includeRelativePeriods, hiddenPeriods), + [includeRelativePeriods, hiddenPeriods] + ) + + // Set default period type useEffect(() => { - const relativePeriodType = { - id: RELATIVE_PERIODS, - name: i18n.t('Relative'), - } + if (!value) { + const isRelativePeriod = !!( + includeRelativePeriods && + period && + getRelativePeriods().find((p) => p.id === period.id) + ) - if (!value && period) { - if (getRelativePeriods().find((p) => p.id === period.id)) { - // false will not clear the period dropdown - onChange(relativePeriodType, false) + if (!period || isRelativePeriod) { + // default to first period type + onChange(periodTypes[0], isRelativePeriod) } - } else if (!value) { - // set relativePeriods as default - onChange(relativePeriodType) } - }, [value, period, onChange]) + }, [value, period, periodTypes, includeRelativePeriods, onChange]) return ( [ - { - layer: EARTH_ENGINE_LAYER, - layerId: 'WorldPop/GP/100m/pop_age_sex_cons_unadj_TOTAL', - datasetId: 'WorldPop/GP/100m/pop_age_sex_cons_unadj', - name: i18n.t('Population'), - unit: i18n.t('people per hectare'), - description: i18n.t('Estimated number of people living in an area.'), - source: 'WorldPop / Google Earth Engine', - sourceUrl: - 'https://developers.google.com/earth-engine/datasets/catalog/WorldPop_GP_100m_pop_age_sex_cons_unadj', - img: 'images/population.png', - defaultAggregations: ['sum', 'mean'], - periodType: 'Yearly', - band: 'population', - filters: ({ id, name, year }) => [ - { - id, - name, - type: 'eq', - arguments: ['year', year], - }, - ], - mosaic: true, - params: { - min: 0, - max: 25, - palette: '#fee5d9,#fcbba1,#fc9272,#fb6a4a,#de2d26,#a50f15', // Reds - }, - opacity: 0.9, - }, - { - layer: EARTH_ENGINE_LAYER, - layerId: 'WorldPop/GP/100m/pop_age_sex_cons_unadj', - datasetId: 'WorldPop/GP/100m/pop_age_sex_cons_unadj', - name: i18n.t('Population age groups'), - unit: i18n.t('people per hectare'), - description: i18n.t( - 'Estimated number of people living in an area, grouped by age and gender.' - ), - source: 'WorldPop / Google Earth Engine', - sourceUrl: - 'https://developers.google.com/earth-engine/datasets/catalog/WorldPop_GP_100m_pop_age_sex_cons_unadj', - img: 'images/population.png', - periodType: 'Yearly', - defaultAggregations: ['sum', 'mean'], - bands: [ - { - id: 'M_0', - name: i18n.t('Male 0 - 1 years'), - }, - { - id: 'M_1', - name: i18n.t('Male 1 - 4 years'), - }, - { - id: 'M_5', - name: i18n.t('Male 5 - 9 years'), - }, - { - id: 'M_10', - name: i18n.t('Male 10 - 14 years'), - }, - { - id: 'M_15', - name: i18n.t('Male 15 - 19 years'), - }, - { - id: 'M_20', - name: i18n.t('Male 20 - 24 years'), - }, - { - id: 'M_25', - name: i18n.t('Male 25 - 29 years'), - }, - { - id: 'M_30', - name: i18n.t('Male 30 - 34 years'), - }, - { - id: 'M_35', - name: i18n.t('Male 35 - 39 years'), - }, - { - id: 'M_40', - name: i18n.t('Male 40 - 44 years'), - }, - { - id: 'M_45', - name: i18n.t('Male 45 - 49 years'), - }, - { - id: 'M_50', - name: i18n.t('Male 50 - 54 years'), - }, - { - id: 'M_55', - name: i18n.t('Male 55 - 59 years'), - }, - { - id: 'M_60', - name: i18n.t('Male 60 - 64 years'), - }, - { - id: 'M_65', - name: i18n.t('Male 65 - 69 years'), - }, - { - id: 'M_70', - name: i18n.t('Male 70 - 74 years'), - }, - { - id: 'M_75', - name: i18n.t('Male 75 - 79 years'), - }, - { - id: 'M_80', - name: i18n.t('Male 80 years and above'), - }, - { - id: 'F_0', - name: i18n.t('Female 0 - 1 years'), - }, - { - id: 'F_1', - name: i18n.t('Female 1 - 4 years'), - }, - { - id: 'F_5', - name: i18n.t('Female 5 - 9 years'), - }, - { - id: 'F_10', - name: i18n.t('Female 10 - 14 years'), - }, - { - id: 'F_15', - name: i18n.t('Female 15 - 19 years'), - }, - { - id: 'F_20', - name: i18n.t('Female 20 - 24 years'), - }, - { - id: 'F_25', - name: i18n.t('Female 25 - 29 years'), - }, - { - id: 'F_30', - name: i18n.t('Female 30 - 34 years'), - }, - { - id: 'F_35', - name: i18n.t('Female 35 - 39 years'), - }, - { - id: 'F_40', - name: i18n.t('Female 40 - 44 years'), - }, - { - id: 'F_45', - name: i18n.t('Female 45 - 49 years'), - }, - { - id: 'F_50', - name: i18n.t('Female 50 - 54 years'), - }, - { - id: 'F_55', - name: i18n.t('Female 55 - 59 years'), - }, - { - id: 'F_60', - name: i18n.t('Female 60 - 64 years'), - }, - { - id: 'F_65', - name: i18n.t('Female 65 - 69 years'), - }, - { - id: 'F_70', - name: i18n.t('Female 70 - 74 years'), - multiple: true, - }, - { - id: 'F_75', - name: i18n.t('Female 75 - 79 years'), - }, - { - id: 'F_80', - name: i18n.t('Female 80 years and above'), - }, - ], - filters: ({ id, name, year }) => [ - { - id, - name, - type: 'eq', - arguments: ['year', year], - }, - ], - mosaic: true, - params: { - min: 0, - max: 10, - palette: '#fee5d9,#fcbba1,#fc9272,#fb6a4a,#de2d26,#a50f15', // Reds - }, - opacity: 0.9, - tileScale: 4, - }, - { - layer: EARTH_ENGINE_LAYER, - layerId: 'GOOGLE/Research/open-buildings/v1/polygons', - datasetId: 'GOOGLE/Research/open-buildings/v1/polygons', - format: 'FeatureCollection', - name: i18n.t('Building footprints'), - unit: i18n.t('Number of buildings'), - description: i18n.t( - 'The outlines of buildings derived from high-resolution satellite imagery. Only for the continent of Africa.' - ), - notice: i18n.t( - 'Building counts are only available for smaller organisation unit areas.' - ), - error: i18n.t( - 'Select a smaller area or single organization unit to see the count of buildings.' - ), - source: 'NASA / USGS / JPL-Caltech / Google Earth Engine', - sourceUrl: 'https://sites.research.google/open-buildings/', - img: 'images/buildings.png', - aggregations: ['count'], - defaultAggregations: ['count'], - opacity: 0.9, - }, - { - layer: EARTH_ENGINE_LAYER, - layerId: 'USGS/SRTMGL1_003', - datasetId: 'USGS/SRTMGL1_003', - name: i18n.t('Elevation'), - unit: i18n.t('meters'), - description: i18n.t('Elevation above sea-level.'), - source: 'NASA / USGS / JPL-Caltech / Google Earth Engine', - sourceUrl: - 'https://explorer.earthengine.google.com/#detail/USGS%2FSRTMGL1_003', - img: 'images/elevation.png', - aggregations: ['min', 'max', 'mean', 'median', 'stdDev', 'variance'], - defaultAggregations: ['mean', 'min', 'max'], - band: 'elevation', - params: { - min: 0, - max: 1500, - palette: '#ffffd4,#fee391,#fec44f,#fe9929,#d95f0e,#993404', // YlOrBr - }, - opacity: 0.9, - }, - { - layer: EARTH_ENGINE_LAYER, - layerId: 'UCSB-CHG/CHIRPS/PENTAD', - datasetId: 'UCSB-CHG/CHIRPS/PENTAD', - name: i18n.t('Precipitation'), - unit: i18n.t('millimeter'), - description: i18n.t( - 'Precipitation collected from satellite and weather stations on the ground. The values are in millimeters within 5 days periods. Updated monthly, during the 3rd week of the following month.' - ), - source: 'UCSB / CHG / Google Earth Engine', - sourceUrl: - 'https://explorer.earthengine.google.com/#detail/UCSB-CHG%2FCHIRPS%2FPENTAD', - periodType: 'Custom', - band: 'precipitation', - aggregations: ['min', 'max', 'mean', 'median', 'stdDev', 'variance'], - defaultAggregations: ['mean', 'min', 'max'], - mask: true, - img: 'images/precipitation.png', - params: { - min: 0, - max: 100, - palette: '#eff3ff,#c6dbef,#9ecae1,#6baed6,#3182bd,#08519c', // Blues - }, - opacity: 0.9, - }, - { - layer: EARTH_ENGINE_LAYER, - layerId: 'MODIS/006/MOD11A2', - datasetId: 'MODIS/006/MOD11A2', - name: i18n.t('Temperature'), - unit: i18n.t('°C during daytime'), - description: i18n.t( - 'Land surface temperatures collected from satellite. Blank spots will appear in areas with a persistent cloud cover.' - ), - source: 'NASA LP DAAC / Google Earth Engine', - sourceUrl: - 'https://explorer.earthengine.google.com/#detail/MODIS%2FMOD11A2', - img: 'images/temperature.png', - aggregations: ['min', 'max', 'mean', 'median', 'stdDev', 'variance'], - defaultAggregations: ['mean', 'min', 'max'], - periodType: 'Custom', - band: 'LST_Day_1km', - mask: true, - methods: { - toFloat: [], - multiply: [0.02], - subtract: [273.15], - }, - params: { - min: 0, - max: 40, - palette: - '#fff5f0,#fee0d2,#fcbba1,#fc9272,#fb6a4a,#ef3b2c,#cb181d,#a50f15,#67000d', // Reds - }, - opacity: 0.9, - }, - { - layer: EARTH_ENGINE_LAYER, - layerId: 'MODIS/006/MCD12Q1', // Layer id kept for backward compability for saved maps - datasetId: 'MODIS/061/MCD12Q1', // No longer in use: 'MODIS/006/MCD12Q1' / 'MODIS/051/MCD12Q1', - name: i18n.t('Landcover'), - description: i18n.t( - 'Distinct landcover types collected from satellites.' - ), - source: 'NASA LP DAAC / Google Earth Engine', - sourceUrl: - 'https://developers.google.com/earth-engine/datasets/catalog/MODIS_061_MCD12Q1', - periodType: 'Yearly', - band: 'LC_Type1', - filters: defaultFilters, - defaultAggregations: 'percentage', - legend: { - items: [ - // http://www.eomf.ou.edu/static/IGBP.pdf - { - id: 1, - name: i18n.t('Evergreen Needleleaf forest'), - color: '#162103', - }, - { - id: 2, - name: i18n.t('Evergreen Broadleaf forest'), - color: '#235123', - }, - { - id: 3, - name: i18n.t('Deciduous Needleleaf forest'), - color: '#399b38', - }, - { - id: 4, - name: i18n.t('Deciduous Broadleaf forest'), - color: '#38eb38', - }, - { - id: 5, - name: i18n.t('Mixed forest'), - color: '#39723b', - }, - { - id: 6, - name: i18n.t('Closed shrublands'), - color: '#6a2424', - }, - { - id: 7, - name: i18n.t('Open shrublands'), - color: '#c3a55f', - }, - { - id: 8, - name: i18n.t('Woody savannas'), - color: '#b76124', - }, - { - id: 9, - name: i18n.t('Savannas'), - color: '#d99125', - }, - { - id: 10, - name: i18n.t('Grasslands'), - color: '#92af1f', - }, - { - id: 11, - name: i18n.t('Permanent wetlands'), - color: '#10104c', - }, - { - id: 12, - name: i18n.t('Croplands'), - color: '#cdb400', - }, - { - id: 13, - name: i18n.t('Urban and built-up'), - color: '#cc0202', - }, - { - id: 14, - name: i18n.t('Cropland/Natural vegetation mosaic'), - color: '#332808', - }, - { - id: 15, - name: i18n.t('Snow and ice'), - color: '#d7cdcc', - }, - { - id: 16, - name: i18n.t('Barren or sparsely vegetated'), - color: '#f7e174', - }, - { - id: 17, - name: i18n.t('Water'), - color: '#aec3d6', - }, - ], - }, - mask: false, - popup: '{name}: {value}', - img: 'images/landcover.png', - opacity: 0.9, - }, - { - layer: EARTH_ENGINE_LAYER, - legacy: true, // Kept for backward compability - layerId: 'WorldPop/GP/100m/pop', - datasetId: 'WorldPop/GP/100m/pop', - name: i18n.t('Population'), - unit: i18n.t('people per hectare'), - description: i18n.t('Estimated number of people living in an area.'), - source: 'WorldPop / Google Earth Engine', - sourceUrl: - 'https://developers.google.com/earth-engine/datasets/catalog/WorldPop_GP_100m_pop', - img: 'images/population.png', - defaultAggregations: ['sum', 'mean'], - periodType: 'Yearly', - filters: ({ id, name, year }) => [ - { - id, - name, - type: 'eq', - arguments: ['year', year], - }, - ], - mosaic: true, - params: { - min: 0, - max: 10, - palette: '#fee5d9,#fcbba1,#fc9272,#fb6a4a,#de2d26,#a50f15', // Reds - }, - opacity: 0.9, - }, - { - layer: EARTH_ENGINE_LAYER, - legacy: true, // Kept for backward compability - layerId: 'WorldPop/POP', - datasetId: 'WorldPop/POP', - name: i18n.t('Population'), - unit: i18n.t('people per km²'), - description: i18n.t('Estimated number of people living in an area.'), - source: 'WorldPop / Google Earth Engine', - sourceUrl: - 'https://explorer.earthengine.google.com/#detail/WorldPop%2FPOP', - img: 'images/population.png', - periodType: 'Yearly', - filters: ({ id, name, year }) => [ - { - id, - name, - type: 'eq', - arguments: ['year', year], - }, - { - type: 'eq', - arguments: ['UNadj', 'yes'], - }, - ], - mosaic: true, - params: { - min: 0, - max: 1000, - palette: '#fee5d9,#fcbba1,#fc9272,#fb6a4a,#de2d26,#a50f15', // Reds - }, - methods: { - multiply: [100], // Convert from people/hectare to people/km2 - }, - opacity: 0.9, - }, - { - layer: EARTH_ENGINE_LAYER, - legacy: true, // Kept for backward compability - layerId: 'NOAA/DMSP-OLS/NIGHTTIME_LIGHTS', - datasetId: 'NOAA/DMSP-OLS/NIGHTTIME_LIGHTS', - name: i18n.t('Nighttime lights'), - unit: i18n.t('light intensity'), - description: i18n.t( - 'Light intensity from cities, towns, and other sites with persistent lighting, including gas flares.' - ), - source: 'NOAA / Google Earth Engine', - sourceUrl: - 'https://explorer.earthengine.google.com/#detail/NOAA%2FDMSP-OLS%2FNIGHTTIME_LIGHTS', - periodType: 'Yearly', - band: 'stable_lights', - mask: true, - img: 'images/nighttime.png', - params: { - min: 0, - max: 63, - palette: '#ffffd4,#fee391,#fec44f,#fe9929,#ec7014,#cc4c02,#8c2d04', // YlOrBr - }, - opacity: 0.9, - }, -] - -export const getEarthEngineLayer = (id) => - earthEngineLayers().find((l) => l.layerId === id) diff --git a/src/constants/earthEngineLayers/buildings_GOOGLE.js b/src/constants/earthEngineLayers/buildings_GOOGLE.js new file mode 100644 index 000000000..79b08f92f --- /dev/null +++ b/src/constants/earthEngineLayers/buildings_GOOGLE.js @@ -0,0 +1,30 @@ +import i18n from '@dhis2/d2-i18n' +import { EARTH_ENGINE_LAYER } from '../layers.js' + +export default { + layer: EARTH_ENGINE_LAYER, + format: 'FeatureCollection', + layerId: 'GOOGLE/Research/open-buildings/v1/polygons', + img: 'images/buildings.png', + datasetId: 'GOOGLE/Research/open-buildings/v1/polygons', + name: i18n.t('Building footprints'), + description: i18n.t( + 'The outlines of buildings derived from high-resolution satellite imagery. Only for the continent of Africa.' + ), + notice: i18n.t( + 'Building counts are only available for smaller organisation unit areas.' + ), + error: i18n.t( + 'Select a smaller area or single organization unit to see the count of buildings.' + ), + source: 'NASA / USGS / JPL-Caltech / Google Earth Engine', + sourceUrl: 'https://sites.research.google/open-buildings/', + unit: i18n.t('Number of buildings'), + aggregations: ['count'], + defaultAggregations: ['count'], + style: { + color: '#FFA500', + strokeWidth: 1, + }, + opacity: 0.9, +} diff --git a/src/constants/earthEngineLayers/elevation_SRTM.js b/src/constants/earthEngineLayers/elevation_SRTM.js new file mode 100644 index 000000000..418db0699 --- /dev/null +++ b/src/constants/earthEngineLayers/elevation_SRTM.js @@ -0,0 +1,33 @@ +import i18n from '@dhis2/d2-i18n' +import { EARTH_ENGINE_LAYER } from '../layers.js' + +export default { + layer: EARTH_ENGINE_LAYER, + layerId: 'USGS/SRTMGL1_003', + datasetId: 'USGS/SRTMGL1_003', + format: 'Image', + img: 'images/elevation.png', + name: i18n.t('Elevation'), + unit: i18n.t('meters'), + description: i18n.t('Elevation above sea-level.'), + source: 'NASA / USGS / JPL-Caltech / Google Earth Engine', + sourceUrl: + 'https://explorer.earthengine.google.com/#detail/USGS%2FSRTMGL1_003', + aggregations: ['min', 'max', 'mean', 'median', 'stdDev', 'variance'], + defaultAggregations: ['mean', 'min', 'max'], + band: 'elevation', + style: { + min: 0, + max: 1500, + palette: [ + '#ffffd4', + '#fee391', + '#fec44f', + '#fe9929', + '#d95f0e', + '#993404', + ], // YlOrBr (ColorBrewer) + }, + maskOperator: 'gte', + opacity: 0.9, +} diff --git a/src/constants/earthEngineLayers/index.js b/src/constants/earthEngineLayers/index.js new file mode 100644 index 000000000..e4053604b --- /dev/null +++ b/src/constants/earthEngineLayers/index.js @@ -0,0 +1,48 @@ +import buildings from './buildings_GOOGLE.js' +import elevation from './elevation_SRTM.js' +import landcover100m from './landcover_CGLS.js' +import landcover from './landcover_MCD12Q1.js' +import nighttimeLights from './legacy/nighttime_DMSP-OLS.js' +import precipitationLegacy from './legacy/precipitation_pentad_CHIRPS.js' +import temperatureLegacy from './legacy/temperature_MOD11A2v061.js' +import nitrogenDioxide from './nitrogen_dioxide_Sentinel-5P.js' +import ozone from './ozone_Sentinel-5P.js' +import particlePollution from './particle_pollution_CAMS.js' +import populationAgeSex from './population_age_sex_WorldPop.js' +import populationTotal from './population_total_WorldPop.js' +import precipitationDailyChirps from './precipitation_daily_CHIRPS.js' +import precipitationDaily from './precipitation_daily_ERA5-Land.js' +import precipitationMonthly from './precipitation_monthly_ERA5-Land.js' +import satelliteImagery from './satellite_imagery_Sentinel-2.js' +import sulfurDioxide from './sulfur_dioxide_Sentinel-5P.js' +import temperatureDaily from './temperature_daily_ERA5-Land.js' +import temperatureMax from './temperature_daily_max_ERA5-Land.js' +import temperatureMonthly from './temperature_monthly_ERA5-Land.js' + +const earthEngineLayers = [ + buildings, + elevation, + landcover, + landcover100m, + nighttimeLights, + nitrogenDioxide, + ozone, + particlePollution, + populationTotal, + populationAgeSex, + precipitationDaily, + precipitationDailyChirps, + precipitationMonthly, + precipitationLegacy, + satelliteImagery, + sulfurDioxide, + temperatureDaily, + temperatureMax, + temperatureMonthly, + temperatureLegacy, +] + +export const getEarthEngineLayer = (id) => + earthEngineLayers.find((l) => l.layerId === id) + +export default earthEngineLayers diff --git a/src/constants/earthEngineLayers/landcover_CGLS.js b/src/constants/earthEngineLayers/landcover_CGLS.js new file mode 100644 index 000000000..05b0e779a --- /dev/null +++ b/src/constants/earthEngineLayers/landcover_CGLS.js @@ -0,0 +1,140 @@ +import i18n from '@dhis2/d2-i18n' +import { EARTH_ENGINE_LAYER } from '../layers.js' + +export default { + layer: EARTH_ENGINE_LAYER, + format: 'ImageCollection', + layerId: 'COPERNICUS/Landcover/100m/Proba-V-C3/Global', + img: 'images/landcover-copernicus.jpeg', + datasetId: 'COPERNICUS/Landcover/100m/Proba-V-C3/Global', + name: i18n.t('Landcover Copernicus'), + description: i18n.t('Distinct landcover types collected from satellites.'), + source: 'Copernicus / Google Earth Engine', + periodType: 'YEARLY', + band: 'discrete_classification', + defaultAggregations: 'percentage', + popup: '{name}: {value}', // In use? + filters: [ + { + type: 'eq', + arguments: ['system:index', '$1'], + }, + ], + style: [ + { + value: 0, + name: i18n.t('Unknown'), + color: '#282828', + }, + { + value: 20, + name: i18n.t('Shrubs'), + color: '#ffbb22', + }, + { + value: 30, + name: i18n.t('Herbaceous vegetation'), + color: '#ffff4c', + }, + { + value: 40, + name: i18n.t('Agriculture'), + color: '#f096ff', + }, + { + value: 50, + name: i18n.t('Urban / built up'), + color: '#fa0000', + }, + { + value: 60, + name: i18n.t('Bare / sparse vegetation'), + color: '#b4b4b4', + }, + { + value: 70, + name: i18n.t('Snow and ice'), + color: '#f0f0f0', + }, + { + value: 80, + name: i18n.t('Permanent water bodies'), + color: '#0032c8', + }, + { + value: 90, + name: i18n.t('Herbaceous wetland'), + color: '#0096a0', + }, + { + value: 100, + name: i18n.t('Moss and lichen'), + color: '#fae6a0', + }, + { + value: 111, + name: i18n.t('Closed forest, evergreen needle leaf'), + color: '#58481f', + }, + { + value: 112, + name: i18n.t('Closed forest, evergreen broad leaf'), + color: '#009900', + }, + { + value: 113, + name: i18n.t('Closed forest, deciduous needle leaf'), + color: '#70663e', + }, + { + value: 114, + name: i18n.t('Closed forest, deciduous broad leaf'), + color: '#00cc00', + }, + { + value: 115, + name: i18n.t('Closed forest, mixed'), + color: '#4e751f', + }, + { + value: 116, + name: i18n.t('Closed forest, other'), + color: '#007800', + }, + { + value: 121, + name: i18n.t('Open forest, evergreen needle leaf'), + color: '#666000', + }, + { + value: 122, + name: i18n.t('Open forest, evergreen broad leaf'), + color: '#8db400', + }, + { + value: 123, + name: i18n.t('Open forest, deciduous needle leaf'), + color: '#8d7400', + }, + { + value: 124, + name: i18n.t('Open forest, deciduous broad leaf'), + color: '#a0dc00', + }, + { + value: 125, + name: i18n.t('Open forest, mixed'), + color: '#929900', + }, + { + value: 126, + name: i18n.t('Open forest, other'), + color: '#648c00', + }, + { + value: 200, + name: i18n.t('Water'), + color: '#000080', + }, + ], +} diff --git a/src/constants/earthEngineLayers/landcover_MCD12Q1.js b/src/constants/earthEngineLayers/landcover_MCD12Q1.js new file mode 100644 index 000000000..48f514bd2 --- /dev/null +++ b/src/constants/earthEngineLayers/landcover_MCD12Q1.js @@ -0,0 +1,114 @@ +import i18n from '@dhis2/d2-i18n' +import { EARTH_ENGINE_LAYER } from '../layers.js' + +export default { + layer: EARTH_ENGINE_LAYER, + format: 'ImageCollection', + layerId: 'MODIS/006/MCD12Q1', // Layer id kept for backward compability for saved maps + datasetId: 'MODIS/061/MCD12Q1', // No longer in use: 'MODIS/006/MCD12Q1' / 'MODIS/051/MCD12Q1', + name: i18n.t('Landcover'), + description: i18n.t('Distinct landcover types collected from satellites.'), + img: 'images/landcover.png', + source: 'NASA LP DAAC / Google Earth Engine', + sourceUrl: + 'https://developers.google.com/earth-engine/datasets/catalog/MODIS_061_MCD12Q1', + periodType: 'YEARLY', + band: 'LC_Type1', + filters: [ + { + type: 'eq', + arguments: ['system:index', '$1'], + }, + ], + defaultAggregations: 'percentage', + style: [ + // http://www.eomf.ou.edu/static/IGBP.pdf + { + value: 1, + name: i18n.t('Evergreen Needleleaf forest'), + color: '#162103', + }, + { + value: 2, + name: i18n.t('Evergreen Broadleaf forest'), + color: '#235123', + }, + { + value: 3, + name: i18n.t('Deciduous Needleleaf forest'), + color: '#399b38', + }, + { + value: 4, + name: i18n.t('Deciduous Broadleaf forest'), + color: '#38eb38', + }, + { + value: 5, + name: i18n.t('Mixed forest'), + color: '#39723b', + }, + { + value: 6, + name: i18n.t('Closed shrublands'), + color: '#6a2424', + }, + { + value: 7, + name: i18n.t('Open shrublands'), + color: '#c3a55f', + }, + { + value: 8, + name: i18n.t('Woody savannas'), + color: '#b76124', + }, + { + value: 9, + name: i18n.t('Savannas'), + color: '#d99125', + }, + { + value: 10, + name: i18n.t('Grasslands'), + color: '#92af1f', + }, + { + value: 11, + name: i18n.t('Permanent wetlands'), + color: '#10104c', + }, + { + value: 12, + name: i18n.t('Croplands'), + color: '#cdb400', + }, + { + value: 13, + name: i18n.t('Urban and built-up'), + color: '#cc0202', + }, + { + value: 14, + name: i18n.t('Cropland/Natural vegetation mosaic'), + color: '#332808', + }, + { + value: 15, + name: i18n.t('Snow and ice'), + color: '#d7cdcc', + }, + { + value: 16, + name: i18n.t('Barren or sparsely vegetated'), + color: '#f7e174', + }, + { + value: 17, + name: i18n.t('Water'), + color: '#aec3d6', + }, + ], + popup: '{name}: {value}', + opacity: 0.9, +} diff --git a/src/constants/earthEngineLayers/legacy/nighttime_DMSP-OLS.js b/src/constants/earthEngineLayers/legacy/nighttime_DMSP-OLS.js new file mode 100644 index 000000000..35c00e86f --- /dev/null +++ b/src/constants/earthEngineLayers/legacy/nighttime_DMSP-OLS.js @@ -0,0 +1,42 @@ +import i18n from '@dhis2/d2-i18n' +import { EARTH_ENGINE_LAYER } from '../../layers.js' + +export default { + legacy: true, // Kept for backward compability + layer: EARTH_ENGINE_LAYER, + format: 'ImageCollection', + layerId: 'NOAA/DMSP-OLS/NIGHTTIME_LIGHTS', + datasetId: 'NOAA/DMSP-OLS/NIGHTTIME_LIGHTS', + name: i18n.t('Nighttime lights'), + unit: i18n.t('light intensity'), + description: i18n.t( + 'Light intensity from cities, towns, and other sites with persistent lighting, including gas flares.' + ), + source: 'NOAA / Google Earth Engine', + sourceUrl: + 'https://explorer.earthengine.google.com/#detail/NOAA%2FDMSP-OLS%2FNIGHTTIME_LIGHTS', + periodType: 'YEARLY', + filters: [ + { + type: 'eq', + arguments: ['system:index', '$1'], + }, + ], + band: 'stable_lights', + img: 'images/nighttime.png', + style: { + min: 0, + max: 63, + palette: [ + '#ffffd4', + '#fee391', + '#fec44f', + '#fe9929', + '#ec7014', + '#cc4c02', + '#8c2d04', + ], // YlOrBr (ColorBrewer) + }, + maskOperator: 'gte', + opacity: 0.9, +} diff --git a/src/constants/earthEngineLayers/legacy/population_WorldPop.js b/src/constants/earthEngineLayers/legacy/population_WorldPop.js new file mode 100644 index 000000000..80847a1de --- /dev/null +++ b/src/constants/earthEngineLayers/legacy/population_WorldPop.js @@ -0,0 +1,46 @@ +import i18n from '@dhis2/d2-i18n' +import { EARTH_ENGINE_LAYER } from '../../layers.js' + +export default { + layer: EARTH_ENGINE_LAYER, + legacy: true, // Kept for backward compability + layerId: 'WorldPop/POP', + datasetId: 'WorldPop/POP', + format: 'ImageCollection', + name: i18n.t('Population'), + unit: i18n.t('people per km²'), + description: i18n.t('Estimated number of people living in an area.'), + source: 'WorldPop / Google Earth Engine', + sourceUrl: 'https://explorer.earthengine.google.com/#detail/WorldPop%2FPOP', + img: 'images/population.png', + periodType: 'YEARLY', + band: 'population', + filters: [ + { + type: 'eq', + arguments: ['year', '$1'], + }, + { + type: 'eq', + arguments: ['UNadj', 'yes'], + }, + ], + mosaic: true, + style: { + min: 0, + max: 1000, + palette: [ + '#fee5d9', + '#fcbba1', + '#fc9272', + '#fb6a4a', + '#de2d26', + '#a50f15', + ], // Reds (ColorBrewer) + }, + methods: { + multiply: [100], // Convert from people/hectare to people/km2 + }, + maskOperator: 'gt', + opacity: 0.9, +} diff --git a/src/constants/earthEngineLayers/legacy/population_WorldPop_100m.js b/src/constants/earthEngineLayers/legacy/population_WorldPop_100m.js new file mode 100644 index 000000000..eb7ec70b6 --- /dev/null +++ b/src/constants/earthEngineLayers/legacy/population_WorldPop_100m.js @@ -0,0 +1,40 @@ +import i18n from '@dhis2/d2-i18n' +import { EARTH_ENGINE_LAYER } from '../../layers.js' + +export default { + layer: EARTH_ENGINE_LAYER, + legacy: true, // Kept for backward compability + layerId: 'WorldPop/GP/100m/pop', + datasetId: 'WorldPop/GP/100m/pop', + name: i18n.t('Population'), + unit: i18n.t('people per hectare'), + description: i18n.t('Estimated number of people living in an area.'), + source: 'WorldPop / Google Earth Engine', + sourceUrl: + 'https://developers.google.com/earth-engine/datasets/catalog/WorldPop_GP_100m_pop', + img: 'images/population.png', + defaultAggregations: ['sum', 'mean'], + periodType: 'YEARLY', + band: 'population', + filters: [ + { + type: 'eq', + arguments: ['year', '$1'], + }, + ], + mosaic: true, + style: { + min: 0, + max: 10, + palette: [ + '#fee5d9', + '#fcbba1', + '#fc9272', + '#fb6a4a', + '#de2d26', + '#a50f15', + ], // Reds (ColorBrewer) + }, + maskOperator: 'gt', + opacity: 0.9, +} diff --git a/src/constants/earthEngineLayers/legacy/precipitation_pentad_CHIRPS.js b/src/constants/earthEngineLayers/legacy/precipitation_pentad_CHIRPS.js new file mode 100644 index 000000000..1d0f56e84 --- /dev/null +++ b/src/constants/earthEngineLayers/legacy/precipitation_pentad_CHIRPS.js @@ -0,0 +1,43 @@ +import i18n from '@dhis2/d2-i18n' +import { EARTH_ENGINE_LAYER } from '../../layers.js' + +export default { + legacy: true, // kept for backward compability + layer: EARTH_ENGINE_LAYER, + format: 'ImageCollection', + layerId: 'UCSB-CHG/CHIRPS/PENTAD', + datasetId: 'UCSB-CHG/CHIRPS/PENTAD', + name: i18n.t('Precipitation'), + unit: i18n.t('millimeter'), + description: i18n.t( + 'Precipitation collected from satellite and weather stations on the ground. The values are in millimeters within 5 days periods. Updated monthly, during the 3rd week of the following month.' + ), + source: 'UCSB / CHG / Google Earth Engine', + sourceUrl: + 'https://explorer.earthengine.google.com/#detail/UCSB-CHG%2FCHIRPS%2FPENTAD', + periodType: 'BY_YEAR', + filters: [ + { + type: 'eq', + arguments: ['system:index', '$1'], + }, + ], + band: 'precipitation', + aggregations: ['min', 'max', 'mean', 'median', 'stdDev', 'variance'], + defaultAggregations: ['mean', 'min', 'max'], + img: 'images/precipitation.png', + style: { + min: 0, + max: 100, + palette: [ + '#eff3ff', + '#c6dbef', + '#9ecae1', + '#6baed6', + '#3182bd', + '#08519c', + ], // Blues (ColorBrewer) + }, + maskOperator: 'gte', + opacity: 0.9, +} diff --git a/src/constants/earthEngineLayers/legacy/temperature_MOD11A2v061.js b/src/constants/earthEngineLayers/legacy/temperature_MOD11A2v061.js new file mode 100644 index 000000000..53f986146 --- /dev/null +++ b/src/constants/earthEngineLayers/legacy/temperature_MOD11A2v061.js @@ -0,0 +1,60 @@ +import i18n from '@dhis2/d2-i18n' +import { EARTH_ENGINE_LAYER } from '../../layers.js' + +export default { + legacy: true, // kept for backward compability + layer: EARTH_ENGINE_LAYER, + format: 'ImageCollection', + layerId: 'MODIS/006/MOD11A2', + datasetId: 'MODIS/006/MOD11A2', + img: 'images/temperature.png', + name: i18n.t('Temperature MODIS'), + unit: i18n.t('°C during daytime'), + description: i18n.t( + 'Land surface temperatures collected from satellite. Blank spots will appear in areas with a persistent cloud cover.' + ), + source: 'NASA LP DAAC / Google Earth Engine', + sourceUrl: + 'https://explorer.earthengine.google.com/#detail/MODIS%2FMOD11A2', + aggregations: ['min', 'max', 'mean', 'median', 'stdDev', 'variance'], + defaultAggregations: ['mean', 'min', 'max'], + band: 'LST_Day_1km', + periodType: 'BY_YEAR', + filters: [ + { + type: 'eq', + arguments: ['system:index', '$1'], + }, + ], + methods: [ + { + name: 'toFloat', + arguments: [], + }, + { + name: 'multiply', + arguments: [0.02], + }, + { + name: 'subtract', + arguments: [273.15], + }, + ], + style: { + min: 0, + max: 40, + palette: [ + '#fff5f0', + '#fee0d2', + '#fcbba1', + '#fc9272', + '#fb6a4a', + '#ef3b2c', + '#cb181d', + '#a50f15', + '#67000d', + ], // Reds (ColorBrewer) + }, + maskOperator: 'gte', + opacity: 0.9, +} diff --git a/src/constants/earthEngineLayers/nitrogen_dioxide_Sentinel-5P.js b/src/constants/earthEngineLayers/nitrogen_dioxide_Sentinel-5P.js new file mode 100644 index 000000000..e3b852046 --- /dev/null +++ b/src/constants/earthEngineLayers/nitrogen_dioxide_Sentinel-5P.js @@ -0,0 +1,44 @@ +import i18n from '@dhis2/d2-i18n' +import { EARTH_ENGINE_LAYER } from '../layers.js' + +export default { + layer: EARTH_ENGINE_LAYER, + format: 'ImageCollection', + layerId: 'COPERNICUS/S5P/NRTI/L3_NO2', + datasetId: 'COPERNICUS/S5P/NRTI/L3_NO2', + img: 'images/nitrogen-dioxide.png', + name: i18n.t('Nitrogen dioxide (NO2)'), + description: + 'Total vertical column of Nitrogen dioxide (NO2). This gas enters the atmosphere as a result of human activities (notably fossil fuel combustion and biomass burning) and natural processes (wildfires, lightning, and microbiological processes in soils).', + source: 'European Union / ESA / Copernicus / Google Earth Engine', + sourceUrl: + 'https://developers.google.com/earth-engine/datasets/catalog/COPERNICUS_S5P_NRTI_L3_NO2', + unit: 'mol/m^2', + periodType: 'WEEKLY', + periodReducer: 'mean', + band: 'NO2_column_number_density', + filters: [ + { + type: 'date', + arguments: ['$1', '$2'], + }, + ], + style: { + min: 0.0001, + max: 0.0005, + palette: [ + '#fff7f3', + '#fde0dd', + '#fcc5c0', + '#fa9fb5', + '#f768a1', + '#dd3497', + '#ae017e', + '#7a0177', + '#49006a', + ], + }, + maskOperator: 'gte', + precision: 5, + opacity: 0.9, +} diff --git a/src/constants/earthEngineLayers/ozone_Sentinel-5P.js b/src/constants/earthEngineLayers/ozone_Sentinel-5P.js new file mode 100644 index 000000000..0e1e29327 --- /dev/null +++ b/src/constants/earthEngineLayers/ozone_Sentinel-5P.js @@ -0,0 +1,42 @@ +import i18n from '@dhis2/d2-i18n' +import { EARTH_ENGINE_LAYER } from '../layers.js' + +export default { + layer: EARTH_ENGINE_LAYER, + format: 'ImageCollection', + layerId: 'COPERNICUS/S5P/NRTI/L3_O3', + datasetId: 'COPERNICUS/S5P/NRTI/L3_O3', + img: 'images/ozone.png', + name: i18n.t('Ozone'), + description: + 'Total atmospheric column of O3 between the surface and the top of atmosphere,', + source: 'European Union / ESA / Copernicus / Google Earth Engine', + sourceUrl: + 'https://developers.google.com/earth-engine/datasets/catalog/COPERNICUS_S5P_NRTI_L3_O3', + unit: 'mol/m^2', + periodType: 'WEEKLY', + periodReducer: 'mean', + band: 'O3_column_number_density', + filters: [ + { + type: 'date', + arguments: ['$1', '$2'], + }, + ], + style: { + min: 0.05, + max: 0.2, + palette: [ + '#ffffd4', + '#fee391', + '#fec44f', + '#fe9929', + '#ec7014', + '#cc4c02', + '#8c2d04', + ], + }, + maskOperator: 'gt', + precision: 3, + opacity: 0.7, +} diff --git a/src/constants/earthEngineLayers/particle_pollution_CAMS.js b/src/constants/earthEngineLayers/particle_pollution_CAMS.js new file mode 100644 index 000000000..7af758a7a --- /dev/null +++ b/src/constants/earthEngineLayers/particle_pollution_CAMS.js @@ -0,0 +1,49 @@ +import i18n from '@dhis2/d2-i18n' +import { EARTH_ENGINE_LAYER } from '../layers.js' + +// TODO: Check if we have the best period type and reducer +// https://link.springer.com/article/10.1007/s10661-023-11212-x +export default { + layer: EARTH_ENGINE_LAYER, + format: 'ImageCollection', + layerId: 'ECMWF/CAMS/NRT/PM2.5', + datasetId: 'ECMWF/CAMS/NRT', + img: 'images/particle-pollution.png', + name: i18n.t('Particle pollution'), + description: 'Particulate matter d < 2.5 um (PM2.5)', + source: 'Copernicus Climate Data Store / Google Earth Engine', + sourceUrl: '', + unit: 'mg/m^3', + // aggregations: ['min', 'max', 'mean', 'median', 'stdDev', 'variance'], + // defaultAggregations: ['mean', 'min', 'max'], + periodType: 'DAILY', + // periodReducer: 'sum', + band: 'particulate_matter_d_less_than_25_um_surface', + filters: [ + { + type: 'date', + arguments: ['$1', '$2'], + }, + ], + methods: [ + { + name: 'multiply', + arguments: [1000000], + }, + ], + style: { + min: 0.02, + max: 0.12, + palette: [ + '#feebe2', + '#fcc5c0', + '#fa9fb5', + '#f768a1', + '#c51b8a', + '#7a0177', + ], + }, + maskOperator: 'gte', + precision: 2, + opacity: 0.9, +} diff --git a/src/constants/earthEngineLayers/population_age_sex_WorldPop.js b/src/constants/earthEngineLayers/population_age_sex_WorldPop.js new file mode 100644 index 000000000..d2a9bb125 --- /dev/null +++ b/src/constants/earthEngineLayers/population_age_sex_WorldPop.js @@ -0,0 +1,189 @@ +import i18n from '@dhis2/d2-i18n' +import { EARTH_ENGINE_LAYER } from '../layers.js' + +export default { + layer: EARTH_ENGINE_LAYER, + format: 'ImageCollection', + layerId: 'WorldPop/GP/100m/pop_age_sex_cons_unadj', + datasetId: 'WorldPop/GP/100m/pop_age_sex_cons_unadj', + img: 'images/population.png', + name: i18n.t('Population age groups'), + unit: 'people per hectare', + description: i18n.t( + 'Estimated number of people living in an area, grouped by age and gender.' + ), + source: 'WorldPop / Google Earth Engine', + sourceUrl: + 'https://developers.google.com/earth-engine/datasets/catalog/WorldPop_GP_100m_pop_age_sex_cons_unadj', + periodType: 'YEARLY', + defaultAggregations: ['sum', 'mean'], + useCentroid: true, + bands: [ + { + id: 'M_0', + name: i18n.t('Male 0 - 1 years'), + }, + { + id: 'M_1', + name: i18n.t('Male 1 - 4 years'), + }, + { + id: 'M_5', + name: i18n.t('Male 5 - 9 years'), + }, + { + id: 'M_10', + name: i18n.t('Male 10 - 14 years'), + }, + { + id: 'M_15', + name: i18n.t('Male 15 - 19 years'), + }, + { + id: 'M_20', + name: i18n.t('Male 20 - 24 years'), + }, + { + id: 'M_25', + name: i18n.t('Male 25 - 29 years'), + }, + { + id: 'M_30', + name: i18n.t('Male 30 - 34 years'), + }, + { + id: 'M_35', + name: i18n.t('Male 35 - 39 years'), + }, + { + id: 'M_40', + name: i18n.t('Male 40 - 44 years'), + }, + { + id: 'M_45', + name: i18n.t('Male 45 - 49 years'), + }, + { + id: 'M_50', + name: i18n.t('Male 50 - 54 years'), + }, + { + id: 'M_55', + name: i18n.t('Male 55 - 59 years'), + }, + { + id: 'M_60', + name: i18n.t('Male 60 - 64 years'), + }, + { + id: 'M_65', + name: i18n.t('Male 65 - 69 years'), + }, + { + id: 'M_70', + name: i18n.t('Male 70 - 74 years'), + }, + { + id: 'M_75', + name: i18n.t('Male 75 - 79 years'), + }, + { + id: 'M_80', + name: i18n.t('Male 80 years and above'), + }, + { + id: 'F_0', + name: i18n.t('Female 0 - 1 years'), + }, + { + id: 'F_1', + name: i18n.t('Female 1 - 4 years'), + }, + { + id: 'F_5', + name: i18n.t('Female 5 - 9 years'), + }, + { + id: 'F_10', + name: i18n.t('Female 10 - 14 years'), + }, + { + id: 'F_15', + name: i18n.t('Female 15 - 19 years'), + }, + { + id: 'F_20', + name: i18n.t('Female 20 - 24 years'), + }, + { + id: 'F_25', + name: i18n.t('Female 25 - 29 years'), + }, + { + id: 'F_30', + name: i18n.t('Female 30 - 34 years'), + }, + { + id: 'F_35', + name: i18n.t('Female 35 - 39 years'), + }, + { + id: 'F_40', + name: i18n.t('Female 40 - 44 years'), + }, + { + id: 'F_45', + name: i18n.t('Female 45 - 49 years'), + }, + { + id: 'F_50', + name: i18n.t('Female 50 - 54 years'), + }, + { + id: 'F_55', + name: i18n.t('Female 55 - 59 years'), + }, + { + id: 'F_60', + name: i18n.t('Female 60 - 64 years'), + }, + { + id: 'F_65', + name: i18n.t('Female 65 - 69 years'), + }, + { + id: 'F_70', + name: i18n.t('Female 70 - 74 years'), + }, + { + id: 'F_75', + name: i18n.t('Female 75 - 79 years'), + }, + { + id: 'F_80', + name: i18n.t('Female 80 years and above'), + }, + ], + filters: [ + { + type: 'eq', + arguments: ['year', '$1'], + }, + ], + mosaic: true, + style: { + min: 0, + max: 10, + palette: [ + '#fee5d9', + '#fcbba1', + '#fc9272', + '#fb6a4a', + '#de2d26', + '#a50f15', + ], + }, + maskOperator: 'gt', + opacity: 0.9, + tileScale: 4, +} diff --git a/src/constants/earthEngineLayers/population_total_WorldPop.js b/src/constants/earthEngineLayers/population_total_WorldPop.js new file mode 100644 index 000000000..d5dfe9e64 --- /dev/null +++ b/src/constants/earthEngineLayers/population_total_WorldPop.js @@ -0,0 +1,41 @@ +import i18n from '@dhis2/d2-i18n' +import { EARTH_ENGINE_LAYER } from '../layers.js' + +export default { + layer: EARTH_ENGINE_LAYER, + layerId: 'WorldPop/GP/100m/pop_age_sex_cons_unadj_TOTAL', + img: 'images/population.png', + datasetId: 'WorldPop/GP/100m/pop_age_sex_cons_unadj', + format: 'ImageCollection', + name: i18n.t('Population'), + description: i18n.t('Estimated number of people living in an area.'), + source: 'WorldPop / Google Earth Engine', + sourceUrl: + 'https://developers.google.com/earth-engine/datasets/catalog/WorldPop_GP_100m_pop_age_sex_cons_unadj', + unit: i18n.t('people per hectare'), + defaultAggregations: ['sum', 'mean'], + periodType: 'YEARLY', + useCentroid: true, + band: 'population', + filters: [ + { + type: 'eq', + arguments: ['year', '$1'], + }, + ], + mosaic: true, + style: { + min: 0, + max: 25, + palette: [ + '#fee5d9', + '#fcbba1', + '#fc9272', + '#fb6a4a', + '#de2d26', + '#a50f15', + ], + }, + maskOperator: 'gt', + opacity: 0.9, +} diff --git a/src/constants/earthEngineLayers/precipitation_daily_CHIRPS.js b/src/constants/earthEngineLayers/precipitation_daily_CHIRPS.js new file mode 100644 index 000000000..a965d8ef9 --- /dev/null +++ b/src/constants/earthEngineLayers/precipitation_daily_CHIRPS.js @@ -0,0 +1,43 @@ +import i18n from '@dhis2/d2-i18n' +import { EARTH_ENGINE_LAYER } from '../layers.js' + +export default { + layer: EARTH_ENGINE_LAYER, + img: 'images/precipitation.png', + layerId: 'UCSB-CHG/CHIRPS/DAILY', + datasetId: 'UCSB-CHG/CHIRPS/DAILY', + format: 'ImageCollection', + name: i18n.t('Precipitation CHIRPS'), + description: i18n.t( + 'Precipitation collected from satellite and weather stations on the ground.' + ), + source: 'CHIRPS / Google Earth Engine', + sourceUrl: + 'https://developers.google.com/earth-engine/datasets/catalog/UCSB-CHG_CHIRPS_DAILY', + unit: i18n.t('millimeter'), + aggregations: ['min', 'max', 'mean', 'median', 'stdDev', 'variance'], + defaultAggregations: ['mean', 'min', 'max'], + periodType: 'DAILY', + periodReducer: 'sum', + band: 'precipitation', + filters: [ + { + type: 'date', + arguments: ['$1', '$2'], + }, + ], + style: { + min: 0, + max: 10, + palette: [ + '#eff3ff', + '#c6dbef', + '#9ecae1', + '#6baed6', + '#3182bd', + '#08519c', + ], + }, + maskOperator: 'gt', + opacity: 0.9, +} diff --git a/src/constants/earthEngineLayers/precipitation_daily_ERA5-Land.js b/src/constants/earthEngineLayers/precipitation_daily_ERA5-Land.js new file mode 100644 index 000000000..58899752f --- /dev/null +++ b/src/constants/earthEngineLayers/precipitation_daily_ERA5-Land.js @@ -0,0 +1,49 @@ +import i18n from '@dhis2/d2-i18n' +import { EARTH_ENGINE_LAYER } from '../layers.js' + +export default { + layer: EARTH_ENGINE_LAYER, + img: 'images/precipitation.png', + layerId: 'ECMWF/ERA5_LAND/DAILY_AGGR/total_precipitation_sum', + datasetId: 'ECMWF/ERA5_LAND/DAILY_AGGR', + format: 'ImageCollection', + name: i18n.t('Precipitation ERA5'), + description: i18n.t( + 'Accumulated liquid and frozen water, including rain and snow, that falls to the surface. Combines model data with observations from across the world.' + ), + source: 'Copernicus Climate Data Store / Google Earth Engine', + sourceUrl: + 'https://developers.google.com/earth-engine/datasets/catalog/ECMWF_ERA5_LAND_DAILY_AGGR', + unit: i18n.t('millimeter'), + aggregations: ['min', 'max', 'mean', 'median', 'stdDev', 'variance'], + defaultAggregations: ['mean', 'min', 'max'], + periodType: 'DAILY', + periodReducer: 'sum', + band: 'total_precipitation_sum', + filters: [ + { + type: 'date', + arguments: ['$1', '$2'], + }, + ], + methods: [ + { + name: 'multiply', + arguments: [1000], + }, + ], + style: { + min: 0, + max: 10, + palette: [ + '#eff3ff', + '#c6dbef', + '#9ecae1', + '#6baed6', + '#3182bd', + '#08519c', + ], + }, + maskOperator: 'gt', + opacity: 0.9, +} diff --git a/src/constants/earthEngineLayers/precipitation_monthly_ERA5-Land.js b/src/constants/earthEngineLayers/precipitation_monthly_ERA5-Land.js new file mode 100644 index 000000000..e9beffc68 --- /dev/null +++ b/src/constants/earthEngineLayers/precipitation_monthly_ERA5-Land.js @@ -0,0 +1,50 @@ +import i18n from '@dhis2/d2-i18n' +import { EARTH_ENGINE_LAYER } from '../layers.js' + +export default { + layer: EARTH_ENGINE_LAYER, + img: 'images/precipitation.png', + layerId: 'ECMWF/ERA5_LAND/MONTHLY_AGGR/total_precipitation_sum', + datasetId: 'ECMWF/ERA5_LAND/MONTHLY_AGGR', + format: 'ImageCollection', + name: i18n.t('Precipitation monthly'), + description: i18n.t( + 'Accumulated liquid and frozen water, including rain and snow, that falls to the surface. Combines model data with observations from across the world.' + ), + source: 'Copernicus Climate Data Store / Google Earth Engine', + sourceUrl: + 'https://developers.google.com/earth-engine/datasets/catalog/ECMWF_ERA5_LAND_DAILY_AGGR', + unit: i18n.t('millimeter'), + aggregations: ['min', 'max', 'mean', 'median', 'stdDev', 'variance'], + defaultAggregations: ['mean', 'min', 'max'], + periodType: 'EE_MONTHLY', + band: 'total_precipitation_sum', + filters: [ + { + type: 'eq', + arguments: ['system:index', '$1'], + }, + ], + methods: [ + { + name: 'multiply', + arguments: [1000], + }, + ], + style: { + min: 0, + max: 700, + palette: [ + '#f7fbff', + '#deebf7', + '#c6dbef', + '#9ecae1', + '#6baed6', + '#4292c6', + '#2171b5', + '#084594', + ], + }, + maskOperator: 'gt', + opacity: 0.9, +} diff --git a/src/constants/earthEngineLayers/satellite_imagery_Sentinel-2.js b/src/constants/earthEngineLayers/satellite_imagery_Sentinel-2.js new file mode 100644 index 000000000..0d498a667 --- /dev/null +++ b/src/constants/earthEngineLayers/satellite_imagery_Sentinel-2.js @@ -0,0 +1,38 @@ +import i18n from '@dhis2/d2-i18n' +import { EARTH_ENGINE_LAYER } from '../layers.js' + +// https://medium.com/google-earth/all-clear-with-cloud-score-bd6ee2e2235e +export default { + layer: EARTH_ENGINE_LAYER, + format: 'ImageCollection', + layerId: 'COPERNICUS/S2_SR_HARMONIZED', + datasetId: 'COPERNICUS/S2_SR_HARMONIZED', + img: 'images/satellite-imagery.png', + name: i18n.t('Satellite imagery (Sentinel-2)'), + description: '', + source: 'Copernicus / Google Earth Engine', + periodType: 'WEEKLY', + periodRange: { + firstDate: '2021-01-01', + lastDate: -1, // today minus one day + }, + periodReducer: 'median', + cloudScore: { + datasetId: 'GOOGLE/CLOUD_SCORE_PLUS/V1/S2_HARMONIZED', + band: 'cs', + clearTreshold: 0.6, + }, + filters: [ + { + type: 'date', + arguments: ['$1', '$2'], + // arguments: ['2022-08-27', '2022-09-05'], // Pakistan floods + }, + ], + style: { + bands: ['B4', 'B3', 'B2'], // red, green, blue + min: 0, + max: 2500, + }, + opacity: 0.9, +} diff --git a/src/constants/earthEngineLayers/sulfur_dioxide_Sentinel-5P.js b/src/constants/earthEngineLayers/sulfur_dioxide_Sentinel-5P.js new file mode 100644 index 000000000..e0b86b858 --- /dev/null +++ b/src/constants/earthEngineLayers/sulfur_dioxide_Sentinel-5P.js @@ -0,0 +1,33 @@ +import i18n from '@dhis2/d2-i18n' +import { EARTH_ENGINE_LAYER } from '../layers.js' + +export default { + layer: EARTH_ENGINE_LAYER, + format: 'ImageCollection', + layerId: 'COPERNICUS/S5P/NRTI/L3_SO2', + datasetId: 'COPERNICUS/S5P/NRTI/L3_SO2', + img: 'images/sulfur-dioxide.png', + name: i18n.t('Sulfur Dioxide (SO2)'), + description: 'SO2 vertical column density at ground level.', + source: 'European Union / ESA / Copernicus / Google Earth Engine', + sourceUrl: + 'https://developers.google.com/earth-engine/datasets/catalog/COPERNICUS_S5P_NRTI_L3_SO2', + unit: 'mol/m^2', + periodType: 'WEEKLY', + periodReducer: 'mean', + band: 'SO2_column_number_density', + filters: [ + { + type: 'date', + arguments: ['$1', '$2'], + }, + ], + style: { + min: 0.0005, + max: 0.0025, + palette: ['#feebe2', '#fbb4b9', '#f768a1', '#c51b8a', '#7a0177'], + }, + maskOperator: 'gte', + precision: 4, + opacity: 0.8, +} diff --git a/src/constants/earthEngineLayers/temperature_daily_ERA5-Land.js b/src/constants/earthEngineLayers/temperature_daily_ERA5-Land.js new file mode 100644 index 000000000..f91d602c6 --- /dev/null +++ b/src/constants/earthEngineLayers/temperature_daily_ERA5-Land.js @@ -0,0 +1,55 @@ +import i18n from '@dhis2/d2-i18n' +import { EARTH_ENGINE_LAYER } from '../layers.js' + +export default { + layer: EARTH_ENGINE_LAYER, + img: 'images/temperature.png', + layerId: 'ECMWF/ERA5_LAND/DAILY_AGGR/temperature_2m', + datasetId: 'ECMWF/ERA5_LAND/DAILY_AGGR', + format: 'ImageCollection', + name: i18n.t('Temperature'), + description: i18n.t( + 'Temperature at 2m above the surface. Combines model data with observations from across the world.' + ), + source: 'Copernicus Climate Data Store / Google Earth Engine', + sourceUrl: + 'https://developers.google.com/earth-engine/datasets/catalog/ECMWF_ERA5_LAND_DAILY_AGGR', + unit: '°C', + // aggregations: ['min', 'max', 'mean', 'median', 'stdDev', 'variance'], + defaultAggregations: ['mean'], + periodType: 'DAILY', + periodReducer: 'mean', + band: 'temperature_2m', + filters: [ + { + type: 'date', + arguments: ['$1', '$2'], + }, + ], + methods: [ + { + name: 'toFloat', + arguments: [], + }, + { + name: 'subtract', + arguments: [273.15], + }, + ], + style: { + min: 0, + max: 40, + palette: [ + '#fff5f0', + '#fee0d2', + '#fcbba1', + '#fc9272', + '#fb6a4a', + '#ef3b2c', + '#cb181d', + '#a50f15', + '#67000d', + ], + }, + opacity: 0.9, +} diff --git a/src/constants/earthEngineLayers/temperature_daily_max_ERA5-Land.js b/src/constants/earthEngineLayers/temperature_daily_max_ERA5-Land.js new file mode 100644 index 000000000..8d222e0a7 --- /dev/null +++ b/src/constants/earthEngineLayers/temperature_daily_max_ERA5-Land.js @@ -0,0 +1,54 @@ +import i18n from '@dhis2/d2-i18n' +import { EARTH_ENGINE_LAYER } from '../layers.js' + +export default { + layer: EARTH_ENGINE_LAYER, + img: 'images/temperature.png', + layerId: 'ECMWF/ERA5_LAND/DAILY_AGGR/temperature_2m_max', + datasetId: 'ECMWF/ERA5_LAND/DAILY_AGGR', + format: 'ImageCollection', + name: i18n.t('Max temperature'), + description: i18n.t( + 'Max temperature at 2m above the surface. Combines model data with observations from across the world.' + ), + source: 'Copernicus Climate Data Store / Google Earth Engine', + sourceUrl: + 'https://developers.google.com/earth-engine/datasets/catalog/ECMWF_ERA5_LAND_DAILY_AGGR', + unit: '°C', + defaultAggregations: ['max'], + periodType: 'DAILY', + periodReducer: 'max', + band: 'temperature_2m_max', + filters: [ + { + type: 'date', + arguments: ['$1', '$2'], + }, + ], + methods: [ + { + name: 'toFloat', + arguments: [], + }, + { + name: 'subtract', + arguments: [273.15], + }, + ], + style: { + min: 0, + max: 40, + palette: [ + '#fff5f0', + '#fee0d2', + '#fcbba1', + '#fc9272', + '#fb6a4a', + '#ef3b2c', + '#cb181d', + '#a50f15', + '#67000d', + ], + }, + opacity: 0.9, +} diff --git a/src/constants/earthEngineLayers/temperature_monthly_ERA5-Land.js b/src/constants/earthEngineLayers/temperature_monthly_ERA5-Land.js new file mode 100644 index 000000000..6c9f44f85 --- /dev/null +++ b/src/constants/earthEngineLayers/temperature_monthly_ERA5-Land.js @@ -0,0 +1,54 @@ +import i18n from '@dhis2/d2-i18n' +import { EARTH_ENGINE_LAYER } from '../layers.js' + +export default { + layer: EARTH_ENGINE_LAYER, + img: 'images/temperature.png', + layerId: 'ECMWF/ERA5_LAND/MONTHLY_AGGR/temperature_2m', + datasetId: 'ECMWF/ERA5_LAND/MONTHLY_AGGR', + format: 'ImageCollection', + name: i18n.t('Temperature monthly'), + description: i18n.t( + 'Temperature at 2m above the surface. Combines model data with observations from across the world.' + ), + source: 'Copernicus Climate Data Store / Google Earth Engine', + sourceUrl: + 'https://developers.google.com/earth-engine/datasets/catalog/ECMWF_ERA5_LAND_MONTHLY_AGGR', + unit: '°C', + aggregations: ['min', 'max', 'mean', 'median', 'stdDev', 'variance'], + defaultAggregations: ['mean', 'min', 'max'], + periodType: 'EE_MONTHLY', + band: 'temperature_2m', + filters: [ + { + type: 'eq', + arguments: ['system:index', '$1'], + }, + ], + methods: [ + { + name: 'toFloat', + arguments: [], + }, + { + name: 'subtract', + arguments: [273.15], + }, + ], + style: { + min: 0, + max: 35, + palette: [ + '#fff5f0', + '#fee0d2', + '#fcbba1', + '#fc9272', + '#fb6a4a', + '#ef3b2c', + '#cb181d', + '#a50f15', + '#67000d', + ], + }, + opacity: 0.9, +} diff --git a/src/constants/earthEngineLayers/testing/cropland.js b/src/constants/earthEngineLayers/testing/cropland.js new file mode 100644 index 000000000..3197b94e2 --- /dev/null +++ b/src/constants/earthEngineLayers/testing/cropland.js @@ -0,0 +1,44 @@ +import i18n from '@dhis2/d2-i18n' +import { EARTH_ENGINE_LAYER } from '../../layers.js' + +export default { + layer: EARTH_ENGINE_LAYER, + format: 'ImageCollection', + layerId: 'USGS/GFSAD1000_V1', + img: 'images/landcover.png', + datasetId: 'USGS/GFSAD1000_V1', + name: i18n.t('Cropland'), + unit: i18n.t('Cropland'), + description: 'Cropland data and their water use.', + source: 'GFSAD / Google Earth Engine', + band: 'landcover', + defaultAggregations: 'percentage', + popup: '{name}: {value}', + style: [ + { + value: 1, + name: 'Irrigation major', + color: 'orange', + }, + { + value: 2, + name: 'Irrigation minor', + color: 'brown', + }, + { + value: 3, + name: 'Rainfed', + color: 'darkseagreen', + }, + { + value: 4, + name: 'Rainfed, minor fragments', + color: 'green', + }, + { + value: 5, + name: 'Rainfed, very minor fragments', + color: 'yellow', + }, + ], +} diff --git a/src/constants/earthEngineLayers/testing/ecoregions.js b/src/constants/earthEngineLayers/testing/ecoregions.js new file mode 100644 index 000000000..f2f5f3219 --- /dev/null +++ b/src/constants/earthEngineLayers/testing/ecoregions.js @@ -0,0 +1,16 @@ +import i18n from '@dhis2/d2-i18n' +import { EARTH_ENGINE_LAYER } from '../../layers.js' + +export default { + layer: EARTH_ENGINE_LAYER, + format: 'FeatureCollection', + layerId: 'RESOLVE/ECOREGIONS/2017', + datasetId: 'RESOLVE/ECOREGIONS/2017', + name: i18n.t('Ecoregions'), + description: '', + source: 'RESOLVE / Google Earth Engine', + style: { + byProperty: 'COLOR', + }, + opacity: 0.9, +} diff --git a/src/constants/earthEngineLayers/testing/rivers.js b/src/constants/earthEngineLayers/testing/rivers.js new file mode 100644 index 000000000..a39b1be76 --- /dev/null +++ b/src/constants/earthEngineLayers/testing/rivers.js @@ -0,0 +1,23 @@ +import i18n from '@dhis2/d2-i18n' +import { EARTH_ENGINE_LAYER } from '../../layers.js' + +export default { + layer: EARTH_ENGINE_LAYER, + layerId: 'WWF/HydroSHEDS/v1/FreeFlowingRivers_FeatureView', + datasetId: 'WWF/HydroSHEDS/v1/FreeFlowingRivers', + format: 'FeatureView', + name: i18n.t('Rivers'), + description: '', + source: 'WWF / Google Earth Engine', + style: { + lineWidth: 2, + color: { + property: 'RIV_ORD', + mode: 'linear', + palette: ['08519c', '3182bd', '6baed6', 'bdd7e7', 'eff3ff'], + min: 1, + max: 10, + }, + }, + opacity: 0.9, +} diff --git a/src/constants/periods.js b/src/constants/periods.js index 91d89ff18..80bc4df2c 100644 --- a/src/constants/periods.js +++ b/src/constants/periods.js @@ -60,11 +60,15 @@ const LAST_FINANCIAL_YEAR = 'LAST_FINANCIAL_YEAR' export const RELATIVE_PERIODS = 'RELATIVE_PERIODS' export const START_END_DATES = 'START_END_DATES' -export const periodTypes = () => [ - { - id: RELATIVE_PERIODS, - name: i18n.t('Relative'), - }, +export const periodTypes = (includeRelativePeriods) => [ + ...(includeRelativePeriods + ? [ + { + id: RELATIVE_PERIODS, + name: i18n.t('Relative'), + }, + ] + : []), { id: DAILY, name: i18n.t('Daily'), diff --git a/src/constants/settings.js b/src/constants/settings.js index 1a42d38c0..d17465a88 100644 --- a/src/constants/settings.js +++ b/src/constants/settings.js @@ -16,3 +16,7 @@ export const SYSTEM_SETTINGS = [ 'keyHideBiMonthlyPeriods', 'keyDefaultBaseMap', ] + +// TODO: Arbitrary authority id used for testing +export const MAPS_ADMIN_AUTHORITY_ID = + 'F_PROGRAM_TRACKED_ENTITY_ATTRIBUTE_GROUP_ADD' diff --git a/src/hooks/useEarthEngineLayersStore.js b/src/hooks/useEarthEngineLayersStore.js new file mode 100644 index 000000000..d5e268e16 --- /dev/null +++ b/src/hooks/useEarthEngineLayersStore.js @@ -0,0 +1,103 @@ +import { useDataEngine } from '@dhis2/app-runtime' +import { useState, useCallback, useEffect } from 'react' + +const MAPS_APP_NAMESPACE = 'MAPS_APP' +const EARTH_ENGINE_LAYERS_KEY = 'EARTH_ENGINE_LAYERS' + +const resource = `dataStore/${MAPS_APP_NAMESPACE}/${EARTH_ENGINE_LAYERS_KEY}` + +// TODO: authorization +const useEarthEngineLayersStore = () => { + const [loading, setLoading] = useState(true) + const [addedLayers, setAddedLayers] = useState([]) + const [error, setError] = useState() + const engine = useDataEngine() + + // Fetch layers / create namspace/key if missing + const getLayers = useCallback(() => { + setLoading(true) + engine + .query({ dataStore: { resource: 'dataStore' } }) + .then(({ dataStore }) => { + if (dataStore.includes(MAPS_APP_NAMESPACE)) { + // Fetch layers if namespace/keys exists in data store + engine + .query({ layers: { resource } }) + .then(({ layers }) => { + setAddedLayers(layers) + setLoading(false) + }) + } else { + // Create namespace/keys if missing in data store + engine + .mutate({ + resource, + type: 'create', + data: [], + }) + .then((response) => { + if (response.httpStatusCode === 201) { + setLoading(false) + } else { + setError(response) + } + }) + .catch(setError) + } + }) + }, [engine]) + + // Add layer id to data store + const addLayer = useCallback( + (layerId) => { + if (!addedLayers.includes(layerId)) { + engine + .mutate({ + resource, + type: 'update', + data: [...addedLayers, layerId], + }) + .then((response) => { + if (response.httpStatusCode === 200) { + getLayers() + } else { + setError(response) + } + }) + .catch(setError) + } + }, + [engine, addedLayers, getLayers] + ) + + // Remove layer id from data store + const removeLayer = useCallback( + (layerId) => { + if (addedLayers.includes(layerId)) { + engine + .mutate({ + resource, + type: 'update', + data: addedLayers.filter((l) => l !== layerId), + }) + .then((response) => { + if (response.httpStatusCode === 200) { + getLayers() + } else { + setError(response) + } + }) + .catch(setError) + } + }, + [engine, addedLayers, getLayers] + ) + + useEffect(() => { + getLayers() + }, [engine, getLayers]) + + return { addedLayers, loading, addLayer, removeLayer, error } +} + +export default useEarthEngineLayersStore diff --git a/src/loaders/earthEngineLoader.js b/src/loaders/earthEngineLoader.js index b72677a23..8d249bae3 100644 --- a/src/loaders/earthEngineLoader.js +++ b/src/loaders/earthEngineLoader.js @@ -1,26 +1,32 @@ import i18n from '@dhis2/d2-i18n' import { getInstance as getD2 } from 'd2' -import { precisionRound } from 'd3-format' -import { getEarthEngineLayer } from '../constants/earthEngine.js' +import { precisionFixed, formatLocale } from 'd3-format' +// import { getEarthEngineLayer } from '../constants/earthEngine.js' import { getOrgUnitsFromRows } from '../util/analytics.js' -import { hasClasses, getPeriodNameFromFilter } from '../util/earthEngine.js' +import { + hasClasses, + getFilterFromPeriod, + getPeriodFromFilter, +} from '../util/earthEngine.js' import { getDisplayProperty } from '../util/helpers.js' import { toGeoJson } from '../util/map.js' -import { numberPrecision } from '../util/numbers.js' +// import { numberPrecision } from '../util/numbers.js' import { getCoordinateField, addAssociatedGeometries, } from '../util/orgUnits.js' +const numberFormat = formatLocale({ minus: '\u002D' }).format + // Returns a promise const earthEngineLoader = async (config) => { - const { rows, aggregationType } = config + const { format, rows, aggregationType } = config const orgUnits = getOrgUnitsFromRows(rows) const coordinateField = getCoordinateField(config) const alerts = [] let layerConfig = {} - let dataset + // let dataset let features if (orgUnits && orgUnits.length) { @@ -86,6 +92,23 @@ const earthEngineLoader = async (config) => { // From database as favorite layerConfig = JSON.parse(config.config) + const { filter, params } = layerConfig + + // Backward compability for layers saved before 2.41 + if (filter) { + layerConfig.period = getPeriodFromFilter(filter) + delete layerConfig.filter + } + + // Backward compability for layers saved before 2.41 + if (params) { + layerConfig.style = params + if (params.palette) { + layerConfig.style.palette = params.palette.split(',') + } + delete layerConfig.params + } + // Backward compability for layers with periods saved before 2.36 // (could also be fixed in a db update script) if (layerConfig.image) { @@ -108,26 +131,49 @@ const earthEngineLoader = async (config) => { } } + // Backward compability for layers saved before 2.40 + if (typeof layerConfig.params?.palette === 'string') { + layerConfig.params.palette = layerConfig.params.palette.split(',') + } + + /* dataset = getEarthEngineLayer(layerConfig.id) if (dataset) { delete layerConfig.id } + */ delete config.config + delete config.filters // Backend returns empty filters array } else { - dataset = getEarthEngineLayer(layerConfig.id) + // dataset = getEarthEngineLayer(layerConfig.id) + // console.log('getEarthEngineLayer', layerConfig.id, dataset) } + // console.log('###', dataset, config, layerConfig) + const layer = { - ...dataset, + // ...dataset, ...config, ...layerConfig, } - const { unit, filter, description, source, sourceUrl, band, bands } = layer + const { + unit, + period, + filters, + description, + source, + sourceUrl, + band, + bands, + style, + maskOperator, + precision, + } = layer + const { name } = dataset || config - const period = getPeriodNameFromFilter(filter) const data = Array.isArray(features) && features.length ? features : undefined const hasBand = (b) => @@ -140,8 +186,9 @@ const earthEngineLoader = async (config) => { const legend = { ...layer.legend, + items: Array.isArray(style) ? style : null, title: name, - period, + period: period?.name, groups, unit, description, @@ -150,15 +197,20 @@ const earthEngineLoader = async (config) => { } // Create/update legend items from params - if (!hasClasses(aggregationType) && layer.params) { - legend.items = createLegend(layer.params) + if (format === 'FeatureCollection') { + // TODO: Add feature collection style + } else if (!hasClasses(aggregationType) && style?.palette) { + legend.items = createLegend(style, !maskOperator, precision) } + const filter = getFilterFromPeriod(period, filters) + return { ...layer, legend, name, data, + filter, alerts, isLoaded: true, isLoading: false, @@ -167,36 +219,39 @@ const earthEngineLoader = async (config) => { } } -export const createLegend = ({ min, max, palette }) => { - const colors = palette.split(',') - const step = (max - min) / (colors.length - (min > 0 ? 2 : 1)) - const precision = precisionRound(step, max) - const valueFormat = numberPrecision(precision) +export const createLegend = ( + { min, max, palette }, + showBelowMin, + precision +) => { + const step = (max - min) / (palette.length - (showBelowMin ? 2 : 1)) + const decimals = precision || precisionFixed(step % 1) + const valueFormat = numberFormat('.' + decimals + 'f') - let from = min + let from = valueFormat(min) let to = valueFormat(min + step) - return colors.map((color, index) => { + return palette.map((color, index) => { const item = { color } - if (index === 0 && min > 0) { + if (index === 0 && showBelowMin) { // Less than min - item.from = 0 + item.from = -Infinity item.to = min item.name = '< ' + min to = min - } else if (from < max) { - item.from = from - item.to = to + } else if (+from < max) { + item.from = +from + item.to = +to item.name = from + ' - ' + to } else { // Higher than max - item.from = from + item.from = +from item.name = '> ' + from } from = to - to = valueFormat(min + step * (index + (min > 0 ? 1 : 2))) + to = valueFormat(min + step * (index + (showBelowMin ? 1 : 2))) return item }) diff --git a/src/reducers/layerEdit.js b/src/reducers/layerEdit.js index 2549cbb36..8c6f39d89 100644 --- a/src/reducers/layerEdit.js +++ b/src/reducers/layerEdit.js @@ -107,9 +107,15 @@ const layerEdit = (state = null, action) => { return { ...state, periodType: action.periodType.id, - filters: action.clearPeriod - ? removePeriodFromFilters(state.filters) - : state.filters, + filters: action.keepPeriod + ? state.filters + : removePeriodFromFilters(state.filters), + } + + case types.LAYER_EDIT_PERIOD_REDUCER_SET: + return { + ...state, + periodReducer: action.payload, } case types.LAYER_EDIT_PERIOD_SET: @@ -318,7 +324,7 @@ const layerEdit = (state = null, action) => { newState = { ...state, colorScale: action.colorScale, - classes: action.colorScale.split(',').length, + classes: action.colorScale.length, } if (newState.styleDataItem) { @@ -440,11 +446,11 @@ const layerEdit = (state = null, action) => { band: action.payload, } - case types.LAYER_EDIT_PARAMS_SET: + case types.LAYER_EDIT_STYLE_SET: return { ...state, - params: { - ...state.params, + style: { + ...state.style, ...action.payload, }, } @@ -569,6 +575,12 @@ const layerEdit = (state = null, action) => { return newState + case types.LAYER_EDIT_EARTH_ENGINE_PERIOD_SET: + return { + ...state, + period: action.payload, + } + default: return state } diff --git a/src/util/__tests__/getMigratedMapConfig.spec.js b/src/util/__tests__/getMigratedMapConfig.spec.js index 09526c407..7b078a832 100644 --- a/src/util/__tests__/getMigratedMapConfig.spec.js +++ b/src/util/__tests__/getMigratedMapConfig.spec.js @@ -172,3 +172,40 @@ test('getMigratedMapConfig with old GIS app format and Boundary layer', () => { }) ) }) + +test('getMigratedMapConfig with colorScale converted to an array', () => { + const config = { + id: 'mapId', + name: 'map name', + basemap: { id: 'osmStreet' }, + mapViews: [ + { + layer: 'thematic', + name: 'Thematic layer', + colorScale: '#fee5d9,#fcbba1,#fc9272,#fb6a4a,#de2d26,#a50f15', + }, + ], + } + + expect(getMigratedMapConfig(config, defaultBasemapId)).toEqual( + expect.objectContaining({ + id: 'mapId', + name: 'map name', + basemap: { id: 'osmStreet' }, + mapViews: [ + { + layer: 'thematic', + name: 'Thematic layer', + colorScale: [ + '#fee5d9', + '#fcbba1', + '#fc9272', + '#fb6a4a', + '#de2d26', + '#a50f15', + ], + }, + ], + }) + ) +}) diff --git a/src/util/__tests__/periods.spec.js b/src/util/__tests__/periods.spec.js new file mode 100644 index 000000000..0069b2102 --- /dev/null +++ b/src/util/__tests__/periods.spec.js @@ -0,0 +1,325 @@ +import { getFixedPeriodsByType } from '../periods.js' + +describe('util/periods', () => { + test('getFixedPeriodsByType - RELATIVE', () => { + expect( + getFixedPeriodsByType({ periodType: 'RELATIVE', year: 2020 }) + ).toBe(null) + }) + + test('getFixedPeriodsByType - YEAR not provided', () => { + expect(getFixedPeriodsByType({ periodType: 'MONTHLY' })).toStrictEqual( + [] + ) + }) + + test('getFixedPeriodsByType - MONTHLY first and last date', () => { + expect( + getFixedPeriodsByType({ + periodType: 'MONTHLY', + year: 2020, + firstDate: '2020-04-01', + lastDate: '2020-08-30', + }) + ).toStrictEqual([ + { + endDate: '2020-07-31', + id: '202007', + iso: '202007', + name: 'July 2020', + startDate: '2020-07-01', + }, + { + endDate: '2020-06-30', + id: '202006', + iso: '202006', + name: 'June 2020', + startDate: '2020-06-01', + }, + { + endDate: '2020-05-31', + id: '202005', + iso: '202005', + name: 'May 2020', + startDate: '2020-05-01', + }, + { + endDate: '2020-04-30', + id: '202004', + iso: '202004', + name: 'April 2020', + startDate: '2020-04-01', + }, + ]) + }) + + test('getFixedPeriodsByType - YEARLY first and last date', () => { + expect( + getFixedPeriodsByType({ + periodType: 'YEARLY', + year: 2020, + firstDate: '2018-04-01', + lastDate: '2021-04-01', + }) + ).toStrictEqual([ + { + endDate: '2020-12-31', + id: '2020', + iso: '2020', + name: '2020', + startDate: '2020-01-01', + }, + { + endDate: '2019-12-31', + id: '2019', + iso: '2019', + name: '2019', + startDate: '2019-01-01', + }, + ]) + }) + + test('getFixedPeriodsByType - YEARLY first date', () => { + expect( + getFixedPeriodsByType({ + periodType: 'YEARLY', + year: 2020, + firstDate: '2020-04-01', + }) + ).toStrictEqual([]) + }) + + test('getFixedPeriodsByType - FYAPR 2020', () => { + expect( + getFixedPeriodsByType({ periodType: 'FYAPR', year: 2020 }) + ).toStrictEqual([ + { + endDate: '2021-03-31', + id: '2020April', + name: 'April 2020 - March 2021', + startDate: '2020-04-01', + }, + { + endDate: '2020-03-31', + id: '2019April', + name: 'April 2019 - March 2020', + startDate: '2019-04-01', + }, + { + endDate: '2019-03-31', + id: '2018April', + name: 'April 2018 - March 2019', + startDate: '2018-04-01', + }, + { + endDate: '2018-03-31', + id: '2017April', + name: 'April 2017 - March 2018', + startDate: '2017-04-01', + }, + { + endDate: '2017-03-31', + id: '2016April', + name: 'April 2016 - March 2017', + startDate: '2016-04-01', + }, + { + endDate: '2016-03-31', + id: '2015April', + name: 'April 2015 - March 2016', + startDate: '2015-04-01', + }, + { + endDate: '2015-03-31', + id: '2014April', + name: 'April 2014 - March 2015', + startDate: '2014-04-01', + }, + { + endDate: '2014-03-31', + id: '2013April', + name: 'April 2013 - March 2014', + startDate: '2013-04-01', + }, + { + endDate: '2013-03-31', + id: '2012April', + name: 'April 2012 - March 2013', + startDate: '2012-04-01', + }, + { + endDate: '2012-03-31', + id: '2011April', + name: 'April 2011 - March 2012', + startDate: '2011-04-01', + }, + ]) + }) + + test('getFixedPeriodsByType YEARLY 2020', () => { + expect( + getFixedPeriodsByType({ periodType: 'YEARLY', year: 2020 }) + ).toStrictEqual([ + { + endDate: '2020-12-31', + id: '2020', + iso: '2020', + name: '2020', + startDate: '2020-01-01', + }, + { + endDate: '2019-12-31', + id: '2019', + iso: '2019', + name: '2019', + startDate: '2019-01-01', + }, + { + endDate: '2018-12-31', + id: '2018', + iso: '2018', + name: '2018', + startDate: '2018-01-01', + }, + { + endDate: '2017-12-31', + id: '2017', + iso: '2017', + name: '2017', + startDate: '2017-01-01', + }, + { + endDate: '2016-12-31', + id: '2016', + iso: '2016', + name: '2016', + startDate: '2016-01-01', + }, + { + endDate: '2015-12-31', + id: '2015', + iso: '2015', + name: '2015', + startDate: '2015-01-01', + }, + { + endDate: '2014-12-31', + id: '2014', + iso: '2014', + name: '2014', + startDate: '2014-01-01', + }, + { + endDate: '2013-12-31', + id: '2013', + iso: '2013', + name: '2013', + startDate: '2013-01-01', + }, + { + endDate: '2012-12-31', + id: '2012', + iso: '2012', + name: '2012', + startDate: '2012-01-01', + }, + { + endDate: '2011-12-31', + id: '2011', + iso: '2011', + name: '2011', + startDate: '2011-01-01', + }, + ]) + }) + test('getFixedPeriodsByType - MONTHLY 2020', () => { + expect( + getFixedPeriodsByType({ periodType: 'MONTHLY', year: 2020 }) + ).toStrictEqual([ + { + endDate: '2020-12-31', + id: '202012', + iso: '202012', + name: 'December 2020', + startDate: '2020-12-01', + }, + { + endDate: '2020-11-30', + id: '202011', + iso: '202011', + name: 'November 2020', + startDate: '2020-11-01', + }, + { + endDate: '2020-10-31', + id: '202010', + iso: '202010', + name: 'October 2020', + startDate: '2020-10-01', + }, + { + endDate: '2020-09-30', + id: '202009', + iso: '202009', + name: 'September 2020', + startDate: '2020-09-01', + }, + { + endDate: '2020-08-31', + id: '202008', + iso: '202008', + name: 'August 2020', + startDate: '2020-08-01', + }, + { + endDate: '2020-07-31', + id: '202007', + iso: '202007', + name: 'July 2020', + startDate: '2020-07-01', + }, + { + endDate: '2020-06-30', + id: '202006', + iso: '202006', + name: 'June 2020', + startDate: '2020-06-01', + }, + { + endDate: '2020-05-31', + id: '202005', + iso: '202005', + name: 'May 2020', + startDate: '2020-05-01', + }, + { + endDate: '2020-04-30', + id: '202004', + iso: '202004', + name: 'April 2020', + startDate: '2020-04-01', + }, + { + endDate: '2020-03-31', + id: '202003', + iso: '202003', + name: 'March 2020', + startDate: '2020-03-01', + }, + { + endDate: '2020-02-29', + id: '202002', + iso: '202002', + name: 'February 2020', + startDate: '2020-02-01', + }, + { + endDate: '2020-01-31', + id: '202001', + iso: '202001', + name: 'January 2020', + startDate: '2020-01-01', + }, + ]) + }) +}) diff --git a/src/util/colors.js b/src/util/colors.js index 37ad87c3d..f52e38a20 100644 --- a/src/util/colors.js +++ b/src/util/colors.js @@ -38,17 +38,15 @@ export const colorScales = [ ] // Returns a color brewer scale for a number of classes -export const getColorPalette = (scale, classes) => { - return colorbrewer[scale][classes].join(',') -} +export const getColorPalette = (scale, classes) => colorbrewer[scale][classes] // Returns color scale name for a palette -export const getColorScale = (palette) => { - const classes = palette.split(',').length - return colorScales.find( - (name) => colorbrewer[name][classes].join(',') === palette +// join(',') is used to compare two arrays of colors +export const getColorScale = (palette) => + colorScales.find( + (name) => + colorbrewer[name][palette.length].join(',') === palette.join(',') ) -} export const defaultColorScaleName = 'YlOrBr' export const defaultClasses = 5 diff --git a/src/util/earthEngine.js b/src/util/earthEngine.js index 920d6e93e..01d60ba4b 100644 --- a/src/util/earthEngine.js +++ b/src/util/earthEngine.js @@ -1,12 +1,24 @@ import i18n from '@dhis2/d2-i18n' import { loadEarthEngineWorker } from '../components/map/MapApi.js' import { apiFetch } from './api.js' -import { formatStartEndDate } from './time.js' +import { formatDate, formatStartEndDate, incrementDate } from './time.js' export const classAggregation = ['percentage', 'hectares', 'acres'] export const hasClasses = (type) => classAggregation.includes(type) +const timeRangeCache = {} + +// Translates from dynamic to static filters +export const translateFilters = (filters, ...args) => + filters.map((filter) => ({ + ...filter, + arguments: filter.arguments.map((arg) => { + const match = arg.match(/^\$([0-9]+)$/) + return match ? args[match[1] - 1] : arg + }), + })) + export const getStartEndDate = (data) => formatStartEndDate( data['system:time_start'], @@ -15,15 +27,42 @@ export const getStartEndDate = (data) => false ) +const getMonth = (data) => { + const date = new Date(data['system:time_start']) + const month = date.toLocaleString('default', { month: 'long' }) // TODO: i18n? + const year = date.getFullYear() + return `${month} ${year}` +} + +export const getFilterFromPeriod = (period, filters) => { + if (!period || !filters) { + return + } + + const { id, startDate, endDate } = period + + if (startDate && endDate) { + return translateFilters(filters, startDate, incrementDate(endDate)) + } else { + return translateFilters(filters, id) + } +} + export const getPeriodFromFilter = (filter) => { if (!Array.isArray(filter) || !filter.length) { return null } const { id, name, year, arguments: args } = filter[0] + let periodId = id || args[1] + + // Remover non-digits from periodId (needed for backward compatibility for population layers saved before 2.41) + if (nonDigits.test(periodId)) { + periodId = Number(periodId.replace(nonDigits, '')) // Remove non-digits + } return { - id: id || args[1], + id: periodId, name, year, } @@ -72,7 +111,6 @@ export const getAuthToken = () => ...token, }) }) - /* eslint-enable no-async-promise-executor */ let workerPromise @@ -89,34 +127,82 @@ const getWorkerInstance = async () => { return workerPromise } -export const getPeriods = async (eeId, periodType) => { - const getPeriod = ({ id, properties }) => { - const year = new Date(properties['system:time_start']).getFullYear() - const name = - periodType === 'Yearly' ? String(year) : getStartEndDate(properties) - - // Remove when old population should not be supported - if (eeId === 'WorldPop/POP') { - return { id: name, name, year } - } +export const getPeriods = async (eeId, periodType, filters) => { + const useSystemIndex = filters.some((f) => + f.arguments.includes('system:index') + ) - return { id, name, year } + const getPeriod = ({ id, properties }) => { + const year = + properties.year || + new Date(properties['system:time_start']).getFullYear() + + return periodType === 'YEARLY' + ? { id: useSystemIndex ? id : year, name: String(year) } + : { + id, + name: + periodType === 'EE_MONTHLY' + ? getMonth(properties) + : getStartEndDate(properties), + year, + } } const eeWorker = await getWorkerInstance() + /* + if (periodType === 'daily') { + const { min, max } = await eeWorker.getTimeRange(eeId) + console.log('time range', new Date(min), new Date(max)) + return [] + } + */ + + // try { const { features } = await eeWorker.getPeriods(eeId) + return features.map(getPeriod) } -export const defaultFilters = ({ id, name, year }) => [ - { - type: 'eq', - arguments: ['system:index', String(id)], - name, - year, - }, -] +// const oneDayInMilliseconds = 24 * 60 * 60 * 1000 + +export const getTimeRange = async (eeId) => { + if (timeRangeCache[eeId]) { + return timeRangeCache[eeId] + } + + const eeWorker = await getWorkerInstance() + + return eeWorker.getTimeRange(eeId).then(({ min, max }) => { + const response = { + firstDate: min ? formatDate(min) : null, + lastDate: max ? formatDate(max) : null, + } + + timeRangeCache[eeId] = response + + return response + }) +} + +const getRelativeDate = (days) => { + const dateObj = new Date() + dateObj.setDate(dateObj.getDate() + days) + return formatDate(dateObj) +} + +export const createTimeRange = (range) => { + const { firstDate, lastDate } = range + + return { + firstDate, + lastDate: + typeof lastDate === 'number' + ? getRelativeDate(range.lastDate) + : lastDate, + } +} export const getPrecision = (values = []) => { if (values.length) { diff --git a/src/util/external.js b/src/util/external.js index d85c31fb0..165c69eed 100644 --- a/src/util/external.js +++ b/src/util/external.js @@ -23,6 +23,10 @@ const mapServiceToTypeMap = { [MAP_SERVICE_XYZ]: TILE_LAYER, [MAP_SERVICE_TMS]: TILE_LAYER, [MAP_SERVICE_VECTOR_STYLE]: VECTOR_STYLE, + xyz: TILE_LAYER, + tms: TILE_LAYER, + wms: WMS_LAYER, + vectorstyle: VECTOR_STYLE, } // Create external layer from a model @@ -40,6 +44,7 @@ export const createExternalLayerConfig = (model) => { id, name, attribution, + service, mapService, url, layers, @@ -48,10 +53,10 @@ export const createExternalLayerConfig = (model) => { legendSetUrl, } = model - const type = mapServiceToTypeMap[mapService] + const type = mapServiceToTypeMap[service || mapService] const format = imageFormat === 'JPG' ? 'image/jpeg' : 'image/png' - const tms = mapService === MAP_SERVICE_TMS + const tms = (service || mapService) === MAP_SERVICE_TMS return { id, diff --git a/src/util/favorites.js b/src/util/favorites.js index 2c6b1b231..deca770ee 100644 --- a/src/util/favorites.js +++ b/src/util/favorites.js @@ -62,8 +62,8 @@ const validLayerProperties = [ 'organisationUnitSelectionMode', 'orgUnitField', 'orgUnitFieldDisplayName', - 'params', - 'periodName', + 'style', + 'period', 'periodType', 'renderingStrategy', 'program', @@ -132,14 +132,14 @@ const models2objects = (config) => { } if (layer === EARTH_ENGINE_LAYER) { - const { layerId: id, band, params, aggregationType, filter } = config + const { layerId: id, band, style, aggregationType, period } = config const eeConfig = { id, - params, + style, band, aggregationType, - filter, + period, } // Removes undefined keys before stringify @@ -147,15 +147,15 @@ const models2objects = (config) => { (key) => eeConfig[key] === undefined && delete eeConfig[key] ) + config.layer = layer config.config = JSON.stringify(eeConfig) delete config.layerId delete config.datasetId - delete config.params - delete config.filter + delete config.style + delete config.period delete config.filters delete config.periodType - delete config.periodName delete config.aggregationType delete config.band } else if (layer === TRACKED_ENTITY_LAYER) { @@ -202,6 +202,11 @@ const models2objects = (config) => { } } + // Color scale needs to be stored as a string in analytical object + if (Array.isArray(config.colorScale)) { + config.colorScale = config.colorScale.join(',') + } + return config } diff --git a/src/util/getDefaultLayerTypes.js b/src/util/getDefaultLayerTypes.js index 836c449b6..2c7e85db8 100644 --- a/src/util/getDefaultLayerTypes.js +++ b/src/util/getDefaultLayerTypes.js @@ -1,5 +1,4 @@ import i18n from '@dhis2/d2-i18n' -import { earthEngineLayers } from '../constants/earthEngine.js' import { THEMATIC_LAYER, EVENT_LAYER, @@ -40,5 +39,4 @@ export const getDefaultLayerTypes = () => [ img: 'images/orgunits.png', opacity: 1, }, - ...earthEngineLayers().filter((l) => !l.legacy), ] diff --git a/src/util/getMigratedMapConfig.js b/src/util/getMigratedMapConfig.js index 312544941..e970c0fbb 100644 --- a/src/util/getMigratedMapConfig.js +++ b/src/util/getMigratedMapConfig.js @@ -2,7 +2,7 @@ import { isString, isObject, sortBy } from 'lodash/fp' import { EXTERNAL_LAYER } from '../constants/layers.js' export const getMigratedMapConfig = (config, defaultBasemapId) => - renameBoundaryLayerToOrgUnitLayer( + upgradeMapViews( upgradeGisAppLayers(extractBasemap(config, defaultBasemapId)) ) @@ -79,15 +79,27 @@ const upgradeGisAppLayers = (config) => { } // Change layer name from boundary to orgUnit when loading an old map +// Change colorScale from string to array // TODO: Change in db with an upgrade script -const renameBoundaryLayerToOrgUnitLayer = (config) => ({ - ...config, - mapViews: config.mapViews.map((v) => - v.layer === 'boundary' - ? { - ...v, - layer: 'orgUnit', - } - : v - ), -}) +const upgradeMapViews = (config) => { + if ( + config.mapViews.find( + (view) => + view.layer === 'boundary' || typeof view.colorScale === 'string' + ) + ) { + return { + ...config, + mapViews: config.mapViews.map((view) => ({ + ...view, + layer: view.layer === 'boundary' ? 'orgUnit' : view.layer, + colorScale: + typeof view.colorScale === 'string' + ? view.colorScale.split(',') + : view.colorScale, + })), + } + } else { + return config + } +} diff --git a/src/util/helpers.js b/src/util/helpers.js index 008b05f13..364043448 100644 --- a/src/util/helpers.js +++ b/src/util/helpers.js @@ -174,3 +174,6 @@ export const getLongestTextLength = (array, key) => // Copied from https://github.com/dhis2/d2/blob/master/src/uid.js const CODE_PATTERN = /^[a-zA-Z]{1}[a-zA-Z0-9]{10}$/ export const isValidUid = (code) => (code ? CODE_PATTERN.test(code) : false) + +export const lowercaseFirstLetter = (string) => + string.charAt(0).toLowerCase() + string.slice(1) diff --git a/src/util/legend.js b/src/util/legend.js index 9048f8572..fd1e49c33 100644 --- a/src/util/legend.js +++ b/src/util/legend.js @@ -73,11 +73,10 @@ export const getAutomaticLegendItems = ( colorScale = defaultColorScale ) => { const items = data.length ? getLegendItems(data, method, classes) : [] - const colors = colorScale.split(',') return items.map((item, index) => ({ ...item, - color: colors[index], + color: colorScale[index], })) } /* eslint-enable max-params */ diff --git a/src/util/periods.js b/src/util/periods.js index 98b9c206e..de3762186 100644 --- a/src/util/periods.js +++ b/src/util/periods.js @@ -6,23 +6,43 @@ import { periodTypes, periodGroups } from '../constants/periods.js' const getYearOffsetFromNow = (year) => year - new Date(Date.now()).getFullYear() -export const getPeriodTypes = (hiddenPeriods = []) => - periodTypes().filter(({ group }) => !hiddenPeriods.includes(group)) +const filterPeriods = (periods, firstDate, lastDate) => + periods.filter( + (p) => + (!firstDate || p.startDate >= firstDate) && + (!lastDate || p.endDate <= lastDate) + ) -export const getFixedPeriodsByType = (periodType, year) => { +export const getPeriodTypes = (includeRelativePeriods, hiddenPeriods = []) => + periodTypes(includeRelativePeriods).filter( + ({ group }) => !hiddenPeriods.includes(group) + ) + +export const getFixedPeriodsByType = ({ + periodType, + year, + firstDate, + lastDate, +}) => { const period = getFixedPeriodsOptionsById(periodType) const forceDescendingForYearTypes = !!periodType.match(/^FY|YEARLY/) const offset = getYearOffsetFromNow(year) - const periods = period?.getPeriods({ offset, reversePeriods: true }) || null - if (periods && forceDescendingForYearTypes) { - // TODO: the reverse() is a workaround for a bug in the analytics - // getPeriods function that no longer correctly reverses the order - // for YEARLY and FY period types - return periods.reverse() + let periods = period?.getPeriods({ offset, reversePeriods: true }) + + if (!periods) { + return null } - return periods + + if (firstDate || lastDate) { + periods = filterPeriods(periods, firstDate, lastDate) + } + + // TODO: the reverse() is a workaround for a bug in the analytics + // getPeriods function that no longer correctly reverses the order + // for YEARLY and FY period types + return forceDescendingForYearTypes ? periods.reverse() : periods } export const getRelativePeriods = (hiddenPeriods = []) => @@ -46,8 +66,8 @@ export const getPeriodNames = () => ({ }, {}), }) -export const filterFuturePeriods = (periods) => { - const now = new Date(Date.now()) +export const filterFuturePeriods = (periods, date) => { + const now = new Date(date || Date.now()) return periods.filter(({ startDate }) => new Date(startDate) < now) } diff --git a/src/util/time.js b/src/util/time.js index e4d305b55..e4b659c1d 100644 --- a/src/util/time.js +++ b/src/util/time.js @@ -131,3 +131,9 @@ export const getStartEndDateError = (startDateStr, endDateStr) => { * @returns {Number} */ export const getYear = (date) => toDate(date || new Date()).getFullYear() + +export const incrementDate = (date, increment = 1) => { + const dateObj = toDate(date) + dateObj.setDate(dateObj.getDate() + increment) + return formatDate(dateObj) +}