From 2273c051b2930757ff6c880caf528b3ce1d4253a Mon Sep 17 00:00:00 2001 From: Bruno Raimbault Date: Sun, 26 Apr 2026 12:28:58 +0200 Subject: [PATCH 01/10] fix: apply digit group separator formatting before bubble legend layout computation --- i18n/en.pot | 10 +++++----- src/components/legend/Bubble.jsx | 11 +---------- src/components/legend/Bubbles.jsx | 17 +++++++++++++++++ src/util/__tests__/bubbles.spec.js | 1 + src/util/bubbles.js | 14 ++++++++++---- 5 files changed, 34 insertions(+), 19 deletions(-) diff --git a/i18n/en.pot b/i18n/en.pot index 2b93f9cbd..c495195fb 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-04-24T10:52:00.804Z\n" -"PO-Revision-Date: 2026-04-24T10:52:00.804Z\n" +"POT-Creation-Date: 2026-04-26T10:03:57.925Z\n" +"PO-Revision-Date: 2026-04-26T10:03:57.925Z\n" msgid "2020" msgstr "2020" @@ -1758,12 +1758,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" diff --git a/src/components/legend/Bubble.jsx b/src/components/legend/Bubble.jsx index 7db2b0f85..5d84adb7f 100644 --- a/src/components/legend/Bubble.jsx +++ b/src/components/legend/Bubble.jsx @@ -1,7 +1,5 @@ import PropTypes from 'prop-types' import React from 'react' -import { formatWithSeparator } from '../../util/numbers.js' -import { useCachedData } from '../cachedDataProvider/CachedDataProvider.jsx' import { guideLength, textPadding } from './Bubbles.jsx' const Bubble = ({ @@ -13,9 +11,6 @@ const Bubble = ({ stroke, pattern, }) => { - const { - systemSettings: { keyAnalysisDigitGroupSeparator }, - } = useCachedData() const leftAlign = textAlign === 'left' const x = maxRadius const y = maxRadius * 2 - radius @@ -55,11 +50,7 @@ const Bubble = ({ alignmentBaseline="middle" style={{ fontSize: 12 }} > - {formatWithSeparator( - text, - keyAnalysisDigitGroupSeparator, - { force: true } - )} + {text} )} diff --git a/src/components/legend/Bubbles.jsx b/src/components/legend/Bubbles.jsx index 1e107069f..ef1128496 100644 --- a/src/components/legend/Bubbles.jsx +++ b/src/components/legend/Bubbles.jsx @@ -7,6 +7,8 @@ import { createSingleColorBubbles, computeLayout, } from '../../util/bubbles.js' +import { formatWithSeparator } from '../../util/numbers.js' +import { useCachedData } from '../cachedDataProvider/CachedDataProvider.jsx' import Bubble from './Bubble.jsx' const style = { @@ -25,6 +27,9 @@ const Bubbles = ({ classes, isPlugin, }) => { + const { + systemSettings: { keyAnalysisDigitGroupSeparator }, + } = useCachedData() const legendWidth = isPlugin ? 150 : 245 const noDataClass = classes.find((c) => c.noData === true) const bubbleClasses = classes.filter((c) => !c.noData) @@ -53,6 +58,18 @@ const Bubbles = ({ radiusHigh, }) + bubbles.forEach((bubble) => { + if (bubble.text !== undefined) { + bubble.text = formatWithSeparator( + bubble.text, + keyAnalysisDigitGroupSeparator, + { + force: true, + } + ) + } + }) + const { alternate, offset, showNumbers } = computeLayout({ bubbles, bubbleClasses, diff --git a/src/util/__tests__/bubbles.spec.js b/src/util/__tests__/bubbles.spec.js index 7e5a16191..9d3f8691a 100644 --- a/src/util/__tests__/bubbles.spec.js +++ b/src/util/__tests__/bubbles.spec.js @@ -28,6 +28,7 @@ jest.mock('../helpers.js', () => ({ })) jest.mock('../numbers.js', () => ({ + formatWithSeparator: jest.fn((n) => String(n)), getRoundToPrecisionFn: jest.fn(() => (n) => n), })) diff --git a/src/util/bubbles.js b/src/util/bubbles.js index 250061cbc..048866741 100644 --- a/src/util/bubbles.js +++ b/src/util/bubbles.js @@ -7,7 +7,7 @@ import { } from '../components/legend/Bubbles.jsx' import { getContrastColor } from './colors.js' import { getLongestTextLength } from './helpers.js' -import { getRoundToPrecisionFn } from './numbers.js' +import { formatWithSeparator, getRoundToPrecisionFn } from './numbers.js' const getBubbleValueFormat = ({ minValue, maxValue, divisor }) => { if (minValue === maxValue) { @@ -111,12 +111,18 @@ export const computeLayout = ({ bubbleClasses, radiusHigh, legendWidth, + keyAnalysisDigitGroupSeparator, }) => { - // Calculate the pixel length of the longest number + // Calculate the pixel length of the longest formatted number + const formattedLen = (v) => + typeof v === 'number' + ? formatWithSeparator(v, keyAnalysisDigitGroupSeparator).length + : 0 let textLength = Math.ceil( Math.max( - getLongestTextLength(bubbleClasses, 'startValue'), - getLongestTextLength(bubbleClasses, 'endValue') + 0, + ...bubbleClasses.map((c) => formattedLen(c.startValue)), + ...bubbleClasses.map((c) => formattedLen(c.endValue)) ) * digitWidth ) From ec962445747f5a9eae15e6d6413b3a3c0e800c51 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault Date: Sun, 26 Apr 2026 13:06:54 +0200 Subject: [PATCH 02/10] fix: apply digit group separator to org unit profile data items --- src/components/orgunits/OrgUnitData.jsx | 12 +++++++++++- src/util/__tests__/bubbles.spec.js | 2 +- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/src/components/orgunits/OrgUnitData.jsx b/src/components/orgunits/OrgUnitData.jsx index 1d271050a..8a8580957 100644 --- a/src/components/orgunits/OrgUnitData.jsx +++ b/src/components/orgunits/OrgUnitData.jsx @@ -3,10 +3,12 @@ import i18n from '@dhis2/d2-i18n' import { CircularLoader } from '@dhis2/ui' import PropTypes from 'prop-types' import React, { useState, useEffect } from 'react' +import { formatWithSeparator } from '../../util/numbers.js' import { getFixedPeriodsByType, filterFuturePeriods, } from '../../util/periods.js' +import { useCachedData } from '../cachedDataProvider/CachedDataProvider.jsx' import PeriodSelect from '../periods/PeriodSelect.jsx' import styles from './styles/OrgUnitData.module.css' @@ -31,6 +33,9 @@ const defaultPeriod = filterFuturePeriods(periods)[0] || periods[0] * (data elements, indicators, reporting rates, program indicators) */ const OrgUnitData = ({ id }) => { + const { + systemSettings: { keyAnalysisDigitGroupSeparator }, + } = useCachedData() const [period, setPeriod] = useState(defaultPeriod) const { loading, data, refetch } = useDataQuery(ORGUNIT_PROFILE_QUERY, { lazy: true, @@ -69,7 +74,12 @@ const OrgUnitData = ({ id }) => { ({ id, label, value }) => ( {label} - {value} + + {formatWithSeparator( + value, + keyAnalysisDigitGroupSeparator + )} + ) )} diff --git a/src/util/__tests__/bubbles.spec.js b/src/util/__tests__/bubbles.spec.js index 9d3f8691a..23916498f 100644 --- a/src/util/__tests__/bubbles.spec.js +++ b/src/util/__tests__/bubbles.spec.js @@ -28,7 +28,7 @@ jest.mock('../helpers.js', () => ({ })) jest.mock('../numbers.js', () => ({ - formatWithSeparator: jest.fn((n) => String(n)), + formatWithSeparator: jest.fn(String), getRoundToPrecisionFn: jest.fn(() => (n) => n), })) From 78342d04475286d50dc66c97ed6634024764be78 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault Date: Sun, 26 Apr 2026 13:31:06 +0200 Subject: [PATCH 03/10] fix: apply digit group separator to Earth Engine popup values --- i18n/en.pot | 12 +++++- .../layers/earthEngine/EarthEngineLayer.jsx | 10 ++++- .../layers/earthEngine/EarthEnginePopup.jsx | 42 +++++++++++++------ 3 files changed, 48 insertions(+), 16 deletions(-) diff --git a/i18n/en.pot b/i18n/en.pot index c495195fb..545bed738 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-04-26T10:03:57.925Z\n" -"PO-Revision-Date: 2026-04-26T10:03:57.925Z\n" +"POT-Creation-Date: 2026-04-26T11:10:26.962Z\n" +"PO-Revision-Date: 2026-04-26T11:10:26.963Z\n" msgid "2020" msgstr "2020" @@ -1824,3 +1824,11 @@ msgstr "End date is invalid" msgid "End date cannot be earlier than start date" msgstr "End date cannot be earlier than start date" + +msgctxt "Application title" +msgid "__MANIFEST_APP_TITLE" +msgstr "Maps" + +msgctxt "Application description" +msgid "__MANIFEST_APP_DESCRIPTION" +msgstr "DHIS2 Maps" diff --git a/src/components/map/layers/earthEngine/EarthEngineLayer.jsx b/src/components/map/layers/earthEngine/EarthEngineLayer.jsx index 2dd758f17..02b47d492 100644 --- a/src/components/map/layers/earthEngine/EarthEngineLayer.jsx +++ b/src/components/map/layers/earthEngine/EarthEngineLayer.jsx @@ -216,7 +216,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 +240,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]), } From f93d7a5d920238ebfc1da31f4faee2aa84949843 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault Date: Sun, 26 Apr 2026 13:53:50 +0200 Subject: [PATCH 04/10] fix: use "Classes" instead of "Steps" label in Earth Engine style select [DHIS2-19203] --- src/components/edit/earthEngine/StyleSelect.jsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/edit/earthEngine/StyleSelect.jsx b/src/components/edit/earthEngine/StyleSelect.jsx index fccb53faf..095842415 100644 --- a/src/components/edit/earthEngine/StyleSelect.jsx +++ b/src/components/edit/earthEngine/StyleSelect.jsx @@ -22,7 +22,7 @@ export const getStyleSelectError = ({ min, max, steps, palette, ranges }) => { return i18n.t('Max should be greater than min') } if (!ranges && steps && (steps < minSteps || steps > maxSteps)) { - return i18n.t('Valid steps are {{minSteps}} to {{maxSteps}}', { + return i18n.t('Valid classes are {{minSteps}} to {{maxSteps}}', { minSteps, maxSteps, }) @@ -75,7 +75,7 @@ const StyleSelect = ({ unit, style, setStyle }) => { className={styles.flexInnerColumn} /> Date: Sun, 26 Apr 2026 14:02:12 +0200 Subject: [PATCH 05/10] fix: widen color picker button in option set and boolean styles [DHIS2-19982] --- i18n/en.pot | 19 ++++--------------- .../core/styles/ColorScale.module.css | 2 +- src/components/optionSet/OptionStyle.jsx | 1 + .../optionSet/styles/OptionStyle.module.css | 17 +++++++---------- 4 files changed, 13 insertions(+), 26 deletions(-) diff --git a/i18n/en.pot b/i18n/en.pot index 545bed738..beb58bb9e 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-04-26T11:10:26.962Z\n" -"PO-Revision-Date: 2026-04-26T11:10:26.963Z\n" +"POT-Creation-Date: 2026-04-26T11:57:55.415Z\n" +"PO-Revision-Date: 2026-04-26T11:57:55.415Z\n" msgid "2020" msgstr "2020" @@ -353,8 +353,8 @@ msgstr "Max value is required" msgid "Max should be greater than min" msgstr "Max should be greater than min" -msgid "Valid steps are {{minSteps}} to {{maxSteps}}" -msgstr "Valid steps are {{minSteps}} to {{maxSteps}}" +msgid "Valid classes are {{minSteps}} to {{maxSteps}}" +msgstr "Valid classes are {{minSteps}} to {{maxSteps}}" msgid "Min" msgstr "Min" @@ -362,9 +362,6 @@ msgstr "Min" msgid "Max" msgstr "Max" -msgid "Steps" -msgstr "Steps" - msgid "Facility buffer" msgstr "Facility buffer" @@ -1824,11 +1821,3 @@ msgstr "End date is invalid" msgid "End date cannot be earlier than start date" msgstr "End date cannot be earlier than start date" - -msgctxt "Application title" -msgid "__MANIFEST_APP_TITLE" -msgstr "Maps" - -msgctxt "Application description" -msgid "__MANIFEST_APP_DESCRIPTION" -msgstr "DHIS2 Maps" 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/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; } From bbcda6362c1fa42b411b75046c3c5147ccaaea9a Mon Sep 17 00:00:00 2001 From: Bruno Raimbault Date: Sun, 26 Apr 2026 14:12:22 +0200 Subject: [PATCH 06/10] fix: show values for NUMBER data items with option sets in data table [DHIS2-19984] --- src/components/datatable/useTableData.js | 9 +++++---- src/loaders/eventLoader.js | 3 ++- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/src/components/datatable/useTableData.js b/src/components/datatable/useTableData.js index 778d54c7c..3b4b98489 100644 --- a/src/components/datatable/useTableData.js +++ b/src/components/datatable/useTableData.js @@ -111,12 +111,13 @@ 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: + !optionSet && numberValueTypes.includes(valueType) + ? TYPE_NUMBER + : TYPE_STRING, })) customFields.push(defaultFieldsMap()[TYPE]) diff --git a/src/loaders/eventLoader.js b/src/loaders/eventLoader.js index 076775831..d12af6a1e 100644 --- a/src/loaders/eventLoader.js +++ b/src/loaders/eventLoader.js @@ -200,7 +200,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) { From 0766788ba84df7cbf4c44bbb7092abec5553feed Mon Sep 17 00:00:00 2001 From: Bruno Raimbault Date: Sun, 26 Apr 2026 17:54:50 +0200 Subject: [PATCH 07/10] fix: sort range column numerically in data table [DHIS2-19983] --- src/components/datatable/useTableData.js | 18 ++++++++++++++++-- src/util/legend.js | 6 ++++++ 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/src/components/datatable/useTableData.js b/src/components/datatable/useTableData.js index 3b4b98489..52a780230 100644 --- a/src/components/datatable/useTableData.js +++ b/src/components/datatable/useTableData.js @@ -13,6 +13,7 @@ import { numberValueTypes } from '../../constants/valueTypes.js' import { hasClasses } from '../../util/earthEngine.js' import { filterData } from '../../util/filter.js' import { getGeojsonDisplayData } from '../../util/geojson.js' +import { parseRange } from '../../util/legend.js' import { getRoundToPrecisionFn, getPrecision } from '../../util/numbers.js' import { isValidUid } from '../../util/uid.js' @@ -308,17 +309,30 @@ export const useTableData = ({ layer, sortField, sortDirection }) => { } if (aVal === undefined) { - return 1 // a goes to end + return 1 // aVal goes to end } if (bVal === undefined) { - return -1 // b goes to end + return -1 // bVal goes to end } 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 ? aVal.localeCompare(bVal) diff --git a/src/util/legend.js b/src/util/legend.js index d21c1499f..270d85683 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: { @@ -73,6 +74,11 @@ export const sortLegendItems = (items) => : bRange.start - aRange.start }) +export const parseRange = (str) => { + const [start, end] = str.split(' - ') + return [parseWithSeparator(start), parseWithSeparator(end)] +} + export const loadDataItemLegendSet = async (dataItem, engine) => { if (!dataItem) { return null From 541661a53e6f6d388706d44a67af3e83995f0ae2 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault Date: Sun, 26 Apr 2026 22:33:50 +0200 Subject: [PATCH 08/10] feat: add legendDecimalPlaces option for automatic legend precision [DHIS2-3156] --- i18n/en.pot | 10 ++- src/actions/layerEdit.js | 5 ++ .../classification/Classification.jsx | 40 ++++++--- .../classification/DecimalPlacesSelect.jsx | 32 +++++++ src/components/classification/SingleColor.jsx | 45 ++++++---- .../styles/Classification.module.css | 27 ++++-- src/components/legend/Bubbles.jsx | 3 + src/components/legend/LegendItem.jsx | 3 + src/components/legend/LegendItemRange.jsx | 19 ++++- src/constants/actionTypes.js | 2 + src/loaders/eventLoader.js | 7 ++ src/loaders/thematicLoader.js | 15 +++- src/reducers/layerEdit.js | 12 +++ src/util/__tests__/classify.spec.js | 52 +++++++----- src/util/classify.js | 83 +++++++++++-------- src/util/config.js | 10 +++ src/util/favorites.js | 13 +++ src/util/legend.js | 9 +- src/util/styleByDataItem.js | 2 + 19 files changed, 292 insertions(+), 97 deletions(-) create mode 100644 src/components/classification/DecimalPlacesSelect.jsx create mode 100644 src/util/config.js diff --git a/i18n/en.pot b/i18n/en.pot index beb58bb9e..2582a3929 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-04-26T11:57:55.415Z\n" -"PO-Revision-Date: 2026-04-26T11:57:55.415Z\n" +"POT-Creation-Date: 2026-04-26T16:23:04.108Z\n" +"PO-Revision-Date: 2026-04-26T16:23:04.108Z\n" msgid "2020" msgstr "2020" @@ -38,6 +38,12 @@ msgstr "Classification" msgid "Classes" msgstr "Classes" +msgid "Auto" +msgstr "Auto" + +msgid "Decimal places" +msgstr "Decimal places" + msgid "Legend set" msgstr "Legend set" diff --git a/src/actions/layerEdit.js b/src/actions/layerEdit.js index 836dfae32..eaf16f49d 100644 --- a/src/actions/layerEdit.js +++ b/src/actions/layerEdit.js @@ -92,6 +92,11 @@ export const setColorScale = (colorScale) => ({ colorScale, }) +export const setLegendDecimalPlaces = (legendDecimalPlaces) => ({ + type: types.LAYER_EDIT_LEGEND_DECIMAL_PLACES_SET, + legendDecimalPlaces, +}) + // Set event status export const setEventStatus = (status) => ({ type: types.LAYER_EDIT_EVENT_STATUS_SET, diff --git a/src/components/classification/Classification.jsx b/src/components/classification/Classification.jsx index 5227c6168..0fd5d3aac 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 DecimalPlacesSelect from './DecimalPlacesSelect.jsx' import styles from './styles/Classification.module.css' const classRange = range(3, 10).map((num) => ({ @@ -27,8 +32,10 @@ const Classification = ({ method, classes, colorScale, + legendDecimalPlaces, setClassification, setColorScale, + setLegendDecimalPlaces, }) => { const colorScaleName = colorScale ? getColorScale(colorScale) @@ -44,22 +51,28 @@ const Classification = ({ className={styles.select} />,
- - setColorScale(getColorPalette(colorScaleName, item.id)) - } - className={styles.classes} - /> +
+ + setColorScale(getColorPalette(colorScaleName, item.id)) + } + className={styles.classes} + /> + +
-
, ] } @@ -67,8 +80,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 +92,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/DecimalPlacesSelect.jsx b/src/components/classification/DecimalPlacesSelect.jsx new file mode 100644 index 000000000..34f36e1e3 --- /dev/null +++ b/src/components/classification/DecimalPlacesSelect.jsx @@ -0,0 +1,32 @@ +import i18n from '@dhis2/d2-i18n' +import { range } from 'lodash/fp' +import PropTypes from 'prop-types' +import React from 'react' +import { SelectField } from '../core/index.js' + +const DECIMAL_PLACES_AUTO = 'auto' + +const decimalPlacesItems = [ + { id: DECIMAL_PLACES_AUTO, name: i18n.t('Auto') }, + ...range(0, 7).map((num) => ({ id: num, name: num.toString() })), +] + +const DecimalPlacesSelect = ({ value, onChange, className }) => ( + + onChange(item.id === DECIMAL_PLACES_AUTO ? undefined : item.id) + } + className={className} + /> +) + +DecimalPlacesSelect.propTypes = { + onChange: PropTypes.func.isRequired, + className: PropTypes.string, + value: PropTypes.number, +} + +export default DecimalPlacesSelect diff --git a/src/components/classification/SingleColor.jsx b/src/components/classification/SingleColor.jsx index e4cc175da..980314acc 100644 --- a/src/components/classification/SingleColor.jsx +++ b/src/components/classification/SingleColor.jsx @@ -1,14 +1,23 @@ 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' -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 DecimalPlacesSelect from './DecimalPlacesSelect.jsx' +import styles from './styles/Classification.module.css' -// Displays a color picker for single color layer -const SingleColor = ({ color, setColorScale }) => { - // Set default color +const SingleColor = ({ + color, + legendDecimalPlaces, + setColorScale, + setLegendDecimalPlaces, +}) => { useEffect(() => { if (!color || color.length !== 7) { setColorScale(THEMATIC_COLOR) @@ -16,26 +25,34 @@ const SingleColor = ({ color, setColorScale }) => { }, [color, setColorScale]) return color ? ( - +
+ + +
) : 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..3f0a05e49 100644 --- a/src/components/classification/styles/Classification.module.css +++ b/src/components/classification/styles/Classification.module.css @@ -2,19 +2,32 @@ width: 100%; } +.classesRow { + display: flex; + gap: var(--spacers-dp16); +} + .classes { - width: 50px; - margin-right: var(--spacers-dp16); - top: -8px; - float: left; + flex: 0 0 auto; } .scale { display: block; padding-top: var(--spacers-dp8); - clear: both; } -.clear { - clear: both; +.decimalPlaces { + flex: 0 0 auto; +} + +.singleColorRow { + display: flex; + gap: var(--spacers-dp16); + align-items: flex-end; + margin-bottom: var(--spacers-dp12); +} + +.singleColorField { + flex-shrink: 0; + margin-bottom: 0; } diff --git a/src/components/legend/Bubbles.jsx b/src/components/legend/Bubbles.jsx index ef1128496..e16cf934f 100644 --- a/src/components/legend/Bubbles.jsx +++ b/src/components/legend/Bubbles.jsx @@ -24,6 +24,7 @@ const Bubbles = ({ color, minValue, maxValue, + legendDecimalPlaces, classes, isPlugin, }) => { @@ -65,6 +66,7 @@ const Bubbles = ({ keyAnalysisDigitGroupSeparator, { force: true, + precision: legendDecimalPlaces, } ) } @@ -145,6 +147,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/LegendItem.jsx b/src/components/legend/LegendItem.jsx index 6b46e16ed..286c6a3e4 100644 --- a/src/components/legend/LegendItem.jsx +++ b/src/components/legend/LegendItem.jsx @@ -20,6 +20,7 @@ const LegendItem = ({ startValue, endValue, count, + decimalPlaces, }) => { if (!name && startValue === undefined) { return null @@ -66,6 +67,7 @@ const LegendItem = ({ startValue={startValue} endValue={endValue} count={count} + decimalPlaces={decimalPlaces} /> ) @@ -74,6 +76,7 @@ const LegendItem = ({ LegendItem.propTypes = { color: PropTypes.string, count: PropTypes.number, + decimalPlaces: PropTypes.number, endValue: PropTypes.number, fillColor: PropTypes.string, image: PropTypes.string, diff --git a/src/components/legend/LegendItemRange.jsx b/src/components/legend/LegendItemRange.jsx index 28b78ca7e..78ace6ba0 100644 --- a/src/components/legend/LegendItemRange.jsx +++ b/src/components/legend/LegendItemRange.jsx @@ -4,7 +4,13 @@ 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 }) => { +const LegendItemRange = ({ + name = '', + startValue, + endValue, + count, + decimalPlaces, +}) => { const { systemSettings: { keyAnalysisDigitGroupSeparator }, } = useCachedData() @@ -15,10 +21,16 @@ const LegendItemRange = ({ name = '', startValue, endValue, count }) => { ? '' : `${formatWithSeparator( startValue, - keyAnalysisDigitGroupSeparator + keyAnalysisDigitGroupSeparator, + { + precision: decimalPlaces, + } )} - ${formatWithSeparator( endValue, - keyAnalysisDigitGroupSeparator + keyAnalysisDigitGroupSeparator, + { + precision: decimalPlaces, + } )}` const countLabel = count === undefined @@ -36,6 +48,7 @@ const LegendItemRange = ({ name = '', startValue, endValue, count }) => { LegendItemRange.propTypes = { count: PropTypes.number, + decimalPlaces: PropTypes.number, endValue: PropTypes.number, name: PropTypes.string, startValue: PropTypes.number, diff --git a/src/constants/actionTypes.js b/src/constants/actionTypes.js index 3c0805e05..dac41a921 100644 --- a/src/constants/actionTypes.js +++ b/src/constants/actionTypes.js @@ -85,6 +85,8 @@ export const LAYER_EDIT_THEMATIC_MAP_TYPE_SET = 'LAYER_EDIT_THEMATIC_MAP_TYPE_SET' export const LAYER_EDIT_CLASSIFICATION_SET = 'LAYER_EDIT_CLASSIFICATION_SET' export const LAYER_EDIT_COLOR_SCALE_SET = 'LAYER_EDIT_COLOR_SCALE_SET' +export const LAYER_EDIT_LEGEND_DECIMAL_PLACES_SET = + 'LAYER_EDIT_LEGEND_DECIMAL_PLACES_SET' export const LAYER_EDIT_DATA_ITEM_SET = 'LAYER_EDIT_DATA_ITEM_SET' export const LAYER_EDIT_EVENT_STATUS_SET = 'LAYER_EDIT_EVENT_STATUS_SET' export const LAYER_EDIT_EVENT_COORDINATE_FIELD_SET = diff --git a/src/loaders/eventLoader.js b/src/loaders/eventLoader.js index d12af6a1e..038254945 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' @@ -101,6 +102,12 @@ const loadEventLayer = async ({ periodTypeData, loadExtended, }) => { + const { legendDecimalPlaces } = parseJsonConfig(config.config) + if (legendDecimalPlaces !== undefined) { + config.legendDecimalPlaces = legendDecimalPlaces + } + delete config.config + const { columns, endDate, diff --git a/src/loaders/thematicLoader.js b/src/loaders/thematicLoader.js index 9d90abe84..155d6b8ed 100644 --- a/src/loaders/thematicLoader.js +++ b/src/loaders/thematicLoader.js @@ -28,6 +28,7 @@ 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, @@ -62,6 +63,12 @@ const thematicLoader = async ({ noDataColor, } = config + const { legendDecimalPlaces } = parseJsonConfig(config.config) + if (legendDecimalPlaces !== undefined) { + config.legendDecimalPlaces = legendDecimalPlaces + } + delete config.config + const dataItem = getDataItemFromColumns(columns) const coordinateField = getCoordinateField(config) @@ -177,6 +184,7 @@ const thematicLoader = async ({ method, classes, colorScale, + legendDecimalPlaces: config.legendDecimalPlaces, }) legendItems = classification.items valueFormat = classification.valueFormat @@ -223,6 +231,7 @@ const thematicLoader = async ({ minValue, maxValue, color: isSingleColor ? colorScale : null, + legendDecimalPlaces: config.legendDecimalPlaces, } } @@ -305,10 +314,12 @@ const thematicLoader = async ({ properties.legend = legendItem.name // Shown in data table properties.range = `${formatWithSeparator( legendItem.startValue, - keyAnalysisDigitGroupSeparator + keyAnalysisDigitGroupSeparator, + { precision: legendItem.decimalPlaces } )} - ${formatWithSeparator( legendItem.endValue, - keyAnalysisDigitGroupSeparator + keyAnalysisDigitGroupSeparator, + { precision: legendItem.decimalPlaces } )}` // Shown in data table } diff --git a/src/reducers/layerEdit.js b/src/reducers/layerEdit.js index 687f22e65..639ec39d2 100644 --- a/src/reducers/layerEdit.js +++ b/src/reducers/layerEdit.js @@ -311,6 +311,18 @@ const layerEdit = (state = null, action) => { return newState + case types.LAYER_EDIT_LEGEND_DECIMAL_PLACES_SET: + newState = { + ...state, + legendDecimalPlaces: action.legendDecimalPlaces, + } + + if (newState.legendDecimalPlaces === undefined) { + delete newState.legendDecimalPlaces + } + + return newState + case types.LAYER_EDIT_EVENT_STATUS_SET: newState = { ...state } diff --git a/src/util/__tests__/classify.spec.js b/src/util/__tests__/classify.spec.js index ace09a558..07d35b9b4 100644 --- a/src/util/__tests__/classify.spec.js +++ b/src/util/__tests__/classify.spec.js @@ -153,7 +153,7 @@ describe('getLegendItems', () => { const { items } = getLegendItems( values, CLASSIFICATION_EQUAL_INTERVALS, - 4 + { numClasses: 4 } ) expect(items).toEqual([ { startValue: 0.0, endValue: 25.0 }, @@ -165,7 +165,9 @@ describe('getLegendItems', () => { it('returns quantiles for CLASSIFICATION_EQUAL_COUNTS', () => { const values = [1, 2, 3, 4, 5, 6] - const { items } = getLegendItems(values, CLASSIFICATION_EQUAL_COUNTS, 3) + const { items } = getLegendItems(values, CLASSIFICATION_EQUAL_COUNTS, { + numClasses: 3, + }) expect(items).toEqual([ { startValue: 1.0, endValue: 3.0 }, { startValue: 3.0, endValue: 5.0 }, @@ -174,7 +176,7 @@ describe('getLegendItems', () => { }) it('returns undefined if method is unknown', () => { - const { items } = getLegendItems([0, 100], 'UNKNOWN', 3) + const { items } = getLegendItems([0, 100], 'UNKNOWN', { numClasses: 3 }) expect(items).toBeUndefined() }) @@ -182,7 +184,7 @@ describe('getLegendItems', () => { const { valueFormat } = getLegendItems( [0, 100], CLASSIFICATION_EQUAL_INTERVALS, - 4 + { numClasses: 4 } ) expect(typeof valueFormat).toBe('function') }) @@ -192,7 +194,7 @@ describe('getLegendItems', () => { const { items } = getLegendItems( values, CLASSIFICATION_NATURAL_BREAKS_RANGES, - 3 + { numClasses: 3 } ) expect(items).toHaveLength(3) expect(items[0].endValue).toBe(items[1].startValue) @@ -206,7 +208,7 @@ describe('getLegendItems', () => { const { items } = getLegendItems( values, CLASSIFICATION_NATURAL_BREAKS_CLUSTERS, - 3 + { numClasses: 3 } ) expect(items).toHaveLength(3) expect(items[0].endValue).toBeLessThan(items[1].startValue) @@ -215,7 +217,9 @@ describe('getLegendItems', () => { it('returns logarithmic bins for strictly positive data', () => { const values = [1, 10, 100, 1000, 10000] - const { items } = getLegendItems(values, CLASSIFICATION_LOGARITHMIC, 4) + const { items } = getLegendItems(values, CLASSIFICATION_LOGARITHMIC, { + numClasses: 4, + }) expect(items).toHaveLength(4) expect(items[0].startValue).toBe(1) expect(items[3].endValue).toBe(10000) @@ -227,12 +231,12 @@ describe('getLegendItems', () => { const { items: logItems } = getLegendItems( values, CLASSIFICATION_LOGARITHMIC, - 4 + { numClasses: 4 } ) const { items: equalItems } = getLegendItems( values, CLASSIFICATION_EQUAL_INTERVALS, - 4 + { numClasses: 4 } ) expect(logItems).toEqual(equalItems) }) @@ -242,7 +246,7 @@ describe('getLegendItems', () => { const { items } = getLegendItems( values, CLASSIFICATION_STANDARD_DEVIATION, - 5 + { numClasses: 5 } ) expect(items[0].startValue).toBe(0) expect(items[items.length - 1].endValue).toBe(100) @@ -254,14 +258,16 @@ describe('getLegendItems', () => { const { items } = getLegendItems( [0, 100], CLASSIFICATION_PRETTY_BREAKS, - 5 + { numClasses: 5 } ) expect(items[0].endValue).toBe(20) }) it('removes consecutive duplicate bins', () => { const values = [5, 5, 5, 5, 5, 10, 10, 10] - const { items } = getLegendItems(values, CLASSIFICATION_EQUAL_COUNTS, 5) + const { items } = getLegendItems(values, CLASSIFICATION_EQUAL_COUNTS, { + numClasses: 5, + }) for (let i = 1; i < items.length; i++) { expect( items[i].startValue === items[i - 1].startValue && @@ -274,7 +280,7 @@ describe('getLegendItems', () => { const { items } = getLegendItems( [1, 2, 3], CLASSIFICATION_NATURAL_BREAKS_RANGES, - 5 + { numClasses: 5 } ) expect(items).toHaveLength(3) }) @@ -283,7 +289,7 @@ describe('getLegendItems', () => { const { items } = getLegendItems( [1, 2, 3], CLASSIFICATION_NATURAL_BREAKS_CLUSTERS, - 5 + { numClasses: 5 } ) expect(items).toHaveLength(3) }) @@ -292,20 +298,24 @@ describe('getLegendItems', () => { const { items } = getLegendItems( [1, 2, 3], CLASSIFICATION_EQUAL_COUNTS, - 5 + { numClasses: 5 } ) expect(items.length).toBeLessThanOrEqual(3) }) it('does not throw for pretty breaks with few distinct values', () => { expect(() => - getLegendItems([1, 2], CLASSIFICATION_PRETTY_BREAKS, 5) + getLegendItems([1, 2], CLASSIFICATION_PRETTY_BREAKS, { + numClasses: 5, + }) ).not.toThrow() }) it('does not throw for standard deviation with few distinct values', () => { expect(() => - getLegendItems([1, 2], CLASSIFICATION_STANDARD_DEVIATION, 5) + getLegendItems([1, 2], CLASSIFICATION_STANDARD_DEVIATION, { + numClasses: 5, + }) ).not.toThrow() }) @@ -313,7 +323,7 @@ describe('getLegendItems', () => { const { items } = getLegendItems( [5, 5, 5, 5], CLASSIFICATION_EQUAL_INTERVALS, - 4 + { numClasses: 4 } ) expect(items).toEqual([{ startValue: 5, endValue: 5 }]) }) @@ -329,7 +339,9 @@ describe('getLegendItems', () => { CLASSIFICATION_PRETTY_BREAKS, ] methods.forEach((method) => { - const { items } = getLegendItems([7, 7, 7], method, 5) + const { items } = getLegendItems([7, 7, 7], method, { + numClasses: 5, + }) expect(items).toEqual([{ startValue: 7, endValue: 7 }]) }) }) @@ -338,7 +350,7 @@ describe('getLegendItems', () => { const { items } = getLegendItems( [7, 7, 7], CLASSIFICATION_EQUAL_INTERVALS, - 5 + { numClasses: 5 } ) expect(getLegendItemForValue({ value: 7, legendItems: items })).toEqual( items[0] diff --git a/src/util/classify.js b/src/util/classify.js index eb3b3cd5c..5cf68d202 100644 --- a/src/util/classify.js +++ b/src/util/classify.js @@ -48,7 +48,11 @@ export const getLegendItemForValue = ({ ) } -export const getLegendItems = (values, method, numClasses) => { +export const getLegendItems = ( + values, + method, + { numClasses, precision } = {} +) => { const minValue = values[0] const maxValue = values[values.length - 1] if (minValue === maxValue) { @@ -61,25 +65,27 @@ export const getLegendItems = (values, method, numClasses) => { const k = Math.min(numClasses, distinctValues.length) let classification - if (method === CLASSIFICATION_EQUAL_INTERVALS) { classification = getEqualIntervals(minValue, maxValue, { numClasses: k, + precision, }) } else if (method === CLASSIFICATION_EQUAL_COUNTS) { - classification = getQuantiles(values, { numClasses: k }) + 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 }) + classification = getStandardDeviation(values, { numClasses, precision }) } else if (method === CLASSIFICATION_LOGARITHMIC) { if (minValue <= 0) { // Logarithmic scale requires strictly positive values. @@ -89,12 +95,19 @@ export const getLegendItems = (values, method, numClasses) => { // them to the unclassified bucket instead of falling back. classification = getEqualIntervals(minValue, maxValue, { numClasses, + precision, }) } else { - classification = getLogarithmic(minValue, maxValue, { numClasses }) + classification = getLogarithmic(minValue, maxValue, { + numClasses, + precision, + }) } } else if (method === CLASSIFICATION_PRETTY_BREAKS) { - classification = getPrettyBreaks(minValue, maxValue, { numClasses }) + classification = getPrettyBreaks(minValue, maxValue, { + numClasses, + precision, + }) } if (!classification) { @@ -111,11 +124,11 @@ export const getLegendItems = (values, method, numClasses) => { } } -const getEqualIntervals = (minValue, maxValue, { numClasses }) => { +const getEqualIntervals = (minValue, maxValue, { numClasses, precision }) => { const items = [] const classSize = (maxValue - minValue) / numClasses - const precision = precisionRound(classSize, maxValue) - const valueFormat = getRoundToPrecisionFn(precision) + const resolvedPrecision = precision ?? precisionRound(classSize, maxValue) + const valueFormat = getRoundToPrecisionFn(resolvedPrecision) for (let i = 0; i < numClasses; i++) { const startValue = minValue + i * classSize @@ -130,16 +143,15 @@ const getEqualIntervals = (minValue, maxValue, { numClasses }) => { return { items, valueFormat } } -const getQuantiles = (values, { numClasses }) => { +const getQuantiles = (values, { numClasses, precision }) => { const minValue = values[0] const maxValue = values[values.length - 1] const items = [] const valuesCount = 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 lastValuePosition = valuesCount if (values.length > 0) { @@ -162,14 +174,13 @@ const getQuantiles = (values, { numClasses }) => { } } -const getCkMeans = (values, { numClasses, continuous }) => { +const getCkMeans = (values, { numClasses, continuous, precision }) => { const minValue = values[0] const maxValue = values[values.length - 1] - const precision = precisionRound( - (maxValue - minValue) / numClasses, - maxValue - ) - const valueFormat = getRoundToPrecisionFn(precision) + const resolvedPrecision = + precision ?? + precisionRound((maxValue - minValue) / numClasses, maxValue) + const valueFormat = getRoundToPrecisionFn(resolvedPrecision) const k = Math.min(numClasses, values.length) const clusters = ckmeans(values, k) @@ -205,7 +216,7 @@ const getCkMeans = (values, { numClasses, continuous }) => { } } -const getStandardDeviation = (values, { numClasses }) => { +const getStandardDeviation = (values, { numClasses, precision }) => { // TODO: DHIS2-19812 std - dev classification is best interpreted when // breaks fall at μ±Nσ regardless of data extremes. Currently: // - breaks outside [minValue, maxValue] are filtered (producing @@ -218,11 +229,10 @@ const getStandardDeviation = (values, { numClasses }) => { const maxValue = values[values.length - 1] const mu = mean(values) const sigma = standardDeviation(values) - const precision = precisionRound( - (maxValue - minValue) / numClasses, - maxValue - ) - const valueFormat = getRoundToPrecisionFn(precision) + const resolvedPrecision = + precision ?? + precisionRound((maxValue - minValue) / numClasses, maxValue) + const valueFormat = getRoundToPrecisionFn(resolvedPrecision) // Place breaks at 1-sigma intervals centered on the mean. const internalBreaks = [] @@ -249,15 +259,14 @@ const getStandardDeviation = (values, { numClasses }) => { } } -const getLogarithmic = (minValue, maxValue, { numClasses }) => { +const getLogarithmic = (minValue, maxValue, { numClasses, precision }) => { const logMin = Math.log(minValue) const logMax = Math.log(maxValue) const logStep = (logMax - logMin) / numClasses - const precision = precisionRound( - (maxValue - minValue) / numClasses, - maxValue - ) - const valueFormat = getRoundToPrecisionFn(precision) + const resolvedPrecision = + precision ?? + precisionRound((maxValue - minValue) / numClasses, maxValue) + const valueFormat = getRoundToPrecisionFn(resolvedPrecision) const items = [] for (let i = 0; i < numClasses; i++) { @@ -273,15 +282,17 @@ const getLogarithmic = (minValue, maxValue, { numClasses }) => { return { items, valueFormat } } -const getPrettyBreaks = (minValue, maxValue, { numClasses }) => { +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].map((n) => n * magnitude) const niceStep = niceSteps.findLast((s) => s <= roughStep) ?? niceSteps[0] - const precision = precisionRound(niceStep, maxValue) - const valueFormat = getRoundToPrecisionFn(precision) + const resolvedPrecision = + precision ?? + precisionRound((maxValue - minValue) / numClasses, maxValue) + const valueFormat = getRoundToPrecisionFn(resolvedPrecision) const internalBreaks = [] let b = Math.ceil(minValue / niceStep) * niceStep 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/favorites.js b/src/util/favorites.js index 9b5271264..c729cec26 100644 --- a/src/util/favorites.js +++ b/src/util/favorites.js @@ -1,7 +1,9 @@ import { isNil, omitBy, pick, isObject, omit } from 'lodash/fp' import { EARTH_ENGINE_LAYER, + EVENT_LAYER, GEOJSON_URL_LAYER, + THEMATIC_LAYER, TRACKED_ENTITY_LAYER, } from '../constants/layers.js' @@ -52,6 +54,7 @@ const validLayerProperties = [ 'labelFontWeight', 'labelFontColor', 'labelTemplate', + 'legendDecimalPlaces', 'lastUpdated', 'layer', 'layerId', @@ -191,6 +194,16 @@ const models2objects = (layer, cleanMapviewConfig) => { delete layer.relationshipLineColor delete layer.relationshipOutsideProgram delete layer.periodType + } else if (layerType === THEMATIC_LAYER || layerType === EVENT_LAYER) { + if (cleanMapviewConfig) { + if (layer.legendDecimalPlaces !== undefined) { + layer.config = JSON.stringify({ + legendDecimalPlaces: layer.legendDecimalPlaces, + }) + } + } + + delete layer.legendDecimalPlaces } else if (layerType === GEOJSON_URL_LAYER) { if (cleanMapviewConfig) { layer.config = { diff --git a/src/util/legend.js b/src/util/legend.js index 270d85683..335edab52 100644 --- a/src/util/legend.js +++ b/src/util/legend.js @@ -160,16 +160,23 @@ export const getAutomaticLegendItems = ({ method = CLASSIFICATION_EQUAL_INTERVALS, classes = defaultClasses, colorScale = defaultColorScale, + legendDecimalPlaces, }) => { if (data.length === 0) { return { items: [] } } - const classification = getLegendItems(data, method, classes) + const classification = getLegendItems(data, method, { + numClasses: classes, + precision: legendDecimalPlaces, + }) return { items: classification.items.map((item, index) => ({ ...item, color: colorScale[index], + ...(legendDecimalPlaces !== undefined && { + decimalPlaces: legendDecimalPlaces, + }), })), valueFormat: classification.valueFormat, } diff --git a/src/util/styleByDataItem.js b/src/util/styleByDataItem.js index c58c89991..b3d3505ad 100644 --- a/src/util/styleByDataItem.js +++ b/src/util/styleByDataItem.js @@ -137,6 +137,7 @@ const styleByNumeric = async (config, engine) => { colorScale, eventPointColor, eventPointRadius, + legendDecimalPlaces, } = config let valueFormat @@ -170,6 +171,7 @@ const styleByNumeric = async (config, engine) => { method, classes, colorScale, + legendDecimalPlaces, }) legend.items = classification.items valueFormat = classification.valueFormat From c520c9bf648867ae42ddc0639dddf183c425eb3b Mon Sep 17 00:00:00 2001 From: Bruno Raimbault Date: Sun, 26 Apr 2026 22:53:45 +0200 Subject: [PATCH 09/10] chore: add tests --- .../datatable/__tests__/useTableData.spec.jsx | 91 +++++++++++++++++++ src/util/__tests__/classify.spec.js | 26 +++++- src/util/__tests__/favorites.spec.js | 84 +++++++++++++++++ src/util/__tests__/legend.spec.js | 49 ++++++++++ 4 files changed, 249 insertions(+), 1 deletion(-) diff --git a/src/components/datatable/__tests__/useTableData.spec.jsx b/src/components/datatable/__tests__/useTableData.spec.jsx index 1340843b7..64355c186 100644 --- a/src/components/datatable/__tests__/useTableData.spec.jsx +++ b/src/components/datatable/__tests__/useTableData.spec.jsx @@ -289,6 +289,97 @@ describe('useTableData headers', () => { expect(isLoading).toBe(false) }) + test('treats NUMBER header with optionSet as string type', () => { + const store = { aggregations: {} } + const layer = { + layer: 'event', + dataFilters: null, + isExtended: true, + headers: [ + { + name: 'AbCdEfGhIjK', + column: 'Severity', + valueType: 'NUMBER', + optionSet: { id: 'xyz123' }, + }, + ], + data: [ + { + properties: { + id: 'evt1', + type: 'Point', + ouname: 'Test OU', + eventdate: '2023-01-01', + AbCdEfGhIjK: 'high', + }, + }, + ], + } + + const { result } = renderHook( + () => + useTableData({ + layer, + sortField: 'name', + sortDirection: 'asc', + }), + { + wrapper: ({ children }) => ( + {children} + ), + } + ) + + const { headers } = result.current + const severityHeader = headers.find((h) => h.dataKey === 'AbCdEfGhIjK') + expect(severityHeader.type).toBe('string') + }) + + test('treats NUMBER header without optionSet as number type', () => { + const store = { aggregations: {} } + const layer = { + layer: 'event', + dataFilters: null, + isExtended: true, + headers: [ + { + name: 'AbCdEfGhIjK', + column: 'Score', + valueType: 'NUMBER', + }, + ], + data: [ + { + properties: { + id: 'evt2', + type: 'Point', + ouname: 'Test OU', + eventdate: '2023-01-01', + AbCdEfGhIjK: 42, + }, + }, + ], + } + + const { result } = renderHook( + () => + useTableData({ + layer, + sortField: 'name', + sortDirection: 'asc', + }), + { + wrapper: ({ children }) => ( + {children} + ), + } + ) + + const { headers } = result.current + const scoreHeader = headers.find((h) => h.dataKey === 'AbCdEfGhIjK') + expect(scoreHeader.type).toBe('number') + }) + test('gets headers and rows for EE population layer', () => { const store = { aggregations: { diff --git a/src/util/__tests__/classify.spec.js b/src/util/__tests__/classify.spec.js index 07d35b9b4..3da31f588 100644 --- a/src/util/__tests__/classify.spec.js +++ b/src/util/__tests__/classify.spec.js @@ -18,7 +18,7 @@ jest.mock('../helpers.js', () => ({ })) jest.mock('../numbers.js', () => ({ - getRoundToPrecisionFn: jest.fn(() => (val) => Number(val.toFixed(2))), + getRoundToPrecisionFn: jest.fn((p) => (val) => Number(val.toFixed(p ?? 2))), })) describe('getLegendItemForValue', () => { @@ -356,4 +356,28 @@ describe('getLegendItems', () => { items[0] ) }) + + it('respects precision: 0 by rounding range values to whole numbers', () => { + const { items } = getLegendItems( + [0, 100], + CLASSIFICATION_EQUAL_INTERVALS, + { numClasses: 4, precision: 0 } + ) + items.forEach(({ startValue, endValue }) => { + expect(Number.isInteger(startValue)).toBe(true) + expect(Number.isInteger(endValue)).toBe(true) + }) + }) + + it('respects precision: 3 by rounding range values to 3 decimal places', () => { + const { items } = getLegendItems( + [0, 1], + CLASSIFICATION_EQUAL_INTERVALS, + { numClasses: 4, precision: 3 } + ) + items.forEach(({ startValue, endValue }) => { + expect(startValue).toBe(Number.parseFloat(startValue.toFixed(3))) + expect(endValue).toBe(Number.parseFloat(endValue.toFixed(3))) + }) + }) }) diff --git a/src/util/__tests__/favorites.spec.js b/src/util/__tests__/favorites.spec.js index c81c444f4..76d9985d6 100644 --- a/src/util/__tests__/favorites.spec.js +++ b/src/util/__tests__/favorites.spec.js @@ -286,6 +286,90 @@ describe('cleanMapConfig', () => { }) }) + test('serializes legendDecimalPlaces into config JSON for thematic layer', () => { + const config = { + mapViews: [ + { + layer: 'thematic', + name: 'ANC 1 Coverage', + opacity: 1, + legendDecimalPlaces: 2, + isLoaded: true, + isLoading: false, + isExpanded: true, + isVisible: true, + }, + ], + } + + const cleanedConfig = cleanMapConfig({ + config, + defaultBasemapId: 'thedefaultBasemap', + }) + + expect(cleanedConfig.mapViews[0].config).toBe( + '{"legendDecimalPlaces":2}' + ) + expect(cleanedConfig.mapViews[0]).not.toHaveProperty( + 'legendDecimalPlaces' + ) + }) + + test('serializes legendDecimalPlaces into config JSON for event layer', () => { + const config = { + mapViews: [ + { + layer: 'event', + name: 'Birth weight', + opacity: 1, + legendDecimalPlaces: 0, + isLoaded: true, + isLoading: false, + isExpanded: true, + isVisible: true, + }, + ], + } + + const cleanedConfig = cleanMapConfig({ + config, + defaultBasemapId: 'thedefaultBasemap', + }) + + expect(cleanedConfig.mapViews[0].config).toBe( + '{"legendDecimalPlaces":0}' + ) + expect(cleanedConfig.mapViews[0]).not.toHaveProperty( + 'legendDecimalPlaces' + ) + }) + + test('does not add config for thematic layer without legendDecimalPlaces', () => { + const config = { + mapViews: [ + { + layer: 'thematic', + name: 'ANC 1 Coverage', + opacity: 1, + isLoaded: true, + isLoading: false, + isExpanded: true, + isVisible: true, + }, + ], + } + + const cleanedConfig = cleanMapConfig({ + config, + defaultBasemapId: 'thedefaultBasemap', + }) + + expect(cleanedConfig.mapViews[0]).not.toHaveProperty('config') + expect(cleanedConfig.mapViews[0]).not.toHaveProperty( + 'legendDecimalPlaces' + ) + }) + test('correctly converts TEI mapview', () => { const config = { bounds: [ diff --git a/src/util/__tests__/legend.spec.js b/src/util/__tests__/legend.spec.js index 115860109..a919ffad0 100644 --- a/src/util/__tests__/legend.spec.js +++ b/src/util/__tests__/legend.spec.js @@ -14,6 +14,7 @@ import { getPredefinedLegendItems, getAutomaticLegendItems, getRenderingLabel, + parseRange, } from '../legend.js' describe('sortLegendItems', () => { @@ -185,6 +186,54 @@ describe('legend utils', () => { expect(items.length).toBe(3) expect(typeof valueFormat).toBe('function') }) + + it('adds decimalPlaces to each item when legendDecimalPlaces is set', () => { + const { items } = getAutomaticLegendItems({ + data: [0, 100], + method: CLASSIFICATION_EQUAL_INTERVALS, + classes: 4, + colorScale: defaultColorScale, + legendDecimalPlaces: 2, + }) + items.forEach((item) => { + expect(item.decimalPlaces).toBe(2) + }) + }) + + it('adds decimalPlaces: 0 when legendDecimalPlaces is 0', () => { + const { items } = getAutomaticLegendItems({ + data: [0, 100], + method: CLASSIFICATION_EQUAL_INTERVALS, + classes: 4, + colorScale: defaultColorScale, + legendDecimalPlaces: 0, + }) + items.forEach((item) => { + expect(item.decimalPlaces).toBe(0) + }) + }) + + it('does not add decimalPlaces when legendDecimalPlaces is undefined', () => { + const { items } = getAutomaticLegendItems({ + data: [0, 100], + method: CLASSIFICATION_EQUAL_INTERVALS, + classes: 4, + colorScale: defaultColorScale, + }) + items.forEach((item) => { + expect(item).not.toHaveProperty('decimalPlaces') + }) + }) + }) + + describe('parseRange', () => { + it('parses a range string into numeric start and end values', () => { + expect(parseRange('10 - 20')).toEqual([10, 20]) + }) + + it('parses a range string with decimal values', () => { + expect(parseRange('1.5 - 3.75')).toEqual([1.5, 3.75]) + }) }) describe('getRenderingLabel', () => { From 12611494e34652bd0dff4b5d0f170cd5cfd14668 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault Date: Mon, 27 Apr 2026 14:55:14 +0200 Subject: [PATCH 10/10] chore: move decimalPlaces from legend items to legend object [DHIS2-3156] --- i18n/en.pot | 12 ++---------- src/components/legend/Legend.jsx | 12 ++++++++++-- src/loaders/eventLoader.js | 3 +++ src/loaders/thematicLoader.js | 7 +++++-- src/util/__tests__/legend.spec.js | 30 ++++++++++++++++-------------- src/util/legend.js | 3 --- 6 files changed, 36 insertions(+), 31 deletions(-) diff --git a/i18n/en.pot b/i18n/en.pot index 6c753a842..332c55c5f 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-04-26T16:23:04.108Z\n" -"PO-Revision-Date: 2026-04-26T16:23:04.108Z\n" +"POT-Creation-Date: 2026-04-27T11:12:19.709Z\n" +"PO-Revision-Date: 2026-04-27T11:12:19.709Z\n" msgid "2020" msgstr "2020" @@ -1827,11 +1827,3 @@ msgstr "End date is invalid" msgid "End date cannot be earlier than start date" msgstr "End date cannot be earlier than start date" - -msgctxt "Application title" -msgid "__MANIFEST_APP_TITLE" -msgstr "Maps" - -msgctxt "Application description" -msgid "__MANIFEST_APP_DESCRIPTION" -msgstr "DHIS2 Maps" diff --git a/src/components/legend/Legend.jsx b/src/components/legend/Legend.jsx index 91ce3e822..6ad671885 100644 --- a/src/components/legend/Legend.jsx +++ b/src/components/legend/Legend.jsx @@ -19,6 +19,7 @@ const Legend = ({ url, source, sourceUrl, + decimalPlaces, isPlugin = false, }) => (
@@ -44,8 +45,14 @@ const Legend = ({ Array.isArray(items) && (
- {sortLegendItems(items).map((item, index) => ( - + {sortLegendItems(items).map((item) => ( + ))}
@@ -97,6 +104,7 @@ Legend.propTypes = { color: PropTypes.string, }), coordinateFields: PropTypes.array, + decimalPlaces: PropTypes.number, description: PropTypes.string, explanation: PropTypes.array, filters: PropTypes.array, diff --git a/src/loaders/eventLoader.js b/src/loaders/eventLoader.js index 038254945..03797a9c5 100644 --- a/src/loaders/eventLoader.js +++ b/src/loaders/eventLoader.js @@ -151,6 +151,9 @@ const loadEventLayer = async ({ getDateArray(endDate) ), items: [], + ...(config.legendDecimalPlaces !== undefined && { + decimalPlaces: config.legendDecimalPlaces, + }), } // Delete serverCluster option if previously set diff --git a/src/loaders/thematicLoader.js b/src/loaders/thematicLoader.js index 155d6b8ed..dee84e519 100644 --- a/src/loaders/thematicLoader.js +++ b/src/loaders/thematicLoader.js @@ -201,6 +201,9 @@ const thematicLoader = async ({ getDateArray(config.endDate) ), items: legendItems, + ...(config.legendDecimalPlaces !== undefined && { + decimalPlaces: config.legendDecimalPlaces, + }), } if (dimensions && dimensions.length) { @@ -315,11 +318,11 @@ const thematicLoader = async ({ properties.range = `${formatWithSeparator( legendItem.startValue, keyAnalysisDigitGroupSeparator, - { precision: legendItem.decimalPlaces } + { precision: config.legendDecimalPlaces } )} - ${formatWithSeparator( legendItem.endValue, keyAnalysisDigitGroupSeparator, - { precision: legendItem.decimalPlaces } + { precision: config.legendDecimalPlaces } )}` // Shown in data table } diff --git a/src/util/__tests__/legend.spec.js b/src/util/__tests__/legend.spec.js index a919ffad0..c7492620c 100644 --- a/src/util/__tests__/legend.spec.js +++ b/src/util/__tests__/legend.spec.js @@ -187,41 +187,43 @@ describe('legend utils', () => { expect(typeof valueFormat).toBe('function') }) - it('adds decimalPlaces to each item when legendDecimalPlaces is set', () => { + it('does not add decimalPlaces when legendDecimalPlaces is undefined', () => { const { items } = getAutomaticLegendItems({ data: [0, 100], method: CLASSIFICATION_EQUAL_INTERVALS, classes: 4, colorScale: defaultColorScale, - legendDecimalPlaces: 2, }) items.forEach((item) => { - expect(item.decimalPlaces).toBe(2) + expect(item).not.toHaveProperty('decimalPlaces') }) }) - it('adds decimalPlaces: 0 when legendDecimalPlaces is 0', () => { + it('applies legendDecimalPlaces as precision to item boundary values', () => { const { items } = getAutomaticLegendItems({ - data: [0, 100], + data: [0, 0.5, 1], method: CLASSIFICATION_EQUAL_INTERVALS, - classes: 4, + classes: 3, colorScale: defaultColorScale, - legendDecimalPlaces: 0, - }) - items.forEach((item) => { - expect(item.decimalPlaces).toBe(0) + legendDecimalPlaces: 2, }) + expect(items[0].endValue).toBe(0.33) + expect(items[1].startValue).toBe(0.33) + expect(items[1].endValue).toBe(0.67) + expect(items[2].startValue).toBe(0.67) }) - it('does not add decimalPlaces when legendDecimalPlaces is undefined', () => { + it('rounds item boundary values to integers when legendDecimalPlaces is 0', () => { const { items } = getAutomaticLegendItems({ - data: [0, 100], + data: [0, 0.5, 1], method: CLASSIFICATION_EQUAL_INTERVALS, - classes: 4, + classes: 3, colorScale: defaultColorScale, + legendDecimalPlaces: 0, }) items.forEach((item) => { - expect(item).not.toHaveProperty('decimalPlaces') + expect(Number.isInteger(item.startValue)).toBe(true) + expect(Number.isInteger(item.endValue)).toBe(true) }) }) }) diff --git a/src/util/legend.js b/src/util/legend.js index 335edab52..390d3f561 100644 --- a/src/util/legend.js +++ b/src/util/legend.js @@ -174,9 +174,6 @@ export const getAutomaticLegendItems = ({ items: classification.items.map((item, index) => ({ ...item, color: colorScale[index], - ...(legendDecimalPlaces !== undefined && { - decimalPlaces: legendDecimalPlaces, - }), })), valueFormat: classification.valueFormat, }