diff --git a/cypress/integration/dataTable.cy.js b/cypress/integration/dataTable.cy.js index 2bcaefea5..0fce15a16 100644 --- a/cypress/integration/dataTable.cy.js +++ b/cypress/integration/dataTable.cy.js @@ -177,7 +177,7 @@ describe('data table', () => { // check number of columns cy.getByDataTest('bottom-panel') .findByDataTest('dhis2-uicore-datatablecellhead') - .should('have.length', 10) + .should('have.length', 11) cy.getByDataTest('bottom-panel') .findByDataTest('dhis2-uicore-datatablecellhead') diff --git a/i18n/en.pot b/i18n/en.pot index df97c59df..5143cecaf 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: 2026-03-12T18:11:56.380Z\n" -"PO-Revision-Date: 2026-03-12T18:11:56.380Z\n" +"POT-Creation-Date: 2026-04-22T17:25:30.092Z\n" +"PO-Revision-Date: 2026-04-22T17:25:30.092Z\n" msgid "2020" msgstr "2020" @@ -32,24 +32,39 @@ msgstr "This app could not retrieve required data." msgid "Network error" msgstr "Network error" +msgid "Auto" +msgstr "Auto" + msgid "Classification" msgstr "Classification" msgid "Classes" msgstr "Classes" -msgid "Legend set" -msgstr "Legend set" +msgid "Decimal places" +msgstr "Decimal places" + +msgid "Isolated class" +msgstr "Isolated class" + +msgid "Min" +msgstr "Min" + +msgid "Max" +msgstr "Max" msgid "Color" msgstr "Color" -msgid "Size" -msgstr "Size" - msgid "Name" msgstr "Name" +msgid "Legend set" +msgstr "Legend set" + +msgid "Size" +msgstr "Size" + msgid "Name and value" msgstr "Name and value" @@ -249,6 +264,9 @@ msgstr "Point color" msgid "Point radius" msgstr "Point radius" +msgid "Count org units without coordinates" +msgstr "Count org units without coordinates" + msgid "event" msgstr "event" @@ -356,15 +374,6 @@ msgstr "Max should be greater than min" msgid "Valid steps are {{minSteps}} to {{maxSteps}}" msgstr "Valid steps are {{minSteps}} to {{maxSteps}}" -msgid "Min" -msgstr "Min" - -msgid "Max" -msgstr "Max" - -msgid "Steps" -msgstr "Steps" - msgid "Facility buffer" msgstr "Facility buffer" @@ -383,9 +392,18 @@ msgstr "View all events" msgid "Radius" msgstr "Radius" +msgid "Count events without coordinates" +msgstr "Count events without coordinates" + msgid "You can style events by data element after selecting a program." msgstr "You can style events by data element after selecting a program." +msgid "Include unclassified events" +msgstr "Include unclassified events" + +msgid "Include events with no data" +msgstr "Include events with no data" + msgid "Program is required" msgstr "Program is required" @@ -443,15 +461,24 @@ msgstr "Polygons are represented by their centroids." msgid "Labels" msgstr "Labels" +msgid "Include org units with no data" +msgstr "Include org units with no data" + +msgid "No data" +msgstr "No data" + +msgid "Include unclassified org units" +msgstr "Include unclassified org units" + +msgid "Unclassified" +msgstr "Unclassified" + msgid "Aggregation type" msgstr "Aggregation type" msgid "Only show completed events" msgstr "Only show completed events" -msgid "Include org units with no data" -msgstr "Include org units with no data" - msgid "Low radius" msgstr "Low radius" @@ -656,6 +683,19 @@ msgstr "Edit layer" msgid "Remove layer" msgstr "Remove layer" +msgid "Data quality" +msgstr "Data quality" + +msgid "{{count}} event without coordinates" +msgid_plural "{{count}} event without coordinates" +msgstr[0] "{{count}} event without coordinates" +msgstr[1] "{{count}} events without coordinates" + +msgid "{{count}} org unit without coordinates" +msgid_plural "{{count}} org unit without coordinates" +msgstr[0] "{{count}} org unit without coordinates" +msgstr[1] "{{count}} org units without coordinates" + msgid "Filters" msgstr "Filters" @@ -714,9 +754,6 @@ msgstr "Groups" msgid "Parent unit" msgstr "Parent unit" -msgid "No data" -msgstr "No data" - msgid "Not set" msgstr "Not set" @@ -967,6 +1004,12 @@ msgstr "Click to unpin legend" msgid "Click to pin legend" msgstr "Click to pin legend" +msgid "Hide layer" +msgstr "Hide layer" + +msgid "Show layer" +msgstr "Show layer" + msgid "No program" msgstr "No program" @@ -1638,6 +1681,18 @@ msgstr "Equal intervals" msgid "Equal counts" msgstr "Equal counts" +msgid "Natural breaks (intervals)" +msgstr "Natural breaks (intervals)" + +msgid "Natural breaks (clusters)" +msgstr "Natural breaks (clusters)" + +msgid "Pretty breaks" +msgstr "Pretty breaks" + +msgid "Logarithmic scale" +msgstr "Logarithmic scale" + msgid "Symbol" msgstr "Symbol" @@ -1707,15 +1762,15 @@ msgstr "Displaying first {{pageSize}} events out of {{total}}" msgid "Event" msgstr "Event" +msgid "GroupSet used for styling was not found" +msgstr "GroupSet used for styling was not found" + msgid "Facilities" msgstr "Facilities" msgid "an error occurred" msgstr "an error occurred" -msgid "GroupSet used for styling was not found" -msgstr "GroupSet used for styling was not found" - msgid "No coordinates found for selected facilities" msgstr "No coordinates found for selected facilities" @@ -1746,12 +1801,12 @@ msgstr "Data item was not found" msgid "Thematic layer" msgstr "Thematic layer" -msgid "Tracked entity" -msgstr "Tracked entity" - msgid "related" msgstr "related" +msgid "Tracked entity" +msgstr "Tracked entity" + msgid "not one of" msgstr "not one of" @@ -1801,9 +1856,6 @@ msgstr "Org units" msgid "Facility" msgstr "Facility" -msgid "Other" -msgstr "Other" - msgid "Start date is invalid" msgstr "Start date is invalid" diff --git a/package.json b/package.json index 2d7ecc54c..d3066fd9e 100644 --- a/package.json +++ b/package.json @@ -73,6 +73,7 @@ "redux": "^4.2.1", "redux-logger": "^3.0.6", "redux-thunk": "^2.4.2", + "simple-statistics": "^7.8.9", "styled-jsx": "^4.0.1", "url-polyfill": "^1.1.14" }, diff --git a/src/actions/layerEdit.js b/src/actions/layerEdit.js index 836dfae32..ad7ff065d 100644 --- a/src/actions/layerEdit.js +++ b/src/actions/layerEdit.js @@ -92,6 +92,16 @@ export const setColorScale = (colorScale) => ({ colorScale, }) +export const setLegendDecimalPlaces = (legendDecimalPlaces) => ({ + type: types.LAYER_EDIT_LEGEND_DECIMAL_PLACES_SET, + legendDecimalPlaces, +}) + +export const setLegendIsolated = (legendIsolated) => ({ + type: types.LAYER_EDIT_LEGEND_ISOLATED_SET, + legendIsolated, +}) + // Set event status export const setEventStatus = (status) => ({ type: types.LAYER_EDIT_EVENT_STATUS_SET, @@ -117,6 +127,12 @@ export const setEventClustering = (checked) => ({ checked, }) +// Set if events without coordinates should be counted and added to data table (event) +export const setCountEventsWithoutCoordinates = (checked) => ({ + type: types.LAYER_EDIT_COUNT_EVENTS_WITHOUT_COORDS_SET, + checked, +}) + // Set event point radius (event layer) export const setEventPointRadius = (radius) => ({ type: types.LAYER_EDIT_EVENT_POINT_RADIUS_SET, @@ -161,6 +177,12 @@ export const setOrganisationUnitField = (payload) => ({ payload, }) +// Set if organisation unit without coordinates should be counted and added to data table +export const setCountOrgUnitsWithoutCoordinates = (checked) => ({ + type: types.LAYER_EDIT_ORGANISATION_UNIT_WITHOUT_COORDS_SET, + checked, +}) + // Set period label (earth engine) export const setPeriodName = (periodName) => ({ type: types.LAYER_EDIT_PERIOD_NAME_SET, @@ -347,10 +369,14 @@ export const setRenderingStrategy = (display) => ({ payload: display, }) -// Set no data color -export const setNoDataColor = (color) => ({ - type: types.LAYER_EDIT_NO_DATA_COLOR_SET, - payload: color, +export const setNoDataLegend = (noDataLegend) => ({ + type: types.LAYER_EDIT_NO_DATA_LEGEND_SET, + payload: noDataLegend, +}) + +export const setUnclassifiedLegend = (unclassifiedLegend) => ({ + type: types.LAYER_EDIT_UNCLASSIFIED_LEGEND_SET, + payload: unclassifiedLegend, }) // Set period for EE layer diff --git a/src/components/app/FileMenu.jsx b/src/components/app/FileMenu.jsx index 5f3bc9f26..cfd1b6c18 100644 --- a/src/components/app/FileMenu.jsx +++ b/src/components/app/FileMenu.jsx @@ -4,7 +4,7 @@ import { preparePayloadForSaveAs, VIS_TYPE_MAP, } from '@dhis2/analytics' -import { useDataMutation, useDataEngine } from '@dhis2/app-runtime' +import { useDataMutation, useDataEngine, useConfig } from '@dhis2/app-runtime' import { useAlert } from '@dhis2/app-service-alerts' import i18n from '@dhis2/d2-i18n' import PropTypes from 'prop-types' @@ -67,6 +67,7 @@ const FileMenu = ({ onFileMenuAction }) => { const map = useSelector((state) => state.map) const dispatch = useDispatch() const engine = useDataEngine() + const { serverVersion } = useConfig() const { systemSettings, currentUser } = useCachedData() const defaultBasemap = systemSettings.keyDefaultBaseMap //alerts @@ -119,6 +120,7 @@ const FileMenu = ({ onFileMenuAction }) => { const cleanedMap = cleanMapConfig({ config: map, defaultBasemapId: defaultBasemap, + serverVersion, }) const config = preparePayloadForSave({ @@ -160,6 +162,7 @@ const FileMenu = ({ onFileMenuAction }) => { config: latestMap, defaultBasemapId: defaultBasemap, cleanMapviewConfig: false, + serverVersion, }) const config = preparePayloadForSave({ @@ -189,6 +192,7 @@ const FileMenu = ({ onFileMenuAction }) => { const cleanedMap = cleanMapConfig({ config: map, defaultBasemapId: defaultBasemap, + serverVersion, }) const config = preparePayloadForSaveAs({ diff --git a/src/components/classification/Classification.jsx b/src/components/classification/Classification.jsx index 5227c6168..66b367bf0 100644 --- a/src/components/classification/Classification.jsx +++ b/src/components/classification/Classification.jsx @@ -3,7 +3,11 @@ import { range } from 'lodash/fp' import PropTypes from 'prop-types' import React from 'react' import { connect } from 'react-redux' -import { setClassification, setColorScale } from '../../actions/layerEdit.js' +import { + setClassification, + setColorScale, + setLegendDecimalPlaces, +} from '../../actions/layerEdit.js' import { getClassificationTypes, CLASSIFICATION_EQUAL_INTERVALS, @@ -16,6 +20,7 @@ import { getColorScale, } from '../../util/colors.js' import { SelectField, ColorScaleSelect } from '../core/index.js' +import IsolatedClass from './IsolatedClass.jsx' import styles from './styles/Classification.module.css' const classRange = range(3, 10).map((num) => ({ @@ -23,12 +28,21 @@ const classRange = range(3, 10).map((num) => ({ name: num.toString(), })) // 3 - 9 +const DECIMAL_PLACES_AUTO = 'auto' + +const decimalPlacesItems = [ + { id: DECIMAL_PLACES_AUTO, name: i18n.t('Auto') }, + ...range(0, 5).map((num) => ({ id: num, name: num.toString() })), +] // Auto, 0 - 4 + const Classification = ({ method, classes, colorScale, + legendDecimalPlaces, setClassification, setColorScale, + setLegendDecimalPlaces, }) => { const colorScaleName = colorScale ? getColorScale(colorScale) @@ -44,22 +58,37 @@ const Classification = ({ className={styles.select} />,
- - setColorScale(getColorPalette(colorScaleName, item.id)) - } - className={styles.classes} - /> +
+ + setColorScale(getColorPalette(colorScaleName, item.id)) + } + className={styles.classes} + /> + + setLegendDecimalPlaces( + item.id === DECIMAL_PLACES_AUTO + ? undefined + : item.id + ) + } + className={styles.decimalPlaces} + /> +
-
+
, ] } @@ -67,8 +96,10 @@ const Classification = ({ Classification.propTypes = { setClassification: PropTypes.func.isRequired, setColorScale: PropTypes.func.isRequired, + setLegendDecimalPlaces: PropTypes.func.isRequired, classes: PropTypes.number, colorScale: PropTypes.array, + legendDecimalPlaces: PropTypes.number, method: PropTypes.number, } @@ -77,6 +108,7 @@ export default connect( method: layerEdit.method, classes: layerEdit.classes, colorScale: layerEdit.colorScale, + legendDecimalPlaces: layerEdit.legendDecimalPlaces, }), - { setClassification, setColorScale } + { setClassification, setColorScale, setLegendDecimalPlaces } )(Classification) diff --git a/src/components/classification/IsolatedClass.jsx b/src/components/classification/IsolatedClass.jsx new file mode 100644 index 000000000..ab65746cd --- /dev/null +++ b/src/components/classification/IsolatedClass.jsx @@ -0,0 +1,86 @@ +import i18n from '@dhis2/d2-i18n' +import PropTypes from 'prop-types' +import React from 'react' +import { connect } from 'react-redux' +import { setLegendIsolated } from '../../actions/layerEdit.js' +import { NO_DATA_COLOR } from '../../constants/layers.js' +import { Checkbox, ColorPicker, NumberField, TextField } from '../core/index.js' +import styles from './styles/Classification.module.css' + +const IsolatedClass = ({ legendIsolated, setLegendIsolated }) => ( + <> + + setLegendIsolated( + checked + ? { min: 0, max: 0, color: NO_DATA_COLOR } + : undefined + ) + } + /> + {legendIsolated !== undefined && ( +
+
+ + setLegendIsolated({ ...legendIsolated, min }) + } + className={styles.isolatedField} + /> + + setLegendIsolated({ ...legendIsolated, max }) + } + className={styles.isolatedField} + /> +
+
+ + setLegendIsolated({ ...legendIsolated, color }) + } + width={50} + className={styles.isolatedColor} + /> + + setLegendIsolated({ + ...legendIsolated, + name: name || undefined, + }) + } + className={styles.isolatedName} + /> +
+
+ )} + +) + +IsolatedClass.propTypes = { + setLegendIsolated: PropTypes.func.isRequired, + legendIsolated: PropTypes.shape({ + color: PropTypes.string, + max: PropTypes.number, + min: PropTypes.number, + name: PropTypes.string, + }), +} + +export default connect( + ({ layerEdit }) => ({ legendIsolated: layerEdit.legendIsolated }), + { setLegendIsolated } +)(IsolatedClass) diff --git a/src/components/classification/LegendSetSelect.jsx b/src/components/classification/LegendSetSelect.jsx index 2c5ba1b36..caf170fa9 100644 --- a/src/components/classification/LegendSetSelect.jsx +++ b/src/components/classification/LegendSetSelect.jsx @@ -1,7 +1,7 @@ import { useDataQuery } from '@dhis2/app-runtime' import i18n from '@dhis2/d2-i18n' import PropTypes from 'prop-types' -import React from 'react' +import React, { useEffect } from 'react' import { useSelector, useDispatch } from 'react-redux' import { setLegendSet } from '../../actions/layerEdit.js' import { SelectField } from '../core/index.js' @@ -21,11 +21,22 @@ const style = { width: '100%', } -const LegendSetSelect = ({ legendSetError }) => { +const LegendSetSelect = ({ defaultLegendSet, legendSetError }) => { const legendSet = useSelector((state) => state.layerEdit.legendSet) const dispatch = useDispatch() const { loading, error, data } = useDataQuery(LEGEND_SETS_QUERY) + useEffect(() => { + if (!legendSet && data?.sets.legendSets?.length) { + const legendSets = data.sets.legendSets + const defaultItem = defaultLegendSet + ? legendSets.find((ls) => ls.id === defaultLegendSet.id) ?? + legendSets[0] + : legendSets[0] + dispatch(setLegendSet(defaultItem)) + } + }, [legendSet, data, defaultLegendSet, dispatch]) + return ( { } LegendSetSelect.propTypes = { + defaultLegendSet: PropTypes.shape({ + id: PropTypes.string.isRequired, + }), legendSetError: PropTypes.string, } diff --git a/src/components/classification/LegendTypeSelect.jsx b/src/components/classification/LegendTypeSelect.jsx index 2fa119705..1254d560d 100644 --- a/src/components/classification/LegendTypeSelect.jsx +++ b/src/components/classification/LegendTypeSelect.jsx @@ -4,24 +4,27 @@ import { connect } from 'react-redux' import { setClassification } from '../../actions/layerEdit.js' import { getLegendTypes, - CLASSIFICATION_EQUAL_INTERVALS, - CLASSIFICATION_EQUAL_COUNTS, + getClassificationTypes, } from '../../constants/layers.js' import { Radio, RadioGroup } from '../core/index.js' +const CLASSIFICATION_AUTO = 2 + // Select between user defined (automatic), predefined or single color const LegendTypeSelect = ({ mapType, method, setClassification }) => method ? ( id) + .includes(method) + ? CLASSIFICATION_AUTO : method - } + )} onChange={(method) => setClassification(Number(method))} > {getLegendTypes(mapType === 'BUBBLE').map(({ id, name }) => ( - + ))} ) : null diff --git a/src/components/classification/NumericLegendStyle.jsx b/src/components/classification/NumericLegendStyle.jsx index dfa64caaa..dfb5315cd 100644 --- a/src/components/classification/NumericLegendStyle.jsx +++ b/src/components/classification/NumericLegendStyle.jsx @@ -1,13 +1,14 @@ import PropTypes from 'prop-types' import React, { useEffect } from 'react' import { connect } from 'react-redux' -import { setClassification, setLegendSet } from '../../actions/layerEdit.js' +import { setClassification } from '../../actions/layerEdit.js' import { CLASSIFICATION_PREDEFINED, CLASSIFICATION_EQUAL_INTERVALS, CLASSIFICATION_SINGLE_COLOR, } from '../../constants/layers.js' import Classification from './Classification.jsx' +import IsolatedClass from './IsolatedClass.jsx' import LegendSetSelect from './LegendSetSelect.jsx' import LegendTypeSelect from './LegendTypeSelect.jsx' import SingleColor from './SingleColor.jsx' @@ -18,9 +19,7 @@ const NumericLegendStyle = (props) => { mapType, method, dataItem, - legendSet, setClassification, - setLegendSet, legendSetError, style, } = props @@ -40,13 +39,6 @@ const NumericLegendStyle = (props) => { } }, [method, dataItem, setClassification]) - useEffect(() => { - // Set legend set defined for data item in use by default - if (isPredefined && !legendSet && dataItem?.legendSet) { - setLegendSet(dataItem.legendSet) - } - }, [isPredefined, legendSet, dataItem, setLegendSet]) - return (
{ dataItem={dataItem} /> {isSingleColor ? ( - + <> + + + ) : isPredefined ? ( - + ) : ( )} @@ -67,9 +65,7 @@ const NumericLegendStyle = (props) => { NumericLegendStyle.propTypes = { setClassification: PropTypes.func.isRequired, - setLegendSet: PropTypes.func.isRequired, dataItem: PropTypes.object, - legendSet: PropTypes.object, legendSetError: PropTypes.string, mapType: PropTypes.string, method: PropTypes.number, @@ -79,7 +75,6 @@ NumericLegendStyle.propTypes = { export default connect( ({ layerEdit }) => ({ method: layerEdit.method, - legendSet: layerEdit.legendSet, }), - { setClassification, setLegendSet } + { setClassification } )(NumericLegendStyle) diff --git a/src/components/classification/SingleColor.jsx b/src/components/classification/SingleColor.jsx index e4cc175da..1cc1db970 100644 --- a/src/components/classification/SingleColor.jsx +++ b/src/components/classification/SingleColor.jsx @@ -1,14 +1,30 @@ import i18n from '@dhis2/d2-i18n' +import cx from 'classnames' +import { range } from 'lodash/fp' import PropTypes from 'prop-types' import React, { useEffect } from 'react' import { connect } from 'react-redux' -import { setColorScale } from '../../actions/layerEdit.js' +import { + setColorScale, + setLegendDecimalPlaces, +} from '../../actions/layerEdit.js' import { THEMATIC_COLOR } from '../../constants/layers.js' -import { ColorPicker } from '../core/index.js' +import { ColorPicker, SelectField } from '../core/index.js' +import styles from './styles/Classification.module.css' -// Displays a color picker for single color layer -const SingleColor = ({ color, setColorScale }) => { - // Set default color +const DECIMAL_PLACES_AUTO = 'auto' + +const decimalPlacesItems = [ + { id: DECIMAL_PLACES_AUTO, name: i18n.t('Auto') }, + ...range(0, 5).map((num) => ({ id: num, name: num.toString() })), +] + +const SingleColor = ({ + color, + legendDecimalPlaces, + setColorScale, + setLegendDecimalPlaces, +}) => { useEffect(() => { if (!color || color.length !== 7) { setColorScale(THEMATIC_COLOR) @@ -16,26 +32,40 @@ const SingleColor = ({ color, setColorScale }) => { }, [color, setColorScale]) return color ? ( - +
+ + + setLegendDecimalPlaces( + item.id === DECIMAL_PLACES_AUTO ? undefined : item.id + ) + } + className={cx(styles.decimalPlaces, styles.singleColorField)} + /> +
) : null } SingleColor.propTypes = { setColorScale: PropTypes.func.isRequired, + setLegendDecimalPlaces: PropTypes.func.isRequired, color: PropTypes.string, + legendDecimalPlaces: PropTypes.number, } export default connect( ({ layerEdit }) => ({ color: layerEdit.colorScale, + legendDecimalPlaces: layerEdit.legendDecimalPlaces, }), - { setColorScale } + { setColorScale, setLegendDecimalPlaces } )(SingleColor) diff --git a/src/components/classification/styles/Classification.module.css b/src/components/classification/styles/Classification.module.css index 020cde86e..fb8fc8ab3 100644 --- a/src/components/classification/styles/Classification.module.css +++ b/src/components/classification/styles/Classification.module.css @@ -2,19 +2,63 @@ width: 100%; } +.classesRow { + display: flex; + gap: var(--spacers-dp64); +} + .classes { width: 50px; - margin-right: var(--spacers-dp16); - top: -8px; - float: left; } .scale { display: block; padding-top: var(--spacers-dp8); - clear: both; } -.clear { - clear: both; +.decimalPlaces { + width: 50px; + white-space: nowrap; +} + +.singleColorRow { + display: flex; + gap: var(--spacers-dp16); + align-items: flex-end; + margin-bottom: var(--spacers-dp12); +} + +.singleColorField { + flex-shrink: 0; + margin-bottom: 0; +} + +.isolatedRows { + display: flex; + flex-direction: column; + gap: var(--spacers-dp8); + margin-left: var(--spacers-dp24); + margin-bottom: var(--spacers-dp12); +} + +.isolatedRow { + display: flex; + gap: var(--spacers-dp8); + align-items: flex-end; +} + +.isolatedField { + flex: 1; + margin-bottom: 0; +} + +.isolatedColor { + flex-shrink: 0; + margin-bottom: 0; +} + +.isolatedName { + flex: 1; + min-width: 0; + margin-bottom: 0; } diff --git a/src/components/core/Checkbox.jsx b/src/components/core/Checkbox.jsx index 4fbb766f1..18a7798a4 100644 --- a/src/components/core/Checkbox.jsx +++ b/src/components/core/Checkbox.jsx @@ -44,7 +44,7 @@ Checkbox.propTypes = { dense: PropTypes.bool, disabled: PropTypes.bool, label: PropTypes.string, - style: PropTypes.string, + style: PropTypes.object, tooltip: PropTypes.string, } diff --git a/src/components/core/TextField.jsx b/src/components/core/TextField.jsx index fd3260f2f..4efb428a8 100644 --- a/src/components/core/TextField.jsx +++ b/src/components/core/TextField.jsx @@ -9,6 +9,7 @@ const TextField = ({ type, label, value, + placeholder, dense = true, onChange, className, @@ -19,6 +20,7 @@ const TextField = ({ type={type} label={label} value={value} + placeholder={placeholder} onChange={({ value }) => onChange(value)} />
@@ -29,6 +31,7 @@ TextField.propTypes = { onChange: PropTypes.func.isRequired, className: PropTypes.string, dense: PropTypes.bool, + placeholder: PropTypes.string, type: PropTypes.string, value: PropTypes.string, } diff --git a/src/components/core/styles/ColorScale.module.css b/src/components/core/styles/ColorScale.module.css index a47463de2..276b7794a 100644 --- a/src/components/core/styles/ColorScale.module.css +++ b/src/components/core/styles/ColorScale.module.css @@ -1,5 +1,5 @@ .colorScale { - margin: var(--spacers-dp8) 0 0 0; + margin: 0 0 var(--spacers-dp8) 0; padding-left: 0; height: 36px; cursor: pointer; diff --git a/src/components/datatable/DataTable.jsx b/src/components/datatable/DataTable.jsx index 37afc82af..d0090238a 100644 --- a/src/components/datatable/DataTable.jsx +++ b/src/components/datatable/DataTable.jsx @@ -26,6 +26,8 @@ import { highlightFeature, setFeatureProfile } from '../../actions/feature.js' import { setOrgUnitProfile } from '../../actions/orgUnits.js' import { EVENT_LAYER, GEOJSON_URL_LAYER } from '../../constants/layers.js' import { isDarkColor } from '../../util/colors.js' +import { formatWithSeparator } from '../../util/numbers.js' +import { useCachedData } from '../cachedDataProvider/CachedDataProvider.jsx' import FilterInput from './FilterInput.jsx' import styles from './styles/DataTable.module.css' import { useTableData } from './useTableData.js' @@ -88,6 +90,10 @@ const TableComponents = { } const Table = ({ availableHeight, availableWidth }) => { + const { + systemSettings: { keyAnalysisDigitGroupSeparator }, + } = useCachedData() + const headerRowRef = useRef(null) const [columnWidths, setColumnWidths] = useState([]) const { mapViews } = useSelector((state) => state.map) @@ -293,7 +299,12 @@ const Table = ({ availableHeight, availableWidth }) => { backgroundColor={dataKey === 'color' ? value : null} align={align} > - {dataKey === 'color' ? value?.toLowerCase() : value} + {dataKey === 'color' + ? value?.toLowerCase() + : formatWithSeparator( + value, + keyAnalysisDigitGroupSeparator + )} )) } diff --git a/src/components/datatable/__tests__/useTableData.spec.jsx b/src/components/datatable/__tests__/useTableData.spec.jsx index 75076ecf9..962d348ab 100644 --- a/src/components/datatable/__tests__/useTableData.spec.jsx +++ b/src/components/datatable/__tests__/useTableData.spec.jsx @@ -136,7 +136,8 @@ describe('useTableData headers', () => { color: '#FFFFB2', legend: 'Great', range: '90 - 120', - value: 106.3, + value: '106.3', + rawValue: 106.3, }, }, ], @@ -160,7 +161,7 @@ describe('useTableData headers', () => { { name: 'Index', dataKey: 'index', type: 'number' }, { name: 'Name', dataKey: 'name', type: 'string' }, { name: 'Id', dataKey: 'id', type: 'string' }, - { name: 'Value', dataKey: 'value', type: 'number' }, + { name: 'Value', dataKey: 'rawValue', type: 'number' }, { name: 'Legend', dataKey: 'legend', type: 'string' }, { name: 'Range', dataKey: 'range', type: 'string' }, { name: 'Level', dataKey: 'level', type: 'number' }, @@ -179,7 +180,7 @@ describe('useTableData headers', () => { { value: 0, dataKey: 'index' }, { value: 'Ngelehun CHC', dataKey: 'name' }, { value: 'thematicId-1', dataKey: 'id' }, - { value: 106.3, dataKey: 'value' }, + { value: 106.3, dataKey: 'rawValue' }, { value: 'Great', dataKey: 'legend' }, { value: '90 - 120', dataKey: 'range' }, { value: 4, dataKey: 'level' }, @@ -257,7 +258,7 @@ describe('useTableData headers', () => { } ) const { headers, rows, isLoading } = result.current - expect(headers).toHaveLength(8) + expect(headers).toHaveLength(9) expect(headers).toMatchObject([ { name: 'Index', dataKey: 'index', type: 'number' }, { name: 'Org unit', dataKey: 'ouname', type: 'string' }, @@ -271,10 +272,15 @@ describe('useTableData headers', () => { { name: 'Last updated on', dataKey: 'lastupdated', type: 'string' }, { name: 'Event status', dataKey: 'eventstatus', type: 'string' }, { name: 'Gender', dataKey: 'oZg33kd9taw', type: 'string' }, + { + name: 'Coordinate field', + dataKey: 'coordinate', + type: 'string', + }, { name: 'Type', dataKey: 'type', type: 'string' }, ]) expect(rows).toHaveLength(1) - expect(rows[0]).toHaveLength(8) + expect(rows[0]).toHaveLength(9) expect(rows[0]).toMatchObject([ { value: 0, dataKey: 'index' }, { value: 'Lumley Hospital', dataKey: 'ouname' }, @@ -283,6 +289,7 @@ describe('useTableData headers', () => { { value: '2018-04-12 20:58:51.31', dataKey: 'lastupdated' }, { value: 'ACTIVE', dataKey: 'eventstatus' }, { value: 'Female', dataKey: 'oZg33kd9taw' }, + { value: null, dataKey: 'coordinate' }, { value: 'Point', dataKey: 'type' }, ]) expect(isLoading).toBe(false) @@ -544,11 +551,34 @@ describe('useTableData sorting', () => { layer: 'thematic', dataFilters: null, data: [ - { id: '1', properties: { name: 'Item A', value: 10 } }, - { id: '2', properties: { name: 'Item B', value: 5 } }, - { id: '3', properties: { name: 'Item C', value: undefined } }, - { id: '4', properties: { name: 'Item D', value: 15 } }, - { id: '5', properties: { name: 'Item E', value: undefined } }, + { + id: '1', + properties: { name: 'Item A', value: '10', rawValue: 10 }, + }, + { + id: '2', + properties: { name: 'Item B', value: '5', rawValue: 5 }, + }, + { + id: '3', + properties: { + name: 'Item C', + value: undefined, + rawValue: undefined, + }, + }, + { + id: '4', + properties: { name: 'Item D', value: '15', rawValue: 15 }, + }, + { + id: '5', + properties: { + name: 'Item E', + value: undefined, + rawValue: undefined, + }, + }, ], } @@ -560,7 +590,7 @@ describe('useTableData sorting', () => { () => useTableData({ layer: mockLayer, - sortField: 'value', + sortField: 'rawValue', sortDirection: 'asc', }), { @@ -582,7 +612,7 @@ describe('useTableData sorting', () => { () => useTableData({ layer: mockLayer, - sortField: 'value', + sortField: 'rawValue', sortDirection: 'desc', }), { @@ -602,10 +632,22 @@ describe('useTableData sorting', () => { layer: 'thematic', dataFilters: null, data: [ - { id: '1', properties: { name: 'Zebra', value: 10 } }, - { id: '2', properties: { name: 'Apple', value: 5 } }, - { id: '3', properties: { name: undefined, value: 20 } }, - { id: '4', properties: { name: 'Banana', value: 15 } }, + { + id: '1', + properties: { name: 'Zebra', value: '10', rawValue: 10 }, + }, + { + id: '2', + properties: { name: 'Apple', value: '5', rawValue: 5 }, + }, + { + id: '3', + properties: { name: undefined, value: '20', rawValue: 20 }, + }, + { + id: '4', + properties: { name: 'Banana', value: '15', rawValue: 15 }, + }, ], } @@ -627,7 +669,7 @@ describe('useTableData sorting', () => { ) const nameColumn = result.current.rows.map((row) => row[1]?.value) // Name column - expect(nameColumn).toEqual(['Apple', 'Banana', 'Zebra', undefined]) + expect(nameColumn).toEqual(['Apple', 'Banana', 'Zebra', null]) }) test('sorts string values in descending order with undefined at end', () => { @@ -636,10 +678,22 @@ describe('useTableData sorting', () => { layer: 'thematic', dataFilters: null, data: [ - { id: '1', properties: { name: 'Zebra', value: 10 } }, - { id: '2', properties: { name: 'Apple', value: 5 } }, - { id: '3', properties: { name: undefined, value: 20 } }, - { id: '4', properties: { name: 'Banana', value: 15 } }, + { + id: '1', + properties: { name: 'Zebra', value: '10', rawValue: 10 }, + }, + { + id: '2', + properties: { name: 'Apple', value: '5', rawValue: 5 }, + }, + { + id: '3', + properties: { name: undefined, value: '20', rawValue: 20 }, + }, + { + id: '4', + properties: { name: 'Banana', value: '15', rawValue: 15 }, + }, ], } @@ -661,7 +715,7 @@ describe('useTableData sorting', () => { ) const nameColumn = result.current.rows.map((row) => row[1]?.value) // Name column - expect(nameColumn).toEqual(['Zebra', 'Banana', 'Apple', undefined]) + expect(nameColumn).toEqual(['Zebra', 'Banana', 'Apple', null]) }) test('handles multiple undefined values correctly', () => { @@ -670,16 +724,30 @@ describe('useTableData sorting', () => { layer: 'thematic', dataFilters: null, data: [ - { id: '1', properties: { name: 'Item A', value: 10 } }, + { + id: '1', + properties: { name: 'Item A', value: '10', rawValue: 10 }, + }, { id: '2', - properties: { name: 'Item B', value: undefined }, + properties: { + name: 'Item B', + value: undefined, + rawValue: undefined, + }, }, { id: '3', - properties: { name: 'Item C', value: undefined }, + properties: { + name: 'Item C', + value: undefined, + rawValue: undefined, + }, + }, + { + id: '4', + properties: { name: 'Item D', value: '5', rawValue: 5 }, }, - { id: '4', properties: { name: 'Item D', value: 5 } }, ], } @@ -690,7 +758,7 @@ describe('useTableData sorting', () => { () => useTableData({ layer: layerWithManyUndefined, - sortField: 'value', + sortField: 'rawValue', sortDirection: 'asc', }), { @@ -712,15 +780,27 @@ describe('useTableData sorting', () => { data: [ { id: '1', - properties: { name: 'Item A', value: undefined }, + properties: { + name: 'Item A', + value: undefined, + rawValue: undefined, + }, }, { id: '2', - properties: { name: 'Item B', value: undefined }, + properties: { + name: 'Item B', + value: undefined, + rawValue: undefined, + }, }, { id: '3', - properties: { name: 'Item C', value: undefined }, + properties: { + name: 'Item C', + value: undefined, + rawValue: undefined, + }, }, ], } @@ -732,7 +812,7 @@ describe('useTableData sorting', () => { () => useTableData({ layer: layerWithAllUndefined, - sortField: 'value', + sortField: 'rawValue', sortDirection: 'asc', }), { diff --git a/src/components/datatable/useTableData.js b/src/components/datatable/useTableData.js index d5586e8d3..dd712e22b 100644 --- a/src/components/datatable/useTableData.js +++ b/src/components/datatable/useTableData.js @@ -1,5 +1,5 @@ import i18n from '@dhis2/d2-i18n' -import { useMemo, useRef } from 'react' +import { useMemo } from 'react' import { useSelector } from 'react-redux' import { EVENT_LAYER, @@ -9,11 +9,20 @@ import { FACILITY_LAYER, GEOJSON_URL_LAYER, } from '../../constants/layers.js' -import { numberValueTypes } from '../../constants/valueTypes.js' +import { + numberValueTypes, + coordinateValueTypes, +} from '../../constants/valueTypes.js' import { hasClasses } from '../../util/earthEngine.js' import { filterData } from '../../util/filter.js' import { getGeojsonDisplayData } from '../../util/geojson.js' -import { getRoundToPrecisionFn, getPrecision } from '../../util/numbers.js' +import { formatCoordinate } from '../../util/helpers.js' +import { parseRange } from '../../util/legend.js' +import { + getRoundToPrecisionFn, + getPrecision, + parseWithSeparator, +} from '../../util/numbers.js' import { isValidUid } from '../../util/uid.js' const ASCENDING = 'asc' @@ -25,7 +34,7 @@ const TYPE_DATE = 'date' const INDEX = 'index' const NAME = 'name' const ID = 'id' -const VALUE = 'value' +const VALUE = 'rawValue' const LEGEND = 'legend' const RANGE = 'range' const LEVEL = 'level' @@ -34,6 +43,7 @@ const TYPE = 'type' const COLOR = 'color' const OUNAME = 'ouname' const EVENTDATE = 'eventdate' +const COORDINATE = 'coordinate' const ERROR_SERVER_CLUSTER = 'SERVER_CLUSTER' const ERROR_NO_VALID_DATA = 'NO_VALID_DATA' @@ -88,6 +98,11 @@ const defaultFieldsMap = () => ({ type: TYPE_STRING, renderer: 'rendercolor', }, + [COORDINATE]: { + name: i18n.t('Coordinate field'), + dataKey: COORDINATE, + type: TYPE_STRING, + }, }) const getThematicHeaders = () => @@ -111,15 +126,16 @@ const getEventHeaders = ({ layerHeaders = [], styleDataItem }) => { const customFields = layerHeaders .filter(({ name }) => isValidUid(name)) - .map(({ name: dataKey, column: name, valueType }) => ({ + .map(({ name: dataKey, column: name, valueType, optionSet }) => ({ name, dataKey, - type: numberValueTypes.includes(valueType) - ? TYPE_NUMBER - : TYPE_STRING, + type: + numberValueTypes.includes(valueType) && !optionSet + ? TYPE_NUMBER + : TYPE_STRING, })) - customFields.push(defaultFieldsMap()[TYPE]) + customFields.push(defaultFieldsMap()[COORDINATE], defaultFieldsMap()[TYPE]) if (styleDataItem) { customFields.push(defaultFieldsMap()[COLOR]) @@ -137,9 +153,9 @@ const getFacilityHeaders = () => [INDEX, NAME, ID, TYPE].map((field) => defaultFieldsMap()[field]) const toTitleCase = (str) => - str.replace( + str.replaceAll( /\w\S*/g, - (txt) => txt.charAt(0).toUpperCase() + txt.substr(1).toLowerCase() + (txt) => txt.charAt(0).toUpperCase() + txt.slice(1).toLowerCase() ) const getEarthEngineHeaders = ({ aggregationType, legend, data }) => { @@ -175,9 +191,6 @@ const getEarthEngineHeaders = ({ aggregationType, legend, data }) => { .concat(customFields) } -const getGeoJsonUrlHeaders = (firstDataItem) => - getGeojsonDisplayData(firstDataItem) - const EMPTY_AGGREGATIONS = {} const EMPTY_LAYER = {} @@ -185,71 +198,105 @@ export const useTableData = ({ layer, sortField, sortDirection }) => { const allAggregations = useSelector((state) => state.aggregations) const aggregations = allAggregations[layer.id] || EMPTY_AGGREGATIONS - const errorCode = useRef(null) - const { layer: layerType, aggregationType, legend, styleDataItem, data, + dataWithoutCoords, dataFilters, headers: layerHeaders, serverCluster, } = layer || EMPTY_LAYER - const dataWithAggregations = useMemo(() => { - errorCode.current = null + const { data: dataWithAggregations, error: dataError } = useMemo(() => { if (serverCluster) { - errorCode.current = ERROR_SERVER_CLUSTER - return null + return { data: null, error: ERROR_SERVER_CLUSTER } } - if (!data?.length) { - errorCode.current = ERROR_NO_VALID_DATA - return null + const allData = dataWithoutCoords?.length + ? [...(data || []), ...dataWithoutCoords] + : data + + if (!allData?.length) { + return { data: null, error: ERROR_NO_VALID_DATA } } if (layerType === GEOJSON_URL_LAYER) { - return data.map((d) => ({ - ...d.properties, - })) + return { + data: allData.map((d) => ({ ...d.properties })), + error: null, + } } - return data - .filter((d) => !d.properties.hasAdditionalGeometry) - .map((d, index) => ({ - ...(d.properties || d), - ...aggregations[d.id], - index, - })) - }, [data, aggregations, serverCluster, layerType]) - - const headers = useMemo(() => { - if (errorCode.current) { - return null + const coordKeys = new Set( + (layerHeaders || []) + .filter(({ valueType }) => + coordinateValueTypes.includes(valueType) + ) + .map(({ name }) => name) + ) + + return { + data: allData + .filter((d) => !d.properties.hasAdditionalGeometry) + .map((d, index) => { + const properties = { ...(d.properties || d) } + coordKeys.forEach((key) => { + if (properties[key] !== undefined) { + properties[key] = formatCoordinate(properties[key]) + } + }) + return { + ...properties, + ...aggregations[d.id], + index, + coordinate: + d.geometry?.type === 'Point' + ? formatCoordinate(d.geometry.coordinates) + : undefined, + } + }), + error: null, + } + }, [ + data, + dataWithoutCoords, + aggregations, + serverCluster, + layerHeaders, + layerType, + ]) + + const { headers, error: headersError } = useMemo(() => { + if (dataError) { + return { headers: null, error: dataError } } - let headers = null + let computedHeaders = null switch (layerType) { case THEMATIC_LAYER: - headers = getThematicHeaders() + computedHeaders = getThematicHeaders() break case EVENT_LAYER: - headers = getEventHeaders({ layerHeaders, styleDataItem }) + computedHeaders = getEventHeaders({ + layerHeaders, + styleDataItem, + }) break case ORG_UNIT_LAYER: - headers = getOrgUnitHeaders() + computedHeaders = getOrgUnitHeaders() break case EARTH_ENGINE_LAYER: - headers = getEarthEngineHeaders({ + computedHeaders = getEarthEngineHeaders({ aggregationType, legend, data: dataWithAggregations, }) break case FACILITY_LAYER: - headers = getFacilityHeaders() + computedHeaders = getFacilityHeaders() break case GEOJSON_URL_LAYER: { if ( @@ -258,22 +305,23 @@ export const useTableData = ({ layer, sortField, sortDirection }) => { feature.geometry.type !== data[0].geometry.type ) ) { - errorCode.current = ERROR_NON_HOMOGENOUS_FEATURES - return null + return { + headers: null, + error: ERROR_NON_HOMOGENOUS_FEATURES, + } } - headers = getGeoJsonUrlHeaders(data[0]) + computedHeaders = getGeojsonDisplayData(data[0]) break } default: break } - if (!headers?.length) { - errorCode.current = ERROR_NO_HEADERS - return null + if (!computedHeaders?.length) { + return { headers: null, error: ERROR_NO_HEADERS } } - return headers + return { headers: computedHeaders, error: null } }, [ layerType, aggregationType, @@ -282,15 +330,11 @@ export const useTableData = ({ layer, sortField, sortDirection }) => { dataWithAggregations, data, layerHeaders, + dataError, ]) const rows = useMemo(() => { - if (errorCode.current) { - return null - } - - if (!headers.length) { - errorCode.current = ERROR_NO_HEADERS + if (headersError) { return null } @@ -298,57 +342,84 @@ export const useTableData = ({ layer, sortField, sortDirection }) => { //sort filteredData.sort((a, b) => { - a = a[sortField] - b = b[sortField] + const aVal = a[sortField] + const bVal = b[sortField] // All undefined values should be sorted to the end - if (a === undefined && b === undefined) { + if (aVal === undefined && bVal === undefined) { return 0 } - if (a === undefined) { - return 1 // a goes to end + if (aVal === undefined) { + return 1 // aVal goes to end } - if (b === undefined) { - return -1 // b goes to end + if (bVal === undefined) { + return -1 // bVal goes to end } - if (typeof a === TYPE_NUMBER) { - return sortDirection === ASCENDING ? a - b : b - a + if (typeof aVal === TYPE_NUMBER) { + return sortDirection === ASCENDING ? aVal - bVal : bVal - aVal + } + + if (sortField === RANGE) { + const [aStart, aEnd] = parseRange(aVal) + const [bStart, bEnd] = parseRange(bVal) + const startDiff = + sortDirection === ASCENDING + ? aStart - bStart + : bStart - aStart + if (startDiff !== 0) { + return startDiff + } + return sortDirection === ASCENDING ? aEnd - bEnd : bEnd - aEnd } // TODO: Make sure sorting works across different locales return sortDirection === ASCENDING - ? a.localeCompare(b) - : b.localeCompare(a) + ? aVal.localeCompare(bVal) + : bVal.localeCompare(aVal) }) return filteredData.map((item) => headers.map(({ dataKey, roundFn, type }) => { - const value = roundFn ? roundFn(item[dataKey]) : item[dataKey] + const raw = roundFn ? roundFn(item[dataKey]) : item[dataKey] + let value + if (type === TYPE_NUMBER) { + const parsed = parseWithSeparator(raw) + value = parsed ?? null + } else { + value = raw ?? null + } return { dataKey, - value: type === TYPE_NUMBER && isNaN(value) ? null : value, + value, align: type === TYPE_NUMBER ? 'right' : 'left', itemId: item.id, } }) ) - }, [headers, dataWithAggregations, dataFilters, sortField, sortDirection]) + }, [ + headers, + dataWithAggregations, + dataFilters, + sortField, + sortDirection, + headersError, + ]) // EE layers and event layers may be loading additional data const isLoading = (layerType === EARTH_ENGINE_LAYER && aggregationType?.length && - (!aggregations || aggregations === EMPTY_AGGREGATIONS)) || + aggregations === EMPTY_AGGREGATIONS) || (layerType === EVENT_LAYER && !layer.isExtended && !serverCluster) return { headers, rows, isLoading, - error: getErrorCodeText(errorCode.current), + error: getErrorCodeText(headersError), } } diff --git a/src/components/edit/FacilityDialog.jsx b/src/components/edit/FacilityDialog.jsx index 25bf1942c..eee263468 100644 --- a/src/components/edit/FacilityDialog.jsx +++ b/src/components/edit/FacilityDialog.jsx @@ -2,12 +2,13 @@ import { useDataQuery } from '@dhis2/app-runtime' import i18n from '@dhis2/d2-i18n' import PropTypes from 'prop-types' import React, { useState, useEffect, useCallback } from 'react' -import { useDispatch } from 'react-redux' +import { useDispatch, useSelector } from 'react-redux' import { setOrgUnits, setRadiusLow, setOrganisationUnitGroupSet, setOrganisationUnitColor, + setCountOrgUnitsWithoutCoordinates, } from '../../actions/layerEdit.js' import { ORG_UNIT_COLOR, @@ -19,7 +20,7 @@ import { NONE, } from '../../constants/layers.js' import { getOrgUnitsFromRows } from '../../util/analytics.js' -import { Tab, Tabs, NumberField, ColorPicker } from '../core/index.js' +import { Tab, Tabs, NumberField, ColorPicker, Checkbox } from '../core/index.js' import StyleByGroupSet from '../groupSet/StyleByGroupSet.jsx' import OrgUnitSelect from '../orgunits/OrgUnitSelect.jsx' import BufferRadius from './shared/BufferRadius.jsx' @@ -49,6 +50,9 @@ const FacilityDialog = ({ const [orgUnitsError, setOrgUnitsError] = useState() const { data } = useDataQuery(QUERY) const dispatch = useDispatch() + const countOrgUnitsWithoutCoordinates = useSelector( + (state) => state.layerEdit.countOrgUnitsWithoutCoordinates + ) const facilityOrgUnitLevel = data?.configuration.facilityOrgUnitLevel const facilityOrgUnitGroupSet = data?.configuration.facilityOrgUnitGroupSet @@ -152,6 +156,19 @@ const FacilityDialog = ({ /> )} + + dispatch( + setCountOrgUnitsWithoutCoordinates( + checked + ) + ) + } + />
)} diff --git a/src/components/edit/earthEngine/LegendPreview.jsx b/src/components/edit/earthEngine/LegendPreview.jsx index 73f0b3fd9..72a1b1f0e 100644 --- a/src/components/edit/earthEngine/LegendPreview.jsx +++ b/src/components/edit/earthEngine/LegendPreview.jsx @@ -3,6 +3,7 @@ import PropTypes from 'prop-types' import React from 'react' import { createLegend } from '../../../loaders/earthEngineLoader.js' import { sortLegendItems } from '../../../util/legend.js' +import { useCachedData } from '../../cachedDataProvider/CachedDataProvider.jsx' import LegendItem from '../../legend/LegendItem.jsx' import styles from '../styles/LayerDialog.module.css' @@ -10,7 +11,12 @@ const styleIsValid = ({ min, max }) => !Number.isNaN(min) && !Number.isNaN(max) && max > min const LegendPreview = ({ style, showBelowMin }) => { - const legend = styleIsValid(style) && createLegend(style, showBelowMin) + const { + systemSettings: { keyAnalysisDigitGroupSeparator }, + } = useCachedData() + const legend = + styleIsValid(style) && + createLegend(style, showBelowMin, keyAnalysisDigitGroupSeparator) return legend ? (
diff --git a/src/components/edit/earthEngine/PeriodSelect.jsx b/src/components/edit/earthEngine/PeriodSelect.jsx index 72f77fb7c..ef36f3440 100644 --- a/src/components/edit/earthEngine/PeriodSelect.jsx +++ b/src/components/edit/earthEngine/PeriodSelect.jsx @@ -16,7 +16,7 @@ import { SelectField } from '../../core/index.js' import styles from './styles/PeriodSelect.module.css' const isValidDate = (d) => { - return d instanceof Date && !isNaN(d) + return d instanceof Date && !Number.isNaN(d) } const normalizeToDayBefore2359 = (date) => { const d = new Date(date) @@ -80,7 +80,7 @@ const EarthEnginePeriodSelect = ({ let name = e.name if (name.includes(AVAILABLE_UP_TO)) { const regex = new RegExp(`\\s*\\(${AVAILABLE_UP_TO}.*\\)$`) - name = name.replace(regex, '') + name = name.replaceAll(regex, '') } onChange({ ...e, diff --git a/src/components/edit/earthEngine/StyleSelect.jsx b/src/components/edit/earthEngine/StyleSelect.jsx index fccb53faf..141864d4d 100644 --- a/src/components/edit/earthEngine/StyleSelect.jsx +++ b/src/components/edit/earthEngine/StyleSelect.jsx @@ -75,7 +75,7 @@ const StyleSelect = ({ unit, style, setStyle }) => { className={styles.flexInnerColumn} /> { + const dispatch = useDispatch() + const countOrgUnitsWithoutCoordinates = useSelector( + (state) => state.layerEdit.countOrgUnitsWithoutCoordinates + ) const { min, max, palette } = style const isClassStyle = min !== undefined && @@ -25,6 +32,13 @@ const StyleTab = ({ unit, style, showBelowMin, hasOrgUnitField }) => { hasOrgUnitField={hasOrgUnitField} forceShowNumberField={true} /> + + dispatch(setCountOrgUnitsWithoutCoordinates(checked)) + } + />
{isClassStyle && ( diff --git a/src/components/edit/event/EventDialog.jsx b/src/components/edit/event/EventDialog.jsx index 5296b41ee..2b675f026 100644 --- a/src/components/edit/event/EventDialog.jsx +++ b/src/components/edit/event/EventDialog.jsx @@ -12,11 +12,14 @@ import { setEventPointColor, setEventPointRadius, // setFallbackCoordinateField, + setNoDataLegend, + setUnclassifiedLegend, setPeriod, setStartDate, setEndDate, setBackupPeriodsDates, setOrgUnits, + setCountEventsWithoutCoordinates, } from '../../../actions/layerEdit.js' import { EVENT_COLOR, @@ -41,6 +44,7 @@ import { NumberField, ImageSelect, ColorPicker, + Checkbox, } from '../../core/index.js' import CoordinateField from '../../dataItem/CoordinateField.jsx' import FilterGroup from '../../dataItem/filter/FilterGroup.jsx' @@ -52,12 +56,15 @@ import ProgramSelect from '../../program/ProgramSelect.jsx' import ProgramStageSelect from '../../program/ProgramStageSelect.jsx' import BufferRadius from '../shared/BufferRadius.jsx' import GeometryCentroid from '../shared/GeometryCentroid.jsx' +import NoDataLegend from '../shared/NoDataLegend.jsx' +import UnclassifiedLegend from '../shared/UnclassifiedLegend.jsx' import styles from '../styles/LayerDialog.module.css' import EventStatusSelect from './EventStatusSelect.jsx' class EventDialog extends Component { static propTypes = { setBackupPeriodsDates: PropTypes.func.isRequired, + setCountEventsWithoutCoordinates: PropTypes.func.isRequired, setEndDate: PropTypes.func.isRequired, setEventClustering: PropTypes.func.isRequired, setEventCoordinateField: PropTypes.func.isRequired, @@ -65,15 +72,18 @@ class EventDialog extends Component { setEventPointRadius: PropTypes.func.isRequired, setEventStatus: PropTypes.func.isRequired, // setFallbackCoordinateField: PropTypes.func.isRequired, + setNoDataLegend: PropTypes.func.isRequired, setOrgUnits: PropTypes.func.isRequired, setPeriod: PropTypes.func.isRequired, setProgram: PropTypes.func.isRequired, setProgramStage: PropTypes.func.isRequired, setStartDate: PropTypes.func.isRequired, + setUnclassifiedLegend: PropTypes.func.isRequired, validateLayer: PropTypes.bool.isRequired, onLayerValidation: PropTypes.func.isRequired, backupPeriodsDates: PropTypes.object, columns: PropTypes.array, + countEventsWithoutCoordinates: PropTypes.bool, endDate: PropTypes.string, eventClustering: PropTypes.bool, eventCoordinateField: PropTypes.string, @@ -85,6 +95,10 @@ class EventDialog extends Component { filters: PropTypes.array, legendSet: PropTypes.object, method: PropTypes.number, + noDataLegend: PropTypes.shape({ + color: PropTypes.string, + name: PropTypes.string, + }), orgUnits: PropTypes.object, periodsSettings: PropTypes.object, program: PropTypes.shape({ @@ -103,6 +117,10 @@ class EventDialog extends Component { }), }), systemSettings: PropTypes.object, + unclassifiedLegend: PropTypes.shape({ + color: PropTypes.string, + name: PropTypes.string, + }), } constructor(props, context) { @@ -211,12 +229,16 @@ class EventDialog extends Component { eventCoordinateFieldType, eventPointColor, eventPointRadius, + noDataLegend, + unclassifiedLegend, // fallbackCoordinateField, filters = [], program, programStage, legendSet, periodsSettings, + countEventsWithoutCoordinates, + styleDataItem, } = this.props const { @@ -229,7 +251,10 @@ class EventDialog extends Component { setEventPointColor, setEventPointRadius, // setFallbackCoordinateField, + setNoDataLegend, + setUnclassifiedLegend, setPeriod, + setCountEventsWithoutCoordinates, } = this.props const { @@ -403,6 +428,13 @@ class EventDialog extends Component { disabled={eventClustering} defaultRadius={EVENT_BUFFER} /> +
{program ? ( @@ -420,6 +452,24 @@ class EventDialog extends Component {
)} + {styleDataItem && ( + + )} + {styleDataItem && ( + + )} )} @@ -518,11 +568,14 @@ export default connect( setEventPointColor, setEventPointRadius, // setFallbackCoordinateField, + setNoDataLegend, + setUnclassifiedLegend, setPeriod, setBackupPeriodsDates, setStartDate, setEndDate, setOrgUnits, + setCountEventsWithoutCoordinates, }, null, { diff --git a/src/components/edit/event/styles/EventDialog.module.css b/src/components/edit/event/styles/EventDialog.module.css deleted file mode 100644 index 37df09c18..000000000 --- a/src/components/edit/event/styles/EventDialog.module.css +++ /dev/null @@ -1,4 +0,0 @@ -.text { - padding-top: var(--spacers-dp8); - line-height: 22px; -} diff --git a/src/components/edit/orgUnit/OrgUnitDialog.jsx b/src/components/edit/orgUnit/OrgUnitDialog.jsx index 9ceae1ff5..a4b19c2b8 100644 --- a/src/components/edit/orgUnit/OrgUnitDialog.jsx +++ b/src/components/edit/orgUnit/OrgUnitDialog.jsx @@ -5,6 +5,7 @@ import { connect } from 'react-redux' import { setRadiusLow, setOrganisationUnitColor, + setCountOrgUnitsWithoutCoordinates, } from '../../../actions/layerEdit.js' import { ORG_UNIT_COLOR, @@ -14,7 +15,13 @@ import { MAX_RADIUS, } from '../../../constants/layers.js' import { getOrgUnitsFromRows } from '../../../util/analytics.js' -import { Tab, Tabs, NumberField, ColorPicker } from '../../core/index.js' +import { + Tab, + Tabs, + NumberField, + ColorPicker, + Checkbox, +} from '../../core/index.js' import StyleByGroupSet from '../../groupSet/StyleByGroupSet.jsx' import OrgUnitSelect from '../../orgunits/OrgUnitSelect.jsx' import Labels from '../shared/Labels.jsx' @@ -22,10 +29,12 @@ import styles from '../styles/LayerDialog.module.css' class OrgUnitDialog extends Component { static propTypes = { + setCountOrgUnitsWithoutCoordinates: PropTypes.func.isRequired, setOrganisationUnitColor: PropTypes.func.isRequired, setRadiusLow: PropTypes.func.isRequired, validateLayer: PropTypes.bool.isRequired, onLayerValidation: PropTypes.func.isRequired, + countOrgUnitsWithoutCoordinates: PropTypes.bool, organisationUnitColor: PropTypes.string, radiusLow: PropTypes.number, rows: PropTypes.array, @@ -47,8 +56,10 @@ class OrgUnitDialog extends Component { const { radiusLow, organisationUnitColor, + countOrgUnitsWithoutCoordinates, setOrganisationUnitColor, setRadiusLow, + setCountOrgUnitsWithoutCoordinates, } = this.props const { tab, orgUnitsError } = this.state @@ -95,6 +106,15 @@ class OrgUnitDialog extends Component { + )} @@ -133,6 +153,7 @@ export default connect( { setRadiusLow, setOrganisationUnitColor, + setCountOrgUnitsWithoutCoordinates, }, null, { diff --git a/src/components/edit/shared/Labels.jsx b/src/components/edit/shared/Labels.jsx index 7f0854549..2b3354e3f 100644 --- a/src/components/edit/shared/Labels.jsx +++ b/src/components/edit/shared/Labels.jsx @@ -1,5 +1,4 @@ import i18n from '@dhis2/d2-i18n' -import cx from 'classnames' import PropTypes from 'prop-types' import React, { useEffect } from 'react' import { connect } from 'react-redux' @@ -37,7 +36,7 @@ const Labels = ({ }, [labels, includeDisplayOption, labelTemplate, setLabelTemplate]) return ( -
+
{ + const onCheck = useCallback( + (checked) => onChange(checked ? { color: NO_DATA_COLOR } : undefined), + [onChange] + ) + + return ( +
+ + {value && ( +
+ onChange({ ...value, color })} + width={50} + className={styles.colorNameField} + /> + + onChange({ ...value, name: name || undefined }) + } + className={styles.colorNameText} + /> +
+ )} +
+ ) +} + +NoDataLegend.propTypes = { + onChange: PropTypes.func.isRequired, + label: PropTypes.string, + placeholder: PropTypes.string, + value: PropTypes.shape({ + color: PropTypes.string.isRequired, + name: PropTypes.string, + }), +} + +export default NoDataLegend diff --git a/src/components/edit/shared/UnclassifiedLegend.jsx b/src/components/edit/shared/UnclassifiedLegend.jsx new file mode 100644 index 000000000..1929627fb --- /dev/null +++ b/src/components/edit/shared/UnclassifiedLegend.jsx @@ -0,0 +1,54 @@ +import i18n from '@dhis2/d2-i18n' +import PropTypes from 'prop-types' +import React, { useCallback } from 'react' +import { NO_DATA_COLOR } from '../../../constants/layers.js' +import { Checkbox, ColorPicker, TextField } from '../../core/index.js' +import styles from '../styles/LayerDialog.module.css' + +const UnclassifiedLegend = ({ value, onChange, label }) => { + const onCheck = useCallback( + (checked) => onChange(checked ? { color: NO_DATA_COLOR } : undefined), + [onChange] + ) + + return ( +
+ + {value && ( +
+ onChange({ ...value, color })} + width={50} + className={styles.colorNameField} + /> + + onChange({ ...value, name: name || undefined }) + } + className={styles.colorNameText} + /> +
+ )} +
+ ) +} + +UnclassifiedLegend.propTypes = { + onChange: PropTypes.func.isRequired, + label: PropTypes.string, + value: PropTypes.shape({ + color: PropTypes.string.isRequired, + name: PropTypes.string, + }), +} + +export default UnclassifiedLegend diff --git a/src/components/edit/shared/styles/BufferRadius.module.css b/src/components/edit/shared/styles/BufferRadius.module.css index 55704237f..08316bb5e 100644 --- a/src/components/edit/shared/styles/BufferRadius.module.css +++ b/src/components/edit/shared/styles/BufferRadius.module.css @@ -1,12 +1,7 @@ .buffer { - margin: 0 -8px; + margin-top: var(--spacers-dp8); clear: both; align-items: center; - min-height: 76px; -} - -.buffer > span > div { - margin: var(--spacers-dp8); } .numberField { diff --git a/src/components/edit/shared/styles/GeometryCentroid.module.css b/src/components/edit/shared/styles/GeometryCentroid.module.css index b7e383ef4..22edf593b 100644 --- a/src/components/edit/shared/styles/GeometryCentroid.module.css +++ b/src/components/edit/shared/styles/GeometryCentroid.module.css @@ -1,15 +1,11 @@ .centroid { - margin: 0 -8px; + margin-top: var(--spacers-dp8); clear: both; align-items: center; } -.centroid > span > div { - margin: var(--spacers-dp8); -} - .notice { - margin: var(--spacers-dp16) 0; + margin: var(--spacers-dp12) 0 var(--spacers-dp4); } .noticeCompact { diff --git a/src/components/edit/styles/LayerDialog.module.css b/src/components/edit/styles/LayerDialog.module.css index 2b261cdc6..dd996c99e 100644 --- a/src/components/edit/styles/LayerDialog.module.css +++ b/src/components/edit/styles/LayerDialog.module.css @@ -94,6 +94,25 @@ margin-left: var(--spacers-dp24); } +.colorNameRow { + display: flex; + gap: var(--spacers-dp8); + align-items: flex-end; + margin-left: var(--spacers-dp24); + margin-bottom: var(--spacers-dp12); +} + +.colorNameField { + flex-shrink: 0; + margin-bottom: 0; +} + +.colorNameText { + flex: 1; + min-width: 0; + margin-bottom: 0; +} + .orgUnitTree { composes: flexColumn; overflow: hidden; @@ -116,6 +135,10 @@ margin: var(--spacers-dp8) 0 0 calc(var(--spacers-dp8) * -1); } +.labels { + margin-top: var(--spacers-dp8); +} + .labelDisplayOptions { margin-left: var(--spacers-dp24); } diff --git a/src/components/edit/thematic/NoDataColor.jsx b/src/components/edit/thematic/NoDataColor.jsx deleted file mode 100644 index 3a3c9b8c9..000000000 --- a/src/components/edit/thematic/NoDataColor.jsx +++ /dev/null @@ -1,39 +0,0 @@ -import i18n from '@dhis2/d2-i18n' -import PropTypes from 'prop-types' -import React, { useCallback } from 'react' -import { NO_DATA_COLOR } from '../../../constants/layers.js' -import { Checkbox, ColorPicker } from '../../core/index.js' -import styles from './styles/NoDataColor.module.css' - -const NoDataColor = ({ value, onChange }) => { - const onCheck = useCallback( - (val) => onChange(val ? NO_DATA_COLOR : undefined), - [onChange] - ) - - return ( -
- - {value && ( - - )} -
- ) -} - -NoDataColor.propTypes = { - onChange: PropTypes.func.isRequired, - value: PropTypes.string, -} - -export default NoDataColor diff --git a/src/components/edit/thematic/RadiusSelect.jsx b/src/components/edit/thematic/RadiusSelect.jsx index 4623bb5a6..155ef8a62 100644 --- a/src/components/edit/thematic/RadiusSelect.jsx +++ b/src/components/edit/thematic/RadiusSelect.jsx @@ -17,8 +17,8 @@ export const isValidRadius = ( radiusLow = THEMATIC_RADIUS_LOW, radiusHigh = THEMATIC_RADIUS_HIGH ) => - !isNaN(radiusLow) && - !isNaN(radiusHigh) && + !Number.isNaN(radiusLow) && + !Number.isNaN(radiusHigh) && radiusLow <= radiusHigh && radiusLow >= THEMATIC_RADIUS_MIN && radiusHigh <= THEMATIC_RADIUS_MAX @@ -33,7 +33,7 @@ const RadiusSelect = ({ { const dispatch = useDispatch() + const countOrgUnitsWithoutCoordinates = useSelector( + (state) => state.layerEdit.countOrgUnitsWithoutCoordinates + ) const { defaultRenderingStrategy, shouldSyncFromOtherLayers, @@ -569,6 +576,19 @@ const ThematicDialog = ({
+ + dispatch( + setCountOrgUnitsWithoutCoordinates( + checked + ) + ) + } + />
- dispatch(setNoDataColor(v))} + {method === CLASSIFICATION_PREDEFINED && ( + + dispatch(setUnclassifiedLegend(v)) + } + /> + )} + dispatch(setNoDataLegend(v))} />
@@ -599,7 +627,10 @@ ThematicDialog.propTypes = { id: PropTypes.string, legendSet: PropTypes.object, method: PropTypes.number, - noDataColor: PropTypes.string, + noDataLegend: PropTypes.shape({ + color: PropTypes.string, + name: PropTypes.string, + }), orgUnits: PropTypes.object, periodType: PropTypes.string, periodsSettings: PropTypes.object, @@ -610,6 +641,10 @@ ThematicDialog.propTypes = { startDate: PropTypes.string, systemSettings: PropTypes.object, thematicMapType: PropTypes.string, + unclassifiedLegend: PropTypes.shape({ + color: PropTypes.string, + name: PropTypes.string, + }), validateLayer: PropTypes.bool, onLayerValidation: PropTypes.func, } diff --git a/src/components/edit/thematic/styles/NoDataColor.module.css b/src/components/edit/thematic/styles/NoDataColor.module.css deleted file mode 100644 index 31d74c995..000000000 --- a/src/components/edit/thematic/styles/NoDataColor.module.css +++ /dev/null @@ -1,3 +0,0 @@ -.colorPicker { - margin-left: var(--spacers-dp24); -} diff --git a/src/components/edit/thematic/styles/RadiusSelect.module.css b/src/components/edit/thematic/styles/RadiusSelect.module.css index 1e3003fc1..29788032e 100644 --- a/src/components/edit/thematic/styles/RadiusSelect.module.css +++ b/src/components/edit/thematic/styles/RadiusSelect.module.css @@ -1,4 +1,4 @@ -div.error { +.error { flex: none; margin: 0 0 var(--spacers-dp8); padding-left: var(--spacers-dp8); diff --git a/src/components/layers/LayerCard.jsx b/src/components/layers/LayerCard.jsx index 193b96b77..655e7a0a9 100644 --- a/src/components/layers/LayerCard.jsx +++ b/src/components/layers/LayerCard.jsx @@ -23,7 +23,7 @@ const LayerCard = ({ className={cx(styles.card, { [styles.expanded]: isExpanded, })} - data-test={`card-${title.replace(/ /g, '')}`} + data-test={`card-${title.replaceAll(' ', '')}`} >
diff --git a/src/components/layers/LayersPanel.jsx b/src/components/layers/LayersPanel.jsx index 042f8b1b5..44c0a3d3b 100644 --- a/src/components/layers/LayersPanel.jsx +++ b/src/components/layers/LayersPanel.jsx @@ -1,5 +1,5 @@ import cx from 'classnames' -import React from 'react' +import React, { useMemo } from 'react' import { useSelector, useDispatch } from 'react-redux' import { SortableContainer, SortableElement } from 'react-sortable-hoc' import { sortLayers } from '../../actions/layers.js' @@ -22,8 +22,8 @@ const SortableLayersList = SortableContainer(({ layers }) => ( const LayersPanel = () => { const layersPanelOpen = useSelector((state) => state.ui.layersPanelOpen) - const layers = useSelector((state) => [...state.map.mapViews].reverse()) - + const mapViews = useSelector((state) => state.map.mapViews) + const layers = useMemo(() => [...mapViews].reverse(), [mapViews]) const dispatch = useDispatch() const onSortStart = () => { diff --git a/src/components/layers/overlays/Layer.jsx b/src/components/layers/overlays/Layer.jsx index b1debc116..9c8800b41 100644 --- a/src/components/layers/overlays/Layer.jsx +++ b/src/components/layers/overlays/Layer.jsx @@ -7,7 +7,9 @@ import styles from './styles/Layer.module.css' const Layer = ({ layer, onClick }) => { const { img, type, name } = layer const label = name || i18n.t(type) - const dataTest = `addlayeritem-${label.toLowerCase().replace(/\s/g, '_')}` + const dataTest = `addlayeritem-${label + .toLowerCase() + .replaceAll(/\s/g, '_')}` return (
{ + const { + systemSettings: { keyAnalysisDigitGroupSeparator }, + } = useCachedData() const leftAlign = textAlign === 'left' const x = maxRadius const y = maxRadius * 2 - radius @@ -50,7 +55,11 @@ const Bubble = ({ alignmentBaseline="middle" style={{ fontSize: 12 }} > - {text} + {formatWithSeparator( + text, + keyAnalysisDigitGroupSeparator, + { force: true } + )} )} diff --git a/src/components/legend/Bubbles.jsx b/src/components/legend/Bubbles.jsx index 1e107069f..a262c0fac 100644 --- a/src/components/legend/Bubbles.jsx +++ b/src/components/legend/Bubbles.jsx @@ -24,15 +24,20 @@ const Bubbles = ({ maxValue, classes, isPlugin, + legendDecimalPlaces, }) => { const legendWidth = isPlugin ? 150 : 245 const noDataClass = classes.find((c) => c.noData === true) - const bubbleClasses = classes.filter((c) => !c.noData) + const isolatedClass = classes.find((c) => c.isLegendIsolated) + const outsideLegendClass = classes.find((c) => c.outsideLegend === true) + const bubbleClasses = classes.filter( + (c) => !c.noData && !c.isLegendIsolated && !c.outsideLegend + ) const height = radiusHigh * 2 + 4 const scale = scaleSqrt().range([radiusLow, radiusHigh]) - if (isNaN(radiusLow) || isNaN(radiusHigh)) { + if (Number.isNaN(radiusLow) || Number.isNaN(radiusHigh)) { return null } @@ -43,6 +48,7 @@ const Bubbles = ({ maxValue, scale, radiusHigh, + legendDecimalPlaces, }) : createSingleColorBubbles({ color, @@ -51,6 +57,7 @@ const Bubbles = ({ scale, radiusLow, radiusHigh, + legendDecimalPlaces, }) const { alternate, offset, showNumbers } = computeLayout({ @@ -68,6 +75,9 @@ const Bubbles = ({ }) } + const extraRowHeight = THEMATIC_RADIUS_DEFAULT * 2 + 4 + const tx = alternate ? offset : '2' + return (
- + {bubbles.map((bubble, i) => ( ))} - {noDataClass && ( + {isolatedClass && ( <> - {' '} + {isolatedClass.name} + + + )} + {outsideLegendClass && ( + <> + + + {outsideLegendClass.name} + + + )} + {noDataClass && ( + <> + + {noDataClass.name} @@ -128,6 +194,7 @@ Bubbles.propTypes = { classes: PropTypes.array, color: PropTypes.string, isPlugin: PropTypes.bool, + legendDecimalPlaces: PropTypes.number, maxValue: PropTypes.number, minValue: PropTypes.number, } diff --git a/src/components/legend/Legend.jsx b/src/components/legend/Legend.jsx index 91ce3e822..babd30df0 100644 --- a/src/components/legend/Legend.jsx +++ b/src/components/legend/Legend.jsx @@ -1,7 +1,7 @@ import i18n from '@dhis2/d2-i18n' import PropTypes from 'prop-types' import React from 'react' -import { sortLegendItems } from '../../util/legend.js' +import { legendNamesContainRange, sortLegendItems } from '../../util/legend.js' import Bubbles from './Bubbles.jsx' import LegendItem from './LegendItem.jsx' import styles from './styles/Legend.module.css' @@ -16,93 +16,139 @@ const Legend = ({ items, bubbles, explanation, + eventsWithoutCoordinatesCount, + orgUnitsWithoutCoordinatesCount, url, source, sourceUrl, isPlugin = false, -}) => ( -
- {description &&
{description}
} - {groups && ( -
- {groups.multiple === false ? ( - <>{groups.list[0].name} - ) : ( - <> - {groups.label} - {groups.list.map(({ id, name }) => ( -
{name}
- ))} - - )} -
- )} - {unit && items &&
{unit}
} - {bubbles ? ( - - ) : ( - Array.isArray(items) && ( - - - {sortLegendItems(items).map((item, index) => ( - - ))} - -
- ) - )} - {url && } - {Array.isArray(coordinateFields) && ( -
-
{i18n.t('Coordinate field')}:
- {coordinateFields.map((coordinateField, index) => ( -
{coordinateField}
- ))} -
- )} - {Array.isArray(filters) && ( -
-
{i18n.t('Filters')}:
- {filters.map((filter, index) => ( -
{filter}
- ))} -
- )} - {Array.isArray(explanation) && ( -
- {explanation.map((expl, index) => ( -
{expl}
- ))} -
- )} - {source && ( -
- {i18n.t('Source')}:  - {sourceUrl ? ( - - {source} - - ) : ( - {source} - )} -
- )} -
-) +}) => { + const showRange = Array.isArray(items) && !legendNamesContainRange(items) + const getShowRange = (item) => + item.isLegendIsolated + ? !legendNamesContainRange([item]) + : !item.name || showRange + + return ( +
+ {description && ( +
{description}
+ )} + {groups && ( +
+ {groups.multiple === false ? ( + <>{groups.list[0].name} + ) : ( + <> + {groups.label} + {groups.list.map(({ id, name }) => ( +
{name}
+ ))} + + )} +
+ )} + {unit && items &&
{unit}
} + {bubbles ? ( + + ) : ( + Array.isArray(items) && ( + + + {Array.isArray(items) && + sortLegendItems(items).map((item) => ( + + ))} + +
+ ) + )} + {(typeof eventsWithoutCoordinatesCount === 'number' || + typeof orgUnitsWithoutCoordinatesCount === 'number') && ( +
+
{i18n.t('Data quality')}:
+ {typeof eventsWithoutCoordinatesCount === 'number' && ( +
+ {i18n.t('{{count}} event without coordinates', { + count: eventsWithoutCoordinatesCount, + defaultValue_plural: + '{{count}} events without coordinates', + })} +
+ )} + {typeof orgUnitsWithoutCoordinatesCount === 'number' && ( +
+ {i18n.t('{{count}} org unit without coordinates', { + count: orgUnitsWithoutCoordinatesCount, + defaultValue_plural: + '{{count}} org units without coordinates', + })} +
+ )} +
+ )} + {url && } + {Array.isArray(coordinateFields) && ( +
+
{i18n.t('Coordinate field')}:
+ {coordinateFields.map((coordinateField, index) => ( +
{coordinateField}
+ ))} +
+ )} + {Array.isArray(filters) && ( +
+
{i18n.t('Filters')}:
+ {filters.map((filter, index) => ( +
{filter}
+ ))} +
+ )} + {Array.isArray(explanation) && ( +
+ {explanation.map((expl, index) => ( +
{expl}
+ ))} +
+ )} + {source && ( +
+ {i18n.t('Source')}:  + {sourceUrl ? ( + + {source} + + ) : ( + {source} + )} +
+ )} +
+ ) +} Legend.propTypes = { bubbles: PropTypes.shape({ radiusHigh: PropTypes.number.isRequired, radiusLow: PropTypes.number.isRequired, color: PropTypes.string, + legendDecimalPlaces: PropTypes.number, }), coordinateFields: PropTypes.array, description: PropTypes.string, + eventsWithoutCoordinatesCount: PropTypes.number, explanation: PropTypes.array, filters: PropTypes.array, groups: PropTypes.oneOfType([PropTypes.array, PropTypes.object]), isPlugin: PropTypes.bool, items: PropTypes.array, + orgUnitsWithoutCoordinatesCount: PropTypes.number, source: PropTypes.string, sourceUrl: PropTypes.string, unit: PropTypes.string, diff --git a/src/components/legend/LegendItem.jsx b/src/components/legend/LegendItem.jsx index 6b46e16ed..12cabdce7 100644 --- a/src/components/legend/LegendItem.jsx +++ b/src/components/legend/LegendItem.jsx @@ -17,9 +17,11 @@ const LegendItem = ({ radius, weight, name, + showRange, startValue, endValue, count, + decimalPlaces, }) => { if (!name && startValue === undefined) { return null @@ -63,9 +65,11 @@ const LegendItem = ({ ) @@ -74,11 +78,13 @@ const LegendItem = ({ LegendItem.propTypes = { color: PropTypes.string, count: PropTypes.number, + decimalPlaces: PropTypes.number, endValue: PropTypes.number, fillColor: PropTypes.string, image: PropTypes.string, name: PropTypes.string, radius: PropTypes.number, + showRange: PropTypes.bool, startValue: PropTypes.number, strokeColor: PropTypes.string, type: PropTypes.string, diff --git a/src/components/legend/LegendItemRange.jsx b/src/components/legend/LegendItemRange.jsx index 4c09d4ddc..67d8fbe11 100644 --- a/src/components/legend/LegendItemRange.jsx +++ b/src/components/legend/LegendItemRange.jsx @@ -1,18 +1,55 @@ import PropTypes from 'prop-types' import React from 'react' +import { formatWithSeparator } from '../../util/numbers.js' +import { useCachedData } from '../cachedDataProvider/CachedDataProvider.jsx' import styles from './styles/LegendItemRange.module.css' -const LegendItemRange = ({ name = '', startValue, endValue, count }) => ( - - {isNaN(startValue) ? name : `${name} ${startValue} - ${endValue}`} - {count !== undefined ? ` (${count})` : ''} - -) +const LegendItemRange = ({ + name = '', + showRange = true, + startValue, + endValue, + count, + decimalPlaces, +}) => { + const { + systemSettings: { keyAnalysisDigitGroupSeparator }, + } = useCachedData() + const nameLabel = name ? `${name} ` : '' + const showRangeValue = + startValue !== undefined && endValue !== undefined && showRange + const precisionOpt = + decimalPlaces === undefined ? undefined : { precision: decimalPlaces } + const rangeLabel = showRangeValue + ? `${formatWithSeparator( + startValue, + keyAnalysisDigitGroupSeparator, + precisionOpt + )} - ${formatWithSeparator( + endValue, + keyAnalysisDigitGroupSeparator, + precisionOpt + )}` + : '' + const countLabel = + count === undefined + ? '' + : ` (${formatWithSeparator(count, keyAnalysisDigitGroupSeparator)})` + return ( + + {nameLabel} + {rangeLabel} + {countLabel} + + ) +} LegendItemRange.propTypes = { count: PropTypes.number, + decimalPlaces: PropTypes.number, endValue: PropTypes.number, name: PropTypes.string, + showRange: PropTypes.bool, startValue: PropTypes.number, } diff --git a/src/components/legend/styles/Legend.module.css b/src/components/legend/styles/Legend.module.css index cf80b8027..bef18afc0 100644 --- a/src/components/legend/styles/Legend.module.css +++ b/src/components/legend/styles/Legend.module.css @@ -20,12 +20,14 @@ max-width: 100%; } +.dataQuality, .filters, .coordinateFields { padding-top: var(--spacers-dp16); font-size: 12px; } +.dataQuality > div:first-child, .filters > div:first-child, .coordinateFields > div:first-child { font-weight: bold; @@ -45,6 +47,10 @@ font-style: italic; } +.noCoordinates { + padding-top: var(--spacers-dp8); +} + .explanation { padding-top: var(--spacers-dp16); } diff --git a/src/components/map/layers/EventLayer.jsx b/src/components/map/layers/EventLayer.jsx index b71cf8a59..36d2cd5d0 100644 --- a/src/components/map/layers/EventLayer.jsx +++ b/src/components/map/layers/EventLayer.jsx @@ -146,13 +146,15 @@ class EventLayer extends Layer { this.layer = map.createLayer(config) map.addLayer(this.layer) + this.setLayerVisibility() // Fit map to layer bounds once (when first created) this.fitBoundsOnce() } render() { - const { styleDataItem, nameProperty } = this.props + const { styleDataItem, nameProperty, keyAnalysisDigitGroupSeparator } = + this.props const { popup, displayItems, eventCoordinateFieldName } = this.state return popup && displayItems ? ( @@ -160,6 +162,7 @@ class EventLayer extends Layer { {...popup} styleDataItem={styleDataItem} nameProperty={nameProperty} + keyAnalysisDigitGroupSeparator={keyAnalysisDigitGroupSeparator} displayItems={displayItems} eventCoordinateFieldName={eventCoordinateFieldName} onClose={this.onPopupClose} diff --git a/src/components/map/layers/EventPopup.jsx b/src/components/map/layers/EventPopup.jsx index f40dbd961..a6ffca021 100644 --- a/src/components/map/layers/EventPopup.jsx +++ b/src/components/map/layers/EventPopup.jsx @@ -19,7 +19,12 @@ const EVENTS_QUERY = { }, } -const getDataRows = ({ displayItems, dataValues, orgUnitNames }) => { +const getDataRows = ({ + displayItems, + dataValues, + orgUnitNames, + keyAnalysisDigitGroupSeparator, +}) => { const dataRows = [] // Include rows for each data item used for styling and displayInReport @@ -30,6 +35,7 @@ const getDataRows = ({ displayItems, dataValues, orgUnitNames }) => { valueType, options, orgUnitNames, + keyAnalysisDigitGroupSeparator, }) dataRows.push( @@ -53,6 +59,7 @@ const EventPopup = ({ feature, styleDataItem, nameProperty, + keyAnalysisDigitGroupSeparator, displayItems, eventCoordinateFieldName, onClose, @@ -151,6 +158,7 @@ const EventPopup = ({ displayItems, dataValues, orgUnitNames, + keyAnalysisDigitGroupSeparator, })} {type === 'Point' && ( @@ -187,6 +195,7 @@ EventPopup.propTypes = { nameProperty: PropTypes.string.isRequired, onClose: PropTypes.func.isRequired, eventCoordinateFieldName: PropTypes.string, + keyAnalysisDigitGroupSeparator: PropTypes.string, styleDataItem: PropTypes.object, } diff --git a/src/components/map/layers/ExternalLayer.js b/src/components/map/layers/ExternalLayer.js index cc7ea39e1..839e70fb5 100644 --- a/src/components/map/layers/ExternalLayer.js +++ b/src/components/map/layers/ExternalLayer.js @@ -14,5 +14,6 @@ export default class ExternalLayer extends Layer { }) map.addLayer(this.layer) + this.setLayerVisibility() } } diff --git a/src/components/map/layers/FacilityLayer.jsx b/src/components/map/layers/FacilityLayer.jsx index 749272621..3ff48172b 100644 --- a/src/components/map/layers/FacilityLayer.jsx +++ b/src/components/map/layers/FacilityLayer.jsx @@ -97,6 +97,7 @@ class FacilityLayer extends Layer { group.addLayer(config) this.layer = group map.addLayer(this.layer).catch(this.onError.bind(this)) + this.setLayerVisibility() // Fit map to layer bounds once (when first created) this.fitBoundsOnce() diff --git a/src/components/map/layers/GeoJsonLayer.js b/src/components/map/layers/GeoJsonLayer.js index 782302960..b837f48a0 100644 --- a/src/components/map/layers/GeoJsonLayer.js +++ b/src/components/map/layers/GeoJsonLayer.js @@ -1,6 +1,7 @@ import { GEOJSON_LAYER } from '../../../constants/layers.js' import { filterData } from '../../../util/filter.js' import { getGeojsonDisplayData } from '../../../util/geojson.js' +import { formatWithSeparator } from '../../../util/numbers.js' import Layer from './Layer.js' class GeoJsonLayer extends Layer { @@ -47,19 +48,25 @@ class GeoJsonLayer extends Layer { }) map.addLayer(this.layer) + this.setLayerVisibility() // Fit map to layer bounds once (when first created) this.fitBoundsOnce() } onFeatureClick(evt) { + const { keyAnalysisDigitGroupSeparator } = this.props + const feature = this.props.data.find( (d) => d.properties.id === evt.feature.properties.id ) const data = getGeojsonDisplayData(feature).reduce( (acc, { dataKey, value }) => { - acc[dataKey] = value + acc[dataKey] = formatWithSeparator( + value, + keyAnalysisDigitGroupSeparator + ) return acc }, {} diff --git a/src/components/map/layers/Layer.js b/src/components/map/layers/Layer.js index 0b2998bef..c8bff1363 100644 --- a/src/components/map/layers/Layer.js +++ b/src/components/map/layers/Layer.js @@ -106,6 +106,7 @@ class Layer extends PureComponent { }) await map.addLayer(this.layer) + this.setLayerVisibility() } async updateLayer() { diff --git a/src/components/map/layers/OrgUnitLayer.jsx b/src/components/map/layers/OrgUnitLayer.jsx index 76fa7ee71..5e1247876 100644 --- a/src/components/map/layers/OrgUnitLayer.jsx +++ b/src/components/map/layers/OrgUnitLayer.jsx @@ -60,6 +60,7 @@ export default class OrgUnitLayer extends Layer { this.layer = map.createLayer(config) map.addLayer(this.layer) + this.setLayerVisibility() // Fit map to layer bounds once (when first created) this.fitBoundsOnce() diff --git a/src/components/map/layers/ThematicLayer.jsx b/src/components/map/layers/ThematicLayer.jsx index c98fe4e1e..cf34915c7 100644 --- a/src/components/map/layers/ThematicLayer.jsx +++ b/src/components/map/layers/ThematicLayer.jsx @@ -47,7 +47,7 @@ class ThematicLayer extends Layer { labels, renderingStrategy = RENDERING_STRATEGY_SINGLE, thematicMapType = THEMATIC_CHOROPLETH, - noDataColor, + noDataLegend, } = this.props const { isPlugin, map } = this.context @@ -64,7 +64,7 @@ class ThematicLayer extends Layer { isVisible, data: filteredData, hoverLabel: '{name} ({value})', - color: noDataColor, + color: noDataLegend?.color, onClick: this.onFeatureClick.bind(this), onRightClick: this.onFeatureRightClick.bind(this), } @@ -105,6 +105,7 @@ class ThematicLayer extends Layer { } map.addLayer(this.layer) + this.setLayerVisibility() const options = {} if (renderingStrategy === RENDERING_STRATEGY_TIMELINE) { @@ -275,7 +276,8 @@ class ThematicLayer extends Layer { valuesByPeriod, renderingStrategy = RENDERING_STRATEGY_SINGLE, thematicMapType = THEMATIC_CHOROPLETH, - noDataColor, + noDataLegend, + unclassifiedLegend, externalPeriod, } = props @@ -300,12 +302,17 @@ class ThematicLayer extends Layer { }, })) - // Remove org unit features if noDataColor is missing - if (!noDataColor) { + if (!noDataLegend) { periodData = periodData.filter( (feature) => values[feature.id] !== undefined ) } + + if (!unclassifiedLegend) { + periodData = periodData.filter( + (feature) => !values[feature.id]?.outsideLegend + ) + } } return filterData(periodData, dataFilters) diff --git a/src/components/map/layers/TrackedEntityLayer.jsx b/src/components/map/layers/TrackedEntityLayer.jsx index 1ffa39578..96f3f0841 100644 --- a/src/components/map/layers/TrackedEntityLayer.jsx +++ b/src/components/map/layers/TrackedEntityLayer.jsx @@ -134,13 +134,15 @@ class TrackedEntityLayer extends Layer { this.layer = group map.addLayer(this.layer) + this.setLayerVisibility() // Fit map to layer bounds once (when first created) this.fitBoundsOnce() } render() { - const { program, nameProperty } = this.props + const { program, nameProperty, keyAnalysisDigitGroupSeparator } = + this.props const { popup, displayAttributes } = this.state return popup ? ( @@ -148,6 +150,7 @@ class TrackedEntityLayer extends Layer { {...popup} program={program} nameProperty={nameProperty} + keyAnalysisDigitGroupSeparator={keyAnalysisDigitGroupSeparator} displayAttributes={displayAttributes || []} onClose={this.onPopupClose} /> diff --git a/src/components/map/layers/TrackedEntityPopup.jsx b/src/components/map/layers/TrackedEntityPopup.jsx index 9260f29a8..8d88906f2 100644 --- a/src/components/map/layers/TrackedEntityPopup.jsx +++ b/src/components/map/layers/TrackedEntityPopup.jsx @@ -22,7 +22,12 @@ const TRACKED_ENTITIES_QUERY = { }, } -const getDataRows = ({ displayAttributes, attributes, orgUnitNames }) => { +const getDataRows = ({ + displayAttributes, + attributes, + orgUnitNames, + keyAnalysisDigitGroupSeparator, +}) => { const dataRows = [] // Include rows for each displayInList attribute @@ -33,6 +38,7 @@ const getDataRows = ({ displayAttributes, attributes, orgUnitNames }) => { valueType, options, orgUnitNames, + keyAnalysisDigitGroupSeparator, }) dataRows.push( @@ -57,6 +63,7 @@ const TrackedEntityPopup = ({ activeDataSource, program, nameProperty, + keyAnalysisDigitGroupSeparator, displayAttributes, onClose, }) => { @@ -154,6 +161,7 @@ const TrackedEntityPopup = ({ displayAttributes, attributes, orgUnitNames, + keyAnalysisDigitGroupSeparator, })} {type === 'Point' && ( @@ -187,6 +195,7 @@ TrackedEntityPopup.propTypes = { nameProperty: PropTypes.string.isRequired, onClose: PropTypes.func.isRequired, activeDataSource: PropTypes.string, + keyAnalysisDigitGroupSeparator: PropTypes.string, program: PropTypes.object, } diff --git a/src/components/map/layers/earthEngine/EarthEngineLayer.jsx b/src/components/map/layers/earthEngine/EarthEngineLayer.jsx index 2dd758f17..8a97a6f23 100644 --- a/src/components/map/layers/earthEngine/EarthEngineLayer.jsx +++ b/src/components/map/layers/earthEngine/EarthEngineLayer.jsx @@ -143,6 +143,7 @@ export default class EarthEngineLayer extends Layer { try { this.layer = map.createLayer(config) await map.addLayer(this.layer) + this.setLayerVisibility() } catch (error) { this.onError(error) } @@ -216,7 +217,12 @@ export default class EarthEngineLayer extends Layer { } render() { - const { legend, aggregationType, loadError } = this.props + const { + legend, + aggregationType, + keyAnalysisDigitGroupSeparator, + loadError, + } = this.props const { isLoading, popup, aggregations, error } = this.state return ( @@ -235,6 +241,9 @@ export default class EarthEngineLayer extends Layer { legend={legend} valueType={aggregationType} onClose={this.onPopupClose} + keyAnalysisDigitGroupSeparator={ + keyAnalysisDigitGroupSeparator + } {...popup} /> )} diff --git a/src/components/map/layers/earthEngine/EarthEnginePopup.jsx b/src/components/map/layers/earthEngine/EarthEnginePopup.jsx index 4970c4830..3833ebc63 100644 --- a/src/components/map/layers/earthEngine/EarthEnginePopup.jsx +++ b/src/components/map/layers/earthEngine/EarthEnginePopup.jsx @@ -7,13 +7,29 @@ import { hasClasses } from '../../../../util/earthEngine.js' import { getRoundToPrecisionFn, getPrecision, + formatWithSeparator, } from '../../../../util/numbers.js' import Popup from '../../Popup.jsx' import styles from '../styles/Popup.module.css' import earthEngineStyles from './styles/EarthEnginePopup.module.css' +const getValuesForType = (data, type) => + Object.values(data).flatMap((ou) => + Object.entries(ou) + .filter(([key]) => key.includes(type)) + .map(([, val]) => val) + ) + const EarthEnginePopup = (props) => { - const { coordinates, feature, data, legend, valueType, onClose } = props + const { + coordinates, + feature, + data, + legend, + valueType, + onClose, + keyAnalysisDigitGroupSeparator, + } = props const { id, name } = feature.properties const { title, unit, items = [], groups } = legend const values = typeof data === 'object' ? data[id] : null @@ -24,7 +40,11 @@ const EarthEnginePopup = (props) => { if (values) { if (classes) { - const valueFormat = getRoundToPrecisionFn(isPercentage ? 2 : 0) + const valueFormat = (value) => + formatWithSeparator( + getRoundToPrecisionFn(isPercentage ? 2 : 0)(value), + keyAnalysisDigitGroupSeparator + ) table = ( @@ -70,17 +90,12 @@ const EarthEnginePopup = (props) => { : `${group}_${type}` // Returns the value format (precision) for an aggregation type - const getValueFormat = (type) => - getRoundToPrecisionFn( - getPrecision( - Object.values(data) - .map((ou) => - Object.keys(ou) - .filter((key) => key.includes(type)) - .map((key) => ou[key]) - ) - .flat() - ) + const getValueFormat = (type) => (value) => + formatWithSeparator( + getRoundToPrecisionFn( + getPrecision(getValuesForType(data, type)) + )(value), + keyAnalysisDigitGroupSeparator ) // Create value format function for each aggregation type @@ -191,6 +206,7 @@ EarthEnginePopup.propTypes = { legend: PropTypes.object.isRequired, onClose: PropTypes.func.isRequired, data: PropTypes.oneOfType([PropTypes.string, PropTypes.object]), + keyAnalysisDigitGroupSeparator: PropTypes.string, valueType: PropTypes.oneOfType([PropTypes.string, PropTypes.array]), } diff --git a/src/components/optionSet/OptionStyle.jsx b/src/components/optionSet/OptionStyle.jsx index f40b13750..ae50f0dd3 100644 --- a/src/components/optionSet/OptionStyle.jsx +++ b/src/components/optionSet/OptionStyle.jsx @@ -9,6 +9,7 @@ const OptionStyle = ({ name, color, onChange }) => ( color={color} onChange={onChange} className={styles.color} + width={50} /> {name} diff --git a/src/components/optionSet/styles/OptionStyle.module.css b/src/components/optionSet/styles/OptionStyle.module.css index 66627757e..b4e6ddb48 100644 --- a/src/components/optionSet/styles/OptionStyle.module.css +++ b/src/components/optionSet/styles/OptionStyle.module.css @@ -1,21 +1,18 @@ .item { - white-space: nowrap; + display: flex; + align-items: center; + gap: var(--spacers-dp8); font-size: 14px; margin-bottom: var(--spacers-dp4); } .color { - display: inline-block; - vertical-align: top; - width: var(--spacers-dp32); - height: var(--spacers-dp32); - margin: 0 var(--spacers-dp8) 0 0; + flex-shrink: 0; + margin-bottom: 0; } .label { - display: inline-block; - vertical-align: top; - height: 32px; - line-height: 32px; overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } diff --git a/src/components/orgunits/OrgUnitData.jsx b/src/components/orgunits/OrgUnitData.jsx index 1d271050a..61b07567c 100644 --- a/src/components/orgunits/OrgUnitData.jsx +++ b/src/components/orgunits/OrgUnitData.jsx @@ -35,7 +35,6 @@ const OrgUnitData = ({ id }) => { const { loading, data, refetch } = useDataQuery(ORGUNIT_PROFILE_QUERY, { lazy: true, }) - useEffect(() => { if (id && period) { refetch({ diff --git a/src/components/orgunits/OrgUnitInfo.jsx b/src/components/orgunits/OrgUnitInfo.jsx index 35ab76c35..ebf07189c 100644 --- a/src/components/orgunits/OrgUnitInfo.jsx +++ b/src/components/orgunits/OrgUnitInfo.jsx @@ -3,8 +3,12 @@ import i18n from '@dhis2/d2-i18n' import { IconDimensionOrgUnit16 } from '@dhis2/ui' import PropTypes from 'prop-types' import React from 'react' -import { getRoundToPrecisionFn } from '../../util/numbers.js' +import { + getRoundToPrecisionFn, + formatWithSeparator, +} from '../../util/numbers.js' import { formatDate } from '../../util/time.js' +import { useCachedData } from '../cachedDataProvider/CachedDataProvider.jsx' import ListItem from '../core/ListItem.jsx' import styles from './styles/OrgUnitInfo.module.css' @@ -41,6 +45,9 @@ const OrgUnitInfo = ({ url, }) => { const { baseUrl } = useConfig() + const { + systemSettings: { keyAnalysisDigitGroupSeparator }, + } = useCachedData() return (
{imageId && ( @@ -111,7 +118,10 @@ const OrgUnitInfo = ({ {comment} {attributes.map(({ id, label, value }) => ( - {value} + {formatWithSeparator( + value, + keyAnalysisDigitGroupSeparator + )} ))}
diff --git a/src/components/orgunits/__tests__/OrgUnitInfo.spec.jsx b/src/components/orgunits/__tests__/OrgUnitInfo.spec.jsx index 34829f9b1..9ef76b15a 100644 --- a/src/components/orgunits/__tests__/OrgUnitInfo.spec.jsx +++ b/src/components/orgunits/__tests__/OrgUnitInfo.spec.jsx @@ -6,6 +6,15 @@ jest.mock('@dhis2/app-runtime', () => ({ useConfig: jest.fn(() => ({ baseUrl: 'dhis2' })), })) +jest.mock( + '../../../components/cachedDataProvider/CachedDataProvider.jsx', + () => ({ + useCachedData: jest.fn(() => ({ + systemSettings: { keyAnalysisDigitGroupSeparator: 'SPACE' }, + })), + }) +) + const groupSets = [ { id: 'Bpx0589u8y0', diff --git a/src/components/plugin/LayerLoader.jsx b/src/components/plugin/LayerLoader.jsx index f4ce94194..d209e6f12 100644 --- a/src/components/plugin/LayerLoader.jsx +++ b/src/components/plugin/LayerLoader.jsx @@ -28,7 +28,10 @@ const LayerLoader = ({ config, onLoad }) => { const { baseUrl, serverVersion } = useConfig() const engine = useDataEngine() const [analyticsEngine] = useState(() => Analytics.getAnalytics(engine)) - const { currentUser } = useCachedData() + const { + systemSettings: { keyAnalysisDigitGroupSeparator }, + currentUser, + } = useCachedData() const { keyAnalysisDisplayProperty, id: userId } = currentUser const periodTypeData = useDataOutputPeriodTypes() @@ -45,6 +48,7 @@ const LayerLoader = ({ config, onLoad }) => { config, engine, keyAnalysisDisplayProperty, // name/shortName + keyAnalysisDigitGroupSeparator, // NONE/SPACE/COMMA userId, baseUrl, analyticsEngine, // Thematic and Event loader @@ -62,6 +66,7 @@ const LayerLoader = ({ config, onLoad }) => { userId, baseUrl, keyAnalysisDisplayProperty, + keyAnalysisDigitGroupSeparator, serverVersion, ]) diff --git a/src/components/plugin/Legend.jsx b/src/components/plugin/Legend.jsx index 83c1dbb31..d1c57a8bf 100644 --- a/src/components/plugin/Legend.jsx +++ b/src/components/plugin/Legend.jsx @@ -6,7 +6,7 @@ import LegendLayer from './LegendLayer.jsx' import './styles/Legend.css' // Renders a legend for all map layers -const Legend = ({ layers }) => { +const Legend = ({ layers, toggleLayerVisibility }) => { const [isOpen, setIsOpen] = useState(false) const [isPinned, setIsPinned] = useState(false) @@ -30,7 +30,11 @@ const Legend = ({ layers }) => { onClick={() => setIsPinned(!isPinned)} > {legendLayers.map((layer) => ( - + ))} @@ -47,6 +51,7 @@ const Legend = ({ layers }) => { Legend.propTypes = { layers: PropTypes.array.isRequired, + toggleLayerVisibility: PropTypes.func, } export default Legend diff --git a/src/components/plugin/LegendLayer.jsx b/src/components/plugin/LegendLayer.jsx index 5753bdcbf..ec020b4eb 100644 --- a/src/components/plugin/LegendLayer.jsx +++ b/src/components/plugin/LegendLayer.jsx @@ -1,3 +1,5 @@ +import i18n from '@dhis2/d2-i18n' +import { IconView24, IconViewOff24 } from '@dhis2/ui' import PropTypes from 'prop-types' import React, { Fragment } from 'react' import { getRenderingLabel } from '../../util/legend.js' @@ -10,17 +12,37 @@ const LegendLayer = ({ id, legend, renderingStrategy, + toggleLayerVisibility, + isVisible = true, alerts = DEFAULT_NO_ALERTS, }) => (
{legend && (

- {legend.title} - - {legend.period} - {getRenderingLabel(renderingStrategy)} + + {legend.title} + + {legend.period} + {getRenderingLabel(renderingStrategy)} + + {toggleLayerVisibility && ( + + )}

@@ -37,10 +59,12 @@ LegendLayer.propTypes = { id: PropTypes.string.isRequired, alerts: PropTypes.array, data: PropTypes.array, + isVisible: PropTypes.bool, layer: PropTypes.string, legend: PropTypes.object, renderingStrategy: PropTypes.string, serverCluster: PropTypes.bool, + toggleLayerVisibility: PropTypes.func, } export default LegendLayer diff --git a/src/components/plugin/Map.jsx b/src/components/plugin/Map.jsx index 4a899774b..bdbff692a 100644 --- a/src/components/plugin/Map.jsx +++ b/src/components/plugin/Map.jsx @@ -35,26 +35,39 @@ const Map = forwardRef((props, ref) => { useEffect(() => { if (didViewsChange(layers.current, mapViews)) { layers.current = mapViews.map((v) => ({ ...v, isLoaded: false })) - setMapIsLoaded(false) + setVisibilityOverrides({}) } }, [mapViews]) const [mapIsLoaded, setMapIsLoaded] = useState(mapViews.length === 0) const [contextMenu, setContextMenu] = useState() const [resizeCount, setResizeCount] = useState(0) + const [visibilityOverrides, setVisibilityOverrides] = useState({}) const onResize = () => setResizeCount((state) => state + 1) const onLayerLoad = useCallback((layer) => { - layers.current = layers.current.map((l) => - layer.id === l.id ? layer : l - ) + layers.current = layers.current.map((l) => { + if (layer.id !== l.id) { + return l + } + return { ...layer, isVisible: l.isVisible ?? true } + }) if (layers.current.every((l) => l.isLoaded)) { setMapIsLoaded(true) } }, []) + const toggleLayerVisibility = useCallback((id) => { + setVisibilityOverrides((prev) => { + const layer = layers.current.find((l) => l.id === id) + const current = + prev[id] === undefined ? layer?.isVisible ?? true : prev[id] + return { ...prev, [id]: !current } + }) + }, []) + // TODO: Remove when map.js is refactored useEffect(() => { if (getResizeFunction) { @@ -126,6 +139,12 @@ const Map = forwardRef((props, ref) => { ) } + const layersWithVisibility = layers.current.map((l) => + visibilityOverrides[l.id] === undefined + ? l + : { ...l, isVisible: visibilityOverrides[l.id] } + ) + return (
@@ -134,13 +153,18 @@ const Map = forwardRef((props, ref) => { isPlugin={true} isFullscreen={false} basemap={basemap} - layers={layers.current} + layers={layersWithVisibility} controls={controls} bounds={defaultBounds} openContextMenu={setContextMenu} resizeCount={resizeCount} /> - {mapViews.length > 0 && } + {mapViews.length > 0 && ( + + )} {contextMenu && ( [ @@ -174,6 +179,26 @@ export const getClassificationTypes = () => [ id: CLASSIFICATION_EQUAL_COUNTS, name: i18n.t('Equal counts'), }, + { + id: CLASSIFICATION_NATURAL_BREAKS_RANGES, + name: i18n.t('Natural breaks (intervals)'), + }, + { + id: CLASSIFICATION_NATURAL_BREAKS_CLUSTERS, + name: i18n.t('Natural breaks (clusters)'), + }, + { + id: CLASSIFICATION_PRETTY_BREAKS, + name: i18n.t('Pretty breaks'), + }, + { + id: CLASSIFICATION_LOGARITHMIC, + name: i18n.t('Logarithmic scale'), + }, + { + id: CLASSIFICATION_STANDARD_DEVIATION, + name: i18n.t('Standard deviation'), + }, ] export const STYLE_TYPE_COLOR = 'COLOR' diff --git a/src/constants/settings.js b/src/constants/settings.js index 3dde6d296..9a15bd947 100644 --- a/src/constants/settings.js +++ b/src/constants/settings.js @@ -8,6 +8,7 @@ export const DEFAULT_SYSTEM_SETTINGS = { } export const SYSTEM_SETTINGS = [ + 'keyAnalysisDigitGroupSeparator', 'keyAnalysisRelativePeriod', 'keyHideDailyPeriods', 'keyHideWeeklyPeriods', diff --git a/src/hooks/useLayersLoader.js b/src/hooks/useLayersLoader.js index 55af2d7f1..bc7f0bbef 100644 --- a/src/hooks/useLayersLoader.js +++ b/src/hooks/useLayersLoader.js @@ -31,7 +31,10 @@ export const useLayersLoader = () => { const { baseUrl, serverVersion } = useConfig() const engine = useDataEngine() const [analyticsEngine] = useState(() => Analytics.getAnalytics(engine)) - const { currentUser } = useCachedData() + const { + systemSettings: { keyAnalysisDigitGroupSeparator }, + currentUser, + } = useCachedData() const { showAlerts } = useLoaderAlerts() const allLayers = useSelector((state) => state.map.mapViews) const dataTable = useSelector((state) => state.dataTable) @@ -50,6 +53,7 @@ export const useLayersLoader = () => { config, engine, keyAnalysisDisplayProperty, // name/shortName + keyAnalysisDigitGroupSeparator, // NONE/SPACE/COMMA userId, baseUrl, analyticsEngine, // Thematic and Event loader @@ -108,6 +112,7 @@ export const useLayersLoader = () => { allLayers, dispatch, keyAnalysisDisplayProperty, + keyAnalysisDigitGroupSeparator, userId, engine, analyticsEngine, diff --git a/src/loaders/earthEngineLoader.js b/src/loaders/earthEngineLoader.js index 23e2725cd..4bcdb2fd7 100644 --- a/src/loaders/earthEngineLoader.js +++ b/src/loaders/earthEngineLoader.js @@ -7,6 +7,7 @@ import { } from '../constants/alerts.js' import { getEarthEngineLayer } from '../constants/earthEngineLayers/index.js' import { getOrgUnitsFromRows } from '../util/analytics.js' +import { parseJsonConfig } from '../util/config.js' import { hasClasses, getStaticFilterFromPeriod, @@ -14,10 +15,11 @@ import { } from '../util/earthEngine.js' import { sortLegendItems } from '../util/legend.js' import { toGeoJson } from '../util/map.js' -import { getRoundToPrecisionFn } from '../util/numbers.js' +import { getRoundToPrecisionFn, formatWithSeparator } from '../util/numbers.js' import { getCoordinateField, addAssociatedGeometries, + getOrgUnitsWithoutCoordsCount, } from '../util/orgUnits.js' import { GEOFEATURES_QUERY } from '../util/requests.js' @@ -25,6 +27,7 @@ const earthEngineLoader = async ({ config, engine, keyAnalysisDisplayProperty, + keyAnalysisDigitGroupSeparator, userId, }) => { const { format, rows, aggregationType } = config @@ -38,9 +41,18 @@ const earthEngineLoader = async ({ let dataset let features + const { countOrgUnitsWithoutCoordinates: flagFromConfig } = parseJsonConfig( + config.config + ) + if (flagFromConfig) { + config.countOrgUnitsWithoutCoordinates = true + } + let orgUnitsWithoutCoordsCount = 0 + if (orgUnits && orgUnits.length) { const orgUnitIds = orgUnits.map((item) => item.id) let mainFeatures + let associatedGeometries try { @@ -56,6 +68,20 @@ const earthEngineLoader = async ({ ? toGeoJson(geoFeatureData.geoFeatures) : null + if (config.countOrgUnitsWithoutCoordinates) { + const { count, missingOrgUnits } = + await getOrgUnitsWithoutCoordsCount({ + engine, + orgUnitIds, + userId, + features: mainFeatures || [], + }) + if (count > 0) { + orgUnitsWithoutCoordsCount = count + config.dataWithoutCoords = missingOrgUnits + } + } + if (coordinateField) { const coordFieldData = await engine.query(GEOFEATURES_QUERY, { variables: { @@ -101,6 +127,7 @@ const earthEngineLoader = async ({ if (typeof config.config === 'string') { // From database as favorite layerConfig = JSON.parse(config.config) + delete layerConfig.countOrgUnitsWithoutCoordinates if (layerConfig.image) { // Backward compability for layers with periods saved before 2.36 @@ -201,7 +228,15 @@ const earthEngineLoader = async ({ !hasClasses(aggregationType) && style?.palette ) { - legend.items = createLegend(style, !maskOperator) + legend.items = createLegend( + style, + !maskOperator, + keyAnalysisDigitGroupSeparator + ) + } + + if (orgUnitsWithoutCoordsCount > 0) { + legend.orgUnitsWithoutCoordinatesCount = orgUnitsWithoutCoordsCount } const filter = getStaticFilterFromPeriod(period, filters) @@ -211,17 +246,22 @@ const earthEngineLoader = async ({ legend, name, data, + keyAnalysisDigitGroupSeparator, filter, alerts, isLoaded: true, isLoading: false, isExpanded: true, - isVisible: true, + isVisible: config.isVisible ?? true, loadError, } } -export const createLegend = ({ min, max, palette, ranges }, showBelowMin) => { +export const createLegend = ( + { min, max, palette, ranges }, + showBelowMin, + keyAnalysisDigitGroupSeparator +) => { if (ranges && ranges.length === palette.length) { return sortLegendItems( ranges.map((range, index) => ({ @@ -246,16 +286,23 @@ export const createLegend = ({ min, max, palette, ranges }, showBelowMin) => { // Less than min item.from = -Infinity item.to = min - item.name = '< ' + min + item.name = + '< ' + + formatWithSeparator(min, keyAnalysisDigitGroupSeparator) to = min } else if (+from < max) { item.from = +from item.to = +to - item.name = from + ' - ' + to + item.name = + formatWithSeparator(from, keyAnalysisDigitGroupSeparator) + + ' - ' + + formatWithSeparator(to, keyAnalysisDigitGroupSeparator) } else { // Higher than max item.from = +from - item.name = '> ' + from + item.name = + '> ' + + formatWithSeparator(from, keyAnalysisDigitGroupSeparator) } from = to diff --git a/src/loaders/eventLoader.js b/src/loaders/eventLoader.js index f8dce4cdc..ed2c3d5f6 100644 --- a/src/loaders/eventLoader.js +++ b/src/loaders/eventLoader.js @@ -15,6 +15,7 @@ import { getPeriodNameFromId, } from '../util/analytics.js' import { cssColor, getContrastColor } from '../util/colors.js' +import { parseJsonConfig } from '../util/config.js' import { loadEventCoordinateFieldName } from '../util/coordinatesName.js' import { getAnalyticsRequest, loadData } from '../util/event.js' import { getBounds } from '../util/geojson.js' @@ -47,11 +48,15 @@ const eventLoader = async ({ config: layerConfig, engine, keyAnalysisDisplayProperty, + keyAnalysisDigitGroupSeparator, analyticsEngine, periodTypeData, loadExtended, }) => { - const config = { ...layerConfig } + const config = { + ...layerConfig, + keyAnalysisDigitGroupSeparator, + } const displayNameProp = keyAnalysisDisplayProperty === 'name' ? 'displayName' @@ -84,7 +89,7 @@ const eventLoader = async ({ config.isLoaded = true config.isLoading = false config.isExpanded = true - config.isVisible = true + config.isVisible = config.isVisible ?? true return config } @@ -97,6 +102,34 @@ const loadEventLayer = async ({ periodTypeData, loadExtended, }) => { + const { + countEventsWithoutCoordinates, + legendDecimalPlaces, + legendIsolated, + unclassifiedLegend, + noDataName, + } = parseJsonConfig(config.config) + if (countEventsWithoutCoordinates) { + config.countEventsWithoutCoordinates = true + } + if (legendDecimalPlaces !== undefined) { + config.legendDecimalPlaces = legendDecimalPlaces + } + if (legendIsolated !== undefined) { + config.legendIsolated = legendIsolated + } + if (unclassifiedLegend) { + config.unclassifiedLegend = unclassifiedLegend + } + if (config.noDataColor) { + config.noDataLegend = { + color: config.noDataColor, + ...(noDataName && { name: noDataName }), + } + delete config.noDataColor + } + delete config.config + const { columns, endDate, @@ -157,7 +190,7 @@ const loadEventLayer = async ({ if (!config.serverCluster) { config.outputIdScheme = 'ID' // Required for StyleByDataItem to work - const { data, response } = await loadData({ + const { data, response, dataWithoutCoords } = await loadData({ request: analyticsRequest, config, analyticsEngine, @@ -165,6 +198,9 @@ const loadEventLayer = async ({ const { total } = response.metaData.pager config.data = data + if (config.countEventsWithoutCoordinates) { + config.dataWithoutCoords = dataWithoutCoords + } if (Array.isArray(config.data) && config.data.length) { if (styleDataItem) { @@ -196,7 +232,8 @@ const loadEventLayer = async ({ const numericDataItemHeaders = config.headers.filter( (header) => isValidUid(header.name) && - numberValueTypes.includes(header.valueType) + numberValueTypes.includes(header.valueType) && + !header.optionSet ) if (numericDataItemHeaders.length) { @@ -243,20 +280,24 @@ const loadEventLayer = async ({ } if (!styleDataItem) { - const color = cssColor(eventPointColor) || EVENT_COLOR - const strokeColor = getContrastColor(color) - - config.legend.items = [ - { - name: i18n.t('Event'), - color, - strokeColor, - radius: eventPointRadius || EVENT_RADIUS, - count: - serverCount || - (Array.isArray(config?.data) ? config.data.length : 0), - }, - ] + const count = + serverCount || + (Array.isArray(config?.data) ? config.data.length : 0) + if (count > 0) { + const color = cssColor(eventPointColor) || EVENT_COLOR + const strokeColor = getContrastColor(color) + config.legend.items = [ + { + name: i18n.t('Event'), + color, + strokeColor, + radius: eventPointRadius || EVENT_RADIUS, + count: + serverCount || + (Array.isArray(config?.data) ? config.data.length : 0), + }, + ] + } } const explanation = [] @@ -273,6 +314,14 @@ const loadEventLayer = async ({ explanation.push(`${i18n.t('Buffer')}: ${areaRadius} ${'m'}`) } + if ( + config.countEventsWithoutCoordinates && + config.dataWithoutCoords?.length > 0 + ) { + config.legend.eventsWithoutCoordinatesCount = + config.dataWithoutCoords.length + } + if (explanation.length) { config.legend.explanation = explanation } diff --git a/src/loaders/externalLoader.js b/src/loaders/externalLoader.js index 678fbb83e..022596492 100644 --- a/src/loaders/externalLoader.js +++ b/src/loaders/externalLoader.js @@ -31,13 +31,13 @@ const externalLoader = async ({ config: layer, engine }) => { return { ...layer, layer: EXTERNAL_LAYER, - name: config.name, + name: config.name, // Overrides layer.name from spread — redundant on 2.42+ (DHIS2-16088), remove when 2.41 support is dropped legend, config, isLoaded: true, isLoading: false, isExpanded: true, - isVisible: true, + isVisible: layer.isVisible ?? true, } } diff --git a/src/loaders/facilityLoader.js b/src/loaders/facilityLoader.js index ec2eeff76..47eb1c0d4 100644 --- a/src/loaders/facilityLoader.js +++ b/src/loaders/facilityLoader.js @@ -5,6 +5,7 @@ import { CUSTOM_ALERT, } from '../constants/alerts.js' import { getOrgUnitsFromRows } from '../util/analytics.js' +import { parseJsonConfig } from '../util/config.js' import { toGeoJson } from '../util/map.js' import { ORG_UNITS_GROUP_SET_QUERY, @@ -13,9 +14,51 @@ import { getStyledOrgUnits, getCoordinateField, parseGroupSet, + getOrgUnitsWithoutCoordsCount, + addGroupCountsToLegend, } from '../util/orgUnits.js' import { GEOFEATURES_QUERY } from '../util/requests.js' +const fetchAndParseGroupSet = async (engine, groupSet) => { + try { + const orgUnitGroups = await engine.query(ORG_UNITS_GROUP_SET_QUERY, { + variables: { id: groupSet?.id }, + }) + const { groupSets } = orgUnitGroups + groupSet.organisationUnitGroups = parseGroupSet({ + organisationUnitGroups: groupSets.organisationUnitGroups, + }) + groupSet.name = groupSets.name + return null + } catch { + return i18n.t('GroupSet used for styling was not found') + } +} + +const fetchAssociatedGeometries = async ( + engine, + { + orgUnitIds, + keyAnalysisDisplayProperty, + includeGroupSets, + coordinateField, + userId, + } +) => { + const rawData = await engine.query(GEOFEATURES_QUERY, { + variables: { + orgUnitIds, + keyAnalysisDisplayProperty, + includeGroupSets, + coordinateField: coordinateField.id, + userId, + }, + }) + return rawData?.geoFeatures + ? toGeoJson(getPolygonItems(rawData.geoFeatures)) + : null +} + const facilityLoader = async ({ config, engine, @@ -24,6 +67,11 @@ const facilityLoader = async ({ baseUrl, }) => { const { rows, organisationUnitGroupSet: groupSet, areaRadius } = config + const { countOrgUnitsWithoutCoordinates } = parseJsonConfig(config.config) + if (countOrgUnitsWithoutCoordinates) { + config.countOrgUnitsWithoutCoordinates = true + } + delete config.config const orgUnits = getOrgUnitsFromRows(rows) const includeGroupSets = !!groupSet @@ -71,26 +119,8 @@ const facilityLoader = async ({ const features = data?.geoFeatures && toGeoJson(getPointItems(data.geoFeatures)) - // Load organisationUnitGroups if not passed - let orgUnitGroups if (includeGroupSets && !groupSet.organisationUnitGroups) { - try { - orgUnitGroups = await engine.query(ORG_UNITS_GROUP_SET_QUERY, { - variables: { - id: groupSet?.id, - }, - }) - } catch (err) { - loadError = i18n.t('GroupSet used for styling was not found') - } - } - - if (orgUnitGroups) { - const { groupSets } = orgUnitGroups - groupSet.organisationUnitGroups = parseGroupSet({ - organisationUnitGroups: groupSets.organisationUnitGroups, - }) - groupSet.name = groupSets.name + loadError = await fetchAndParseGroupSet(engine, groupSet) } const { styledFeatures, legend } = getStyledOrgUnits({ @@ -101,20 +131,33 @@ const facilityLoader = async ({ }) legend.title = name - if (coordinateField) { - const rawData = await engine.query(GEOFEATURES_QUERY, { - variables: { - orgUnitIds, - keyAnalysisDisplayProperty, - includeGroupSets, - coordinateField: coordinateField.id, - userId, - }, + if (groupSet?.id) { + addGroupCountsToLegend(legend.items, styledFeatures, groupSet) + } else if (legend.items[0]) { + legend.items[0].count = styledFeatures.length + } + + if (config.countOrgUnitsWithoutCoordinates) { + const { count, missingOrgUnits } = await getOrgUnitsWithoutCoordsCount({ + engine, + orgUnitIds, + userId, + features: features || [], }) + if (count > 0) { + legend.orgUnitsWithoutCoordinatesCount = count + config.dataWithoutCoords = missingOrgUnits + } + } - associatedGeometries = rawData?.geoFeatures - ? toGeoJson(getPolygonItems(rawData.geoFeatures)) - : null + if (coordinateField) { + associatedGeometries = await fetchAssociatedGeometries(engine, { + orgUnitIds, + keyAnalysisDisplayProperty, + includeGroupSets, + coordinateField, + userId, + }) if (!associatedGeometries.length) { alerts.push({ @@ -154,7 +197,7 @@ const facilityLoader = async ({ isLoaded: true, isLoading: false, isExpanded: true, - isVisible: true, + isVisible: config.isVisible ?? true, loadError, } } diff --git a/src/loaders/geoJsonUrlLoader.js b/src/loaders/geoJsonUrlLoader.js index b412f538c..4e6cd004f 100644 --- a/src/loaders/geoJsonUrlLoader.js +++ b/src/loaders/geoJsonUrlLoader.js @@ -45,7 +45,12 @@ const fetchData = async (url, engine, baseUrl) => { } const EMPTY_FEATURE_STYLE = {} -const geoJsonUrlLoader = async ({ config: layer, engine, baseUrl }) => { +const geoJsonUrlLoader = async ({ + config: layer, + engine, + baseUrl, + keyAnalysisDigitGroupSeparator, +}) => { const { config } = layer let newConfig @@ -114,15 +119,16 @@ const geoJsonUrlLoader = async ({ config: layer, engine, baseUrl }) => { return { ...layer, - name: newConfig.name, // TODO - will be fixed by DHIS2-16088 + name: newConfig.name, // Overrides layer.name from spread — redundant on 2.42+ (DHIS2-16088), remove when 2.41 support is dropped legend, data, + keyAnalysisDigitGroupSeparator, config: newConfig, featureStyle, isLoaded: true, isLoading: false, isExpanded: true, - isVisible: true, + isVisible: layer.isVisible ?? true, loadError, } } diff --git a/src/loaders/orgUnitLoader.js b/src/loaders/orgUnitLoader.js index 0c0c8b34f..5ccd44d12 100644 --- a/src/loaders/orgUnitLoader.js +++ b/src/loaders/orgUnitLoader.js @@ -6,6 +6,7 @@ import { ERROR_CRITICAL, } from '../constants/alerts.js' import { getOrgUnitsFromRows } from '../util/analytics.js' +import { parseJsonConfig } from '../util/config.js' import { toGeoJson } from '../util/map.js' import { ORG_UNITS_GROUP_SET_QUERY, @@ -13,9 +14,50 @@ import { getStyledOrgUnits, getCoordinateField, parseGroupSet, + getOrgUnitsWithoutCoordsCount, + fetchOrgUnitDetails, + addGroupCountsToLegend, } from '../util/orgUnits.js' import { GEOFEATURES_QUERY } from '../util/requests.js' +const fetchAssociatedGeometries = async ( + engine, + { + orgUnitIds, + keyAnalysisDisplayProperty, + includeGroupSets, + coordinateField, + userId, + } +) => { + const rawData = await engine.query(GEOFEATURES_QUERY, { + variables: { + orgUnitIds, + keyAnalysisDisplayProperty, + includeGroupSets, + coordinateField: coordinateField.id, + userId, + }, + }) + return rawData?.geoFeatures ? toGeoJson(rawData.geoFeatures) : null +} + +const fetchAndParseGroupSet = async (engine, groupSet) => { + try { + const orgUnitGroups = await engine.query(ORG_UNITS_GROUP_SET_QUERY, { + variables: { id: groupSet?.id }, + }) + const { groupSets } = orgUnitGroups + groupSet.organisationUnitGroups = parseGroupSet({ + organisationUnitGroups: groupSets.organisationUnitGroups, + }) + groupSet.name = groupSets.name + return null + } catch { + return i18n.t('GroupSet used for styling was not found') + } +} + const orgUnitLoader = async ({ config, engine, @@ -24,6 +66,11 @@ const orgUnitLoader = async ({ baseUrl, }) => { const { rows, organisationUnitGroupSet: groupSet } = config + const { countOrgUnitsWithoutCoordinates } = parseJsonConfig(config.config) + if (countOrgUnitsWithoutCoordinates) { + config.countOrgUnitsWithoutCoordinates = true + } + delete config.config const orgUnits = getOrgUnitsFromRows(rows) const includeGroupSets = !!groupSet @@ -61,18 +108,8 @@ const orgUnitLoader = async ({ const orgUnitLevels = await apiFetchOrganisationUnitLevels(engine) - // Load organisationUnitGroups if not passed - let orgUnitGroups if (includeGroupSets && !groupSet.organisationUnitGroups) { - try { - orgUnitGroups = await engine.query(ORG_UNITS_GROUP_SET_QUERY, { - variables: { - id: groupSet?.id, - }, - }) - } catch (err) { - loadError = i18n.t('GroupSet used for styling was not found') - } + loadError = await fetchAndParseGroupSet(engine, groupSet) } if (!mainFeatures.length && !alerts.length) { @@ -82,30 +119,16 @@ const orgUnitLoader = async ({ }) } - if (orgUnitGroups) { - const { groupSets } = orgUnitGroups - groupSet.organisationUnitGroups = parseGroupSet({ - organisationUnitGroups: groupSets.organisationUnitGroups, - }) - groupSet.name = groupSets.name - } - if (coordinateField) { - const rawData = await engine.query(GEOFEATURES_QUERY, { - variables: { - orgUnitIds, - keyAnalysisDisplayProperty, - includeGroupSets, - coordinateField: coordinateField.id, - userId, - }, + associatedGeometries = await fetchAssociatedGeometries(engine, { + orgUnitIds, + keyAnalysisDisplayProperty, + includeGroupSets, + coordinateField, + userId, }) - associatedGeometries = rawData?.geoFeatures - ? toGeoJson(rawData.geoFeatures) - : null - - if (!associatedGeometries.length) { + if (!associatedGeometries?.length) { alerts.push({ code: WARNING_NO_GEOMETRY_COORD, message: coordinateField.name, @@ -131,6 +154,49 @@ const orgUnitLoader = async ({ legend.title = name + legend.items.forEach((item) => (item.count = 0)) + + if (groupSet?.id) { + addGroupCountsToLegend(legend.items, mainFeatures, groupSet) + } else { + // Level items — match by level number using the orgUnitLevels array + mainFeatures.forEach((f) => { + const levelInfo = orgUnitLevels.find( + (l) => l.level === f.properties.level + ) + const item = + levelInfo && + legend.items.find((i) => i.name === levelInfo.displayName) + if (item) { + item.count++ + } + }) + } + + if (config.countOrgUnitsWithoutCoordinates) { + const { count, missingOrgUnits } = await getOrgUnitsWithoutCoordsCount({ + engine, + orgUnitIds, + userId, + features: mainFeatures || [], + }) + if (count > 0) { + legend.orgUnitsWithoutCoordinatesCount = count + const details = await fetchOrgUnitDetails( + engine, + missingOrgUnits.map((o) => o.id) + ) + config.dataWithoutCoords = missingOrgUnits.map((ou) => ({ + ...ou, + properties: { + ...ou.properties, + level: details[ou.id]?.level, + parentName: details[ou.id]?.parentName, + }, + })) + } + } + return { ...config, data: styledFeatures, @@ -140,7 +206,7 @@ const orgUnitLoader = async ({ isLoaded: true, isLoading: false, isExpanded: true, - isVisible: true, + isVisible: config.isVisible ?? true, loadError, } } diff --git a/src/loaders/thematicLoader.js b/src/loaders/thematicLoader.js index 860570a70..6b63d55c2 100644 --- a/src/loaders/thematicLoader.js +++ b/src/loaders/thematicLoader.js @@ -28,15 +28,19 @@ import { getApiResponseNames, } from '../util/analytics.js' import { getLegendItemForValue } from '../util/classify.js' +import { parseJsonConfig } from '../util/config.js' import { hasValue } from '../util/helpers.js' import { getPredefinedLegendItems, getAutomaticLegendItems, } from '../util/legend.js' import { toGeoJson } from '../util/map.js' +import { formatWithSeparator } from '../util/numbers.js' import { getCoordinateField, addAssociatedGeometries, + getOrgUnitsWithoutCoordsCount, + fetchOrgUnitDetails, } from '../util/orgUnits.js' import { LEGEND_SET_QUERY, GEOFEATURES_QUERY } from '../util/requests.js' import { trimTime, formatStartEndDate, getDateArray } from '../util/time.js' @@ -45,6 +49,7 @@ const thematicLoader = async ({ config, engine, keyAnalysisDisplayProperty, + keyAnalysisDigitGroupSeparator, userId, analyticsEngine, periodTypeData, @@ -57,9 +62,44 @@ const thematicLoader = async ({ colorScale, renderingStrategy = RENDERING_STRATEGY_SINGLE, thematicMapType, - noDataColor, } = config + const { + countOrgUnitsWithoutCoordinates, + legendDecimalPlaces, + legendIsolated, + unclassifiedLegend: unclassifiedLegendFromConfig, + noDataName, + } = parseJsonConfig(config.config) + if (countOrgUnitsWithoutCoordinates) { + config.countOrgUnitsWithoutCoordinates = true + } + if (legendDecimalPlaces !== undefined) { + config.legendDecimalPlaces = legendDecimalPlaces + } + if (legendIsolated !== undefined) { + config.legendIsolated = legendIsolated + } + // Reconstruct noData from schema field (saved favorites) or use redux state directly + if (config.noDataColor) { + config.noDataLegend = { + color: config.noDataColor, + ...(noDataName && { name: noDataName }), + } + delete config.noDataColor + } + // Reconstruct unclassifiedLegend from config JSON (saved favorites) + if (unclassifiedLegendFromConfig) { + config.unclassifiedLegend = unclassifiedLegendFromConfig + } + delete config.config + + const noData = config.noDataLegend + const unclassifiedLegend = config.unclassifiedLegend + + const orgUnitIds = getOrgUnitsFromRows(config.rows).map((item) => item.id) + let orgUnitsWithoutCoordsCount = 0 + const dataItem = getDataItemFromColumns(columns) const coordinateField = getCoordinateField(config) @@ -100,12 +140,45 @@ const thematicLoader = async ({ legend: null, isLoaded: true, isLoading: false, - isVisible: true, + isVisible: config.isVisible ?? true, loadError, } } const [mainFeatures, data, associatedGeometries] = response + if (config.countOrgUnitsWithoutCoordinates) { + const { count, missingOrgUnits } = await getOrgUnitsWithoutCoordsCount({ + engine, + orgUnitIds, + userId, + features: mainFeatures || [], + }) + orgUnitsWithoutCoordsCount = count + if (count > 0) { + const ouValues = getValueById(data) + const details = await fetchOrgUnitDetails( + engine, + missingOrgUnits.map((o) => o.id) + ) + config.dataWithoutCoords = missingOrgUnits.map((ou) => ({ + ...ou, + properties: { + ...ou.properties, + level: details[ou.id]?.level, + parentName: details[ou.id]?.parentName, + rawValue: ouValues[ou.id], + value: + ouValues[ou.id] === undefined + ? undefined + : formatWithSeparator( + ouValues[ou.id], + keyAnalysisDigitGroupSeparator + ), + }, + })) + } + } + const features = addAssociatedGeometries(mainFeatures, associatedGeometries) const isSingleMap = renderingStrategy === RENDERING_STRATEGY_SINGLE const isBubbleMap = thematicMapType === THEMATIC_BUBBLE @@ -132,7 +205,7 @@ const thematicLoader = async ({ const dimensions = getValidDimensionsFromFilters(config.filters) const valuesByPeriod = !isSingleMap ? getValuesByPeriod(data) : null const valueById = getValueById(data) - const valueFeatures = noDataColor + let valueFeatures = noData ? features : features.filter(({ id }) => valueById[id] !== undefined) const orderedValues = getOrderedValues(data) @@ -164,16 +237,41 @@ const thematicLoader = async ({ } let legendItems = [] + let valueFormat if (!isSingleColor) { - legendItems = legendSet - ? getPredefinedLegendItems(legendSet) - : getAutomaticLegendItems( - orderedValues, - method, - classes, - colorScale - ) + if (legendSet) { + legendItems = getPredefinedLegendItems(legendSet) + } else { + const classification = getAutomaticLegendItems({ + data: orderedValues, + method, + classes, + colorScale, + legendDecimalPlaces: config.legendDecimalPlaces, + legendIsolated: config.legendIsolated, + }) + legendItems = classification.items + valueFormat = classification.valueFormat + } + } else if (config.legendIsolated) { + const { min, max, color, name } = config.legendIsolated + legendItems = [ + { + startValue: min, + endValue: max, + color, + name, + isLegendIsolated: true, + }, + ] + const nonIsolatedValues = orderedValues.filter( + (v) => v < min || v > max + ) + if (nonIsolatedValues.length > 0) { + minValue = nonIsolatedValues[0] + maxValue = nonIsolatedValues[nonIsolatedValues.length - 1] + } } const legend = { @@ -188,6 +286,10 @@ const thematicLoader = async ({ items: legendItems, } + if (orgUnitsWithoutCoordsCount > 0) { + legend.orgUnitsWithoutCoordinatesCount = orgUnitsWithoutCoordsCount + } + if (dimensions && dimensions.length) { legend.filters = dimensions.map( (d) => @@ -197,10 +299,18 @@ const thematicLoader = async ({ ) } - if (noDataColor && Array.isArray(legend.items)) { + if (unclassifiedLegend && legendSet && Array.isArray(legend.items)) { legend.items.push({ - color: noDataColor, - name: i18n.t('No data'), + color: unclassifiedLegend.color, + name: unclassifiedLegend.name || i18n.t('Unclassified'), + outsideLegend: true, + }) + } + + if (noData && Array.isArray(legend.items)) { + legend.items.push({ + color: noData.color, + name: noData.name || i18n.t('No data'), noData: true, }) } @@ -216,19 +326,29 @@ const thematicLoader = async ({ minValue, maxValue, color: isSingleColor ? colorScale : null, + ...(config.legendDecimalPlaces !== undefined && { + legendDecimalPlaces: config.legendDecimalPlaces, + }), } } const getLegendItem = (value) => getLegendItemForValue({ value, - legendItems: legend.items.filter((item) => !item.noData), + valueFormat, + method, + legendItems: legend.items.filter( + (item) => !item.noData && !item.outsideLegend + ), clamp: !legendSet, }) if (legendSet && Array.isArray(legend.items) && legend.items.length >= 2) { - minValue = legend.items[0].startValue - maxValue = legend.items[legend.items.length - 1].endValue + const regularItems = legend.items.filter( + (i) => !i.noData && !i.outsideLegend + ) + minValue = regularItems[0].startValue + maxValue = regularItems.at(-1).endValue } const getRadiusForValue = scaleSqrt() @@ -257,6 +377,10 @@ const thematicLoader = async ({ }) } + const outsideLegendLegendItem = legend.items.find( + (i) => i.outsideLegend === true + ) + if (valuesByPeriod) { const periods = Object.keys(valuesByPeriod) periods.forEach((period) => { @@ -265,50 +389,123 @@ const thematicLoader = async ({ const item = valuesByPeriod[period][orgunit] const value = Number(item.value) const legendItem = getLegendItem(value) + const isOutsideLegend = + legendSet && !legendItem && !Number.isNaN(value) if (isSingleColor) { - item.color = colorScale + item.color = legendItem?.isLegendIsolated + ? legendItem.color + : colorScale } else if (legendItem) { item.color = legendItem.color + } else if (isOutsideLegend) { + if (outsideLegendLegendItem) { + item.color = outsideLegendLegendItem.color + } else { + item.outsideLegend = true + } } - item.radius = getRadiusForValue(value) + item.radius = + legendItem?.isLegendIsolated || isOutsideLegend + ? THEMATIC_RADIUS_DEFAULT + : getRadiusForValue(value) }) }) } else { const noDataLegendItem = legend.items.find((i) => i.noData === true) - valueFeatures.forEach(({ id, geometry, properties }) => { - const value = valueById[id] - const legendItem = getLegendItem(value) - const isPoint = geometry.type === 'Point' - const { hasAdditionalGeometry } = properties - + const filteredValueFeatures = [] + + const assignStyleProps = ( + properties, + { + value, + legendItem, + isPoint, + hasAdditionalGeometry, + isOutsideLegend, + } + ) => { if (isSingleColor) { - properties.color = hasValue(value) + const fallbackColor = hasValue(value) ? colorScale : noDataLegendItem?.color + properties.color = legendItem?.isLegendIsolated + ? legendItem.color + : fallbackColor } else if (legendItem) { properties.color = hasAdditionalGeometry && isPoint ? ORG_UNIT_COLOR : legendItem.color properties.legend = legendItem.name // Shown in data table - properties.range = `${legendItem.startValue} - ${legendItem.endValue}` // Shown in data table + const decimalPlacesOpt = + legendItem.decimalPlaces === undefined + ? undefined + : { precision: legendItem.decimalPlaces } + properties.range = `${formatWithSeparator( + legendItem.startValue, + keyAnalysisDigitGroupSeparator, + decimalPlacesOpt + )} - ${formatWithSeparator( + legendItem.endValue, + keyAnalysisDigitGroupSeparator, + decimalPlacesOpt + )}` // Shown in data table + } else if (isOutsideLegend) { + properties.color = outsideLegendLegendItem.color + properties.legend = outsideLegendLegendItem.name } + } + + valueFeatures.forEach(({ id, geometry, properties }) => { + const value = valueById[id] + const legendItem = getLegendItem(value) + const isPoint = geometry.type === 'Point' + const { hasAdditionalGeometry } = properties + const isOutsideLegend = legendSet && !legendItem && hasValue(value) + + if (isOutsideLegend && !outsideLegendLegendItem) { + return + } + + assignStyleProps(properties, { + value, + legendItem, + isPoint, + hasAdditionalGeometry, + isOutsideLegend, + }) // Only count org units once in legend if (!hasAdditionalGeometry) { - const targetItem = legendItem || noDataLegendItem + const targetItem = + legendItem || + (isOutsideLegend + ? outsideLegendLegendItem + : noDataLegendItem) if (targetItem) { targetItem.count++ } } - properties.value = value + properties.value = formatWithSeparator( + value, + keyAnalysisDigitGroupSeparator + ) // Shown in tooltip, label, pop-up and data table + properties.rawValue = value // Used in data table + const innerRadius = + legendItem?.isLegendIsolated || isOutsideLegend + ? THEMATIC_RADIUS_DEFAULT + : getRadiusForValue(value) || THEMATIC_RADIUS_DEFAULT properties.radius = hasAdditionalGeometry ? ORG_UNIT_RADIUS_SMALL - : getRadiusForValue(value) || THEMATIC_RADIUS_DEFAULT + : innerRadius + + filteredValueFeatures.push({ id, geometry, properties }) }) + + valueFeatures = filteredValueFeatures } return { @@ -316,6 +513,7 @@ const thematicLoader = async ({ data: valueFeatures, periods, valuesByPeriod, + keyAnalysisDigitGroupSeparator, name, legend, method, @@ -323,7 +521,7 @@ const thematicLoader = async ({ isLoaded: true, isLoading: false, isExpanded: true, - isVisible: true, + isVisible: config.isVisible ?? true, loadError, } } diff --git a/src/loaders/trackedEntityLoader.js b/src/loaders/trackedEntityLoader.js index db9fc6bbe..ed8816731 100644 --- a/src/loaders/trackedEntityLoader.js +++ b/src/loaders/trackedEntityLoader.js @@ -100,7 +100,7 @@ const TRACKED_ENTITY_TYPES_QUERY = { }, } -const trackedEntityLoader = async ({ config, engine, serverVersion }) => { +const parseJsonConfig = (config) => { if (config.config && typeof config.config === 'string') { try { const customConfig = JSON.parse(config.config) @@ -116,6 +116,113 @@ const trackedEntityLoader = async ({ config, engine, serverVersion }) => { } delete config.config } +} + +const fetchRelationshipData = async ({ + engine, + isVersion40, + instances, + relationshipTypeID, + orgUnits, + organisationUnitSelectionMode, + relatedPointColor, + relatedPointRadius, + relationshipLineColor, + legend, +}) => { + const { relationshipType } = await engine.query( + { relationshipType: RELATIONSHIP_TYPES_QUERY }, + { variables: { id: relationshipTypeID } } + ) + + const { relatedEntityType } = await engine.query( + { relatedEntityType: TRACKED_ENTITY_TYPES_QUERY }, + { + variables: { + id: relationshipType.toConstraint.trackedEntityType.id, + }, + } + ) + + const isPoint = + relatedEntityType.featureType === GEO_TYPE_POINT.toUpperCase() + + legend.items.push( + { + type: GEO_TYPE_LINE, + name: relationshipType.displayName, + color: relationshipLineColor || TEI_RELATIONSHIP_LINE_COLOR, + weight: 1, + }, + { + name: `${relatedEntityType.displayName} (${i18n.t('related')})`, + color: relatedPointColor || TEI_RELATED_COLOR, + radius: isPoint + ? relatedPointRadius || TEI_RELATED_RADIUS + : undefined, + weight: isPoint ? undefined : 1, + } + ) + + const dataWithRels = await getDataWithRelationships({ + isVersion40, + instances, + queryOptions: { + relationshipType, + orgUnits, + organisationUnitSelectionMode, + }, + engine, + }) + + return { + data: toGeoJson(dataWithRels.primary), + relationships: dataWithRels.relationships, + secondaryData: toGeoJson(dataWithRels.secondary), + } +} + +const buildQueryVariables = ({ + fields, + orgUnits, + orgUnitMode, + program, + programStatus, + followUp, + trackedEntityType, + periodType, + startDate, + endDate, +}) => { + const followUpBool = followUp ? 'TRUE' : 'FALSE' + const boolFollowUp = + program && followUp !== undefined ? followUpBool : undefined + + return { + fields, + orgUnits, + orgUnitMode, + program: program?.id, + programStatus, + followUp: boolFollowUp, + trackedEntityType: program ? undefined : trackedEntityType?.id, + enrollmentEnrolledAfter: + periodType === 'program' ? trimTime(startDate) : undefined, + enrollmentEnrolledBefore: + periodType === 'program' ? trimTime(endDate) : undefined, + updatedAfter: + periodType === 'program' ? undefined : trimTime(startDate), + updatedBefore: periodType === 'program' ? undefined : trimTime(endDate), + } +} + +const trackedEntityLoader = async ({ + config, + engine, + keyAnalysisDigitGroupSeparator, + serverVersion, +}) => { + parseJsonConfig(config) const { trackedEntityType, @@ -164,7 +271,6 @@ const trackedEntityLoader = async ({ config, engine, serverVersion }) => { const fieldsWithRelationships = [...fields, 'relationships'] let explanation - let boolFollowUp = undefined if (program && programStatus) { explanation = `${i18n.t('Program status')}: ${ @@ -172,30 +278,21 @@ const trackedEntityLoader = async ({ config, engine, serverVersion }) => { }` } - if (program && followUp !== undefined) { - boolFollowUp = followUp ? 'TRUE' : 'FALSE' - } - const { trackedEntities } = await engine.query( { trackedEntities: isVersion40 ? TEI_40_QUERY : TEI_41_QUERY }, { - variables: { + variables: buildQueryVariables({ fields: fieldsWithRelationships, - orgUnits: orgUnits, + orgUnits, orgUnitMode: organisationUnitSelectionMode, - program: program?.id, + program, programStatus, - followUp: boolFollowUp, - trackedEntityType: !program ? trackedEntityType?.id : undefined, - enrollmentEnrolledAfter: - periodType === 'program' ? trimTime(startDate) : undefined, - enrollmentEnrolledBefore: - periodType === 'program' ? trimTime(endDate) : undefined, - updatedAfter: - periodType !== 'program' ? trimTime(startDate) : undefined, - updatedBefore: - periodType !== 'program' ? trimTime(endDate) : undefined, - }, + followUp, + trackedEntityType, + periodType, + startDate, + endDate, + }), } ) @@ -216,63 +313,22 @@ const trackedEntityLoader = async ({ config, engine, serverVersion }) => { } } - let data, relationships, secondaryData + let data = toGeoJson(instances) + let relationships, secondaryData if (relationshipTypeID) { - const { relationshipType } = await engine.query( - { relationshipType: RELATIONSHIP_TYPES_QUERY }, - { - variables: { - id: relationshipTypeID, - }, - } - ) - - const { relatedEntityType } = await engine.query( - { relatedEntityType: TRACKED_ENTITY_TYPES_QUERY }, - { - variables: { - id: relationshipType.toConstraint.trackedEntityType.id, - }, - } - ) - - const isPoint = - relatedEntityType.featureType === GEO_TYPE_POINT.toUpperCase() - - legend.items.push( - { - type: GEO_TYPE_LINE, - name: relationshipType.displayName, - color: relationshipLineColor || TEI_RELATIONSHIP_LINE_COLOR, - weight: 1, - }, - { - name: `${relatedEntityType.displayName} (${i18n.t('related')})`, - color: relatedPointColor || TEI_RELATED_COLOR, - radius: isPoint - ? relatedPointRadius || TEI_RELATED_RADIUS - : undefined, - weight: !isPoint ? 1 : undefined, - } - ) - - const dataWithRels = await getDataWithRelationships({ + ;({ data, relationships, secondaryData } = await fetchRelationshipData({ + engine, isVersion40, instances, - queryOptions: { - relationshipType, - orgUnits, - organisationUnitSelectionMode, - }, - engine, - }) - - data = toGeoJson(dataWithRels.primary) - relationships = dataWithRels.relationships - secondaryData = toGeoJson(dataWithRels.secondary) - } else { - data = toGeoJson(instances) + relationshipTypeID, + orgUnits, + organisationUnitSelectionMode, + relatedPointColor, + relatedPointRadius, + relationshipLineColor, + legend, + })) } if (explanation) { @@ -283,6 +339,7 @@ const trackedEntityLoader = async ({ config, engine, serverVersion }) => { ...config, name, data, + keyAnalysisDigitGroupSeparator, relationships, secondaryData, legend, @@ -290,7 +347,7 @@ const trackedEntityLoader = async ({ config, engine, serverVersion }) => { isLoaded: true, isLoading: false, isExpanded: true, - isVisible: true, + isVisible: config.isVisible ?? true, } } diff --git a/src/reducers/layerEdit.js b/src/reducers/layerEdit.js index 687f22e65..bbebe9365 100644 --- a/src/reducers/layerEdit.js +++ b/src/reducers/layerEdit.js @@ -290,6 +290,15 @@ const layerEdit = (state = null, action) => { if (action.method !== CLASSIFICATION_PREDEFINED) { delete newState.legendSet + delete newState.unclassifiedLegend + } + + if (action.method === CLASSIFICATION_PREDEFINED) { + delete newState.legendDecimalPlaces + } + + if (action.method === CLASSIFICATION_PREDEFINED) { + delete newState.legendIsolated } if (newState.styleDataItem) { @@ -311,6 +320,28 @@ const layerEdit = (state = null, action) => { return newState + case types.LAYER_EDIT_LEGEND_DECIMAL_PLACES_SET: + newState = { ...state } + + if (action.legendDecimalPlaces === undefined) { + delete newState.legendDecimalPlaces + } else { + newState.legendDecimalPlaces = action.legendDecimalPlaces + } + + return newState + + case types.LAYER_EDIT_LEGEND_ISOLATED_SET: + newState = { ...state } + + if (action.legendIsolated === undefined) { + delete newState.legendIsolated + } else { + newState.legendIsolated = action.legendIsolated + } + + return newState + case types.LAYER_EDIT_EVENT_STATUS_SET: newState = { ...state } @@ -347,6 +378,12 @@ const layerEdit = (state = null, action) => { eventClustering: action.checked, } + case types.LAYER_EDIT_COUNT_EVENTS_WITHOUT_COORDS_SET: + return { + ...state, + countEventsWithoutCoordinates: action.checked, + } + case types.LAYER_EDIT_EVENT_POINT_RADIUS_SET: return { ...state, @@ -419,6 +456,12 @@ const layerEdit = (state = null, action) => { organisationUnitSelectionMode: action.payload, } + case types.LAYER_EDIT_ORGANISATION_UNIT_WITHOUT_COORDS_SET: + return { + ...state, + countOrgUnitsWithoutCoordinates: action.checked, + } + case types.LAYER_EDIT_BAND_SET: return { ...state, @@ -548,16 +591,22 @@ const layerEdit = (state = null, action) => { followUp: action.payload, } - case types.LAYER_EDIT_NO_DATA_COLOR_SET: + case types.LAYER_EDIT_NO_DATA_LEGEND_SET: newState = { ...state } - - // Default is to show no feature if (!action.payload) { - delete newState.noDataColor + delete newState.noDataLegend } else { - newState.noDataColor = action.payload + newState.noDataLegend = action.payload } + return newState + case types.LAYER_EDIT_UNCLASSIFIED_LEGEND_SET: + newState = { ...state } + if (action.payload) { + newState.unclassifiedLegend = action.payload + } else { + delete newState.unclassifiedLegend + } return newState case types.LAYER_EDIT_EARTH_ENGINE_PERIOD_SET: diff --git a/src/reducers/map.js b/src/reducers/map.js index 0f3b835e9..2fb94014e 100644 --- a/src/reducers/map.js +++ b/src/reducers/map.js @@ -240,6 +240,7 @@ const map = (state = defaultState, action) => { mapViews: [ ...state.mapViews, { + isVisible: true, ...action.payload, id: generateUid(), }, diff --git a/src/util/__tests__/classify.spec.js b/src/util/__tests__/classify.spec.js index 9db2e936f..703510ed5 100644 --- a/src/util/__tests__/classify.spec.js +++ b/src/util/__tests__/classify.spec.js @@ -1,6 +1,9 @@ import { CLASSIFICATION_EQUAL_INTERVALS, CLASSIFICATION_EQUAL_COUNTS, + CLASSIFICATION_STANDARD_DEVIATION, + CLASSIFICATION_LOGARITHMIC, + CLASSIFICATION_PRETTY_BREAKS, } from '../../constants/layers.js' import { getLegendItemForValue, getLegendItems } from '../classify.js' @@ -65,13 +68,74 @@ describe('getLegendItemForValue', () => { getLegendItemForValue({ value: -1, legendItems }) ).toBeUndefined() }) + + it('does not clamp values below isolated value to the isolated item', () => { + const isolatedItem = { + startValue: 15, + endValue: 15, + isLegendIsolated: true, + } + const itemsWithIsolated = [isolatedItem, ...legendItems] + // value=5 is below isolated=15 but should map to legendItems[0], not isolated + expect( + getLegendItemForValue({ + value: 5, + legendItems: itemsWithIsolated, + clamp: true, + }) + ).toEqual(legendItems[0]) + }) + + it('returns isolated item when isolated value is the minimum', () => { + const isolatedItem = { + startValue: 0, + endValue: 0, + isLegendIsolated: true, + } + const rangeItems = [ + { startValue: 1, endValue: 15 }, + { startValue: 15, endValue: 30 }, + ] + expect( + getLegendItemForValue({ + value: 0, + legendItems: [isolatedItem, ...rangeItems], + clamp: true, + }) + ).toEqual(isolatedItem) + }) + + it('returns isolated item when isolated value is the maximum', () => { + const isolatedItem = { + startValue: 30, + endValue: 30, + isLegendIsolated: true, + } + const rangeItems = [ + { startValue: 0, endValue: 15 }, + { startValue: 15, endValue: 29 }, + ] + expect( + getLegendItemForValue({ + value: 30, + legendItems: [isolatedItem, ...rangeItems], + clamp: true, + }) + ).toEqual(isolatedItem) + }) }) describe('getLegendItems', () => { it('returns equal intervals for CLASSIFICATION_EQUAL_INTERVALS', () => { const values = [0, 100] - const result = getLegendItems(values, CLASSIFICATION_EQUAL_INTERVALS, 4) - expect(result).toEqual([ + const { items } = getLegendItems( + values, + CLASSIFICATION_EQUAL_INTERVALS, + { + numClasses: 4, + } + ) + expect(items).toEqual([ { startValue: 0.0, endValue: 25.0 }, { startValue: 25.0, endValue: 50.0 }, { startValue: 50.0, endValue: 75.0 }, @@ -81,8 +145,10 @@ describe('getLegendItems', () => { it('returns quantiles for CLASSIFICATION_EQUAL_COUNTS', () => { const values = [1, 2, 3, 4, 5, 6] - const result = getLegendItems(values, CLASSIFICATION_EQUAL_COUNTS, 3) - expect(result).toEqual([ + const { items } = getLegendItems(values, CLASSIFICATION_EQUAL_COUNTS, { + numClasses: 3, + }) + expect(items).toEqual([ { startValue: 1.0, endValue: 3.0 }, { startValue: 3.0, endValue: 5.0 }, { startValue: 5.0, endValue: 6.0 }, @@ -90,7 +156,68 @@ describe('getLegendItems', () => { }) it('returns undefined if method is unknown', () => { - const result = getLegendItems([0, 100], 'UNKNOWN', 3) - expect(result).toBeUndefined() + const { items } = getLegendItems([0, 100], 'UNKNOWN', { numClasses: 3 }) + expect(items).toBeUndefined() + }) + + it('returns standard deviation classification', () => { + // values: mean=50, sd≈31.62, 5 classes → breaks at 50-2*sd, 50-sd, 50, 50+sd + const values = [0, 10, 20, 50, 80, 90, 100] + const { items } = getLegendItems( + values, + CLASSIFICATION_STANDARD_DEVIATION, + { numClasses: 5 } + ) + expect(items.length).toBeGreaterThanOrEqual(1) + expect(items.length).toBeLessThanOrEqual(5) + expect(items[0].startValue).toBe(0) + expect(items[items.length - 1].endValue).toBe(100) + }) + + it('returns logarithmic classification with equal number of items', () => { + const values = [1, 10, 100, 1000, 10000] + const { items } = getLegendItems(values, CLASSIFICATION_LOGARITHMIC, { + numClasses: 4, + }) + expect(items).toHaveLength(4) + expect(items[0].startValue).toBe(1) + expect(items[3].endValue).toBe(10000) + // Each class should span one order of magnitude on the log scale + expect(items[0].endValue).toBe(items[1].startValue) + }) + + it('falls back to equal intervals for logarithmic when min <= 0', () => { + const values = [0, 25, 50, 75, 100] + const { items: logItems } = getLegendItems( + values, + CLASSIFICATION_LOGARITHMIC, + { numClasses: 4 } + ) + const { items: equalItems } = getLegendItems( + values, + CLASSIFICATION_EQUAL_INTERVALS, + { numClasses: 4 } + ) + expect(logItems).toEqual(equalItems) + }) + + it('returns pretty breaks with round class boundaries', () => { + const values = [0, 100] + const { items } = getLegendItems(values, CLASSIFICATION_PRETTY_BREAKS, { + numClasses: 5, + }) + expect(items.length).toBeGreaterThanOrEqual(1) + expect(items.length).toBeLessThanOrEqual(5) + expect(items[0].startValue).toBe(0) + expect(items[items.length - 1].endValue).toBe(100) + }) + + it('pretty breaks internal boundaries are multiples of a round step', () => { + const values = [0, 100] + const { items } = getLegendItems(values, CLASSIFICATION_PRETTY_BREAKS, { + numClasses: 5, + }) + // Internal break should be at 20 (niceStep=20 for range=100, n=5) + expect(items[0].endValue).toBe(20) }) }) diff --git a/src/util/__tests__/favorites.spec.js b/src/util/__tests__/favorites.spec.js index c81c444f4..79505e740 100644 --- a/src/util/__tests__/favorites.spec.js +++ b/src/util/__tests__/favorites.spec.js @@ -16,8 +16,9 @@ describe('cleanMapConfig', () => { }) expect(cleanedConfig).toEqual( expect.objectContaining({ - basemap: 'thedefaultBasemap', - mapViews: [{ layer: 'layer1' }], + basemap: + '{"id":"thedefaultBasemap","opacity":0.9,"hidden":false}', + mapViews: [{ hidden: false, layer: 'layer1' }], name: 'my new map', }) ) @@ -38,7 +39,7 @@ describe('cleanMapConfig', () => { isVisible: true, opacity: 0.9, }, - mapViews: [{ layer: 'layer1' }], + mapViews: [{ hidden: false, layer: 'layer1' }], name: 'my new map', } @@ -48,8 +49,9 @@ describe('cleanMapConfig', () => { }) expect(cleanedConfig).toEqual( expect.objectContaining({ - basemap: 'myUniqueBasemap', - mapViews: [{ layer: 'layer1' }], + basemap: + '{"id":"myUniqueBasemap","opacity":0.9,"hidden":false}', + mapViews: [{ hidden: false, layer: 'layer1' }], name: 'my new map', }) ) @@ -176,10 +178,11 @@ describe('cleanMapConfig', () => { }) expect(cleanedConfig).toEqual({ - basemap: 'thedefaultBasemap', + basemap: '{"id":"thedefaultBasemap","opacity":1,"hidden":false}', mapViews: [ { areaRadius: 5000, + hidden: false, layer: 'earthEngine', name: 'Population', opacity: 0.9, @@ -274,10 +277,11 @@ describe('cleanMapConfig', () => { }) expect(cleanedConfig).toEqual({ - basemap: 'thedefaultBasemap', + basemap: '{"id":"thedefaultBasemap","opacity":1,"hidden":false}', mapViews: [ { config: '{"id":"CSYRWeK81E7","type":"geoJson","url":"https://debug.dhis2.org/analytics-dev/api/routes/aaa11122233/run","name":"Bo catchment areas","tms":false,"format":"image/png","featureStyle":{"color":"transparent","strokeColor":"#333333","weight":1,"pointSize":5}}', + hidden: false, layer: 'geoJsonUrl', name: 'Bo catchment areas', opacity: 1, @@ -382,11 +386,12 @@ describe('cleanMapConfig', () => { }) expect(cleanedConfig).toEqual({ - basemap: 'thedefaultBasemap', + basemap: '{"id":"thedefaultBasemap","opacity":1,"hidden":false}', mapViews: [ { startDate: '2018-02-19', endDate: '2024-02-19', + hidden: false, layer: 'trackedEntity', name: 'Tracked entity', opacity: 0.5, diff --git a/src/util/__tests__/getMigratedMapConfig.spec.js b/src/util/__tests__/getMigratedMapConfig.spec.js index dbd1c465f..1e125433b 100644 --- a/src/util/__tests__/getMigratedMapConfig.spec.js +++ b/src/util/__tests__/getMigratedMapConfig.spec.js @@ -36,6 +36,7 @@ test('getMigratedMapConfig when basemap in mapViews', () => { layer: 'thematic', id: 'thematic layer id', name: 'All the pretty colors', + isVisible: true, config: { mapLayerPosition: 'OVERLAY', }, @@ -62,8 +63,16 @@ test('getMigratedMapConfig when basemap is a string but not "none"', () => { name: 'map name', basemap: { id: 'TheRainbowBasemap' }, mapViews: [ - { layer: 'thematic', name: 'All the pretty colors' }, - { layer: 'facilities', name: 'All the facilities' }, + { + layer: 'thematic', + name: 'All the pretty colors', + isVisible: true, + }, + { + layer: 'facilities', + name: 'All the facilities', + isVisible: true, + }, ], }) ) @@ -75,8 +84,16 @@ test('getMigratedMapConfig when basemap is string "none"', () => { name: 'map name', basemap: 'none', mapViews: [ - { layer: 'thematic', name: 'All the pretty colors' }, - { layer: 'facilities', name: 'All the facilities' }, + { + layer: 'thematic', + name: 'All the pretty colors', + isVisible: true, + }, + { + layer: 'facilities', + name: 'All the facilities', + isVisible: true, + }, ], } @@ -89,8 +106,16 @@ test('getMigratedMapConfig when basemap is string "none"', () => { isVisible: false, }, mapViews: [ - { layer: 'thematic', name: 'All the pretty colors' }, - { layer: 'facilities', name: 'All the facilities' }, + { + layer: 'thematic', + name: 'All the pretty colors', + isVisible: true, + }, + { + layer: 'facilities', + name: 'All the facilities', + isVisible: true, + }, ], }) ) @@ -105,8 +130,16 @@ test('getMigratedMapConfig when basemap is an object', () => { displayName: 'Basemap name', }, mapViews: [ - { layer: 'thematic', name: 'All the pretty colors' }, - { layer: 'facilities', name: 'All the facilities' }, + { + layer: 'thematic', + name: 'All the pretty colors', + isVisible: true, + }, + { + layer: 'facilities', + name: 'All the facilities', + isVisible: true, + }, ], } @@ -116,8 +149,16 @@ test('getMigratedMapConfig when basemap is an object', () => { name: 'map name', basemap: { id: 'osmStreet', displayName: 'Basemap name' }, mapViews: [ - { layer: 'thematic', name: 'All the pretty colors' }, - { layer: 'facilities', name: 'All the facilities' }, + { + layer: 'thematic', + name: 'All the pretty colors', + isVisible: true, + }, + { + layer: 'facilities', + name: 'All the facilities', + isVisible: true, + }, ], }) ) @@ -128,8 +169,16 @@ test('getMigratedMapConfig when no basemap in config', () => { id: 'mapId', name: 'map name', mapViews: [ - { layer: 'thematic', name: 'All the pretty colors' }, - { layer: 'facilities', name: 'All the facilities' }, + { + layer: 'thematic', + name: 'All the pretty colors', + isVisible: true, + }, + { + layer: 'facilities', + name: 'All the facilities', + isVisible: true, + }, ], } @@ -139,8 +188,16 @@ test('getMigratedMapConfig when no basemap in config', () => { name: 'map name', basemap: { id: defaultBasemapId }, mapViews: [ - { layer: 'thematic', name: 'All the pretty colors' }, - { layer: 'facilities', name: 'All the facilities' }, + { + layer: 'thematic', + name: 'All the pretty colors', + isVisible: true, + }, + { + layer: 'facilities', + name: 'All the facilities', + isVisible: true, + }, ], }) ) @@ -153,9 +210,21 @@ test('getMigratedMapConfig with old GIS app format and Boundary layer', () => { name: 'map name', basemap: { id: 'osmStreet' }, mapViews: [ - { layer: 'thematic1', name: 'Thematic layer 1' }, - { layer: 'thematic2', name: 'Thematic layer 2' }, - { layer: 'boundary', name: 'Boundary layer' }, + { + layer: 'thematic1', + name: 'Thematic layer 1', + isVisible: true, + }, + { + layer: 'thematic2', + name: 'Thematic layer 2', + isVisible: true, + }, + { + layer: 'boundary', + name: 'Boundary layer', + isVisible: true, + }, ], } @@ -165,9 +234,21 @@ test('getMigratedMapConfig with old GIS app format and Boundary layer', () => { name: 'map name', basemap: { id: 'osmStreet' }, mapViews: [ - { layer: 'thematic', name: 'Thematic layer 2' }, - { layer: 'thematic', name: 'Thematic layer 1' }, - { layer: 'orgUnit', name: 'Boundary layer' }, + { + layer: 'thematic', + name: 'Thematic layer 2', + isVisible: true, + }, + { + layer: 'thematic', + name: 'Thematic layer 1', + isVisible: true, + }, + { + layer: 'orgUnit', + name: 'Boundary layer', + isVisible: true, + }, ], }) ) @@ -196,6 +277,7 @@ test('getMigratedMapConfig with colorScale with multiple values converted to an { layer: 'thematic', name: 'Thematic layer', + isVisible: true, colorScale: [ '#fee5d9', '#fcbba1', @@ -233,6 +315,7 @@ test('getMigratedMapConfig with colorScale with single value returns value', () { layer: 'thematic', name: 'Thematic layer', + isVisible: true, colorScale: '#fee5d9', }, ], diff --git a/src/util/__tests__/legend.spec.js b/src/util/__tests__/legend.spec.js index dd6c9e94f..26eca088b 100644 --- a/src/util/__tests__/legend.spec.js +++ b/src/util/__tests__/legend.spec.js @@ -6,6 +6,7 @@ import { import { defaultClasses, defaultColorScale } from '../colors.js' import { loadDataItemLegendSet, + sortLegendItems, formatLegendItems, getBinsFromLegendItems, getColorScaleFromLegendItems, @@ -88,20 +89,19 @@ describe('legend utils', () => { const result = getPredefinedLegendItems(legendSet) // sorted by startValue -> first item is startValue 0 (name 'A') expect(result[0].name).toBe('A') - // second item had name equal to range and should be cleared - expect(result[1].name).toBe('') + expect(result[1].name).toBe('10 - 20') }) }) describe('getAutomaticLegendItems', () => { it('returns items with colors from default color scale', () => { const data = [1, 2, 3, 4, 5] - const items = getAutomaticLegendItems( + const { items } = getAutomaticLegendItems({ data, - CLASSIFICATION_EQUAL_INTERVALS, - defaultClasses, - defaultColorScale - ) + method: CLASSIFICATION_EQUAL_INTERVALS, + classes: defaultClasses, + colorScale: defaultColorScale, + }) expect(items.length).toBeGreaterThan(0) // each item should have a color from the provided colorScale items.forEach((item, idx) => { @@ -110,16 +110,92 @@ describe('legend utils', () => { }) it('returns empty array when no data', () => { - const items = getAutomaticLegendItems( - [], - CLASSIFICATION_EQUAL_INTERVALS, - defaultClasses, - defaultColorScale - ) + const { items } = getAutomaticLegendItems({ + data: [], + method: CLASSIFICATION_EQUAL_INTERVALS, + classes: defaultClasses, + colorScale: defaultColorScale, + }) expect(items).toEqual([]) }) }) + describe('sortLegendItems', () => { + it('returns a new array, does not mutate input', () => { + const input = [ + { startValue: 0, endValue: 10 }, + { startValue: 20, endValue: 30 }, + ] + const result = sortLegendItems(input) + expect(result).not.toBe(input) + expect(input[0].startValue).toBe(0) + }) + + it('sorts from/to items descending by start', () => { + const items = [ + { from: 0, to: 10 }, + { from: 20, to: 30 }, + { from: 10, to: 20 }, + ] + const result = sortLegendItems(items) + expect(result.map((i) => i.from)).toEqual([20, 10, 0]) + }) + + it('sorts startValue/endValue items descending by start', () => { + const items = [ + { startValue: 0, endValue: 10 }, + { startValue: 20, endValue: 30 }, + { startValue: 10, endValue: 20 }, + ] + const result = sortLegendItems(items) + expect(result.map((i) => i.startValue)).toEqual([20, 10, 0]) + }) + + it('uses end as tiebreaker when start values are equal', () => { + const items = [ + { from: 10, to: 20 }, + { from: 10, to: 30 }, + ] + const result = sortLegendItems(items) + expect(result[0].to).toBe(30) + }) + + it('places no-range items last', () => { + const items = [ + { name: 'label only' }, + { startValue: 10, endValue: 20 }, + ] + const result = sortLegendItems(items) + expect(result[0].startValue).toBe(10) + expect(result[1].name).toBe('label only') + }) + + it('places isLegendIsolated items after regular, before no-range', () => { + const items = [ + { name: 'label only' }, + { startValue: 5, endValue: 10, isLegendIsolated: true }, + { startValue: 10, endValue: 20 }, + ] + const result = sortLegendItems(items) + expect(result[0].startValue).toBe(10) + expect(result[1].isLegendIsolated).toBe(true) + expect(result[2].name).toBe('label only') + }) + + it('sorts both-isolated items numerically', () => { + const items = [ + { startValue: 5, endValue: 10, isLegendIsolated: true }, + { startValue: 15, endValue: 20, isLegendIsolated: true }, + ] + const result = sortLegendItems(items) + expect(result[0].startValue).toBe(15) + }) + + it('handles empty array', () => { + expect(sortLegendItems([])).toEqual([]) + }) + }) + describe('getRenderingLabel', () => { it('returns label for split strategy', () => { const label = getRenderingLabel(RENDERING_STRATEGY_SPLIT_BY_PERIOD) diff --git a/src/util/__tests__/styleByDataItem.spec.js b/src/util/__tests__/styleByDataItem.spec.js index b09386fdc..058e0931d 100644 --- a/src/util/__tests__/styleByDataItem.spec.js +++ b/src/util/__tests__/styleByDataItem.spec.js @@ -18,7 +18,6 @@ const OPTION_SET_NAME = 'optionSetName' const LEGEND_SET_ID = 'legendSetId' const LEGEND_SET_NAME = 'legendSetName' const LEGEND_ITEM_EVENT = 'Event' -const LEGEND_ITEM_OTHER = 'Other' const NOTSET_VALUE = 'Not set' const SOME_VALUE = 'some value' @@ -135,9 +134,9 @@ describe('styleByDataItem', () => { { properties: { [STYLE_DATA_ITEM_ID]: 0.5 } }, { properties: { [STYLE_DATA_ITEM_ID]: 1.5 } }, { properties: { [STYLE_DATA_ITEM_ID]: 2.5 } }, - { properties: { [STYLE_DATA_ITEM_ID]: 3.5 } }, - { properties: {} }, - { properties: { [STYLE_DATA_ITEM_ID]: SOME_VALUE } }, + { properties: { [STYLE_DATA_ITEM_ID]: 3.5 } }, // outside legend range + { properties: {} }, // no data + { properties: { [STYLE_DATA_ITEM_ID]: SOME_VALUE } }, // non-numeric ], method: 1, legendSet: { id: LEGEND_SET_ID }, @@ -149,6 +148,8 @@ describe('styleByDataItem', () => { expect(mockEngine.query).toHaveBeenCalled() + // Only 3 features classified — outside-legend, no-data, non-numeric are filtered out + expect(result.data).toHaveLength(3) expect(result.data[0].properties).toMatchObject({ value: 0.5, color: 'green', @@ -161,18 +162,6 @@ describe('styleByDataItem', () => { value: 2.5, color: 'red', }) - expect(result.data[3].properties).toMatchObject({ - value: 3.5, - color: EVENT_COLOR, - }) - expect(result.data[4].properties).toMatchObject({ - value: NOTSET_VALUE, - color: EVENT_COLOR, - }) - expect(result.data[5].properties).toMatchObject({ - value: SOME_VALUE, - color: EVENT_COLOR, - }) expect(result.legend.items).toEqual( expect.arrayContaining([ @@ -200,17 +189,50 @@ describe('styleByDataItem', () => { radius: 5, count: 1, }), - expect.objectContaining({ - name: LEGEND_ITEM_OTHER, - color: EVENT_COLOR, - radius: 5, - count: 3, - }), ]) ) expect(result.legend.unit).toEqual(LEGEND_SET_NAME) }) + it('should apply numeric styling - predefined - with unclassifiedLegend and noDataLegend', async () => { + const unclassifiedLegend = { color: '#aaaaaa', name: 'Unclassified' } + const noDataLegend = { color: '#cccccc', name: 'No data' } + const config = { + styleDataItem: { + id: STYLE_DATA_ITEM_ID, + valueType: numberValueTypes[3], + }, + data: [ + { properties: { [STYLE_DATA_ITEM_ID]: 0.5 } }, + { properties: { [STYLE_DATA_ITEM_ID]: 3.5 } }, // outside legend + { properties: {} }, // no data + ], + method: 1, + legendSet: { id: LEGEND_SET_ID }, + eventPointRadius: 5, + legend: { items: [] }, + unclassifiedLegend, + noDataLegend, + } + + const result = await styleByDataItem(config, mockEngine) + + expect(result.data).toHaveLength(3) + expect(result.data[0].properties).toMatchObject({ + value: 0.5, + color: 'green', + }) + expect(result.data[1].properties).toMatchObject({ color: '#aaaaaa' }) + expect(result.data[2].properties).toMatchObject({ + value: NOTSET_VALUE, + color: '#cccccc', + }) + + const legendNames = result.legend.items.map((i) => i.name) + expect(legendNames).toContain('Unclassified') + expect(legendNames).toContain('No data') + }) + it('should apply numeric styling when valueType is a number - classification auto', async () => { const config = { styleDataItem: { @@ -221,8 +243,8 @@ describe('styleByDataItem', () => { { properties: { [STYLE_DATA_ITEM_ID]: 0 } }, { properties: { [STYLE_DATA_ITEM_ID]: 1 } }, { properties: { [STYLE_DATA_ITEM_ID]: 2 } }, - { properties: {} }, - { properties: { [STYLE_DATA_ITEM_ID]: SOME_VALUE } }, + { properties: {} }, // no data + { properties: { [STYLE_DATA_ITEM_ID]: SOME_VALUE } }, // non-numeric ], method: 2, classes: 3, @@ -235,6 +257,8 @@ describe('styleByDataItem', () => { expect(mockEngine.query).toHaveBeenCalled() + // Only 3 classified features — no-data and non-numeric are filtered out + expect(result.data).toHaveLength(3) expect(result.data[0].properties).toMatchObject({ value: 0, color: '#ff0000', @@ -247,14 +271,6 @@ describe('styleByDataItem', () => { value: 2, color: '#0000ff', }) - expect(result.data[3].properties).toMatchObject({ - value: NOTSET_VALUE, - color: EVENT_COLOR, - }) - expect(result.data[4].properties).toMatchObject({ - value: SOME_VALUE, - color: EVENT_COLOR, - }) expect(result.legend.items).toEqual( expect.arrayContaining([ @@ -279,12 +295,6 @@ describe('styleByDataItem', () => { radius: 5, count: 1, }), - expect.objectContaining({ - name: LEGEND_ITEM_OTHER, - color: EVENT_COLOR, - radius: 5, - count: 2, - }), ]) ) expect(result.legend.unit).toEqual(STYLE_DATA_ITEM_NAME) @@ -300,8 +310,8 @@ describe('styleByDataItem', () => { data: [ { properties: { [STYLE_DATA_ITEM_ID]: '1' } }, { properties: { [STYLE_DATA_ITEM_ID]: '0' } }, - { properties: {} }, - { properties: { [STYLE_DATA_ITEM_ID]: SOME_VALUE } }, + { properties: {} }, // no data — filtered without noDataLegend + { properties: { [STYLE_DATA_ITEM_ID]: SOME_VALUE } }, // unclassified — filtered without unclassifiedLegend ], legend: { items: [] }, eventPointRadius: 10, @@ -311,6 +321,8 @@ describe('styleByDataItem', () => { expect(mockEngine.query).toHaveBeenCalled() + // Only 2 features — no-data and unclassified are filtered out + expect(result.data).toHaveLength(2) expect(result.data[0].properties).toMatchObject({ value: 'Yes', color: 'red', @@ -319,14 +331,6 @@ describe('styleByDataItem', () => { value: 'No', color: 'blue', }) - expect(result.data[2].properties).toMatchObject({ - value: NOTSET_VALUE, - color: EVENT_COLOR, - }) - expect(result.data[3].properties).toMatchObject({ - value: SOME_VALUE, - color: EVENT_COLOR, - }) expect(result.legend.items).toEqual( expect.arrayContaining([ @@ -342,17 +346,51 @@ describe('styleByDataItem', () => { radius: 10, count: 1, }), - expect.objectContaining({ - name: LEGEND_ITEM_OTHER, - color: EVENT_COLOR, - radius: 10, - count: 2, - }), ]) ) + // No "Other" or "No data" items when unclassifiedLegend/noDataLegend are not set + expect(result.legend.items).toHaveLength(2) expect(result.legend.unit).toEqual(STYLE_DATA_ITEM_NAME) }) + it('should apply boolean styling - with unclassifiedLegend and noDataLegend', async () => { + const unclassifiedLegend = { color: '#aaaaaa' } + const noDataLegend = { color: '#cccccc' } + const config = { + styleDataItem: { + id: STYLE_DATA_ITEM_ID, + valueType: booleanValueTypes[0], + values: { true: 'red', false: 'blue' }, + }, + data: [ + { properties: { [STYLE_DATA_ITEM_ID]: '1' } }, + { properties: {} }, // no data + { properties: { [STYLE_DATA_ITEM_ID]: SOME_VALUE } }, // unclassified + ], + legend: { items: [] }, + eventPointRadius: 10, + unclassifiedLegend, + noDataLegend, + } + + const result = await styleByDataItem(config, mockEngine) + + expect(result.data).toHaveLength(3) + expect(result.data[0].properties).toMatchObject({ + value: 'Yes', + color: 'red', + }) + expect(result.data[1].properties).toMatchObject({ + value: NOTSET_VALUE, + color: '#cccccc', + }) + expect(result.data[2].properties).toMatchObject({ color: '#aaaaaa' }) + + const legendNames = result.legend.items.map((i) => i.name) + expect(legendNames).toContain('Unclassified') + expect(legendNames).toContain('No data') + }) + it('should handle option set styling correctly', async () => { const config = { styleDataItem: { @@ -365,8 +403,8 @@ describe('styleByDataItem', () => { data: [ { properties: { [STYLE_DATA_ITEM_ID]: 'Option 1' } }, { properties: { [STYLE_DATA_ITEM_ID]: 'Option 2' } }, - { properties: {} }, - { properties: { [STYLE_DATA_ITEM_ID]: SOME_VALUE } }, + { properties: {} }, // no data — filtered without noDataLegend + { properties: { [STYLE_DATA_ITEM_ID]: SOME_VALUE } }, // unclassified — filtered without unclassifiedLegend ], legend: { items: [] }, eventPointRadius: 8, @@ -376,6 +414,8 @@ describe('styleByDataItem', () => { expect(mockEngine.query).toHaveBeenCalled() + // Only 2 matched features — no-data and unclassified are filtered out + expect(result.data).toHaveLength(2) expect(result.data[0].properties).toMatchObject({ value: 'Option 1', color: 'green', @@ -384,14 +424,6 @@ describe('styleByDataItem', () => { value: 'Option 2', color: 'yellow', }) - expect(result.data[2].properties).toMatchObject({ - value: NOTSET_VALUE, - color: EVENT_COLOR, - }) - expect(result.data[3].properties).toMatchObject({ - value: SOME_VALUE, - color: EVENT_COLOR, - }) expect(result.legend.items).toEqual( expect.arrayContaining([ @@ -407,13 +439,50 @@ describe('styleByDataItem', () => { radius: 8, count: 1, }), - expect.objectContaining({ - name: LEGEND_ITEM_OTHER, - color: '#333333', - radius: 8, - }), ]) ) + // No "Other" or "No data" items when unclassifiedLegend/noDataLegend are not set + expect(result.legend.items).toHaveLength(2) expect(result.legend.unit).toEqual(OPTION_SET_NAME) }) + + it('should handle option set styling - with unclassifiedLegend and noDataLegend', async () => { + const unclassifiedLegend = { color: '#aaaaaa' } + const noDataLegend = { color: '#cccccc' } + const config = { + styleDataItem: { + id: STYLE_DATA_ITEM_ID, + optionSet: { + id: OPTION_SET_ID, + options: [{ id: '1' }, { id: '2' }], + }, + }, + data: [ + { properties: { [STYLE_DATA_ITEM_ID]: 'Option 1' } }, + { properties: {} }, // no data + { properties: { [STYLE_DATA_ITEM_ID]: SOME_VALUE } }, // unclassified + ], + legend: { items: [] }, + eventPointRadius: 8, + unclassifiedLegend, + noDataLegend, + } + + const result = await styleByDataItem(config, mockEngine) + + expect(result.data).toHaveLength(3) + expect(result.data[0].properties).toMatchObject({ + value: 'Option 1', + color: 'green', + }) + expect(result.data[1].properties).toMatchObject({ + value: NOTSET_VALUE, + color: '#cccccc', + }) + expect(result.data[2].properties).toMatchObject({ color: '#aaaaaa' }) + + const legendNames = result.legend.items.map((i) => i.name) + expect(legendNames).toContain('Unclassified') + expect(legendNames).toContain('No data') + }) }) diff --git a/src/util/__tests__/time.spec.js b/src/util/__tests__/time.spec.js index 8510ca8cd..acb8cd4ae 100644 --- a/src/util/__tests__/time.spec.js +++ b/src/util/__tests__/time.spec.js @@ -20,7 +20,7 @@ const currentYear = new Date().getFullYear() // https://stackoverflow.com/questions/1353684/detecting-an-invalid-date-date-instance-in-javascript const isValidDateString = (str) => { const d = new Date(str) - return d instanceof Date && !isNaN(d.getTime()) + return d instanceof Date && !Number.isNaN(d.getTime()) } describe('time utils', () => { diff --git a/src/util/bubbles.js b/src/util/bubbles.js index 8151d9528..292b455f3 100644 --- a/src/util/bubbles.js +++ b/src/util/bubbles.js @@ -9,17 +9,36 @@ import { getContrastColor } from './colors.js' import { getLongestTextLength } from './helpers.js' import { getRoundToPrecisionFn } from './numbers.js' +const getValueFormat = ({ + minValue, + maxValue, + divisor, + legendDecimalPlaces, +}) => { + if (legendDecimalPlaces !== undefined) { + return (n) => Number(n).toFixed(legendDecimalPlaces) + } + if (minValue === maxValue) { + return (n) => n.toString() + } + const precision = precisionRound((maxValue - minValue) / divisor, maxValue) + return (n) => getRoundToPrecisionFn(precision)(n).toString() +} + export const createBubbleItems = ({ classes, minValue, maxValue, scale, radiusHigh, + legendDecimalPlaces, }) => { - const binSize = (maxValue - minValue) / classes.length - const precision = precisionRound(binSize, maxValue) - const valueFormat = (n) => getRoundToPrecisionFn(precision)(n).toString() - + const valueFormat = getValueFormat({ + minValue, + maxValue, + divisor: classes.length, + legendDecimalPlaces, + }) const startValue = classes[0].startValue const endValue = classes[classes.length - 1].endValue const itemScale = scale.domain([startValue, endValue]) @@ -47,13 +66,29 @@ export const createSingleColorBubbles = ({ scale, radiusLow, radiusHigh, + legendDecimalPlaces, }) => { - const binSize = (maxValue - minValue) / 3 - const precision = precisionRound(binSize, maxValue) - const valueFormat = (n) => getRoundToPrecisionFn(precision)(n).toString() - const stroke = color && getContrastColor(color) const itemScale = scale.domain([minValue, maxValue]) + + if (minValue === maxValue) { + return [ + { + radius: itemScale(minValue), + maxRadius: radiusHigh, + color, + stroke, + text: minValue.toString(), + }, + ] + } + + const valueFormat = getValueFormat({ + minValue, + maxValue, + divisor: 3, + legendDecimalPlaces, + }) const midValue = (maxValue + minValue) / 2 return [ diff --git a/src/util/classify.js b/src/util/classify.js index bc11ae5c9..922f482c1 100644 --- a/src/util/classify.js +++ b/src/util/classify.js @@ -1,8 +1,15 @@ // Utils for thematic mapping import { precisionRound } from 'd3-format' +import { ckmeans, mean, standardDeviation } from 'simple-statistics' import { CLASSIFICATION_EQUAL_INTERVALS, CLASSIFICATION_EQUAL_COUNTS, + CLASSIFICATION_NATURAL_BREAKS_RANGES, + CLASSIFICATION_NATURAL_BREAKS_CLUSTERS, + CLASSIFICATION_STANDARD_DEVIATION, + CLASSIFICATION_LOGARITHMIC, + CLASSIFICATION_PRETTY_BREAKS, + getClassificationTypes, } from '../constants/layers.js' import { hasValue } from './helpers.js' import { getRoundToPrecisionFn } from './numbers.js' @@ -10,105 +17,320 @@ import { getRoundToPrecisionFn } from './numbers.js' // Returns legend item where a value belongs export const getLegendItemForValue = ({ value, + valueFormat, + method, legendItems, clamp = false, }) => { if (!hasValue(value) || legendItems.length === 0) { return } + if (valueFormat) { + value = valueFormat(value) + } + + const isolatedItem = legendItems.find( + (item) => + item.isLegendIsolated && + value >= item.startValue && + value <= item.endValue + ) + if (isolatedItem) { + return isolatedItem + } if (clamp) { - if (value < legendItems[0].startValue) { - return legendItems[0] + const rangeItems = legendItems.filter((item) => !item.isLegendIsolated) + if (rangeItems.length > 0 && value < rangeItems[0].startValue) { + return rangeItems[0] } - if (value > legendItems[legendItems.length - 1].endValue) { - return legendItems[legendItems.length - 1] + if ( + rangeItems.length > 0 && + value > rangeItems[rangeItems.length - 1].endValue + ) { + return rangeItems[rangeItems.length - 1] } } + const isClusters = method === CLASSIFICATION_NATURAL_BREAKS_CLUSTERS + const isLast = (index) => index === legendItems.length - 1 - return legendItems.find( - (item, index) => - value >= item.startValue && - (value < item.endValue || (isLast(index) && value == item.endValue)) + return legendItems.find((item, index) => + item.startValue == item.endValue + ? value == item.startValue + : value >= item.startValue && + (value < item.endValue || + (value === item.endValue && (isClusters || isLast(index)))) ) } -export const getLegendItems = (values, method, numClasses) => { +export const getLegendItems = ( + values, + method, + { numClasses, precision } = {} +) => { + if ( + !getClassificationTypes() + .map(({ id }) => id) + .includes(method) + ) { + return {} + } const minValue = values[0] const maxValue = values[values.length - 1] - let bins + if (minValue === maxValue) { + return { + items: [{ startValue: minValue, endValue: maxValue }], + } + } + + const distinctValues = [...new Set(values)] + const k = Math.min(numClasses, distinctValues.length) + const hasInsufficientValues = k < numClasses + const methodHandlesInsufficientValues = [ + CLASSIFICATION_EQUAL_INTERVALS, + CLASSIFICATION_STANDARD_DEVIATION, + CLASSIFICATION_LOGARITHMIC, + CLASSIFICATION_PRETTY_BREAKS, + ].includes(method) + if (hasInsufficientValues && !methodHandlesInsufficientValues) { + method = CLASSIFICATION_NATURAL_BREAKS_CLUSTERS + } + + let classification if (method === CLASSIFICATION_EQUAL_INTERVALS) { - bins = getEqualIntervals(minValue, maxValue, numClasses) + classification = getEqualIntervals(minValue, maxValue, { + numClasses, + precision, + }) } else if (method === CLASSIFICATION_EQUAL_COUNTS) { - bins = getQuantiles(values, numClasses) + classification = getQuantiles(values, { numClasses: k, precision }) + } else if (method === CLASSIFICATION_NATURAL_BREAKS_RANGES) { + classification = getCkMeans(values, { + numClasses: k, + continuous: true, + precision, + }) + } else if (method === CLASSIFICATION_NATURAL_BREAKS_CLUSTERS) { + classification = getCkMeans(values, { + numClasses: k, + continuous: false, + precision, + }) + } else if (method === CLASSIFICATION_STANDARD_DEVIATION) { + classification = getStandardDeviation(values, { numClasses, precision }) + } else if (method === CLASSIFICATION_LOGARITHMIC) { + if (minValue <= 0) { + classification = getEqualIntervals(minValue, maxValue, { + numClasses, + precision, + }) + } else { + classification = getLogarithmic(minValue, maxValue, { + numClasses, + precision, + }) + } + } else if (method === CLASSIFICATION_PRETTY_BREAKS) { + classification = getPrettyBreaks(minValue, maxValue, { + numClasses, + precision, + }) } - return bins + return { + items: classification.items.filter( + (bin, index, arr) => + index === 0 || + bin.startValue !== arr[index - 1].startValue || + bin.endValue !== arr[index - 1].endValue + ), + valueFormat: classification.valueFormat, + } } -// This function is not in use, but keeping it -// just in case it's needed in the future -// export const getClassBins = (values, method, numClasses) => { -// const minValue = values[0] -// const maxValue = values[values.length - 1] -// let bins - -// if (method === CLASSIFICATION_EQUAL_INTERVALS) { -// bins = getEqualIntervals(minValue, maxValue, numClasses) -// } else if (method === CLASSIFICATION_EQUAL_COUNTS) { -// bins = getQuantiles(values, numClasses) -// } - -// return bins -// } - -const getEqualIntervals = (minValue, maxValue, numClasses) => { - const bins = [] +const getEqualIntervals = (minValue, maxValue, { numClasses, precision }) => { + const items = [] const binSize = (maxValue - minValue) / numClasses - const precision = precisionRound(binSize, maxValue) - const valueFormat = getRoundToPrecisionFn(precision) + const resolvedPrecision = precision ?? precisionRound(binSize, maxValue) + const valueFormat = getRoundToPrecisionFn(resolvedPrecision) for (let i = 0; i < numClasses; i++) { const startValue = minValue + i * binSize const endValue = i < numClasses - 1 ? startValue + binSize : maxValue - bins.push({ + items.push({ startValue: valueFormat(startValue), endValue: valueFormat(endValue), }) } - return bins + return { + items, + valueFormat, + } } -const getQuantiles = (values, numClasses) => { +const getQuantiles = (values, { numClasses, precision }) => { const minValue = values[0] const maxValue = values[values.length - 1] - const bins = [] + const items = [] const binCount = values.length / numClasses - const precision = precisionRound( - (maxValue - minValue) / numClasses, - maxValue - ) - const valueFormat = getRoundToPrecisionFn(precision) + const resolvedPrecision = + precision ?? + precisionRound((maxValue - minValue) / numClasses, maxValue) + const valueFormat = getRoundToPrecisionFn(resolvedPrecision) let binLastValPos = binCount === 0 ? 0 : binCount if (values.length > 0) { - bins[0] = minValue + items[0] = minValue for (let i = 1; i < numClasses; i++) { - bins[i] = values[Math.round(binLastValPos)] + items[i] = values[Math.round(binLastValPos)] binLastValPos += binCount } } // bin can be undefined if few values - return bins - .filter((bin) => bin !== undefined) - .map((value, index) => ({ - startValue: valueFormat(value), - endValue: valueFormat(bins[index + 1] || maxValue), - })) + return { + items: items + .filter((bin) => bin !== undefined) + .map((value, index) => ({ + startValue: valueFormat(value), + endValue: valueFormat(items[index + 1] || maxValue), + })), + valueFormat, + } +} + +const getCkMeans = (values, { numClasses, continuous = false, precision }) => { + const minValue = values[0] + const maxValue = values[values.length - 1] + const resolvedPrecision = + precision ?? + precisionRound((maxValue - minValue) / numClasses, maxValue) + const valueFormat = getRoundToPrecisionFn(resolvedPrecision) + + const k = Math.min(numClasses, values.length) + const clusters = ckmeans(values, k) + + if (continuous) { + const boundaries = [ + minValue, + ...clusters + .slice(0, -1) + .map( + (cluster, i) => + (cluster[cluster.length - 1] + clusters[i + 1][0]) / 2 + ), + maxValue, + ] + return { + items: clusters.map((_, index) => ({ + startValue: valueFormat(boundaries[index]), + endValue: valueFormat(boundaries[index + 1]), + })), + valueFormat, + } + } + + return { + items: clusters.map((cluster) => ({ + startValue: valueFormat(cluster[0]), + endValue: valueFormat(cluster[cluster.length - 1]), + })), + valueFormat, + } +} + +const getStandardDeviation = (values, { numClasses, precision }) => { + const minValue = values[0] + const maxValue = values[values.length - 1] + const mu = mean(values) + const sigma = standardDeviation(values) + const resolvedPrecision = + precision ?? + precisionRound((maxValue - minValue) / numClasses, maxValue) + const valueFormat = getRoundToPrecisionFn(resolvedPrecision) + + // Place numClasses-1 internal breaks at 1-sigma intervals centered on mean + const internalBreaks = [] + for (let i = 0; i < numClasses - 1; i++) { + const b = mu + (-Math.floor(numClasses / 2) + i) * sigma + if (b > minValue && b < maxValue) { + internalBreaks.push(b) + } + } + + const allBreaks = [minValue, ...internalBreaks, maxValue] + return { + items: allBreaks.slice(0, -1).map((start, i) => ({ + startValue: valueFormat(start), + endValue: valueFormat(allBreaks[i + 1]), + })), + valueFormat, + } +} + +const getLogarithmic = (minValue, maxValue, { numClasses, precision }) => { + const logMin = Math.log(minValue) + const logMax = Math.log(maxValue) + const logStep = (logMax - logMin) / numClasses + const resolvedPrecision = + precision ?? + precisionRound((maxValue - minValue) / numClasses, maxValue) + const valueFormat = getRoundToPrecisionFn(resolvedPrecision) + + const items = [] + for (let i = 0; i < numClasses; i++) { + const startValue = Math.exp(logMin + i * logStep) + const endValue = + i < numClasses - 1 ? Math.exp(logMin + (i + 1) * logStep) : maxValue + items.push({ + startValue: valueFormat(startValue), + endValue: valueFormat(endValue), + }) + } + + return { items, valueFormat } +} + +const getPrettyBreaks = (minValue, maxValue, { numClasses, precision }) => { + const range = maxValue - minValue + const roughStep = range / numClasses + const magnitude = Math.pow(10, Math.floor(Math.log10(roughStep))) + const niceSteps = [1, 2, 5, 10].map((n) => n * magnitude) + const niceStep = niceSteps.reduce( + (best, step) => + Math.abs(step - roughStep) < Math.abs(best - roughStep) + ? step + : best, + niceSteps[0] + ) + + const resolvedPrecision = + precision ?? + precisionRound((maxValue - minValue) / numClasses, maxValue) + const valueFormat = getRoundToPrecisionFn(resolvedPrecision) + + // Collect internal breaks at niceStep intervals, capped at numClasses-1 + const internalBreaks = [] + let b = Math.ceil(minValue / niceStep) * niceStep + if (b === minValue) { + b += niceStep + } + while (b < maxValue && internalBreaks.length < numClasses - 1) { + internalBreaks.push(b) + b += niceStep + } + + const allBreaks = [minValue, ...internalBreaks, maxValue] + return { + items: allBreaks.slice(0, -1).map((start, i) => ({ + startValue: valueFormat(start), + endValue: valueFormat(allBreaks[i + 1]), + })), + valueFormat, + } } diff --git a/src/util/colors.js b/src/util/colors.js index 360dffd36..5fafda63c 100644 --- a/src/util/colors.js +++ b/src/util/colors.js @@ -74,7 +74,7 @@ export const getUniqueColor = (defaultColors) => { const colors = [...defaultColors] function randomColor() { - const color = '#000000'.replace(/0/g, () => + const color = '#000000'.replaceAll('0', () => (~~(Math.random() * 16)).toString(16) ) diff --git a/src/util/config.js b/src/util/config.js new file mode 100644 index 000000000..8fd602ffa --- /dev/null +++ b/src/util/config.js @@ -0,0 +1,10 @@ +export const parseJsonConfig = (jsonString) => { + if (!jsonString || typeof jsonString !== 'string') { + return {} + } + try { + return JSON.parse(jsonString) + } catch { + return {} + } +} diff --git a/src/util/dataDownload.js b/src/util/dataDownload.js index 648d876a3..298edc173 100644 --- a/src/util/dataDownload.js +++ b/src/util/dataDownload.js @@ -25,7 +25,7 @@ export const getFormatOptions = () => [ // Other layers will include layer name after aggregation type export const addPropNames = (layer, data) => { const { aggregationType, name, legend } = layer - const layerName = name.replace(/ /g, '_').toLowerCase() + const layerName = name.replaceAll(' ', '_').toLowerCase() const { items } = legend return hasClasses(aggregationType) @@ -48,7 +48,7 @@ export const addPropNames = (layer, data) => { // Replaces anything that's not a letter, number or space // Multiple spaces is replaced by a single space in the last replace export const standardizeFilename = (name, ext) => - `${name.replace(/[^a-z0-9 ]/gi, '').replace(/ +/g, ' ')}.${ext}` + `${name.replaceAll(/[^a-z0-9 ]/gi, '').replaceAll(/ +/g, ' ')}.${ext}` export const createGeoJsonBlob = (data) => { const geojson = { diff --git a/src/util/date.js b/src/util/date.js index 39f860697..c9f3ec3ab 100644 --- a/src/util/date.js +++ b/src/util/date.js @@ -209,7 +209,7 @@ export const getCurrentYearInCalendar = (calendar) => { } export function replaceAt(str, index, replacement) { - const cleanReplacement = replacement.replace(/\D/g, '') + const cleanReplacement = replacement.replaceAll(/\D/g, '') if (index >= str.length) { return str + cleanReplacement } @@ -255,7 +255,7 @@ export const formatDateInput = ({ finalHyphen = '-' } - const numericDate = date.replace(/\D/g, '') + const numericDate = date.replaceAll(/\D/g, '') const year = numericDate.slice(0, 4) const month = numericDate.slice(4, 6) diff --git a/src/util/earthEngine.js b/src/util/earthEngine.js index c34ad7a0a..74e1ee762 100644 --- a/src/util/earthEngine.js +++ b/src/util/earthEngine.js @@ -119,7 +119,7 @@ export const getPeriodFromFilter = (filter, datasetId) => { // Remove non-digits from periodId (needed for backward compatibility for population layers saved before 2.41) if (!isNightTimeLights && nonDigits.test(periodId)) { - periodId = Number(periodId.replace(nonDigits, '')) // Remove non-digits + periodId = Number(periodId.replaceAll(nonDigits, '')) // Remove non-digits } return { diff --git a/src/util/event.js b/src/util/event.js index 52366960c..45879546a 100644 --- a/src/util/event.js +++ b/src/util/event.js @@ -72,6 +72,7 @@ export const getAnalyticsRequest = async ( fallbackCoordinateField, relativePeriodDate, isExtended, + countEventsWithoutCoordinates, }, { nameProperty, engine, analyticsEngine } ) => { @@ -99,7 +100,7 @@ export const getAnalyticsRequest = async ( let analyticsRequest = new analyticsEngine.request() .withProgram(program.id) .withStage(programStage.id) - .withCoordinatesOnly(true) + .withCoordinatesOnly(!countEventsWithoutCoordinates) analyticsRequest = period ? analyticsRequest.addPeriodFilter(period.id) @@ -157,11 +158,15 @@ export const loadData = async ({ request.withPageSize(pageSize) ) // DHIS2-10742 - const { data, names } = createEventFeatures(response, config) + const { data, names, dataWithoutCoords } = createEventFeatures( + response, + config + ) return { data, names, response, + dataWithoutCoords, } } diff --git a/src/util/favorites.js b/src/util/favorites.js index 9b5271264..79bc5f473 100644 --- a/src/util/favorites.js +++ b/src/util/favorites.js @@ -1,13 +1,17 @@ import { isNil, omitBy, pick, isObject, omit } from 'lodash/fp' import { + THEMATIC_LAYER, + EVENT_LAYER, + TRACKED_ENTITY_LAYER, + FACILITY_LAYER, + ORG_UNIT_LAYER, EARTH_ENGINE_LAYER, GEOJSON_URL_LAYER, - TRACKED_ENTITY_LAYER, } from '../constants/layers.js' // TODO: get latitude, longitude, zoom from map + basemap: 'none' const validMapProperties = [ - 'basemap', + // 'basemap' and 'basemaps' removed — set exclusively by getBasemapPayload 'id', 'latitude', 'longitude', @@ -38,6 +42,7 @@ const validLayerProperties = [ 'endDate', 'eventCoordinateField', 'eventClustering', + 'countEventsWithoutCoordinates', 'eventPointColor', 'eventPointRadius', 'eventStatus', @@ -52,19 +57,24 @@ const validLayerProperties = [ 'labelFontWeight', 'labelFontColor', 'labelTemplate', + 'legendDecimalPlaces', + 'legendIsolated', 'lastUpdated', 'layer', 'layerId', 'legendSet', 'method', 'name', - 'noDataColor', + 'noDataLegend', + 'unclassifiedLegend', 'opacity', + 'hidden', 'organisationUnitColor', 'organisationUnitGroupSet', 'organisationUnitSelectionMode', 'orgUnitField', 'orgUnitFieldDisplayName', + 'countOrgUnitsWithoutCoordinates', 'style', 'period', 'periodType', @@ -101,29 +111,48 @@ const validModelProperties = [ export const cleanMapConfig = ({ config, defaultBasemapId, + serverVersion, cleanMapviewConfig = true, }) => ({ ...omitBy(isNil, pick(validMapProperties, config)), - basemap: getBasemapString(config.basemap, defaultBasemapId), + ...getBasemapPayload(config.basemap, defaultBasemapId, serverVersion), mapViews: config.mapViews.map((view) => cleanLayerConfig(view, cleanMapviewConfig) ), }) -const getBasemapString = (basemap, defaultBasemapId) => { - if (!basemap) { - return defaultBasemapId +// VERSION-TOGGLE: https://dhis2.atlassian.net/browse/DHIS2-20417 +const getBasemapPayload = (basemap, defaultBasemapId, serverVersion) => { + if (serverVersion?.minor >= 43) { + return { + basemaps: [ + { + id: basemap?.id || defaultBasemapId, + opacity: basemap?.opacity ?? 1, + hidden: basemap?.isVisible === false, + }, + ], + } } - if (basemap.isVisible === false) { - return 'none' + // Legacy: store as JSON to preserve opacity and id when hidden + return { + basemap: JSON.stringify({ + id: basemap?.id || defaultBasemapId, + opacity: basemap?.opacity ?? 1, + hidden: basemap?.isVisible === false, + }), } - - return basemap.id || defaultBasemapId } const cleanLayerConfig = (layer, cleanMapviewConfig) => ({ - ...models2objects(pick(validLayerProperties, layer), cleanMapviewConfig), + ...models2objects( + pick(validLayerProperties, { + ...layer, + hidden: layer.isVisible === false, + }), + cleanMapviewConfig + ), }) // TODO: This feels hacky, find better way to clean map configs before saving @@ -140,9 +169,48 @@ const models2objects = (layer, cleanMapviewConfig) => { layer.rows = layer.rows.map(cleanDimension) } - if (layerType === EARTH_ENGINE_LAYER) { + if ( + layerType === THEMATIC_LAYER || + layerType === ORG_UNIT_LAYER || + layerType === FACILITY_LAYER + ) { + const configData = {} + if (cleanMapviewConfig && layer.countOrgUnitsWithoutCoordinates) { + configData.countOrgUnitsWithoutCoordinates = true + } + if (layer.legendDecimalPlaces !== undefined) { + configData.legendDecimalPlaces = layer.legendDecimalPlaces + } + if (layer.legendIsolated !== undefined) { + configData.legendIsolated = layer.legendIsolated + } + if (layer.unclassifiedLegend) { + configData.unclassifiedLegend = layer.unclassifiedLegend + } + if (layer.noDataLegend) { + layer.noDataColor = layer.noDataLegend.color + if (layer.noDataLegend.name) { + configData.noDataName = layer.noDataLegend.name + } + } + if (Object.keys(configData).length) { + layer.config = JSON.stringify(configData) + } + delete layer.countOrgUnitsWithoutCoordinates + delete layer.legendDecimalPlaces + delete layer.legendIsolated + delete layer.unclassifiedLegend + delete layer.noDataLegend + } else if (layerType === EARTH_ENGINE_LAYER) { if (cleanMapviewConfig) { - const { layerId: id, band, style, aggregationType, period } = layer + const { + layerId: id, + band, + style, + aggregationType, + period, + countOrgUnitsWithoutCoordinates, + } = layer const eeConfig = { id, @@ -150,12 +218,14 @@ const models2objects = (layer, cleanMapviewConfig) => { band, aggregationType, period, + countOrgUnitsWithoutCoordinates, } // Removes undefined keys before stringify Object.keys(eeConfig).forEach( (key) => eeConfig[key] === undefined && delete eeConfig[key] ) + layer.config = JSON.stringify(eeConfig) } @@ -168,6 +238,7 @@ const models2objects = (layer, cleanMapviewConfig) => { delete layer.periodType delete layer.aggregationType delete layer.band + delete layer.countOrgUnitsWithoutCoordinates } else if (layerType === TRACKED_ENTITY_LAYER) { if (cleanMapviewConfig) { layer.config = JSON.stringify({ @@ -191,6 +262,34 @@ const models2objects = (layer, cleanMapviewConfig) => { delete layer.relationshipLineColor delete layer.relationshipOutsideProgram delete layer.periodType + } else if (layerType === EVENT_LAYER) { + const configData = {} + if (cleanMapviewConfig && layer.countEventsWithoutCoordinates) { + configData.countEventsWithoutCoordinates = true + } + if (layer.legendDecimalPlaces !== undefined) { + configData.legendDecimalPlaces = layer.legendDecimalPlaces + } + if (layer.legendIsolated !== undefined) { + configData.legendIsolated = layer.legendIsolated + } + if (layer.unclassifiedLegend) { + configData.unclassifiedLegend = layer.unclassifiedLegend + } + if (layer.noDataLegend) { + layer.noDataColor = layer.noDataLegend.color + if (layer.noDataLegend.name) { + configData.noDataName = layer.noDataLegend.name + } + } + if (Object.keys(configData).length) { + layer.config = JSON.stringify(configData) + } + delete layer.countEventsWithoutCoordinates + delete layer.legendDecimalPlaces + delete layer.legendIsolated + delete layer.unclassifiedLegend + delete layer.noDataLegend } else if (layerType === GEOJSON_URL_LAYER) { if (cleanMapviewConfig) { layer.config = { diff --git a/src/util/geojson.js b/src/util/geojson.js index c5f18ba41..5f1e78168 100644 --- a/src/util/geojson.js +++ b/src/util/geojson.js @@ -65,7 +65,10 @@ export const createEventFeature = ({ export const buildEventGeometryGetter = (headers) => { const geomCol = findIndex(headers, (h) => h.name === 'geometry') - return (event) => JSON.parse(event[geomCol]) + return (event) => { + const value = event[geomCol] + return value ? JSON.parse(value) : null + } } export const createEventFeatures = (response, config = {}) => { @@ -88,8 +91,11 @@ export const createEventFeatures = (response, config = {}) => { ) const options = Object.values(response.metaData.items) - const data = response.rows.map((row) => - createEventFeature({ + const data = [] + const dataWithoutCoords = [] + + response.rows.forEach((row) => { + const feature = createEventFeature({ headers: response.headers, names: config.outputIdScheme !== 'ID' ? names : {}, options, @@ -98,7 +104,12 @@ export const createEventFeatures = (response, config = {}) => { getGeometry, geometryCentroid: config.geometryCentroid, }) - ) + if (feature.geometry) { + data.push(feature) + } else { + dataWithoutCoords.push(feature) + } + }) // Sort to draw polygons before points data.sort((feature) => @@ -109,7 +120,7 @@ export const createEventFeatures = (response, config = {}) => { : 0 ) - return { data, names } + return { data, names, dataWithoutCoords } } // Include column for data element used for styling (if not already used in filter) @@ -241,7 +252,7 @@ export const buildGeoJsonFeatures = (geoJson) => { const types = [] const featureCollection = finalGeoJson.features.map((f, i) => { - const nonMultiType = f.geometry.type.replace('Multi', '') + const nonMultiType = f.geometry.type.replaceAll('Multi', '') // return list of types in the data (but not Multi* types, // because those should get lumped in with the non-Multi* type for the legend) if (!types.includes(nonMultiType)) { diff --git a/src/util/getMigratedMapConfig.js b/src/util/getMigratedMapConfig.js index 6d213e7a7..2e143f76e 100644 --- a/src/util/getMigratedMapConfig.js +++ b/src/util/getMigratedMapConfig.js @@ -21,11 +21,30 @@ const extractBasemap = (config, defaultBasemapId) => { mapViews = config.mapViews.filter( (view) => view.id !== externalBasemap.id ) + } else if (Array.isArray(config.basemaps) && config.basemaps.length > 0) { + const { id, opacity, hidden } = config.basemaps[0] + basemap = { + id: id || defaultBasemapId, + opacity: opacity ?? 1, + isVisible: !hidden, + } } else if (isString(config.basemap)) { - basemap = - config.basemap === 'none' - ? { id: defaultBasemapId, isVisible: false } - : { id: config.basemap } + if (config.basemap === 'none') { + basemap = { id: defaultBasemapId, isVisible: false } + } else { + try { + // JSON-encoded config with opacity and id when hidden + const parsed = JSON.parse(config.basemap) + basemap = { + id: parsed.id || defaultBasemapId, + opacity: parsed.opacity ?? 1, + isVisible: !parsed.hidden, + } + } catch { + // Plain basemap ID saved before JSON encoding + basemap = { id: config.basemap } + } + } } else if (isObject(config.basemap)) { basemap = config.basemap } else { @@ -79,43 +98,41 @@ const upgradeGisAppLayers = (config) => { } const upgradeMapViews = (config) => { - const needsUpgrade = config.mapViews.some( + const needsLegacyUpgrade = config.mapViews.some( (view) => view.layer === 'boundary' || typeof view.colorScale === 'string' || view.geometryCentroid === undefined ) - if (!needsUpgrade) { - return config - } - const upgradedViews = config.mapViews.map((view) => { let layer = view.layer - if (layer === 'boundary') { - layer = 'orgUnit' - } - - if ( - view.geometryCentroid === undefined && - view.layer === 'event' && - !EVENT_CENTROID_DEFAULT.includes(view.eventCoordinateField) - ) { - // We should test !EVENT_CENTROID_DEFAULT.includes(view.eventCoordinateFieldType) but it is not currently saved with the mapView. - // This will set geometryCentroid: true when eventCoordinateField is a DE/TEA of type 'COORDINATE' too, which is unnecessary but harmless. - view.geometryCentroid = true - } - let colorScale = view.colorScale - if (typeof colorScale === 'string') { - const parts = colorScale.split(',') - colorScale = parts.length === 1 ? parts[0] : parts + + if (needsLegacyUpgrade) { + if (layer === 'boundary') { + layer = 'orgUnit' + } + if ( + view.geometryCentroid === undefined && + view.layer === 'event' && + !EVENT_CENTROID_DEFAULT.includes(view.eventCoordinateField) + ) { + // We should test !EVENT_CENTROID_DEFAULT.includes(view.eventCoordinateFieldType) but it is not currently saved with the mapView. + // This will set geometryCentroid: true when eventCoordinateField is a DE/TEA of type 'COORDINATE' too, which is unnecessary but harmless. + view.geometryCentroid = true + } + if (typeof colorScale === 'string') { + const parts = colorScale.split(',') + colorScale = parts.length === 1 ? parts[0] : parts + } } return { ...view, layer, colorScale, + isVisible: view.hidden !== true, } }) diff --git a/src/util/helpers.js b/src/util/helpers.js index 31a2a0e8a..402535dd2 100644 --- a/src/util/helpers.js +++ b/src/util/helpers.js @@ -6,8 +6,10 @@ import { dateValueTypes, datetimeValueTypes, coordinateValueTypes, + numberValueTypes, ouValueTypes, } from '../constants/valueTypes.js' +import { formatWithSeparator } from './numbers.js' const getBaseFields = (withSubscribers) => { const baseFields = [ @@ -21,6 +23,7 @@ const getBaseFields = (withSubscribers) => { 'latitude', 'zoom', 'basemap', + 'basemaps', 'created', 'lastUpdated', 'access', @@ -144,7 +147,7 @@ export const formatCoordinate = (value) => { if ( Array.isArray(array) && array.length === 2 && - array.every((v) => !isNaN(Number(v))) + array.every((v) => !Number.isNaN(Number(v))) ) { return array.map((v) => Number(v).toFixed(6)).join(', ') } @@ -193,6 +196,7 @@ export const formatValueForDisplay = ({ valueType, options, orgUnitNames, + keyAnalysisDigitGroupSeparator, }) => { if (!hasValue(value)) { return i18n.t('Not set') @@ -224,7 +228,11 @@ export const formatValueForDisplay = ({ if (datetimeValueTypes.includes(valueType)) { return formatDatetime(value) } - // TODO formatNumeric + if (numberValueTypes.includes(valueType)) { + return formatWithSeparator(value, keyAnalysisDigitGroupSeparator, { + force: true, + }) + } return value } @@ -246,5 +254,5 @@ export const getCssVar = (cssVar) => Number( getComputedStyle(document.documentElement) .getPropertyValue(cssVar) - .replace('px', '') + .replaceAll('px', '') ) diff --git a/src/util/legend.js b/src/util/legend.js index 52a881f45..183297118 100644 --- a/src/util/legend.js +++ b/src/util/legend.js @@ -12,6 +12,7 @@ import { } from '../constants/layers.js' import { getLegendItems } from '../util/classify.js' import { defaultClasses, defaultColorScale } from '../util/colors.js' +import { parseWithSeparator } from './numbers.js' const INDICATOR_QUERY = { dimension: { @@ -43,14 +44,41 @@ const DATA_SET_QUERY = { }, } +const getRange = (item) => { + if ('from' in item) { + return { start: item.from, end: item.to } + } + if ('startValue' in item) { + return { start: item.startValue, end: item.endValue } + } + return null +} + export const sortLegendItems = (items) => - items.sort((a, b) => { - if ('from' in a) { - return b.from - a.from + [...items].sort((a, b) => { + const aRange = getRange(a) + const bRange = getRange(b) + + if (!aRange && !bRange) { + return 0 } - if ('startValue' in a) { - return b.startValue - a.startValue + if (!aRange) { + return 1 } + if (!bRange) { + return -1 + } + + if (a.isLegendIsolated && !b.isLegendIsolated) { + return 1 + } + if (!a.isLegendIsolated && b.isLegendIsolated) { + return -1 + } + + return bRange.start === aRange.start + ? bRange.end - aRange.end + : bRange.start - aRange.start }) export const loadDataItemLegendSet = async (dataItem, engine) => { @@ -88,6 +116,11 @@ export const loadDataItemLegendSet = async (dataItem, engine) => { return result.dimension.legendSet } +export const parseRange = (str) => { + const [start, end] = str.split(' - ') + return [parseWithSeparator(start), parseWithSeparator(end)] +} + export const formatLegendItems = (legendItems) => { const sortedItems = sortBy('startValue', legendItems) return sortedItems.map((item) => ({ @@ -120,30 +153,79 @@ export const getLabelsFromLegendItems = (legendItems) => { export const getPredefinedLegendItems = (legendSet) => { const pickSome = pick(['name', 'startValue', 'endValue', 'color']) - return sortBy('startValue', legendSet.legends) - .map(pickSome) - .map((item) => - item.name === `${item.startValue} - ${item.endValue}` - ? { ...item, name: '' } // Clear name if same as startValue - endValue - : item - ) + return sortBy('startValue', legendSet.legends).map(pickSome) } -/* eslint-disable max-params */ -export const getAutomaticLegendItems = ( +export const getAutomaticLegendItems = ({ data, method = CLASSIFICATION_EQUAL_INTERVALS, classes = defaultClasses, - colorScale = defaultColorScale -) => { - const items = data.length ? getLegendItems(data, method, classes) : [] + colorScale = defaultColorScale, + legendDecimalPlaces, + legendIsolated, +}) => { + if (data.length === 0) { + return { items: [] } + } - return items.map((item, index) => ({ + const applyColor = (item, color) => ({ ...item, - color: colorScale[index], - })) + color, + ...(legendDecimalPlaces !== undefined && { + decimalPlaces: legendDecimalPlaces, + }), + }) + + const { + min: isolatedMin, + max: isolatedMax, + color: isolatedColor, + name: isolatedName, + } = legendIsolated ?? {} + + if (isolatedMin !== undefined) { + const nonIsolatedData = data.filter( + (v) => v < isolatedMin || v > isolatedMax + ) + const isolatedItem = applyColor( + { + startValue: isolatedMin, + endValue: isolatedMax, + isLegendIsolated: true, + ...(isolatedName && { name: isolatedName }), + }, + isolatedColor || colorScale[0] + ) + + if (nonIsolatedData.length === 0) { + return { items: [isolatedItem] } + } + + const cls = getLegendItems(nonIsolatedData, method, { + numClasses: classes, + precision: legendDecimalPlaces, + }) + const colorOffset = isolatedColor ? 0 : 1 + return { + items: [ + isolatedItem, + ...cls.items.map((item, i) => + applyColor(item, colorScale[colorOffset + i]) + ), + ], + valueFormat: cls.valueFormat, + } + } + + const cls = getLegendItems(data, method, { + numClasses: classes, + precision: legendDecimalPlaces, + }) + return { + items: cls.items.map((item, i) => applyColor(item, colorScale[i])), + valueFormat: cls.valueFormat, + } } -/* eslint-enable max-params */ export const getRenderingLabel = (strategy) => { const map = { @@ -152,3 +234,35 @@ export const getRenderingLabel = (strategy) => { } return map[strategy] ? ' • ' + map[strategy] : null } + +const normalize = (str) => String(str).replaceAll(/[\s,]/g, '') + +const nameContainsValue = (name, val) => { + const normalizedName = normalize(name) + const normalizedVal = normalize(val) + return new RegExp(String.raw`(? + (String(startValue) !== '' && nameContainsValue(name, startValue)) || + (String(endValue) !== '' && nameContainsValue(name, endValue)) + +export const legendNamesContainRange = (items) => { + const numericItems = items.filter( + ({ startValue, endValue }) => + !Number.isNaN(startValue) && !Number.isNaN(endValue) + ) + + if (!numericItems.length) { + return false + } + + const itemsWithRange = numericItems.filter( + ({ name = '', startValue, endValue }) => + rangeInName(name, startValue, endValue) + ) + + return itemsWithRange.length / numericItems.length >= 0.5 +} diff --git a/src/util/numbers.js b/src/util/numbers.js index eda577aa4..31ac10cb6 100644 --- a/src/util/numbers.js +++ b/src/util/numbers.js @@ -57,3 +57,38 @@ export const getPrecision = (values = []) => { return 0 } + +const DIGIT_GROUP_SEPARATORS = { + SPACE: ' ', + COMMA: ',', + NONE: '', +} + +export const formatWithSeparator = ( + value, + separator, + { force = false, precision } = {} +) => { + if (!force && typeof value !== 'number') { + return value + } + const sep = DIGIT_GROUP_SEPARATORS[separator] ?? '' + const formatted = + precision === undefined + ? String(value) + : Number(value).toFixed(precision) + const [integer, decimal] = formatted.split('.') + const isNegative = integer.startsWith('-') + const digits = isNegative ? integer.slice(1) : integer + const groups = [] + for (let i = digits.length; i > 0; i -= 3) { + groups.unshift(digits.slice(Math.max(0, i - 3), i)) + } + const grouped = (isNegative ? '-' : '') + groups.join(sep) + return decimal ? `${grouped}.${decimal}` : grouped +} + +export const parseWithSeparator = (value) => { + const num = Number(String(value).replaceAll(/[\s,]/g, '')) + return Number.isNaN(num) ? undefined : num +} diff --git a/src/util/orgUnits.js b/src/util/orgUnits.js index 2217db204..5d0096ce4 100644 --- a/src/util/orgUnits.js +++ b/src/util/orgUnits.js @@ -10,6 +10,7 @@ import { NONE, } from '../constants/layers.js' import { getUniqueColor } from './colors.js' +import { FIRST_DATA_ELEMENT_QUERY, ORG_UNITS_COUNT_QUERY } from './requests.js' const getGroupColor = (groups) => { const groupsWithoutColors = groups.filter((g) => !g.color) @@ -191,7 +192,9 @@ export const getStyledOrgUnits = ({ styledFeatures, legend: { unit: name, - items: [...levelItems, ...groupItems, ...facilityItems], + items: groupItems.length + ? groupItems + : [...levelItems, ...facilityItems], }, } } @@ -261,3 +264,90 @@ export const addAssociatedGeometries = (mainFeatures, associatedGeometries) => { }) .concat(associatedGeometries) } + +export const getOrgUnitsWithoutCoordsCount = async ({ + engine, + orgUnitIds, + userId, + features = [], +}) => { + try { + const deResult = await engine.query(FIRST_DATA_ELEMENT_QUERY) + const dataElementId = deResult?.dataElements?.dataElements?.[0]?.id + if (!dataElementId) { + return { count: 0, missingOrgUnits: [] } + } + + const countData = await engine.query(ORG_UNITS_COUNT_QUERY, { + variables: { dataElementId, orgUnitIds, userId }, + }) + const allOuIds = + countData?.orgUnitsCount?.metaData?.dimensions?.ou || [] + const metaItems = countData?.orgUnitsCount?.metaData?.items || {} + + const featureIds = new Set(features.map((f) => f.id)) + const missingOrgUnits = allOuIds + .filter((id) => !featureIds.has(id)) + .map((id) => ({ + id, + properties: { id, name: metaItems[id]?.name || id }, + })) + + return { count: missingOrgUnits.length, missingOrgUnits } + } catch { + return { count: 0, missingOrgUnits: [] } + } +} + +const OU_DETAILS_QUERY = { + orgUnits: { + resource: 'organisationUnits', + params: ({ ids }) => ({ + filter: `id:in:[${ids.join(',')}]`, + fields: 'id,level,parent[displayName~rename(name)]', + paging: false, + }), + }, +} + +const OU_DETAILS_BATCH_SIZE = 100 + +export const fetchOrgUnitDetails = async (engine, ids) => { + if (!ids.length) { + return {} + } + + const batches = [] + for (let i = 0; i < ids.length; i += OU_DETAILS_BATCH_SIZE) { + batches.push(ids.slice(i, i + OU_DETAILS_BATCH_SIZE)) + } + + const results = await Promise.all( + batches.map((batch) => + engine.query(OU_DETAILS_QUERY, { variables: { ids: batch } }) + ) + ) + + return results.reduce((acc, result) => { + ;(result.orgUnits.organisationUnits || []).forEach((ou) => { + acc[ou.id] = { level: ou.level, parentName: ou.parent?.name } + }) + return acc + }, {}) +} + +export const addGroupCountsToLegend = (legendItems, features, groupSet) => { + legendItems.forEach((item) => (item.count = 0)) + features.forEach((f) => { + const groupId = f.properties?.dimensions?.[groupSet.id] + const group = groupSet.organisationUnitGroups?.find( + (g) => g.id === groupId + ) + if (group) { + const item = legendItems.find((i) => i.name === group.name) + if (item) { + item.count++ + } + } + }) +} diff --git a/src/util/requests.js b/src/util/requests.js index e569b00b5..fdc46e608 100644 --- a/src/util/requests.js +++ b/src/util/requests.js @@ -143,3 +143,23 @@ export const GEOFEATURES_QUERY = { }), }, } + +export const FIRST_DATA_ELEMENT_QUERY = { + dataElements: { + resource: 'dataElements', + params: { pageSize: 1, fields: 'id' }, + }, +} + +export const ORG_UNITS_COUNT_QUERY = { + orgUnitsCount: { + resource: 'analytics', + params: ({ dataElementId, orgUnitIds, userId }) => ({ + dimension: `dx:${dataElementId},ou:${orgUnitIds.join(';')}`, + filter: 'pe:THIS_YEAR', + skipData: true, + skipMeta: false, + _: userId, + }), + }, +} diff --git a/src/util/styleByDataItem.js b/src/util/styleByDataItem.js index e75b2c672..95d32f55a 100644 --- a/src/util/styleByDataItem.js +++ b/src/util/styleByDataItem.js @@ -64,8 +64,14 @@ const styleByDefault = async (config, engine) => { } const styleByBoolean = async (config, engine) => { - const { styleDataItem, data, legend, eventPointColor, eventPointRadius } = - config + const { + styleDataItem, + data, + legend, + eventPointRadius, + noDataLegend, + unclassifiedLegend, + } = config const { id, values } = styleDataItem legend.unit = await getLegendUnit(engine, styleDataItem) @@ -88,14 +94,30 @@ const styleByBoolean = async (config, engine) => { }) } - legend.items.push({ - name: i18n.t('Other'), - color: cssColor(eventPointColor) || EVENT_COLOR, - radius: eventPointRadius || EVENT_RADIUS, - count: 0, - }) + if (unclassifiedLegend) { + legend.items.push({ + name: unclassifiedLegend.name || i18n.t('Unclassified'), + color: unclassifiedLegend.color, + radius: eventPointRadius || EVENT_RADIUS, + count: 0, + unclassified: true, + }) + } - config.data = data.map((feature) => { + if (noDataLegend) { + legend.items.push({ + name: noDataLegend.name || i18n.t('No data'), + color: noDataLegend.color, + radius: eventPointRadius || EVENT_RADIUS, + count: 0, + noData: true, + }) + } + + const noDataItem = legend.items.find((i) => i.noData) + const unclassifiedItem = legend.items.find((i) => i.unclassified) + + config.data = data.reduce((acc, feature) => { const value = feature.properties[id] let displayValue let color @@ -108,21 +130,33 @@ const styleByBoolean = async (config, engine) => { displayValue = i18n.t('No') color = values.false legend.items[1].count++ + } else if (hasValue(value)) { + if (!unclassifiedItem) { + return acc + } + displayValue = value + color = unclassifiedLegend.color + unclassifiedItem.count++ } else { - displayValue = hasValue(value) ? value : i18n.t('Not set') - color = cssColor(eventPointColor) || EVENT_COLOR - legend.items[legend.items.length - 1].count++ + if (!noDataItem) { + return acc + } + displayValue = i18n.t('Not set') + color = noDataLegend.color + noDataItem.count++ } - return { + acc.push({ ...feature, properties: { ...feature.properties, value: displayValue, color: color, }, - } - }) + }) + + return acc + }, []) return config } @@ -135,10 +169,15 @@ const styleByNumeric = async (config, engine) => { method, classes, colorScale, - eventPointColor, + legendDecimalPlaces, + legendIsolated, eventPointRadius, + noDataLegend, + unclassifiedLegend, } = config + let valueFormat + // If legend set if (method === CLASSIFICATION_PREDEFINED) { // Load legend set from server @@ -157,25 +196,40 @@ const styleByNumeric = async (config, engine) => { .map((feature) => feature.properties[styleDataItem.id]) .filter(hasValue) .map(Number) - .filter((value) => !isNaN(value)) + .filter((value) => !Number.isNaN(value)) .sort((a, b) => a - b) // Use data item name as legend unit (load from server if needed) legend.unit = await getLegendUnit(engine, styleDataItem) // Generate legend items based on layer config - legend.items = getAutomaticLegendItems( - sortedValues, + const classification = getAutomaticLegendItems({ + data: sortedValues, method, classes, - colorScale - ) + colorScale, + legendDecimalPlaces, + legendIsolated, + }) + legend.items = classification.items + valueFormat = classification.valueFormat } - legend.items.push({ - name: i18n.t('Other'), - color: cssColor(eventPointColor) || EVENT_COLOR, - }) + if (unclassifiedLegend) { + legend.items.push({ + name: unclassifiedLegend.name || i18n.t('Unclassified'), + color: unclassifiedLegend.color, + unclassified: true, + }) + } + + if (noDataLegend) { + legend.items.push({ + name: noDataLegend.name || i18n.t('No data'), + color: noDataLegend.color, + noData: true, + }) + } // Add radius and count to each legend item legend.items.forEach((item) => { @@ -183,46 +237,77 @@ const styleByNumeric = async (config, engine) => { item.count = 0 }) + const noDataItem = legend.items.find((i) => i.noData) + const unclassifiedItem = legend.items.find((i) => i.unclassified) + // Helper function to get legend item for data value - const getLegendItem = (value) => + // Exclude no-data and unclassified items from classification lookup + const classificationItems = legend.items.filter( + (i) => !i.noData && !i.unclassified + ) + const getLegendItem = (value, method) => getLegendItemForValue({ value, - legendItems: config.legend.items.slice(0, -1), + valueFormat, + method, + legendItems: classificationItems, }) // Add style data value and color to each feature - config.data = data.map((feature) => { + config.data = data.reduce((acc, feature) => { const value = feature.properties[styleDataItem.id] - let legendItem - if (hasValue(value)) { - const numericValue = Number(value) - legendItem = getLegendItem(numericValue) + if (!hasValue(value)) { + if (!noDataItem) { + return acc + } + noDataItem.count++ + acc.push({ + ...feature, + properties: { + ...feature.properties, + value: i18n.t('Not set'), + color: noDataLegend.color, + }, + }) + return acc } + const numericValue = Number(value) + const legendItem = getLegendItem(numericValue, method) + if (legendItem) { legendItem.count++ } else { - legend.items[legend.items.length - 1].count++ + if (!unclassifiedItem) { + return acc + } + unclassifiedItem.count++ } - return { + acc.push({ ...feature, properties: { ...feature.properties, - value: hasValue(value) ? value : i18n.t('Not set'), - color: legendItem - ? legendItem.color - : cssColor(eventPointColor) || EVENT_COLOR, + value, + color: legendItem ? legendItem.color : unclassifiedLegend.color, }, - } - }) + }) + + return acc + }, []) return config } const styleByOptionSet = async (config, engine) => { - const { styleDataItem, legend, eventPointColor, eventPointRadius } = config + const { + styleDataItem, + legend, + eventPointRadius, + noDataLegend, + unclassifiedLegend, + } = config const optionSet = await getOptionSet(styleDataItem.optionSet, engine) const id = styleDataItem.id @@ -242,12 +327,28 @@ const styleByOptionSet = async (config, engine) => { count: 0, })) - legend.items.push({ - name: i18n.t('Other'), - color: cssColor(eventPointColor) || EVENT_COLOR, - radius: eventPointRadius || EVENT_RADIUS, - count: 0, - }) + if (unclassifiedLegend) { + legend.items.push({ + name: unclassifiedLegend.name || i18n.t('Unclassified'), + color: unclassifiedLegend.color, + radius: eventPointRadius || EVENT_RADIUS, + count: 0, + unclassified: true, + }) + } + + if (noDataLegend) { + legend.items.push({ + name: noDataLegend.name || i18n.t('No data'), + color: noDataLegend.color, + radius: eventPointRadius || EVENT_RADIUS, + count: 0, + noData: true, + }) + } + + const noDataItem = legend.items.find((i) => i.noData) + const unclassifiedItem = legend.items.find((i) => i.unclassified) // For easier and faster lookup below // TODO: There might be options with duplicate name, so code/id would be safer @@ -258,38 +359,57 @@ const styleByOptionSet = async (config, engine) => { }, {}) // Add style data value and color to each feature - config.data = config.data.map((feature) => { + config.data = config.data.reduce((acc, feature) => { const name = feature.properties[id] - if (name) { - const option = optionsByName[name.toLowerCase()] - - if (option) { - const optionIndex = legend.items.findIndex( - (item) => item.name === option.name - ) - legend.items[optionIndex].count++ - return { - ...feature, - properties: { - ...feature.properties, - value: option.name, - color: option.style.color, - }, - } + if (!hasValue(name)) { + if (!noDataItem) { + return acc } + noDataItem.count++ + acc.push({ + ...feature, + properties: { + ...feature.properties, + value: i18n.t('Not set'), + color: noDataLegend.color, + }, + }) + return acc } - legend.items[legend.items.length - 1].count++ - return { - ...feature, - properties: { - ...feature.properties, - value: hasValue(name) ? name : i18n.t('Not set'), - color: cssColor(eventPointColor) || EVENT_COLOR, - }, + const option = optionsByName[name.toLowerCase()] + + if (option) { + const optionIndex = legend.items.findIndex( + (item) => item.name === option.name + ) + legend.items[optionIndex].count++ + acc.push({ + ...feature, + properties: { + ...feature.properties, + value: option.name, + color: option.style.color, + }, + }) + } else { + if (!unclassifiedItem) { + return acc + } + unclassifiedItem.count++ + acc.push({ + ...feature, + properties: { + ...feature.properties, + value: name, + color: unclassifiedLegend.color, + }, + }) } - }) + + return acc + }, []) return config } diff --git a/src/util/time.js b/src/util/time.js index ffb1e8010..353e4641e 100644 --- a/src/util/time.js +++ b/src/util/time.js @@ -4,7 +4,7 @@ const DEFAULT_LOCALE = 'en' // BCP 47 locale format const dateLocale = (locale) => - locale && locale.includes('_') ? locale.replace('_', '-') : locale + locale && locale.includes('_') ? locale.replaceAll('_', '-') : locale /** * Trims the time part from an ISO date-time string, returning only the date (YYYY-MM-DD). diff --git a/yarn.lock b/yarn.lock index 7a5c7183d..01bfd691c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -14134,6 +14134,11 @@ simple-concat@^1.0.0: resolved "https://registry.yarnpkg.com/simple-concat/-/simple-concat-1.0.1.tgz#f46976082ba35c2263f1c8ab5edfe26c41c9552f" integrity sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q== +simple-statistics@^7.8.9: + version "7.8.9" + resolved "https://registry.yarnpkg.com/simple-statistics/-/simple-statistics-7.8.9.tgz#daa0f089d88ab47a4d6187ace534c459be05742f" + integrity sha512-YT6MLqYsz7y1rQZOLFlOCCgSRpCi6bqY417yhoOLI7aVoBi29dD39EPrOE03W9DY25H0J0jizVsHZnkLzyGJFg== + slash@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/slash/-/slash-2.0.0.tgz#de552851a1759df3a8f206535442f5ec4ddeab44"