diff --git a/i18n/en.pot b/i18n/en.pot index 545bed738..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-26T11:10:26.962Z\n" -"PO-Revision-Date: 2026-04-26T11:10:26.963Z\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" @@ -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" @@ -353,8 +359,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 +368,6 @@ msgstr "Min" msgid "Max" msgstr "Max" -msgid "Steps" -msgstr "Steps" - msgid "Facility buffer" msgstr "Facility buffer" @@ -1824,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/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/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/__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/components/datatable/useTableData.js b/src/components/datatable/useTableData.js index 778d54c7c..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' @@ -111,12 +112,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]) @@ -307,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/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} /> { @@ -120,6 +121,7 @@ const Bubbles = ({ keyAnalysisDigitGroupSeparator, { force: true, + precision: legendDecimalPlaces, } ) } @@ -186,6 +188,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..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/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/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/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 076775831..03797a9c5 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, @@ -144,6 +151,9 @@ const loadEventLayer = async ({ getDateArray(endDate) ), items: [], + ...(config.legendDecimalPlaces !== undefined && { + decimalPlaces: config.legendDecimalPlaces, + }), } // Delete serverCluster option if previously set @@ -200,7 +210,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) { diff --git a/src/loaders/thematicLoader.js b/src/loaders/thematicLoader.js index 397d0eba5..d5f472a1f 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 @@ -193,6 +201,9 @@ const thematicLoader = async ({ getDateArray(config.endDate) ), items: legendItems, + ...(config.legendDecimalPlaces !== undefined && { + decimalPlaces: config.legendDecimalPlaces, + }), } if (dimensions && dimensions.length) { @@ -223,6 +234,7 @@ const thematicLoader = async ({ minValue, maxValue, color: isSingleColor ? colorScale : null, + legendDecimalPlaces: config.legendDecimalPlaces, } } @@ -309,10 +321,12 @@ const thematicLoader = async ({ properties.legend = legendItem.name // Shown in data table properties.range = `${formatWithSeparator( legendItem.startValue, - keyAnalysisDigitGroupSeparator + keyAnalysisDigitGroupSeparator, + { precision: config.legendDecimalPlaces } )} - ${formatWithSeparator( legendItem.endValue, - keyAnalysisDigitGroupSeparator + keyAnalysisDigitGroupSeparator, + { precision: config.legendDecimalPlaces } )}` // Shown in data table } diff --git a/src/reducers/layerEdit.js b/src/reducers/layerEdit.js index 9474d0c17..a5885cadf 100644 --- a/src/reducers/layerEdit.js +++ b/src/reducers/layerEdit.js @@ -309,6 +309,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..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', () => { @@ -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,10 +350,34 @@ describe('getLegendItems', () => { const { items } = getLegendItems( [7, 7, 7], CLASSIFICATION_EQUAL_INTERVALS, - 5 + { numClasses: 5 } ) expect(getLegendItemForValue({ value: 7, legendItems: items })).toEqual( 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..c7492620c 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,56 @@ describe('legend utils', () => { expect(items.length).toBe(3) expect(typeof valueFormat).toBe('function') }) + + 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') + }) + }) + + it('applies legendDecimalPlaces as precision to item boundary values', () => { + const { items } = getAutomaticLegendItems({ + data: [0, 0.5, 1], + method: CLASSIFICATION_EQUAL_INTERVALS, + classes: 3, + colorScale: defaultColorScale, + 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('rounds item boundary values to integers when legendDecimalPlaces is 0', () => { + const { items } = getAutomaticLegendItems({ + data: [0, 0.5, 1], + method: CLASSIFICATION_EQUAL_INTERVALS, + classes: 3, + colorScale: defaultColorScale, + legendDecimalPlaces: 0, + }) + items.forEach((item) => { + expect(Number.isInteger(item.startValue)).toBe(true) + expect(Number.isInteger(item.endValue)).toBe(true) + }) + }) + }) + + 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', () => { 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 d21c1499f..390d3f561 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 @@ -154,12 +160,16 @@ 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, 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