From 9269581f4fc57095d2c567558e18392ed9588ef2 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault Date: Mon, 27 Apr 2026 13:15:55 +0200 Subject: [PATCH 01/10] chore: move decimalPlaces from legend items to legend object [DHIS2-3156] --- src/components/legend/Legend.jsx | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/components/legend/Legend.jsx b/src/components/legend/Legend.jsx index 6ad671885..51fcf4285 100644 --- a/src/components/legend/Legend.jsx +++ b/src/components/legend/Legend.jsx @@ -49,9 +49,8 @@ const Legend = ({ ))} From 10628647afb96f93cb5283c4adc85c0ffc9d020f Mon Sep 17 00:00:00 2001 From: Bruno Raimbault Date: Sun, 26 Apr 2026 23:49:23 +0200 Subject: [PATCH 02/10] feat: improve predefined legend display by detecting range-encoded names [DHIS2-10823] --- src/components/legend/Legend.jsx | 156 +++++++++++----------- src/components/legend/LegendItem.jsx | 3 + src/components/legend/LegendItemRange.jsx | 8 +- src/util/__tests__/legend.spec.js | 6 +- src/util/legend.js | 40 +++++- 5 files changed, 125 insertions(+), 88 deletions(-) diff --git a/src/components/legend/Legend.jsx b/src/components/legend/Legend.jsx index 51fcf4285..78ade1a2d 100644 --- a/src/components/legend/Legend.jsx +++ b/src/components/legend/Legend.jsx @@ -1,7 +1,7 @@ import i18n from '@dhis2/d2-i18n' import PropTypes from 'prop-types' import React from 'react' -import { sortLegendItems } from '../../util/legend.js' +import { legendNamesContainRange, sortLegendItems } from '../../util/legend.js' import Bubbles from './Bubbles.jsx' import LegendItem from './LegendItem.jsx' import styles from './styles/Legend.module.css' @@ -21,80 +21,86 @@ const Legend = ({ sourceUrl, decimalPlaces, isPlugin = false, -}) => ( -
- {description &&
{description}
} - {groups && ( -
- {groups.multiple === false ? ( - <>{groups.list[0].name} - ) : ( - <> - {groups.label} - {groups.list.map(({ id, name }) => ( -
{name}
- ))} - - )} -
- )} - {unit && items &&
{unit}
} - {bubbles ? ( - - ) : ( - Array.isArray(items) && ( - - - {sortLegendItems(items).map((item) => ( - - ))} - -
- ) - )} - {url && } - {Array.isArray(coordinateFields) && ( -
-
{i18n.t('Coordinate field')}:
- {coordinateFields.map((coordinateField, index) => ( -
{coordinateField}
- ))} -
- )} - {Array.isArray(filters) && ( -
-
{i18n.t('Filters')}:
- {filters.map((filter, index) => ( -
{filter}
- ))} -
- )} - {Array.isArray(explanation) && ( -
- {explanation.map((expl, index) => ( -
{expl}
- ))} -
- )} - {source && ( -
- {i18n.t('Source')}:  - {sourceUrl ? ( - - {source} - - ) : ( - {source} - )} -
- )} -
-) +}) => { + const showRange = Array.isArray(items) && !legendNamesContainRange(items) + + return ( +
+ {description && ( +
{description}
+ )} + {groups && ( +
+ {groups.multiple === false ? ( + <>{groups.list[0].name} + ) : ( + <> + {groups.label} + {groups.list.map(({ id, name }) => ( +
{name}
+ ))} + + )} +
+ )} + {unit && items &&
{unit}
} + {bubbles ? ( + + ) : ( + Array.isArray(items) && ( + + + {sortLegendItems(items).map((item) => ( + + ))} + +
+ ) + )} + {url && } + {Array.isArray(coordinateFields) && ( +
+
{i18n.t('Coordinate field')}:
+ {coordinateFields.map((coordinateField, index) => ( +
{coordinateField}
+ ))} +
+ )} + {Array.isArray(filters) && ( +
+
{i18n.t('Filters')}:
+ {filters.map((filter, index) => ( +
{filter}
+ ))} +
+ )} + {Array.isArray(explanation) && ( +
+ {explanation.map((expl, index) => ( +
{expl}
+ ))} +
+ )} + {source && ( +
+ {i18n.t('Source')}:  + {sourceUrl ? ( + + {source} + + ) : ( + {source} + )} +
+ )} +
+ ) +} Legend.propTypes = { bubbles: PropTypes.shape({ diff --git a/src/components/legend/LegendItem.jsx b/src/components/legend/LegendItem.jsx index 286c6a3e4..12cabdce7 100644 --- a/src/components/legend/LegendItem.jsx +++ b/src/components/legend/LegendItem.jsx @@ -17,6 +17,7 @@ const LegendItem = ({ radius, weight, name, + showRange, startValue, endValue, count, @@ -64,6 +65,7 @@ const LegendItem = ({ { }) describe('getPredefinedLegendItems', () => { - it('returns legends sorted and clears name when equals range', () => { + it('returns legends sorted and preserves names as-is', () => { const legendSet = { legends: [ { @@ -145,8 +145,8 @@ describe('legend utils', () => { const result = getPredefinedLegendItems(legendSet) // sorted by startValue -> first item is startValue 0 (name 'A') expect(result[0].name).toBe('A') - // second item had name equal to range and should be cleared - expect(result[1].name).toBe('') + // name equal to range is preserved as-is + expect(result[1].name).toBe('10 - 20') }) }) diff --git a/src/util/legend.js b/src/util/legend.js index 390d3f561..d34ce6c08 100644 --- a/src/util/legend.js +++ b/src/util/legend.js @@ -146,13 +146,7 @@ export const getLabelsFromLegendItems = (legendItems) => { export const getPredefinedLegendItems = (legendSet) => { const pickSome = pick(['name', 'startValue', 'endValue', 'color']) - return sortBy('startValue', legendSet.legends) - .map(pickSome) - .map((item) => - item.name === `${item.startValue} - ${item.endValue}` - ? { ...item, name: '' } // Clear name if same as startValue - endValue - : item - ) + return sortBy('startValue', legendSet.legends).map(pickSome) } export const getAutomaticLegendItems = ({ @@ -186,3 +180,35 @@ export const getRenderingLabel = (strategy) => { } return map[strategy] ? ' • ' + map[strategy] : null } + +const normalize = (str) => String(str).replaceAll(/[\s,]/g, '') + +const nameContainsValue = (name, val) => { + const normalizedName = normalize(name) + const normalizedVal = normalize(val) + return new RegExp(String.raw`(? + (String(startValue) !== '' && nameContainsValue(name, startValue)) || + (String(endValue) !== '' && nameContainsValue(name, endValue)) + +export const legendNamesContainRange = (items) => { + const numericItems = items.filter( + ({ startValue, endValue }) => + !Number.isNaN(startValue) && !Number.isNaN(endValue) + ) + + if (!numericItems.length) { + return false + } + + const itemsWithRange = numericItems.filter( + ({ name = '', startValue, endValue }) => + rangeInName(name, startValue, endValue) + ) + + return itemsWithRange.length / numericItems.length >= 0.5 +} From d103bd0c70e12e4ceadbe973daeb345b35a458bb Mon Sep 17 00:00:00 2001 From: Bruno Raimbault Date: Tue, 28 Apr 2026 09:14:54 +0200 Subject: [PATCH 03/10] feat: add isolated class to layer classification [DHIS2-15514] --- i18n/en.pot | 38 ++-- src/actions/layerEdit.js | 5 + .../classification/Classification.jsx | 2 + .../classification/IsolatedClass.jsx | 104 ++++++++++ .../classification/NumericLegendStyle.jsx | 6 +- .../styles/Classification.module.css | 30 +++ src/components/core/Checkbox.jsx | 2 +- src/components/core/Tab.jsx | 16 +- src/components/core/TextField.jsx | 3 + src/components/datatable/ResizeHandle.jsx | 2 +- src/components/edit/event/EventDialog.jsx | 11 ++ .../edit/thematic/ThematicDialog.jsx | 4 + .../edit/thematic/validateThematicLayer.js | 9 + src/components/legend/Bubbles.jsx | 183 ++++++++++++------ src/components/legend/Legend.jsx | 12 +- src/components/legend/LegendItemRange.jsx | 21 +- src/constants/actionTypes.js | 1 + src/loaders/eventLoader.js | 15 +- src/loaders/thematicLoader.js | 67 +++++-- src/reducers/layerEdit.js | 13 ++ src/util/classify.js | 22 ++- src/util/favorites.js | 13 +- src/util/legend.js | 47 ++++- src/util/numbers.js | 9 + src/util/styleByDataItem.js | 2 + 25 files changed, 499 insertions(+), 138 deletions(-) create mode 100644 src/components/classification/IsolatedClass.jsx diff --git a/i18n/en.pot b/i18n/en.pot index 332c55c5f..4de817029 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-27T11:12:19.709Z\n" -"PO-Revision-Date: 2026-04-27T11:12:19.709Z\n" +"POT-Creation-Date: 2026-04-27T11:58:44.556Z\n" +"PO-Revision-Date: 2026-04-27T11:58:44.557Z\n" msgid "2020" msgstr "2020" @@ -44,18 +44,30 @@ msgstr "Auto" msgid "Decimal places" msgstr "Decimal places" -msgid "Legend set" -msgstr "Legend set" +msgid "Isolated class" +msgstr "Isolated class" + +msgid "Min" +msgstr "Min" + +msgid "Max" +msgstr "Max" + +msgid "Max should be greater than min" +msgstr "Max should be greater than min" msgid "Color" msgstr "Color" -msgid "Size" -msgstr "Size" - msgid "Name" msgstr "Name" +msgid "Legend set" +msgstr "Legend set" + +msgid "Size" +msgstr "Size" + msgid "Name and value" msgstr "Name and value" @@ -356,18 +368,9 @@ msgstr "Min value is required" msgid "Max value is required" msgstr "Max value is required" -msgid "Max should be greater than min" -msgstr "Max should be greater than min" - msgid "Valid classes are {{minSteps}} to {{maxSteps}}" msgstr "Valid classes are {{minSteps}} to {{maxSteps}}" -msgid "Min" -msgstr "Min" - -msgid "Max" -msgstr "Max" - msgid "Facility buffer" msgstr "Facility buffer" @@ -401,6 +404,9 @@ msgstr "No organisation units are selected." msgid "No legend set is selected" msgstr "No legend set is selected" +msgid "Isolated class max should be greater than min" +msgstr "Isolated class max should be greater than min" + msgid "Event status" msgstr "Event status" diff --git a/src/actions/layerEdit.js b/src/actions/layerEdit.js index eaf16f49d..12858de9b 100644 --- a/src/actions/layerEdit.js +++ b/src/actions/layerEdit.js @@ -97,6 +97,11 @@ export const setLegendDecimalPlaces = (legendDecimalPlaces) => ({ legendDecimalPlaces, }) +export const setLegendIsolated = (legendIsolated) => ({ + type: types.LAYER_EDIT_LEGEND_ISOLATED_SET, + legendIsolated, +}) + // Set event status export const setEventStatus = (status) => ({ type: types.LAYER_EDIT_EVENT_STATUS_SET, diff --git a/src/components/classification/Classification.jsx b/src/components/classification/Classification.jsx index 0fd5d3aac..1c1f50e68 100644 --- a/src/components/classification/Classification.jsx +++ b/src/components/classification/Classification.jsx @@ -21,6 +21,7 @@ import { } from '../../util/colors.js' import { SelectField, ColorScaleSelect } from '../core/index.js' import DecimalPlacesSelect from './DecimalPlacesSelect.jsx' +import IsolatedClass from './IsolatedClass.jsx' import styles from './styles/Classification.module.css' const classRange = range(3, 10).map((num) => ({ @@ -73,6 +74,7 @@ const Classification = ({ width={190} className={styles.scale} /> + , ] } diff --git a/src/components/classification/IsolatedClass.jsx b/src/components/classification/IsolatedClass.jsx new file mode 100644 index 000000000..0ce1b5bf9 --- /dev/null +++ b/src/components/classification/IsolatedClass.jsx @@ -0,0 +1,104 @@ +import i18n from '@dhis2/d2-i18n' +import { Help } from '@dhis2/ui' +import PropTypes from 'prop-types' +import React, { useRef } from 'react' +import { connect } from 'react-redux' +import { setLegendIsolated } from '../../actions/layerEdit.js' +import { NO_DATA_COLOR } from '../../constants/layers.js' +import { Checkbox, ColorPicker, NumberField, TextField } from '../core/index.js' +import styles from './styles/Classification.module.css' + +export const isValidIsolatedClass = ({ min, max } = {}) => + min === undefined || max === undefined || min <= max + +const DEFAULT_ISOLATED = { min: 0, max: 0, color: NO_DATA_COLOR } + +const IsolatedClass = ({ legendIsolated, setLegendIsolated }) => { + const lastValue = useRef(null) + + const onCheckboxChange = (checked) => { + if (checked) { + setLegendIsolated(lastValue.current ?? DEFAULT_ISOLATED) + } else { + lastValue.current = legendIsolated + setLegendIsolated(undefined) + } + } + + return ( + <> + + {legendIsolated !== undefined && ( +
+
+ + setLegendIsolated({ ...legendIsolated, min }) + } + className={styles.isolatedField} + /> + + setLegendIsolated({ ...legendIsolated, max }) + } + className={styles.isolatedField} + /> +
+ {!isValidIsolatedClass(legendIsolated) && ( + + {i18n.t('Max should be greater than min')} + + )} +
+ + setLegendIsolated({ ...legendIsolated, color }) + } + width={50} + className={styles.isolatedColor} + /> + + setLegendIsolated({ + ...legendIsolated, + name: name || undefined, + }) + } + className={styles.isolatedName} + /> +
+
+ )} + + ) +} + +IsolatedClass.propTypes = { + setLegendIsolated: PropTypes.func.isRequired, + legendIsolated: PropTypes.shape({ + color: PropTypes.string, + max: PropTypes.number, + min: PropTypes.number, + name: PropTypes.string, + }), +} + +export default connect( + ({ layerEdit }) => ({ legendIsolated: layerEdit.legendIsolated }), + { setLegendIsolated } +)(IsolatedClass) diff --git a/src/components/classification/NumericLegendStyle.jsx b/src/components/classification/NumericLegendStyle.jsx index 72d869743..eae056faa 100644 --- a/src/components/classification/NumericLegendStyle.jsx +++ b/src/components/classification/NumericLegendStyle.jsx @@ -8,6 +8,7 @@ import { CLASSIFICATION_SINGLE_COLOR, } from '../../constants/layers.js' import Classification from './Classification.jsx' +import IsolatedClass from './IsolatedClass.jsx' import LegendSetSelect from './LegendSetSelect.jsx' import LegendTypeSelect from './LegendTypeSelect.jsx' import SingleColor from './SingleColor.jsx' @@ -46,7 +47,10 @@ const NumericLegendStyle = (props) => { dataItem={dataItem} /> {isSingleColor ? ( - + <> + + + ) : isPredefined ? ( { +const Tab = forwardRef(({ value, dataTest, children }, ref) => { const { tab, onChange } = useContext(TabContext) - // onChange is from the parent component const onClick = () => { if (value !== tab) { onChange(value) @@ -14,11 +13,18 @@ const Tab = ({ value, dataTest, children }) => { } return ( - + {children} ) -} +}) + +Tab.displayName = 'Tab' Tab.propTypes = { children: PropTypes.node.isRequired, diff --git a/src/components/core/TextField.jsx b/src/components/core/TextField.jsx index fd3260f2f..4efb428a8 100644 --- a/src/components/core/TextField.jsx +++ b/src/components/core/TextField.jsx @@ -9,6 +9,7 @@ const TextField = ({ type, label, value, + placeholder, dense = true, onChange, className, @@ -19,6 +20,7 @@ const TextField = ({ type={type} label={label} value={value} + placeholder={placeholder} onChange={({ value }) => onChange(value)} /> @@ -29,6 +31,7 @@ TextField.propTypes = { onChange: PropTypes.func.isRequired, className: PropTypes.string, dense: PropTypes.bool, + placeholder: PropTypes.string, type: PropTypes.string, value: PropTypes.string, } diff --git a/src/components/datatable/ResizeHandle.jsx b/src/components/datatable/ResizeHandle.jsx index 18a085b82..9049bcd5a 100644 --- a/src/components/datatable/ResizeHandle.jsx +++ b/src/components/datatable/ResizeHandle.jsx @@ -72,7 +72,7 @@ const ResizeHandle = ({ ResizeHandle.propTypes = { maxHeight: PropTypes.number.isRequired, - minHeight: PropTypes.number.isRequired, + minHeight: PropTypes.number, onResize: PropTypes.func, onResizeEnd: PropTypes.func, } diff --git a/src/components/edit/event/EventDialog.jsx b/src/components/edit/event/EventDialog.jsx index 5296b41ee..48690bc83 100644 --- a/src/components/edit/event/EventDialog.jsx +++ b/src/components/edit/event/EventDialog.jsx @@ -35,6 +35,7 @@ import { cssColor } from '../../../util/colors.js' import { getDefaultDatesInCalendar } from '../../../util/date.js' import { isPeriodAvailable } from '../../../util/periods.js' import { getStartEndDateError } from '../../../util/time.js' +import { isValidIsolatedClass } from '../../classification/IsolatedClass.jsx' import { Tab, Tabs, @@ -83,6 +84,7 @@ class EventDialog extends Component { eventStatus: PropTypes.string, // fallbackCoordinateField: PropTypes.string, filters: PropTypes.array, + legendIsolated: PropTypes.object, legendSet: PropTypes.object, method: PropTypes.number, orgUnits: PropTypes.object, @@ -449,6 +451,7 @@ class EventDialog extends Component { method, legendSet, styleDataItem, + legendIsolated, } = this.props const period = getPeriodFromFilters(filters) || { @@ -494,6 +497,14 @@ class EventDialog extends Component { ) } + if (!isValidIsolatedClass(legendIsolated)) { + return this.setErrorState( + 'isolatedClassError', + i18n.t('Isolated class max should be greater than min'), + 'style' + ) + } + if ( styleDataItem && styleDataItem.optionSet && diff --git a/src/components/edit/thematic/ThematicDialog.jsx b/src/components/edit/thematic/ThematicDialog.jsx index 0043c53e3..5db71abb2 100644 --- a/src/components/edit/thematic/ThematicDialog.jsx +++ b/src/components/edit/thematic/ThematicDialog.jsx @@ -75,6 +75,7 @@ const ThematicDialog = ({ radiusHigh, method, thematicMapType, + legendIsolated, }) => { const dispatch = useDispatch() const { @@ -301,6 +302,7 @@ const ThematicDialog = ({ renderingStrategy, method, periods, + legendIsolated, }) }, [ dataItem, @@ -314,6 +316,7 @@ const ThematicDialog = ({ renderingStrategy, method, periods, + legendIsolated, ]) // Run layer validation @@ -597,6 +600,7 @@ ThematicDialog.propTypes = { eventStatus: PropTypes.string, filters: PropTypes.array, id: PropTypes.string, + legendIsolated: PropTypes.object, legendSet: PropTypes.object, method: PropTypes.number, noDataColor: PropTypes.string, diff --git a/src/components/edit/thematic/validateThematicLayer.js b/src/components/edit/thematic/validateThematicLayer.js index 6de5fe1a1..27db89fb7 100644 --- a/src/components/edit/thematic/validateThematicLayer.js +++ b/src/components/edit/thematic/validateThematicLayer.js @@ -13,6 +13,7 @@ import { import { getOrgUnitsFromRows } from '../../../util/analytics.js' import { countPeriods } from '../../../util/periods.js' import { getStartEndDateError } from '../../../util/time.js' +import { isValidIsolatedClass } from '../../classification/IsolatedClass.jsx' import { isValidRadius } from './RadiusSelect.jsx' export const validateThematicLayer = ({ @@ -27,6 +28,7 @@ export const validateThematicLayer = ({ renderingStrategy, method, periods, + legendIsolated, }) => { const errors = {} const setError = ({ key, msg, tab }) => { @@ -123,6 +125,13 @@ export const validateThematicLayer = ({ key: 'radiusError', msg: i18n.t('Specified radius values are invalid'), tab: 'style', + }, + { + // Isolated class + condition: !isValidIsolatedClass(legendIsolated), + key: 'isolatedClassError', + msg: i18n.t('Isolated class max should be greater than min'), + tab: 'style', } ) diff --git a/src/components/legend/Bubbles.jsx b/src/components/legend/Bubbles.jsx index 171120caf..abbd9d9d1 100644 --- a/src/components/legend/Bubbles.jsx +++ b/src/components/legend/Bubbles.jsx @@ -7,7 +7,10 @@ import { createSingleColorBubbles, computeLayout, } from '../../util/bubbles.js' -import { formatWithSeparator } from '../../util/numbers.js' +import { + formatRangeWithSeparator, + formatWithSeparator, +} from '../../util/numbers.js' import { useCachedData } from '../cachedDataProvider/CachedDataProvider.jsx' import Bubble from './Bubble.jsx' @@ -18,13 +21,32 @@ export const digitWidth = 6.8 export const guideLength = 16 export const textPadding = 4 +const formatBubbleText = ( + bubbles, + keyAnalysisDigitGroupSeparator, + legendDecimalPlaces +) => { + bubbles.forEach((bubble) => { + if (bubble.text !== undefined) { + bubble.text = formatWithSeparator( + bubble.text, + keyAnalysisDigitGroupSeparator, + { + force: true, + precision: legendDecimalPlaces, + } + ) + } + }) +} + const filterBubbleText = (bubbles, showNumbers) => { if (!showNumbers) { return } - bubbles.forEach((b, i) => { + bubbles.forEach((bubble, i) => { if (!showNumbers.includes(i)) { - delete b.text + delete bubble.text } }) } @@ -38,6 +60,8 @@ const computeBubbleLayout = ({ radiusLow, radiusHigh, legendWidth, + legendDecimalPlaces, + keyAnalysisDigitGroupSeparator, }) => { const bubbles = bubbleClasses.length ? createBubbleItems({ @@ -56,17 +80,56 @@ const computeBubbleLayout = ({ radiusHigh, }) + formatBubbleText( + bubbles, + keyAnalysisDigitGroupSeparator, + legendDecimalPlaces + ) + const layout = computeLayout({ bubbles, bubbleClasses, radiusHigh, legendWidth, }) + filterBubbleText(bubbles, layout.showNumbers) return { bubbles, alternate: layout.alternate, offset: layout.offset } } +const SpecialClassRow = ({ tx, ty, radiusHigh, cy, color, label, count }) => ( + <> + + + {label} + {count !== undefined && ` (${count})`} + + +) + +SpecialClassRow.propTypes = { + cy: PropTypes.number.isRequired, + radiusHigh: PropTypes.number.isRequired, + tx: PropTypes.number.isRequired, + ty: PropTypes.number.isRequired, + color: PropTypes.string, + count: PropTypes.number, + label: PropTypes.string, +} + const Bubbles = ({ radiusLow, radiusHigh, @@ -80,29 +143,37 @@ const Bubbles = ({ const { systemSettings: { keyAnalysisDigitGroupSeparator }, } = useCachedData() - const legendWidth = isPlugin ? 150 : 245 - const noDataClass = classes.find((c) => c.noData === true) - const bubbleClasses = classes.filter((c) => !c.noData) - const hasDataRange = minValue != null && maxValue != null - const height = hasDataRange - ? radiusHigh * 2 + 4 - : THEMATIC_RADIUS_DEFAULT + 2 - const scale = scaleSqrt().range([radiusLow, radiusHigh]) - const noDataTranslateY = hasDataRange ? 20 : 0 - if (isNaN(radiusLow) || isNaN(radiusHigh)) { + const noDataClass = classes.find((c) => c.noData) + const isolatedClass = classes.find((c) => c.isLegendIsolated) + const bubbleClasses = classes.filter( + (c) => !c.noData && !c.isLegendIsolated + ) + + const hasDataRange = minValue != null && maxValue != null + if (!hasDataRange && !noDataClass && !isolatedClass) { return null } - - if (!hasDataRange && !noDataClass) { + if (Number.isNaN(radiusLow) || Number.isNaN(radiusHigh)) { return null } + const mainRowHeight = radiusHigh * 2 + const extraRowHeight = THEMATIC_RADIUS_DEFAULT * 2 + 4 + const ty = 10 + + const yIsolated = hasDataRange ? mainRowHeight + extraRowHeight : 0 + const yNoData = yIsolated + (isolatedClass ? extraRowHeight : 0) + + const legendHeight = yNoData + ty + THEMATIC_RADIUS_DEFAULT + 2 + const legendWidth = isPlugin ? 150 : 245 + let bubbles = [] let alternate = false - let offset = '2' + let offset = 2 if (hasDataRange) { + const scale = scaleSqrt().range([radiusLow, radiusHigh]) ;({ bubbles, alternate, offset } = computeBubbleLayout({ bubbleClasses, color, @@ -112,37 +183,25 @@ const Bubbles = ({ radiusLow, radiusHigh, legendWidth, + legendDecimalPlaces, + keyAnalysisDigitGroupSeparator, })) - - bubbles.forEach((bubble) => { - if (bubble.text !== undefined) { - bubble.text = formatWithSeparator( - bubble.text, - keyAnalysisDigitGroupSeparator, - { - force: true, - precision: legendDecimalPlaces, - } - ) - } - }) } + const tx = alternate ? offset : 2 - const xTranslate = alternate ? offset : '2' + const isolatedLabel = isolatedClass + ? isolatedClass.name ?? + formatRangeWithSeparator( + isolatedClass, + keyAnalysisDigitGroupSeparator, + { precision: legendDecimalPlaces } + ) + : null return (
- - + + {bubbles.map((bubble, i) => ( ))} + {isolatedClass && ( + + )} {noDataClass && ( - <> - {' '} - - - {noDataClass.name} - - + )}
diff --git a/src/components/legend/Legend.jsx b/src/components/legend/Legend.jsx index 78ade1a2d..d750398d1 100644 --- a/src/components/legend/Legend.jsx +++ b/src/components/legend/Legend.jsx @@ -23,6 +23,10 @@ const Legend = ({ isPlugin = false, }) => { const showRange = Array.isArray(items) && !legendNamesContainRange(items) + const getShowRange = (item) => + item.isLegendIsolated + ? !legendNamesContainRange([item]) + : !item.name || showRange return (
@@ -53,9 +57,11 @@ const Legend = ({ {sortLegendItems(items).map((item) => ( ))} diff --git a/src/components/legend/LegendItemRange.jsx b/src/components/legend/LegendItemRange.jsx index 48ff4418d..ca91512d1 100644 --- a/src/components/legend/LegendItemRange.jsx +++ b/src/components/legend/LegendItemRange.jsx @@ -1,6 +1,9 @@ import PropTypes from 'prop-types' import React from 'react' -import { formatWithSeparator } from '../../util/numbers.js' +import { + formatRangeWithSeparator, + formatWithSeparator, +} from '../../util/numbers.js' import { useCachedData } from '../cachedDataProvider/CachedDataProvider.jsx' import styles from './styles/LegendItemRange.module.css' @@ -19,19 +22,11 @@ const LegendItemRange = ({ const nameLabel = name ? `${name} ` : '' const rangeLabel = startValue !== undefined && endValue !== undefined && showRange - ? `${formatWithSeparator( - startValue, + ? formatRangeWithSeparator( + { startValue, endValue }, keyAnalysisDigitGroupSeparator, - { - precision: decimalPlaces, - } - )} - ${formatWithSeparator( - endValue, - keyAnalysisDigitGroupSeparator, - { - precision: decimalPlaces, - } - )}` + { precision: decimalPlaces } + ) : '' const countLabel = count === undefined diff --git a/src/constants/actionTypes.js b/src/constants/actionTypes.js index dac41a921..698100b83 100644 --- a/src/constants/actionTypes.js +++ b/src/constants/actionTypes.js @@ -87,6 +87,7 @@ 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_LEGEND_ISOLATED_SET = 'LAYER_EDIT_LEGEND_ISOLATED_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 03797a9c5..c98e6134f 100644 --- a/src/loaders/eventLoader.js +++ b/src/loaders/eventLoader.js @@ -102,10 +102,15 @@ const loadEventLayer = async ({ periodTypeData, loadExtended, }) => { - const { legendDecimalPlaces } = parseJsonConfig(config.config) + const { legendDecimalPlaces, legendIsolated } = parseJsonConfig( + config.config + ) if (legendDecimalPlaces !== undefined) { config.legendDecimalPlaces = legendDecimalPlaces } + if (legendIsolated !== undefined) { + config.legendIsolated = legendIsolated + } delete config.config const { @@ -180,11 +185,11 @@ const loadEventLayer = async ({ config.data = data - if (Array.isArray(config.data) && config.data.length) { - if (styleDataItem) { - await styleByDataItem(config, engine) - } + if (styleDataItem) { + await styleByDataItem(config, engine) + } + if (Array.isArray(config.data) && config.data.length) { if (total > EVENT_CLIENT_PAGE_SIZE) { alert = { warning: true, diff --git a/src/loaders/thematicLoader.js b/src/loaders/thematicLoader.js index d5f472a1f..c3bc6e6a4 100644 --- a/src/loaders/thematicLoader.js +++ b/src/loaders/thematicLoader.js @@ -33,9 +33,13 @@ import { hasValue } from '../util/helpers.js' import { getPredefinedLegendItems, getAutomaticLegendItems, + buildIsolatedLegendItem, } from '../util/legend.js' import { toGeoJson } from '../util/map.js' -import { formatWithSeparator } from '../util/numbers.js' +import { + formatRangeWithSeparator, + formatWithSeparator, +} from '../util/numbers.js' import { getCoordinateField, addAssociatedGeometries, @@ -63,10 +67,15 @@ const thematicLoader = async ({ noDataColor, } = config - const { legendDecimalPlaces } = parseJsonConfig(config.config) + const { legendDecimalPlaces, legendIsolated } = parseJsonConfig( + config.config + ) if (legendDecimalPlaces !== undefined) { config.legendDecimalPlaces = legendDecimalPlaces } + if (legendIsolated !== undefined) { + config.legendIsolated = legendIsolated + } delete config.config const dataItem = getDataItemFromColumns(columns) @@ -185,10 +194,21 @@ const thematicLoader = async ({ classes, colorScale, legendDecimalPlaces: config.legendDecimalPlaces, + legendIsolated: config.legendIsolated, }) legendItems = classification.items valueFormat = classification.valueFormat } + } else if (config.legendIsolated) { + const { min, max } = config.legendIsolated + legendItems = [buildIsolatedLegendItem(config.legendIsolated)] + const nonIsolatedValues = orderedValues.filter( + (v) => v < min || v > max + ) + if (nonIsolatedValues.length > 0) { + minValue = nonIsolatedValues[0] + maxValue = nonIsolatedValues[nonIsolatedValues.length - 1] + } } const legend = { @@ -262,6 +282,28 @@ const thematicLoader = async ({ .domain([minValue, maxValue]) .clamp(true) + const noDataLegendItem = legend.items.find((i) => i.noData === true) + + const getSingleColor = (legendItem, value) => { + if (legendItem?.isLegendIsolated) { + return legendItem.color + } + if (!hasValue(value)) { + return noDataLegendItem?.color + } + return colorScale + } + + const getFeatureRadius = (legendItem, value) => { + if (legendItem?.isLegendIsolated) { + return THEMATIC_RADIUS_DEFAULT + } + if (!hasValue(value)) { + return THEMATIC_RADIUS_DEFAULT + } + return getRadiusForValue(value) || THEMATIC_RADIUS_DEFAULT + } + if (!valueFeatures.length) { if (!features.length) { alerts.push({ @@ -293,16 +335,15 @@ const thematicLoader = async ({ const legendItem = getLegendItem(value) if (isSingleColor) { - item.color = colorScale + item.color = getSingleColor(legendItem, value) } else if (legendItem) { item.color = legendItem.color } - item.radius = getRadiusForValue(value) + item.radius = getFeatureRadius(legendItem, value) }) }) } else { - const noDataLegendItem = legend.items.find((i) => i.noData === true) valueFeatures.forEach(({ id, geometry, properties }) => { const value = valueById[id] const legendItem = getLegendItem(value) @@ -310,24 +351,18 @@ const thematicLoader = async ({ const { hasAdditionalGeometry } = properties if (isSingleColor) { - properties.color = hasValue(value) - ? colorScale - : noDataLegendItem?.color + properties.color = getSingleColor(legendItem, value) } else if (legendItem) { properties.color = hasAdditionalGeometry && isPoint ? ORG_UNIT_COLOR : legendItem.color properties.legend = legendItem.name // Shown in data table - properties.range = `${formatWithSeparator( - legendItem.startValue, - keyAnalysisDigitGroupSeparator, - { precision: config.legendDecimalPlaces } - )} - ${formatWithSeparator( - legendItem.endValue, + properties.range = formatRangeWithSeparator( + legendItem, keyAnalysisDigitGroupSeparator, { precision: config.legendDecimalPlaces } - )}` // Shown in data table + ) // Shown in data table } // Only count org units once in legend @@ -345,7 +380,7 @@ const thematicLoader = async ({ properties.rawValue = value // Numeric form for data table sorting properties.radius = hasAdditionalGeometry ? ORG_UNIT_RADIUS_SMALL - : getRadiusForValue(value) || THEMATIC_RADIUS_DEFAULT + : getFeatureRadius(legendItem, value) }) } diff --git a/src/reducers/layerEdit.js b/src/reducers/layerEdit.js index a5885cadf..71e037577 100644 --- a/src/reducers/layerEdit.js +++ b/src/reducers/layerEdit.js @@ -290,6 +290,10 @@ const layerEdit = (state = null, action) => { delete newState.legendSet } + if (action.method === CLASSIFICATION_PREDEFINED) { + delete newState.legendDecimalPlaces + } + if (newState.styleDataItem) { delete newState.styleDataItem.optionSet } @@ -321,6 +325,15 @@ const layerEdit = (state = null, action) => { return newState + case types.LAYER_EDIT_LEGEND_ISOLATED_SET: + newState = { ...state, legendIsolated: action.legendIsolated } + + if (!action.legendIsolated) { + delete newState.legendIsolated + } + + return newState + case types.LAYER_EDIT_EVENT_STATUS_SET: newState = { ...state } diff --git a/src/util/classify.js b/src/util/classify.js index 5cf68d202..658a97350 100644 --- a/src/util/classify.js +++ b/src/util/classify.js @@ -28,12 +28,26 @@ export const getLegendItemForValue = ({ value = valueFormat(value) } + const isolatedItem = legendItems.find( + (item) => + item.isLegendIsolated && + value >= item.startValue && + value <= item.endValue + ) + if (isolatedItem) { + return isolatedItem + } + if (clamp) { - if (value < legendItems[0].startValue) { - return legendItems[0] + const rangeItems = legendItems.filter((item) => !item.isLegendIsolated) + if (rangeItems.length > 0 && value < rangeItems[0].startValue) { + return rangeItems[0] } - if (value > legendItems[legendItems.length - 1].endValue) { - return legendItems[legendItems.length - 1] + if ( + rangeItems.length > 0 && + value > rangeItems[rangeItems.length - 1].endValue + ) { + return rangeItems[rangeItems.length - 1] } } diff --git a/src/util/favorites.js b/src/util/favorites.js index c729cec26..c592ad8eb 100644 --- a/src/util/favorites.js +++ b/src/util/favorites.js @@ -55,6 +55,7 @@ const validLayerProperties = [ 'labelFontColor', 'labelTemplate', 'legendDecimalPlaces', + 'legendIsolated', 'lastUpdated', 'layer', 'layerId', @@ -196,14 +197,20 @@ const models2objects = (layer, cleanMapviewConfig) => { delete layer.periodType } else if (layerType === THEMATIC_LAYER || layerType === EVENT_LAYER) { if (cleanMapviewConfig) { + const configData = {} if (layer.legendDecimalPlaces !== undefined) { - layer.config = JSON.stringify({ - legendDecimalPlaces: layer.legendDecimalPlaces, - }) + configData.legendDecimalPlaces = layer.legendDecimalPlaces + } + if (layer.legendIsolated !== undefined) { + configData.legendIsolated = layer.legendIsolated + } + if (Object.keys(configData).length) { + layer.config = JSON.stringify(configData) } } delete layer.legendDecimalPlaces + delete layer.legendIsolated } else if (layerType === GEOJSON_URL_LAYER) { if (cleanMapviewConfig) { layer.config = { diff --git a/src/util/legend.js b/src/util/legend.js index d34ce6c08..e54c7c97a 100644 --- a/src/util/legend.js +++ b/src/util/legend.js @@ -69,6 +69,13 @@ export const sortLegendItems = (items) => return -1 } + if (a.isLegendIsolated && !b.isLegendIsolated) { + return 1 + } + if (!a.isLegendIsolated && b.isLegendIsolated) { + return -1 + } + return bRange.start === aRange.start ? bRange.end - aRange.end : bRange.start - aRange.start @@ -149,26 +156,52 @@ export const getPredefinedLegendItems = (legendSet) => { return sortBy('startValue', legendSet.legends).map(pickSome) } +export const buildIsolatedLegendItem = ({ min, max, color, name }) => ({ + startValue: min, + endValue: max, + color, + isLegendIsolated: true, + ...(name && { name }), +}) + export const getAutomaticLegendItems = ({ - data, + data, // data must be sorted ascending — getLegendItems treats values[0] as min and values[last] as max method = CLASSIFICATION_EQUAL_INTERVALS, classes = defaultClasses, colorScale = defaultColorScale, legendDecimalPlaces, + legendIsolated, }) => { - if (data.length === 0) { + if (data.length === 0 && !legendIsolated) { return { items: [] } } - const classification = getLegendItems(data, method, { + let isolatedItem = null + let dataToClassify = data + + if (legendIsolated) { + const { min: isolatedMin, max: isolatedMax } = legendIsolated + dataToClassify = data.filter((v) => v < isolatedMin || v > isolatedMax) + isolatedItem = buildIsolatedLegendItem(legendIsolated) + + if (dataToClassify.length === 0) { + return { items: [isolatedItem] } + } + } + + const classification = getLegendItems(dataToClassify, method, { numClasses: classes, precision: legendDecimalPlaces, }) + const classifiedItems = classification.items?.map((item, i) => ({ + ...item, + color: colorScale[i], + })) + return { - items: classification.items.map((item, index) => ({ - ...item, - color: colorScale[index], - })), + items: isolatedItem + ? [isolatedItem, ...classifiedItems] + : classifiedItems, valueFormat: classification.valueFormat, } } diff --git a/src/util/numbers.js b/src/util/numbers.js index 66a7d4420..e91934630 100644 --- a/src/util/numbers.js +++ b/src/util/numbers.js @@ -94,6 +94,15 @@ export const formatWithSeparator = ( return decimal ? `${grouped}.${decimal}` : grouped } +export const formatRangeWithSeparator = ( + { startValue, endValue }, + separator, + { precision } = {} +) => + `${formatWithSeparator(startValue, separator, { + precision, + })} - ${formatWithSeparator(endValue, separator, { precision })}` + export const parseWithSeparator = (value) => { const num = Number(String(value).replaceAll(/[\s,]/g, '')) return Number.isNaN(num) ? undefined : num diff --git a/src/util/styleByDataItem.js b/src/util/styleByDataItem.js index b3d3505ad..89fab5ab6 100644 --- a/src/util/styleByDataItem.js +++ b/src/util/styleByDataItem.js @@ -138,6 +138,7 @@ const styleByNumeric = async (config, engine) => { eventPointColor, eventPointRadius, legendDecimalPlaces, + legendIsolated, } = config let valueFormat @@ -172,6 +173,7 @@ const styleByNumeric = async (config, engine) => { classes, colorScale, legendDecimalPlaces, + legendIsolated, }) legend.items = classification.items valueFormat = classification.valueFormat From 471d2272de2c12f8d552a93a9e52b84f032f56ca Mon Sep 17 00:00:00 2001 From: Bruno Raimbault Date: Thu, 30 Apr 2026 16:43:55 +0200 Subject: [PATCH 04/10] feat: add no data and unclassified legend controls [DHIS2-19812] --- i18n/en.pot | 31 +- src/actions/layerEdit.js | 12 +- src/components/dataItem/DataItemStyle.jsx | 31 ++ src/components/edit/shared/NoDataLegend.jsx | 26 ++ .../edit/shared/OptionalLegendItem.jsx | 63 +++ .../edit/shared/UnclassifiedLegend.jsx | 26 ++ .../edit/styles/LayerDialog.module.css | 19 + src/components/edit/thematic/NoDataColor.jsx | 39 -- .../edit/thematic/ThematicDialog.jsx | 30 +- .../thematic/styles/NoDataColor.module.css | 3 - src/components/legend/Bubbles.jsx | 23 +- src/components/legend/Legend.jsx | 2 +- src/components/map/layers/ThematicLayer.jsx | 16 +- src/constants/actionTypes.js | 4 +- src/loaders/eventLoader.js | 24 +- src/loaders/thematicLoader.js | 414 +++++++++++------- src/reducers/layerEdit.js | 16 +- src/util/__tests__/styleByDataItem.spec.js | 312 ++++++++++--- src/util/classify.js | 38 +- src/util/favorites.js | 11 + src/util/legend.js | 9 +- src/util/styleByDataItem.js | 322 ++++++++------ 22 files changed, 1020 insertions(+), 451 deletions(-) create mode 100644 src/components/edit/shared/NoDataLegend.jsx create mode 100644 src/components/edit/shared/OptionalLegendItem.jsx create mode 100644 src/components/edit/shared/UnclassifiedLegend.jsx delete mode 100644 src/components/edit/thematic/NoDataColor.jsx delete mode 100644 src/components/edit/thematic/styles/NoDataColor.module.css diff --git a/i18n/en.pot b/i18n/en.pot index 4de817029..3c604cc9a 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-27T11:58:44.556Z\n" -"PO-Revision-Date: 2026-04-27T11:58:44.557Z\n" +"POT-Creation-Date: 2026-04-28T15:36:36.629Z\n" +"PO-Revision-Date: 2026-04-28T15:36:36.629Z\n" msgid "2020" msgstr "2020" @@ -113,6 +113,12 @@ msgstr "Enrollment > event > tracked entity > org unit coordinate" msgid "Event > org unit coordinate" msgstr "Event > org unit coordinate" +msgid "Include unclassified events" +msgstr "Include unclassified events" + +msgid "Include events with no data" +msgstr "Include events with no data" + msgid "Previously selected value not available in list: {{id}}" msgstr "Previously selected value not available in list: {{id}}" @@ -452,15 +458,24 @@ msgstr "Polygons are represented by their centroids." msgid "Labels" msgstr "Labels" +msgid "Include org units with no data" +msgstr "Include org units with no data" + +msgid "No data" +msgstr "No data" + +msgid "Include unclassified org units" +msgstr "Include unclassified org units" + +msgid "Unclassified" +msgstr "Unclassified" + msgid "Aggregation type" msgstr "Aggregation type" msgid "Only show completed events" msgstr "Only show completed events" -msgid "Include org units with no data" -msgstr "Include org units with no data" - msgid "Low radius" msgstr "Low radius" @@ -723,9 +738,6 @@ msgstr "Groups" msgid "Parent unit" msgstr "Parent unit" -msgid "No data" -msgstr "No data" - msgid "Not set" msgstr "Not set" @@ -1822,9 +1834,6 @@ msgstr "Org units" msgid "Facility" msgstr "Facility" -msgid "Other" -msgstr "Other" - msgid "Start date is invalid" msgstr "Start date is invalid" diff --git a/src/actions/layerEdit.js b/src/actions/layerEdit.js index 12858de9b..458f5e557 100644 --- a/src/actions/layerEdit.js +++ b/src/actions/layerEdit.js @@ -357,10 +357,14 @@ export const setRenderingStrategy = (display) => ({ payload: display, }) -// Set no data color -export const setNoDataColor = (color) => ({ - type: types.LAYER_EDIT_NO_DATA_COLOR_SET, - payload: color, +export const setNoDataLegend = (noDataLegend) => ({ + type: types.LAYER_EDIT_NO_DATA_LEGEND_SET, + payload: noDataLegend, +}) + +export const setUnclassifiedLegend = (unclassifiedLegend) => ({ + type: types.LAYER_EDIT_UNCLASSIFIED_LEGEND_SET, + payload: unclassifiedLegend, }) // Set period for EE layer diff --git a/src/components/dataItem/DataItemStyle.jsx b/src/components/dataItem/DataItemStyle.jsx index 22c459c61..4c8743f65 100644 --- a/src/components/dataItem/DataItemStyle.jsx +++ b/src/components/dataItem/DataItemStyle.jsx @@ -1,19 +1,37 @@ +import i18n from '@dhis2/d2-i18n' import PropTypes from 'prop-types' import React from 'react' +import { useSelector, useDispatch } from 'react-redux' +import { + setNoDataLegend, + setUnclassifiedLegend, +} from '../../actions/layerEdit.js' import { numberValueTypes, booleanValueTypes, } from '../../constants/valueTypes.js' import NumericLegendStyle from '../classification/NumericLegendStyle.jsx' +import NoDataLegend from '../edit/shared/NoDataLegend.jsx' +import UnclassifiedLegend from '../edit/shared/UnclassifiedLegend.jsx' import OptionSetStyle from '../optionSet/OptionSetStyle.jsx' import BooleanStyle from './BooleanStyle.jsx' const DataItemStyle = ({ dataItem, style }) => { + const noDataLegend = useSelector((state) => state.layerEdit.noDataLegend) + const unclassifiedLegend = useSelector( + (state) => state.layerEdit.unclassifiedLegend + ) + const dispatch = useDispatch() + if (!dataItem) { return null } const { valueType, optionSet } = dataItem + const hasClassification = + numberValueTypes.includes(valueType) || + booleanValueTypes.includes(valueType) || + !!optionSet return (
@@ -29,6 +47,19 @@ const DataItemStyle = ({ dataItem, style }) => { ) : null} {optionSet ? : null} + + {hasClassification && ( + dispatch(setUnclassifiedLegend(v))} + /> + )} + dispatch(setNoDataLegend(v))} + />
) } diff --git a/src/components/edit/shared/NoDataLegend.jsx b/src/components/edit/shared/NoDataLegend.jsx new file mode 100644 index 000000000..04271db5e --- /dev/null +++ b/src/components/edit/shared/NoDataLegend.jsx @@ -0,0 +1,26 @@ +import i18n from '@dhis2/d2-i18n' +import PropTypes from 'prop-types' +import React from 'react' +import { NO_DATA_COLOR } from '../../../constants/layers.js' +import OptionalLegendItem from './OptionalLegendItem.jsx' + +const NoDataLegend = ({ value, onChange, label }) => ( + +) + +NoDataLegend.propTypes = { + onChange: PropTypes.func.isRequired, + label: PropTypes.string, + value: PropTypes.shape({ + color: PropTypes.string.isRequired, + name: PropTypes.string, + }), +} + +export default NoDataLegend diff --git a/src/components/edit/shared/OptionalLegendItem.jsx b/src/components/edit/shared/OptionalLegendItem.jsx new file mode 100644 index 000000000..ab7736a84 --- /dev/null +++ b/src/components/edit/shared/OptionalLegendItem.jsx @@ -0,0 +1,63 @@ +import i18n from '@dhis2/d2-i18n' +import PropTypes from 'prop-types' +import React, { useRef } from 'react' +import { Checkbox, ColorPicker, TextField } from '../../core/index.js' +import styles from '../styles/LayerDialog.module.css' + +const OptionalLegendItem = ({ + value, + onChange, + label, + placeholder, + defaultColor, +}) => { + const lastValue = useRef(null) + + const onCheck = (checked) => { + if (checked) { + onChange(lastValue.current ?? { color: defaultColor }) + } else { + lastValue.current = value + onChange(undefined) + } + } + + return ( +
+ + {value && ( +
+ onChange({ ...value, color })} + width={50} + className={styles.colorNameField} + /> + + onChange({ ...value, name: name || undefined }) + } + className={styles.colorNameText} + /> +
+ )} +
+ ) +} + +OptionalLegendItem.propTypes = { + defaultColor: PropTypes.string.isRequired, + label: PropTypes.string.isRequired, + placeholder: PropTypes.string.isRequired, + onChange: PropTypes.func.isRequired, + value: PropTypes.shape({ + color: PropTypes.string.isRequired, + name: PropTypes.string, + }), +} + +export default OptionalLegendItem diff --git a/src/components/edit/shared/UnclassifiedLegend.jsx b/src/components/edit/shared/UnclassifiedLegend.jsx new file mode 100644 index 000000000..d736d5ae4 --- /dev/null +++ b/src/components/edit/shared/UnclassifiedLegend.jsx @@ -0,0 +1,26 @@ +import i18n from '@dhis2/d2-i18n' +import PropTypes from 'prop-types' +import React from 'react' +import { NO_DATA_COLOR } from '../../../constants/layers.js' +import OptionalLegendItem from './OptionalLegendItem.jsx' + +const UnclassifiedLegend = ({ value, onChange, label }) => ( + +) + +UnclassifiedLegend.propTypes = { + onChange: PropTypes.func.isRequired, + label: PropTypes.string, + value: PropTypes.shape({ + color: PropTypes.string.isRequired, + name: PropTypes.string, + }), +} + +export default UnclassifiedLegend diff --git a/src/components/edit/styles/LayerDialog.module.css b/src/components/edit/styles/LayerDialog.module.css index 2b261cdc6..90fad90ba 100644 --- a/src/components/edit/styles/LayerDialog.module.css +++ b/src/components/edit/styles/LayerDialog.module.css @@ -94,6 +94,25 @@ margin-left: var(--spacers-dp24); } +.colorNameRow { + display: flex; + gap: var(--spacers-dp8); + align-items: flex-end; + margin-left: var(--spacers-dp24); + margin-bottom: var(--spacers-dp12); +} + +.colorNameField { + flex-shrink: 0; + margin-bottom: 0; +} + +.colorNameText { + flex: 1; + min-width: 0; + margin-bottom: 0; +} + .orgUnitTree { composes: flexColumn; overflow: hidden; diff --git a/src/components/edit/thematic/NoDataColor.jsx b/src/components/edit/thematic/NoDataColor.jsx deleted file mode 100644 index 3a3c9b8c9..000000000 --- a/src/components/edit/thematic/NoDataColor.jsx +++ /dev/null @@ -1,39 +0,0 @@ -import i18n from '@dhis2/d2-i18n' -import PropTypes from 'prop-types' -import React, { useCallback } from 'react' -import { NO_DATA_COLOR } from '../../../constants/layers.js' -import { Checkbox, ColorPicker } from '../../core/index.js' -import styles from './styles/NoDataColor.module.css' - -const NoDataColor = ({ value, onChange }) => { - const onCheck = useCallback( - (val) => onChange(val ? NO_DATA_COLOR : undefined), - [onChange] - ) - - return ( -
- - {value && ( - - )} -
- ) -} - -NoDataColor.propTypes = { - onChange: PropTypes.func.isRequired, - value: PropTypes.string, -} - -export default NoDataColor diff --git a/src/components/edit/thematic/ThematicDialog.jsx b/src/components/edit/thematic/ThematicDialog.jsx index 5db71abb2..78d54f329 100644 --- a/src/components/edit/thematic/ThematicDialog.jsx +++ b/src/components/edit/thematic/ThematicDialog.jsx @@ -9,7 +9,8 @@ import { setClassification, setDataItem, setLegendSet, - setNoDataColor, + setNoDataLegend, + setUnclassifiedLegend, setPeriods, setStartDate, setEndDate, @@ -43,11 +44,12 @@ import OrgUnitSelect from '../../orgunits/OrgUnitSelect.jsx' import RenderingStrategy from '../../periods/RenderingStrategy.jsx' import StartEndDate from '../../periods/StartEndDate.jsx' import Labels from '../shared/Labels.jsx' +import NoDataLegend from '../shared/NoDataLegend.jsx' +import UnclassifiedLegend from '../shared/UnclassifiedLegend.jsx' import styles from '../styles/LayerDialog.module.css' import AggregationTypeSelect from './AggregationTypeSelect.jsx' import CompletedOnlyCheckbox from './CompletedOnlyCheckbox.jsx' import { initializeThematicLayer } from './initializeThematicLayer.js' -import NoDataColor from './NoDataColor.jsx' import RadiusSelect from './RadiusSelect.jsx' import ThematicMapTypeSelect from './ThematicMapTypeSelect.jsx' import { validateThematicLayer } from './validateThematicLayer.js' @@ -65,7 +67,8 @@ const ThematicDialog = ({ periodType, renderingStrategy, id, - noDataColor, + noDataLegend, + unclassifiedLegend, periodsSettings, currentUser, validateLayer, @@ -580,9 +583,15 @@ const ThematicDialog = ({ legendSetError={errors.legendSetError} className={styles.select} /> - dispatch(setNoDataColor(v))} + + dispatch(setUnclassifiedLegend(v)) + } + /> + dispatch(setNoDataLegend(v))} /> @@ -603,7 +612,10 @@ ThematicDialog.propTypes = { legendIsolated: PropTypes.object, legendSet: PropTypes.object, method: PropTypes.number, - noDataColor: PropTypes.string, + noDataLegend: PropTypes.shape({ + color: PropTypes.string.isRequired, + name: PropTypes.string, + }), orgUnits: PropTypes.object, periodType: PropTypes.string, periodsSettings: PropTypes.object, @@ -614,6 +626,10 @@ ThematicDialog.propTypes = { startDate: PropTypes.string, systemSettings: PropTypes.object, thematicMapType: PropTypes.string, + unclassifiedLegend: PropTypes.shape({ + color: PropTypes.string.isRequired, + name: PropTypes.string, + }), validateLayer: PropTypes.bool, onLayerValidation: PropTypes.func, } diff --git a/src/components/edit/thematic/styles/NoDataColor.module.css b/src/components/edit/thematic/styles/NoDataColor.module.css deleted file mode 100644 index 31d74c995..000000000 --- a/src/components/edit/thematic/styles/NoDataColor.module.css +++ /dev/null @@ -1,3 +0,0 @@ -.colorPicker { - margin-left: var(--spacers-dp24); -} diff --git a/src/components/legend/Bubbles.jsx b/src/components/legend/Bubbles.jsx index abbd9d9d1..ca903d3da 100644 --- a/src/components/legend/Bubbles.jsx +++ b/src/components/legend/Bubbles.jsx @@ -144,14 +144,15 @@ const Bubbles = ({ systemSettings: { keyAnalysisDigitGroupSeparator }, } = useCachedData() - const noDataClass = classes.find((c) => c.noData) - const isolatedClass = classes.find((c) => c.isLegendIsolated) + const noDataClass = classes.find((c) => c.isNoData) + const isolatedClass = classes.find((c) => c.isIsolated) + const unclassifiedClass = classes.find((c) => c.isUnclassified) const bubbleClasses = classes.filter( - (c) => !c.noData && !c.isLegendIsolated + (c) => !c.isNoData && !c.isIsolated && !c.isUnclassified ) const hasDataRange = minValue != null && maxValue != null - if (!hasDataRange && !noDataClass && !isolatedClass) { + if (!hasDataRange && !noDataClass && !isolatedClass && !unclassifiedClass) { return null } if (Number.isNaN(radiusLow) || Number.isNaN(radiusHigh)) { @@ -163,7 +164,8 @@ const Bubbles = ({ const ty = 10 const yIsolated = hasDataRange ? mainRowHeight + extraRowHeight : 0 - const yNoData = yIsolated + (isolatedClass ? extraRowHeight : 0) + const yUnclassified = yIsolated + (isolatedClass ? extraRowHeight : 0) + const yNoData = yUnclassified + (unclassifiedClass ? extraRowHeight : 0) const legendHeight = yNoData + ty + THEMATIC_RADIUS_DEFAULT + 2 const legendWidth = isPlugin ? 150 : 245 @@ -223,6 +225,17 @@ const Bubbles = ({ count={isolatedClass.count} /> )} + {unclassifiedClass && ( + + )} {noDataClass && ( { const showRange = Array.isArray(items) && !legendNamesContainRange(items) const getShowRange = (item) => - item.isLegendIsolated + item.isIsolated ? !legendNamesContainRange([item]) : !item.name || showRange diff --git a/src/components/map/layers/ThematicLayer.jsx b/src/components/map/layers/ThematicLayer.jsx index c98fe4e1e..6518ad2da 100644 --- a/src/components/map/layers/ThematicLayer.jsx +++ b/src/components/map/layers/ThematicLayer.jsx @@ -47,7 +47,7 @@ class ThematicLayer extends Layer { labels, renderingStrategy = RENDERING_STRATEGY_SINGLE, thematicMapType = THEMATIC_CHOROPLETH, - noDataColor, + noDataLegend, } = this.props const { isPlugin, map } = this.context @@ -64,7 +64,7 @@ class ThematicLayer extends Layer { isVisible, data: filteredData, hoverLabel: '{name} ({value})', - color: noDataColor, + color: noDataLegend?.color, onClick: this.onFeatureClick.bind(this), onRightClick: this.onFeatureRightClick.bind(this), } @@ -275,7 +275,8 @@ class ThematicLayer extends Layer { valuesByPeriod, renderingStrategy = RENDERING_STRATEGY_SINGLE, thematicMapType = THEMATIC_CHOROPLETH, - noDataColor, + noDataLegend, + unclassifiedLegend, externalPeriod, } = props @@ -300,12 +301,17 @@ class ThematicLayer extends Layer { }, })) - // Remove org unit features if noDataColor is missing - if (!noDataColor) { + if (!noDataLegend) { periodData = periodData.filter( (feature) => values[feature.id] !== undefined ) } + + if (!unclassifiedLegend) { + periodData = periodData.filter( + (feature) => !values[feature.id]?.isUnclassified + ) + } } return filterData(periodData, dataFilters) diff --git a/src/constants/actionTypes.js b/src/constants/actionTypes.js index 698100b83..c09e25f77 100644 --- a/src/constants/actionTypes.js +++ b/src/constants/actionTypes.js @@ -144,7 +144,9 @@ export const LAYER_EDIT_TRACKED_ENTITY_RELATIONSHIP_OUTSIDE_PROGRAM_SET = 'LAYER_EDIT_TRACKED_ENTITY_RELATIONSHIP_OUTSIDE_PROGRAM_SET' export const LAYER_EDIT_PROGRAM_STATUS_SET = 'LAYER_EDIT_PROGRAM_STATUS_SET' export const LAYER_EDIT_FOLLOW_UP_SET = 'LAYER_EDIT_FOLLOW_UP_SET' -export const LAYER_EDIT_NO_DATA_COLOR_SET = 'LAYER_EDIT_NO_DATA_COLOR_SET' +export const LAYER_EDIT_NO_DATA_LEGEND_SET = 'LAYER_EDIT_NO_DATA_LEGEND_SET' +export const LAYER_EDIT_UNCLASSIFIED_LEGEND_SET = + 'LAYER_EDIT_UNCLASSIFIED_LEGEND_SET' export const LAYER_EDIT_BAND_SET = 'LAYER_EDIT_BAND_SET' export const LAYER_EDIT_FEATURE_STYLE_SET = 'LAYER_EDIT_FEATURE_STYLE_SET' export const LAYER_EDIT_FALLBACK_COORDINATE_FIELD_SET = diff --git a/src/loaders/eventLoader.js b/src/loaders/eventLoader.js index c98e6134f..d49d5ce3d 100644 --- a/src/loaders/eventLoader.js +++ b/src/loaders/eventLoader.js @@ -102,15 +102,31 @@ const loadEventLayer = async ({ periodTypeData, loadExtended, }) => { - const { legendDecimalPlaces, legendIsolated } = parseJsonConfig( - config.config - ) + const { + legendDecimalPlaces, + legendIsolated, + unclassifiedLegend: unclassifiedLegendFromConfig, + noDataLegend: noDataLegendFromConfig, + } = parseJsonConfig(config.config) if (legendDecimalPlaces !== undefined) { config.legendDecimalPlaces = legendDecimalPlaces } - if (legendIsolated !== undefined) { + if (legendIsolated) { config.legendIsolated = legendIsolated } + if (unclassifiedLegendFromConfig) { + config.unclassifiedLegend = unclassifiedLegendFromConfig + } + if (noDataLegendFromConfig) { + config.noDataLegend = noDataLegendFromConfig + } + if (config.noDataColor) { + config.noDataLegend = { + ...noDataLegendFromConfig, + color: config.noDataColor, + } + } + delete config.noDataColor delete config.config const { diff --git a/src/loaders/thematicLoader.js b/src/loaders/thematicLoader.js index c3bc6e6a4..824725817 100644 --- a/src/loaders/thematicLoader.js +++ b/src/loaders/thematicLoader.js @@ -34,6 +34,7 @@ import { getPredefinedLegendItems, getAutomaticLegendItems, buildIsolatedLegendItem, + isRegularLegendItem, } from '../util/legend.js' import { toGeoJson } from '../util/map.js' import { @@ -56,6 +57,9 @@ const thematicLoader = async ({ analyticsEngine, periodTypeData, }) => { + // Config parsing + // ----- + const { columns, radiusLow = THEMATIC_RADIUS_LOW, @@ -64,25 +68,64 @@ const thematicLoader = async ({ colorScale, renderingStrategy = RENDERING_STRATEGY_SINGLE, thematicMapType, - noDataColor, } = config - const { legendDecimalPlaces, legendIsolated } = parseJsonConfig( - config.config - ) + const dataItem = getDataItemFromColumns(columns) + const coordinateField = getCoordinateField(config) + + const { + legendDecimalPlaces, + legendIsolated, + unclassifiedLegend: unclassifiedLegendFromConfig, + noDataLegend: noDataLegendFromConfig, + } = parseJsonConfig(config.config) if (legendDecimalPlaces !== undefined) { config.legendDecimalPlaces = legendDecimalPlaces } - if (legendIsolated !== undefined) { + if (legendIsolated) { config.legendIsolated = legendIsolated } + if (unclassifiedLegendFromConfig) { + config.unclassifiedLegend = unclassifiedLegendFromConfig + } + if (noDataLegendFromConfig) { + config.noDataLegend = noDataLegendFromConfig + } + if (config.noDataColor) { + config.noDataLegend = { + ...noDataLegendFromConfig, + color: config.noDataColor, + } + } + delete config.noDataColor delete config.config - const dataItem = getDataItemFromColumns(columns) - const coordinateField = getCoordinateField(config) + // Resolve legendSet and method (favorites may have the wrong method) + const legendSet = await resolveLegendSet(config, dataItem, engine) + const method = legendSet ? CLASSIFICATION_PREDEFINED : config.method - let loadError + // Set flags to navigate paths + // ----- + // Rendering: Single | Timeline / Split + const isSingleMap = renderingStrategy === RENDERING_STRATEGY_SINGLE + // Map type: Choropleth | Bubble + const isBubbleMap = thematicMapType === THEMATIC_BUBBLE + // Classification: Predefined legend set | Automatic | Single [only for Bubble] + const isPredefined = method === CLASSIFICATION_PREDEFINED + const isSingleColor = method === CLASSIFICATION_SINGLE_COLOR + // Special items: + // - Isolated class configured [only for Automatic and Single] | not + const hasIsolatedClass = !!config.legendIsolated + // - No data class configured | not + const hasNoDataClass = !!config.noDataLegend + // - Unclassified class configured | not + const hasUnclassifiedClass = !!config.unclassifiedLegend + + // Data loading + // ----- + + let loadError const response = await loadData({ config, engine, @@ -123,11 +166,13 @@ const thematicLoader = async ({ } } + // Data setup + // ----- + const [mainFeatures, data, associatedGeometries] = response - const features = addAssociatedGeometries(mainFeatures, associatedGeometries) - const isSingleMap = renderingStrategy === RENDERING_STRATEGY_SINGLE - const isBubbleMap = thematicMapType === THEMATIC_BUBBLE - const isSingleColor = config.method === CLASSIFICATION_SINGLE_COLOR + const valueById = getValueById(data) + const valuesByPeriod = !isSingleMap ? getValuesByPeriod(data) : null // [PATH] null → Single; populated → Timeline / Split (do not creates OrgUnits with no data) + const names = getApiResponseNames( periodTypeData?.enabledPeriodTypesData?.metaData ? { @@ -142,64 +187,65 @@ const thematicLoader = async ({ } : data ) + const name = names[dataItem.id] + const presetPeriods = getPeriodsFromFilters(config.filters).map((pe) => { pe.name = names[pe.id] return pe }) const periods = getPeriodsFromMetaData(data.metaData) const dimensions = getValidDimensionsFromFilters(config.filters) - const valuesByPeriod = !isSingleMap ? getValuesByPeriod(data) : null - const valueById = getValueById(data) - const valueFeatures = noDataColor - ? features - : features.filter(({ id }) => valueById[id] !== undefined) + const orderedValues = getOrderedValues(data) let minValue = orderedValues[0] - let maxValue = orderedValues[orderedValues.length - 1] - const name = names[dataItem.id] - const alerts = [] + let maxValue = orderedValues.at(-1) - let legendSet = config.legendSet + let features = addAssociatedGeometries(mainFeatures, associatedGeometries) - // Use legend set defined for data item as default - if ( - !legendSet && - dataItem.legendSet && - (config.method === undefined || - config.method === CLASSIFICATION_PREDEFINED) - ) { - legendSet = dataItem.legendSet - } + // Alerts + // ----- - // Favorites often have wrong method - const method = legendSet ? CLASSIFICATION_PREDEFINED : config.method + const alerts = [] - if (legendSet) { - const result = await engine.query(LEGEND_SET_QUERY, { - variables: { id: config.legendSet.id }, + if (!features.length) { + alerts.push({ + code: WARNING_NO_OU_COORD, + message: i18n.t('Thematic layer'), + }) + } else if (!data.rows.length) { + alerts.push({ + code: WARNING_NO_DATA, + message: name, }) - legendSet = result.legendSet } + if (coordinateField && !associatedGeometries.length) { + alerts.push({ + code: WARNING_NO_GEOMETRY_COORD, + message: coordinateField.name, + }) + } + + // Legend + // ----- + let legendItems = [] let valueFormat - if (!isSingleColor) { - if (legendSet) { - legendItems = getPredefinedLegendItems(legendSet) - } else { - const classification = getAutomaticLegendItems({ - data: orderedValues, - method, - classes, - colorScale, - legendDecimalPlaces: config.legendDecimalPlaces, - legendIsolated: config.legendIsolated, - }) - legendItems = classification.items - valueFormat = classification.valueFormat - } - } else if (config.legendIsolated) { + if (isPredefined) { + legendItems = getPredefinedLegendItems(legendSet) + } else if (!isSingleColor) { + const classification = getAutomaticLegendItems({ + data: orderedValues, + method, + classes, + colorScale, + legendDecimalPlaces: config.legendDecimalPlaces, + legendIsolated: config.legendIsolated, + }) + legendItems = classification.items + valueFormat = classification.valueFormat + } else if (hasIsolatedClass) { const { min, max } = config.legendIsolated legendItems = [buildIsolatedLegendItem(config.legendIsolated)] const nonIsolatedValues = orderedValues.filter( @@ -207,7 +253,7 @@ const thematicLoader = async ({ ) if (nonIsolatedValues.length > 0) { minValue = nonIsolatedValues[0] - maxValue = nonIsolatedValues[nonIsolatedValues.length - 1] + maxValue = nonIsolatedValues.at(-1) } } @@ -221,9 +267,7 @@ const thematicLoader = async ({ getDateArray(config.endDate) ), items: legendItems, - ...(config.legendDecimalPlaces !== undefined && { - decimalPlaces: config.legendDecimalPlaces, - }), + decimalPlaces: config.legendDecimalPlaces, } if (dimensions && dimensions.length) { @@ -235,18 +279,6 @@ const thematicLoader = async ({ ) } - if (noDataColor && Array.isArray(legend.items)) { - legend.items.push({ - color: noDataColor, - name: i18n.t('No data'), - noData: true, - }) - } - - if (isSingleMap) { - legend.items.forEach((item) => (item.count = 0)) - } - if (isBubbleMap) { legend.bubbles = { radiusLow, @@ -256,25 +288,88 @@ const thematicLoader = async ({ color: isSingleColor ? colorScale : null, legendDecimalPlaces: config.legendDecimalPlaces, } + if (!isSingleColor) { + const regularItems = legend.items.filter(isRegularLegendItem) + if (regularItems.length) { + minValue = regularItems[0].startValue + maxValue = regularItems.at(-1).endValue + legend.bubbles.minValue ??= minValue + legend.bubbles.maxValue ??= maxValue + } + } + } + + let noDataLegendItem = null + if (hasNoDataClass) { + noDataLegendItem = { + color: config.noDataLegend.color, + name: config.noDataLegend.name || i18n.t('No data'), + isNoData: true, + } + legend.items.push(noDataLegendItem) + } + + let unclassifiedLegendItem = null + if (hasUnclassifiedClass) { + unclassifiedLegendItem = { + color: config.unclassifiedLegend.color, + name: config.unclassifiedLegend.name || i18n.t('Unclassified'), + isUnclassified: true, + } + legend.items.push(unclassifiedLegendItem) } + // Counting for Timeline / Split would be ambiguous + if (isSingleMap) { + legend.items.forEach((item) => (item.count = 0)) + } + + // Feature styling - Helpers + // ----- + + // Returns the matching classified item, including isolated values, or undefined for no-data / unclassified values const getLegendItem = (value) => getLegendItemForValue({ value, valueFormat, method, - legendItems: legend.items.filter((item) => !item.noData), - clamp: method !== CLASSIFICATION_PREDEFINED, + legendItems: legend.items, + clamp: !isPredefined, }) - if (legendSet && Array.isArray(legend.items) && legend.items.length >= 2) { - const regularItems = legend.items.filter((item) => !item.noData) - minValue = regularItems[0].startValue - maxValue = regularItems.at(-1).endValue - if (legend.bubbles) { - legend.bubbles.minValue ??= minValue - legend.bubbles.maxValue ??= maxValue + const getFeatureColor = (legendItem, { isNoData, isUnclassified }) => { + if (legendItem) { + return { color: legendItem.color } } + if (isNoData) { + return { color: noDataLegendItem.color } + } + if (isUnclassified) { + return { color: unclassifiedLegendItem.color } + } + return { color: colorScale } + } + + const getFeatureLegend = (legendItem, { isNoData, isUnclassified }) => { + if (legendItem) { + return { + legend: legendItem.name, + range: formatRangeWithSeparator( + legendItem, + keyAnalysisDigitGroupSeparator, + { + precision: config.legendDecimalPlaces, + } + ), + } + } + if (isNoData) { + return { legend: noDataLegendItem.name } + } + if (isUnclassified) { + return { legend: unclassifiedLegendItem.name } + } + return {} } const getRadiusForValue = scaleSqrt() @@ -282,50 +377,34 @@ const thematicLoader = async ({ .domain([minValue, maxValue]) .clamp(true) - const noDataLegendItem = legend.items.find((i) => i.noData === true) - - const getSingleColor = (legendItem, value) => { - if (legendItem?.isLegendIsolated) { - return legendItem.color - } - if (!hasValue(value)) { - return noDataLegendItem?.color + const getFeatureRadius = ( + legendItem, + { isNoData, isUnclassified }, + value + ) => { + if (legendItem?.isIsolated || isNoData || isUnclassified) { + return { radius: THEMATIC_RADIUS_DEFAULT } } - return colorScale + return { radius: getRadiusForValue(value) || THEMATIC_RADIUS_DEFAULT } } - const getFeatureRadius = (legendItem, value) => { - if (legendItem?.isLegendIsolated) { - return THEMATIC_RADIUS_DEFAULT - } - if (!hasValue(value)) { - return THEMATIC_RADIUS_DEFAULT + const countLegendItem = (legendItem, { isNoData, isUnclassified }) => { + const item = + legendItem ?? + (isNoData + ? noDataLegendItem + : isUnclassified + ? unclassifiedLegendItem + : null) + if (item) { + item.count++ } - return getRadiusForValue(value) || THEMATIC_RADIUS_DEFAULT } - if (!valueFeatures.length) { - if (!features.length) { - alerts.push({ - code: WARNING_NO_OU_COORD, - message: i18n.t('Thematic layer'), - }) - } else { - alerts.push({ - code: WARNING_NO_DATA, - message: name, - }) - } - } + // Feature styling - Processing + // ----- - if (coordinateField && !associatedGeometries.length) { - alerts.push({ - code: WARNING_NO_GEOMETRY_COORD, - message: coordinateField.name, - }) - } - - if (valuesByPeriod) { + if (!isSingleMap) { const periods = Object.keys(valuesByPeriod) periods.forEach((period) => { const orgUnits = Object.keys(valuesByPeriod[period]) @@ -333,60 +412,83 @@ const thematicLoader = async ({ const item = valuesByPeriod[period][orgunit] const value = Number(item.value) const legendItem = getLegendItem(value) - - if (isSingleColor) { - item.color = getSingleColor(legendItem, value) - } else if (legendItem) { - item.color = legendItem.color + const isNoData = !hasValue(item.value) + const isUnclassified = + !isSingleColor && !legendItem && !isNoData + + // No data org units are absent from valuesByPeriod; + if (isUnclassified && !hasUnclassifiedClass) { + Object.assign(item, { isUnclassified: true }) + return } - - item.radius = getFeatureRadius(legendItem, value) + // ThematicLayer handles no data and unclassified inclusion/exclusion + + Object.assign(item, { + ...getFeatureColor(legendItem, { + isNoData, + isUnclassified, + }), + ...getFeatureRadius( + legendItem, + { isNoData, isUnclassified }, + value + ), + }) }) }) } else { - valueFeatures.forEach(({ id, geometry, properties }) => { + // Style and filter features in place + features = features.flatMap(({ id, geometry, properties }) => { const value = valueById[id] const legendItem = getLegendItem(value) + const isNoData = !hasValue(value) + const isUnclassified = !isSingleColor && !legendItem && !isNoData + + if (isNoData && !hasNoDataClass) { + return [] + } + if (isUnclassified && !hasUnclassifiedClass) { + return [] + } + const isPoint = geometry.type === 'Point' const { hasAdditionalGeometry } = properties - if (isSingleColor) { - properties.color = getSingleColor(legendItem, value) - } else if (legendItem) { - properties.color = - hasAdditionalGeometry && isPoint - ? ORG_UNIT_COLOR - : legendItem.color - properties.legend = legendItem.name // Shown in data table - properties.range = formatRangeWithSeparator( + Object.assign(properties, { + ...getFeatureColor(legendItem, { isNoData, isUnclassified }), + ...getFeatureLegend(legendItem, { isNoData, isUnclassified }), + ...getFeatureRadius( legendItem, - keyAnalysisDigitGroupSeparator, - { precision: config.legendDecimalPlaces } - ) // Shown in data table - } + { + isNoData, + isUnclassified, + }, + value + ), + ...(hasAdditionalGeometry && + legendItem && + isPoint && { color: ORG_UNIT_COLOR }), + ...(hasAdditionalGeometry && { + radius: ORG_UNIT_RADIUS_SMALL, + }), + value: formatWithSeparator( + value, + keyAnalysisDigitGroupSeparator + ), // Shown in tooltip, label, pop-up, data table + rawValue: value, // Numeric form for data table sorting + }) - // Only count org units once in legend if (!hasAdditionalGeometry) { - const targetItem = legendItem || noDataLegendItem - if (targetItem) { - targetItem.count++ - } + countLegendItem(legendItem, { isUnclassified, isNoData }) } - properties.value = formatWithSeparator( - value, - keyAnalysisDigitGroupSeparator - ) // Shown in tooltip, label, pop-up, data table - properties.rawValue = value // Numeric form for data table sorting - properties.radius = hasAdditionalGeometry - ? ORG_UNIT_RADIUS_SMALL - : getFeatureRadius(legendItem, value) + return [{ id, geometry, properties }] }) } return { ...config, - data: valueFeatures, + data: features, periods, valuesByPeriod, name, @@ -401,6 +503,20 @@ const thematicLoader = async ({ } } +// Resolves the legendSet to use: config > dataItem fallback (when no explicit method), +// then fetches the full legendSet from the server. Returns null if not found or deleted. +const resolveLegendSet = async (config, dataItem, engine) => { + const legendSet = + config.legendSet ?? (!config.method ? dataItem.legendSet : null) + if (!legendSet) { + return null + } + const result = await engine.query(LEGEND_SET_QUERY, { + variables: { id: legendSet.id }, + }) + return result.legendSet ?? null +} + const getPeriodsFromMetaData = ({ dimensions, items }) => dimensions.pe.map((id) => { const { name, startDate, endDate } = items[id] diff --git a/src/reducers/layerEdit.js b/src/reducers/layerEdit.js index 71e037577..f00631f93 100644 --- a/src/reducers/layerEdit.js +++ b/src/reducers/layerEdit.js @@ -571,16 +571,22 @@ const layerEdit = (state = null, action) => { followUp: action.payload, } - case types.LAYER_EDIT_NO_DATA_COLOR_SET: + case types.LAYER_EDIT_NO_DATA_LEGEND_SET: newState = { ...state } - - // Default is to show no feature if (!action.payload) { - delete newState.noDataColor + delete newState.noDataLegend } else { - newState.noDataColor = action.payload + newState.noDataLegend = action.payload } + return newState + case types.LAYER_EDIT_UNCLASSIFIED_LEGEND_SET: + newState = { ...state } + if (!action.payload) { + delete newState.unclassifiedLegend + } else { + newState.unclassifiedLegend = action.payload + } return newState case types.LAYER_EDIT_EARTH_ENGINE_PERIOD_SET: diff --git a/src/util/__tests__/styleByDataItem.spec.js b/src/util/__tests__/styleByDataItem.spec.js index b09386fdc..7ad4ccce1 100644 --- a/src/util/__tests__/styleByDataItem.spec.js +++ b/src/util/__tests__/styleByDataItem.spec.js @@ -18,7 +18,6 @@ const OPTION_SET_NAME = 'optionSetName' const LEGEND_SET_ID = 'legendSetId' const LEGEND_SET_NAME = 'legendSetName' const LEGEND_ITEM_EVENT = 'Event' -const LEGEND_ITEM_OTHER = 'Other' const NOTSET_VALUE = 'Not set' const SOME_VALUE = 'some value' @@ -89,6 +88,40 @@ describe('styleByDataItem', () => { }) it('should apply default styling when no specific type is matched', async () => { + const config = { + styleDataItem: { + id: STYLE_DATA_ITEM_ID, + name: STYLE_DATA_ITEM_NAME, + valueType: 'TEXT', + }, + data: [ + { properties: { [STYLE_DATA_ITEM_ID]: SOME_VALUE } }, + { properties: {} }, // no data - dropped + ], + legend: { items: [] }, + } + + const result = await styleByDataItem(config, mockEngine) + + expect(result.data).toHaveLength(1) + expect(result.data[0].properties).toMatchObject({ + value: SOME_VALUE, + color: EVENT_COLOR, + }) + + expect(result.legend.items).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: LEGEND_ITEM_EVENT, + color: EVENT_COLOR, + radius: EVENT_RADIUS, + count: 1, + }), + ]) + ) + }) + + it('should include no-data events when noDataLegend is configured (default)', async () => { const config = { styleDataItem: { id: STYLE_DATA_ITEM_ID, @@ -100,26 +133,31 @@ describe('styleByDataItem', () => { { properties: {} }, ], legend: { items: [] }, + noDataLegend: { color: '#aaaaaa' }, } const result = await styleByDataItem(config, mockEngine) + expect(result.data).toHaveLength(2) expect(result.data[0].properties).toMatchObject({ value: SOME_VALUE, color: EVENT_COLOR, }) expect(result.data[1].properties).toMatchObject({ value: NOTSET_VALUE, - color: EVENT_COLOR, + color: '#aaaaaa', }) expect(result.legend.items).toEqual( expect.arrayContaining([ expect.objectContaining({ name: LEGEND_ITEM_EVENT, - color: EVENT_COLOR, - radius: EVENT_RADIUS, - count: 2, + count: 1, + }), + expect.objectContaining({ + isNoData: true, + color: '#aaaaaa', + count: 1, }), ]) ) @@ -135,9 +173,9 @@ describe('styleByDataItem', () => { { properties: { [STYLE_DATA_ITEM_ID]: 0.5 } }, { properties: { [STYLE_DATA_ITEM_ID]: 1.5 } }, { properties: { [STYLE_DATA_ITEM_ID]: 2.5 } }, - { properties: { [STYLE_DATA_ITEM_ID]: 3.5 } }, - { properties: {} }, - { properties: { [STYLE_DATA_ITEM_ID]: SOME_VALUE } }, + { properties: { [STYLE_DATA_ITEM_ID]: 3.5 } }, // outside range - dropped + { properties: {} }, // no data - dropped + { properties: { [STYLE_DATA_ITEM_ID]: SOME_VALUE } }, // outside range - dropped ], method: 1, legendSet: { id: LEGEND_SET_ID }, @@ -149,6 +187,7 @@ describe('styleByDataItem', () => { expect(mockEngine.query).toHaveBeenCalled() + expect(result.data).toHaveLength(3) expect(result.data[0].properties).toMatchObject({ value: 0.5, color: 'green', @@ -161,19 +200,8 @@ describe('styleByDataItem', () => { value: 2.5, color: 'red', }) - expect(result.data[3].properties).toMatchObject({ - value: 3.5, - color: EVENT_COLOR, - }) - expect(result.data[4].properties).toMatchObject({ - value: NOTSET_VALUE, - color: EVENT_COLOR, - }) - expect(result.data[5].properties).toMatchObject({ - value: SOME_VALUE, - color: EVENT_COLOR, - }) + expect(result.legend.items).toHaveLength(3) expect(result.legend.items).toEqual( expect.arrayContaining([ expect.objectContaining({ @@ -200,15 +228,64 @@ describe('styleByDataItem', () => { radius: 5, count: 1, }), + ]) + ) + expect(result.legend.unit).toEqual(LEGEND_SET_NAME) + }) + + it('should include outside and no-data features when unclassifiedLegend and noDataLegend are configured (predefined)', async () => { + const config = { + styleDataItem: { + id: STYLE_DATA_ITEM_ID, + valueType: numberValueTypes[3], + }, + data: [ + { properties: { [STYLE_DATA_ITEM_ID]: 0.5 } }, + { properties: { [STYLE_DATA_ITEM_ID]: 3.5 } }, // outside range + { properties: {} }, // no data + ], + method: 1, + legendSet: { id: LEGEND_SET_ID }, + eventPointRadius: 5, + legend: { items: [] }, + noDataLegend: { color: '#aaaaaa' }, + unclassifiedLegend: { color: '#bbbbbb' }, + } + + const result = await styleByDataItem(config, mockEngine) + + expect(result.data).toHaveLength(3) + expect(result.data[0].properties).toMatchObject({ + value: 0.5, + color: 'green', + }) + expect(result.data[1].properties).toMatchObject({ + value: 3.5, + color: '#bbbbbb', + }) + expect(result.data[2].properties).toMatchObject({ + value: NOTSET_VALUE, + color: '#aaaaaa', + }) + + expect(result.legend.items).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: 'Low', + count: 1, + }), expect.objectContaining({ - name: LEGEND_ITEM_OTHER, - color: EVENT_COLOR, - radius: 5, - count: 3, + isUnclassified: true, + color: '#bbbbbb', + count: 1, + }), + expect.objectContaining({ + isNoData: true, + color: '#aaaaaa', + count: 1, }), ]) ) - expect(result.legend.unit).toEqual(LEGEND_SET_NAME) }) it('should apply numeric styling when valueType is a number - classification auto', async () => { @@ -221,8 +298,8 @@ describe('styleByDataItem', () => { { properties: { [STYLE_DATA_ITEM_ID]: 0 } }, { properties: { [STYLE_DATA_ITEM_ID]: 1 } }, { properties: { [STYLE_DATA_ITEM_ID]: 2 } }, - { properties: {} }, - { properties: { [STYLE_DATA_ITEM_ID]: SOME_VALUE } }, + { properties: {} }, // no data - dropped (no noDataLegend) + { properties: { [STYLE_DATA_ITEM_ID]: SOME_VALUE } }, // non-numeric - included with fallback color ], method: 2, classes: 3, @@ -235,6 +312,7 @@ describe('styleByDataItem', () => { expect(mockEngine.query).toHaveBeenCalled() + expect(result.data).toHaveLength(4) expect(result.data[0].properties).toMatchObject({ value: 0, color: '#ff0000', @@ -248,14 +326,11 @@ describe('styleByDataItem', () => { color: '#0000ff', }) expect(result.data[3].properties).toMatchObject({ - value: NOTSET_VALUE, - color: EVENT_COLOR, - }) - expect(result.data[4].properties).toMatchObject({ value: SOME_VALUE, color: EVENT_COLOR, }) + expect(result.legend.items).toHaveLength(3) expect(result.legend.items).toEqual( expect.arrayContaining([ expect.objectContaining({ @@ -279,15 +354,51 @@ describe('styleByDataItem', () => { radius: 5, count: 1, }), + ]) + ) + expect(result.legend.unit).toEqual(STYLE_DATA_ITEM_NAME) + }) + + it('should include no-data features when noDataLegend is configured (auto)', async () => { + const config = { + styleDataItem: { + id: STYLE_DATA_ITEM_ID, + valueType: numberValueTypes[3], + }, + data: [ + { properties: { [STYLE_DATA_ITEM_ID]: 0 } }, + { properties: {} }, // no data + ], + method: 2, + classes: 2, + colorScale: ['#ff0000', '#0000ff'], + eventPointRadius: 5, + legend: { items: [] }, + noDataLegend: { color: '#cccccc', name: 'Missing' }, + } + + const result = await styleByDataItem(config, mockEngine) + + expect(result.data).toHaveLength(2) + expect(result.data[0].properties).toMatchObject({ + value: 0, + color: '#ff0000', + }) + expect(result.data[1].properties).toMatchObject({ + value: NOTSET_VALUE, + color: '#cccccc', + }) + + expect(result.legend.items).toEqual( + expect.arrayContaining([ expect.objectContaining({ - name: LEGEND_ITEM_OTHER, - color: EVENT_COLOR, - radius: 5, - count: 2, + isNoData: true, + name: 'Missing', + color: '#cccccc', + count: 1, }), ]) ) - expect(result.legend.unit).toEqual(STYLE_DATA_ITEM_NAME) }) it('should apply boolean styling correctly', async () => { @@ -300,8 +411,8 @@ describe('styleByDataItem', () => { data: [ { properties: { [STYLE_DATA_ITEM_ID]: '1' } }, { properties: { [STYLE_DATA_ITEM_ID]: '0' } }, - { properties: {} }, - { properties: { [STYLE_DATA_ITEM_ID]: SOME_VALUE } }, + { properties: {} }, // no data - dropped + { properties: { [STYLE_DATA_ITEM_ID]: SOME_VALUE } }, // unclassified - dropped ], legend: { items: [] }, eventPointRadius: 10, @@ -311,6 +422,7 @@ describe('styleByDataItem', () => { expect(mockEngine.query).toHaveBeenCalled() + expect(result.data).toHaveLength(2) expect(result.data[0].properties).toMatchObject({ value: 'Yes', color: 'red', @@ -319,15 +431,8 @@ describe('styleByDataItem', () => { value: 'No', color: 'blue', }) - expect(result.data[2].properties).toMatchObject({ - value: NOTSET_VALUE, - color: EVENT_COLOR, - }) - expect(result.data[3].properties).toMatchObject({ - value: SOME_VALUE, - color: EVENT_COLOR, - }) + expect(result.legend.items).toHaveLength(2) expect(result.legend.items).toEqual( expect.arrayContaining([ expect.objectContaining({ @@ -342,15 +447,55 @@ describe('styleByDataItem', () => { radius: 10, count: 1, }), + ]) + ) + expect(result.legend.unit).toEqual(STYLE_DATA_ITEM_NAME) + }) + + it('should include unclassified and no-data events when configured (boolean)', async () => { + const config = { + styleDataItem: { + id: STYLE_DATA_ITEM_ID, + valueType: booleanValueTypes[0], + values: { true: 'red', false: 'blue' }, + }, + data: [ + { properties: { [STYLE_DATA_ITEM_ID]: '1' } }, + { properties: {} }, // no data + { properties: { [STYLE_DATA_ITEM_ID]: SOME_VALUE } }, // unclassified + ], + legend: { items: [] }, + eventPointRadius: 10, + noDataLegend: { color: '#aaaaaa' }, + unclassifiedLegend: { color: '#bbbbbb' }, + } + + const result = await styleByDataItem(config, mockEngine) + + expect(result.data).toHaveLength(3) + expect(result.data[1].properties).toMatchObject({ + value: NOTSET_VALUE, + color: '#aaaaaa', + }) + expect(result.data[2].properties).toMatchObject({ + value: SOME_VALUE, + color: '#bbbbbb', + }) + + expect(result.legend.items).toEqual( + expect.arrayContaining([ expect.objectContaining({ - name: LEGEND_ITEM_OTHER, - color: EVENT_COLOR, - radius: 10, - count: 2, + isNoData: true, + color: '#aaaaaa', + count: 1, + }), + expect.objectContaining({ + isUnclassified: true, + color: '#bbbbbb', + count: 1, }), ]) ) - expect(result.legend.unit).toEqual(STYLE_DATA_ITEM_NAME) }) it('should handle option set styling correctly', async () => { @@ -365,8 +510,8 @@ describe('styleByDataItem', () => { data: [ { properties: { [STYLE_DATA_ITEM_ID]: 'Option 1' } }, { properties: { [STYLE_DATA_ITEM_ID]: 'Option 2' } }, - { properties: {} }, - { properties: { [STYLE_DATA_ITEM_ID]: SOME_VALUE } }, + { properties: {} }, // no data - dropped + { properties: { [STYLE_DATA_ITEM_ID]: SOME_VALUE } }, // unclassified - dropped ], legend: { items: [] }, eventPointRadius: 8, @@ -376,6 +521,7 @@ describe('styleByDataItem', () => { expect(mockEngine.query).toHaveBeenCalled() + expect(result.data).toHaveLength(2) expect(result.data[0].properties).toMatchObject({ value: 'Option 1', color: 'green', @@ -384,15 +530,8 @@ describe('styleByDataItem', () => { value: 'Option 2', color: 'yellow', }) - expect(result.data[2].properties).toMatchObject({ - value: NOTSET_VALUE, - color: EVENT_COLOR, - }) - expect(result.data[3].properties).toMatchObject({ - value: SOME_VALUE, - color: EVENT_COLOR, - }) + expect(result.legend.items).toHaveLength(2) expect(result.legend.items).toEqual( expect.arrayContaining([ expect.objectContaining({ @@ -407,13 +546,56 @@ describe('styleByDataItem', () => { radius: 8, count: 1, }), + ]) + ) + expect(result.legend.unit).toEqual(OPTION_SET_NAME) + }) + + it('should include unclassified and no-data events when configured (option set)', async () => { + const config = { + styleDataItem: { + id: STYLE_DATA_ITEM_ID, + optionSet: { + id: OPTION_SET_ID, + options: [{ id: '1' }, { id: '2' }], + }, + }, + data: [ + { properties: { [STYLE_DATA_ITEM_ID]: 'Option 1' } }, + { properties: {} }, // no data + { properties: { [STYLE_DATA_ITEM_ID]: SOME_VALUE } }, // unclassified + ], + legend: { items: [] }, + eventPointRadius: 8, + noDataLegend: { color: '#aaaaaa' }, + unclassifiedLegend: { color: '#bbbbbb' }, + } + + const result = await styleByDataItem(config, mockEngine) + + expect(result.data).toHaveLength(3) + expect(result.data[1].properties).toMatchObject({ + value: NOTSET_VALUE, + color: '#aaaaaa', + }) + expect(result.data[2].properties).toMatchObject({ + value: SOME_VALUE, + color: '#bbbbbb', + }) + + expect(result.legend.items).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + isNoData: true, + color: '#aaaaaa', + count: 1, + }), expect.objectContaining({ - name: LEGEND_ITEM_OTHER, - color: '#333333', - radius: 8, + isUnclassified: true, + color: '#bbbbbb', + count: 1, }), ]) ) - expect(result.legend.unit).toEqual(OPTION_SET_NAME) }) }) diff --git a/src/util/classify.js b/src/util/classify.js index 658a97350..c93acb347 100644 --- a/src/util/classify.js +++ b/src/util/classify.js @@ -11,6 +11,7 @@ import { CLASSIFICATION_PRETTY_BREAKS, } from '../constants/layers.js' import { hasValue } from './helpers.js' +import { isRegularLegendItem } from './legend.js' import { getRoundToPrecisionFn } from './numbers.js' // Returns legend item where a value belongs @@ -24,41 +25,38 @@ export const getLegendItemForValue = ({ if (!hasValue(value) || legendItems.length === 0) { return } - if (valueFormat) { - value = valueFormat(value) - } + + const formattedValue = valueFormat ? valueFormat(value) : value const isolatedItem = legendItems.find( (item) => - item.isLegendIsolated && - value >= item.startValue && - value <= item.endValue + item.isIsolated && + formattedValue >= item.startValue && + formattedValue <= item.endValue ) if (isolatedItem) { return isolatedItem } - if (clamp) { - const rangeItems = legendItems.filter((item) => !item.isLegendIsolated) - if (rangeItems.length > 0 && value < rangeItems[0].startValue) { + const rangeItems = legendItems.filter(isRegularLegendItem) + + if (clamp && rangeItems.length > 0) { + if (formattedValue < rangeItems[0].startValue) { return rangeItems[0] } - if ( - rangeItems.length > 0 && - value > rangeItems[rangeItems.length - 1].endValue - ) { - return rangeItems[rangeItems.length - 1] + if (formattedValue > rangeItems.at(-1).endValue) { + return rangeItems.at(-1) } } const isClusters = method === CLASSIFICATION_NATURAL_BREAKS_CLUSTERS - const isLast = (index) => index === legendItems.length - 1 - return legendItems.find((item, index) => + return rangeItems.find((item, index) => item.startValue === item.endValue - ? value === item.startValue - : value >= item.startValue && - (value < item.endValue || - (value === item.endValue && (isClusters || isLast(index)))) + ? formattedValue === item.startValue + : formattedValue >= item.startValue && + (formattedValue < item.endValue || + (formattedValue === item.endValue && + (isClusters || index === rangeItems.length - 1))) ) } diff --git a/src/util/favorites.js b/src/util/favorites.js index c592ad8eb..0966ff9a9 100644 --- a/src/util/favorites.js +++ b/src/util/favorites.js @@ -63,6 +63,8 @@ const validLayerProperties = [ 'method', 'name', 'noDataColor', + 'noDataLegend', + 'unclassifiedLegend', 'opacity', 'organisationUnitColor', 'organisationUnitGroupSet', @@ -204,6 +206,13 @@ const models2objects = (layer, cleanMapviewConfig) => { if (layer.legendIsolated !== undefined) { configData.legendIsolated = layer.legendIsolated } + if (layer.unclassifiedLegend) { + configData.unclassifiedLegend = layer.unclassifiedLegend + } + if (layer.noDataLegend) { + layer.noDataColor = layer.noDataLegend.color // noDataColor is the DHIS2 API schema field — store color there for backward compatibility + configData.noDataLegend = layer.noDataLegend + } if (Object.keys(configData).length) { layer.config = JSON.stringify(configData) } @@ -211,6 +220,8 @@ const models2objects = (layer, cleanMapviewConfig) => { delete layer.legendDecimalPlaces delete layer.legendIsolated + delete layer.noDataLegend + delete layer.unclassifiedLegend } else if (layerType === GEOJSON_URL_LAYER) { if (cleanMapviewConfig) { layer.config = { diff --git a/src/util/legend.js b/src/util/legend.js index e54c7c97a..af2e0184a 100644 --- a/src/util/legend.js +++ b/src/util/legend.js @@ -69,10 +69,10 @@ export const sortLegendItems = (items) => return -1 } - if (a.isLegendIsolated && !b.isLegendIsolated) { + if (a.isIsolated && !b.isIsolated) { return 1 } - if (!a.isLegendIsolated && b.isLegendIsolated) { + if (!a.isIsolated && b.isIsolated) { return -1 } @@ -150,6 +150,9 @@ export const getLabelsFromLegendItems = (legendItems) => { } // Returns a legend created from a pre-defined legend set +export const isRegularLegendItem = (item) => + !item.isNoData && !item.isUnclassified && !item.isIsolated + export const getPredefinedLegendItems = (legendSet) => { const pickSome = pick(['name', 'startValue', 'endValue', 'color']) @@ -160,7 +163,7 @@ export const buildIsolatedLegendItem = ({ min, max, color, name }) => ({ startValue: min, endValue: max, color, - isLegendIsolated: true, + isIsolated: true, ...(name && { name }), }) diff --git a/src/util/styleByDataItem.js b/src/util/styleByDataItem.js index 89fab5ab6..8e7e4f33b 100644 --- a/src/util/styleByDataItem.js +++ b/src/util/styleByDataItem.js @@ -8,11 +8,59 @@ import { numberValueTypes, booleanValueTypes } from '../constants/valueTypes.js' import { cssColor } from '../util/colors.js' import { OPTION_SET_QUERY, LEGEND_SET_QUERY } from '../util/requests.js' import { getLegendItemForValue } from './classify.js' -import { getAutomaticLegendItems, getPredefinedLegendItems } from './legend.js' +import { + getAutomaticLegendItems, + getPredefinedLegendItems, + isRegularLegendItem, +} from './legend.js' const hasValue = (value) => value !== undefined && value !== null && value !== '' +const addSpecialLegendItems = ( + legend, + { noDataLegend, unclassifiedLegend } +) => { + if (unclassifiedLegend) { + legend.items.push({ + name: unclassifiedLegend.name || i18n.t('Unclassified'), + color: unclassifiedLegend.color, + isUnclassified: true, + }) + } + if (noDataLegend) { + legend.items.push({ + name: noDataLegend.name || i18n.t('No data'), + color: noDataLegend.color, + isNoData: true, + }) + } + return { + unclassifiedLegendItem: legend.items.find( + (i) => i.isUnclassified === true + ), + noDataLegendItem: legend.items.find((i) => i.isNoData === true), + } +} + +const stampLegendItems = (items, eventPointRadius) => + items.forEach((item) => { + item.radius = eventPointRadius || EVENT_RADIUS + item.count = 0 + }) + +const addFeature = (acc, feature, { item, value }) => { + item.count++ + acc.push({ + ...feature, + properties: { + ...feature.properties, + value, + color: item.color, + }, + }) +} + // "Style by data item" handling for event layer // Can be reused for TEI layer when the Web API is improved // This function is modifiyng the config object before it's added to the redux store @@ -32,97 +80,97 @@ export const styleByDataItem = async (config, engine) => { } const styleByDefault = async (config, engine) => { - const { styleDataItem, data, legend, eventPointColor, eventPointRadius } = - config + const { + styleDataItem, + data, + legend, + eventPointColor, + eventPointRadius, + noDataLegend, + } = config const { id } = styleDataItem legend.unit = await getLegendUnit(engine, styleDataItem) - legend.items = [ - { - name: i18n.t('Event'), - color: cssColor(eventPointColor) || EVENT_COLOR, - radius: eventPointRadius || EVENT_RADIUS, - count: data.length, - }, - ] + const eventItem = { + name: i18n.t('Event'), + color: cssColor(eventPointColor) || EVENT_COLOR, + } + legend.items = [eventItem] - config.data = data.map((feature) => { + const { noDataLegendItem } = addSpecialLegendItems(legend, { noDataLegend }) + stampLegendItems(legend.items, eventPointRadius) + + config.data = data.reduce((acc, feature) => { const value = feature.properties[id] + const isNoData = !hasValue(value) - return { - ...feature, - properties: { - ...feature.properties, - value: hasValue(value) ? value : i18n.t('Not set'), - color: cssColor(eventPointColor) || EVENT_COLOR, - }, + if (isNoData && !noDataLegendItem) { + return acc } - }) + + addFeature(acc, feature, { + item: isNoData ? noDataLegendItem : eventItem, + value: isNoData ? i18n.t('Not set') : value, + }) + return acc + }, []) return config } const styleByBoolean = async (config, engine) => { - const { styleDataItem, data, legend, eventPointColor, eventPointRadius } = - config + const { + styleDataItem, + data, + legend, + eventPointRadius, + noDataLegend, + unclassifiedLegend, + } = config const { id, values } = styleDataItem legend.unit = await getLegendUnit(engine, styleDataItem) - legend.items = [ - { - name: i18n.t('Yes'), - color: values.true, - radius: eventPointRadius || EVENT_RADIUS, - count: 0, - }, - ] + const yesItem = { name: i18n.t('Yes'), color: values.true } + const noItem = values.false + ? { name: i18n.t('No'), color: values.false } + : null - if (values.false) { - legend.items.push({ - name: i18n.t('No'), - color: values.false, - radius: eventPointRadius || EVENT_RADIUS, - count: 0, - }) - } + legend.items = [yesItem, noItem].filter(Boolean) - legend.items.push({ - name: i18n.t('Other'), - color: cssColor(eventPointColor) || EVENT_COLOR, - radius: eventPointRadius || EVENT_RADIUS, - count: 0, - }) + const { unclassifiedLegendItem, noDataLegendItem } = addSpecialLegendItems( + legend, + { noDataLegend, unclassifiedLegend } + ) + stampLegendItems(legend.items, eventPointRadius) - config.data = data.map((feature) => { + config.data = data.reduce((acc, feature) => { const value = feature.properties[id] - let displayValue - let color + const isNoData = !hasValue(value) + const isUnclassified = !isNoData && value !== '1' && value !== '0' - if (value === '1') { - displayValue = i18n.t('Yes') - color = values.true - legend.items[0].count++ - } else if (value === '0') { - displayValue = i18n.t('No') - color = values.false - legend.items[1].count++ - } else { - displayValue = hasValue(value) ? value : i18n.t('Not set') - color = cssColor(eventPointColor) || EVENT_COLOR - legend.items[legend.items.length - 1].count++ + if (isUnclassified && !unclassifiedLegendItem) { + return acc + } + if (isNoData && !noDataLegendItem) { + return acc } - return { - ...feature, - properties: { - ...feature.properties, - value: displayValue, - color: color, - }, + if (value === '1') { + addFeature(acc, feature, { item: yesItem, value: i18n.t('Yes') }) + } else if (value === '0' && noItem) { + addFeature(acc, feature, { item: noItem, value: i18n.t('No') }) + } else if (isUnclassified) { + addFeature(acc, feature, { item: unclassifiedLegendItem, value }) + } else { + addFeature(acc, feature, { + item: noDataLegendItem, + value: i18n.t('Not set'), + }) } - }) + return acc + }, []) return config } @@ -139,6 +187,8 @@ const styleByNumeric = async (config, engine) => { eventPointRadius, legendDecimalPlaces, legendIsolated, + noDataLegend, + unclassifiedLegend, } = config let valueFormat @@ -179,17 +229,11 @@ const styleByNumeric = async (config, engine) => { valueFormat = classification.valueFormat } - legend.items.push({ - name: i18n.t('Other'), - color: cssColor(eventPointColor) || EVENT_COLOR, - noData: true, - }) - - // Add radius and count to each legend item - legend.items.forEach((item) => { - item.radius = eventPointRadius || EVENT_RADIUS - item.count = 0 - }) + const { unclassifiedLegendItem, noDataLegendItem } = addSpecialLegendItems( + legend, + { noDataLegend, unclassifiedLegend } + ) + stampLegendItems(legend.items, eventPointRadius) // Helper function to get legend item for data value const getLegendItem = (value) => @@ -197,12 +241,12 @@ const styleByNumeric = async (config, engine) => { value, valueFormat, method, - legendItems: legend.items.filter((item) => !item.noData), + legendItems: legend.items.filter(isRegularLegendItem), clamp: method !== CLASSIFICATION_PREDEFINED, }) // Add style data value and color to each feature - config.data = data.map((feature) => { + config.data = data.reduce((acc, feature) => { const value = feature.properties[styleDataItem.id] let legendItem @@ -211,29 +255,53 @@ const styleByNumeric = async (config, engine) => { legendItem = getLegendItem(numericValue) } - if (legendItem) { - legendItem.count++ - } else { - legend.items[legend.items.length - 1].count++ + const isUnclassified = + method === CLASSIFICATION_PREDEFINED && + hasValue(value) && + !legendItem + const isNoData = !hasValue(value) + + if (isUnclassified && !unclassifiedLegendItem) { + return acc } + if (isNoData && !noDataLegendItem) { + return acc + } + + const activeItem = + legendItem ?? + (isUnclassified ? unclassifiedLegendItem : noDataLegendItem) - return { - ...feature, - properties: { - ...feature.properties, + if (activeItem) { + addFeature(acc, feature, { + item: activeItem, value: hasValue(value) ? value : i18n.t('Not set'), - color: legendItem - ? legendItem.color - : cssColor(eventPointColor) || EVENT_COLOR, - }, + }) + } else { + // Non-numeric string value in auto mode: include with default color + acc.push({ + ...feature, + properties: { + ...feature.properties, + value, + color: cssColor(eventPointColor) || EVENT_COLOR, + }, + }) } - }) + return acc + }, []) return config } const styleByOptionSet = async (config, engine) => { - const { styleDataItem, legend, eventPointColor, eventPointRadius } = config + const { + styleDataItem, + legend, + eventPointRadius, + noDataLegend, + unclassifiedLegend, + } = config const optionSet = await getOptionSet(styleDataItem.optionSet, engine) const id = styleDataItem.id @@ -249,16 +317,13 @@ const styleByOptionSet = async (config, engine) => { legend.items = optionSet.options.map((option) => ({ name: option.name, color: option.style.color, - radius: eventPointRadius || EVENT_RADIUS, - count: 0, })) - legend.items.push({ - name: i18n.t('Other'), - color: cssColor(eventPointColor) || EVENT_COLOR, - radius: eventPointRadius || EVENT_RADIUS, - count: 0, - }) + const { unclassifiedLegendItem, noDataLegendItem } = addSpecialLegendItems( + legend, + { noDataLegend, unclassifiedLegend } + ) + stampLegendItems(legend.items, eventPointRadius) // For easier and faster lookup below // TODO: There might be options with duplicate name, so code/id would be safer @@ -269,38 +334,37 @@ const styleByOptionSet = async (config, engine) => { }, {}) // Add style data value and color to each feature - config.data = config.data.map((feature) => { + config.data = config.data.reduce((acc, feature) => { const name = feature.properties[id] + const isNoData = !hasValue(name) + const option = !isNoData ? optionsByName[name.toLowerCase()] : null + const isUnclassified = !isNoData && !option - if (name) { - const option = optionsByName[name.toLowerCase()] - - if (option) { - const optionIndex = legend.items.findIndex( - (item) => item.name === option.name - ) - legend.items[optionIndex].count++ - return { - ...feature, - properties: { - ...feature.properties, - value: option.name, - color: option.style.color, - }, - } - } + if (isUnclassified && !unclassifiedLegendItem) { + return acc + } + if (isNoData && !noDataLegendItem) { + return acc } - legend.items[legend.items.length - 1].count++ - return { - ...feature, - properties: { - ...feature.properties, - value: hasValue(name) ? name : i18n.t('Not set'), - color: cssColor(eventPointColor) || EVENT_COLOR, - }, + if (option) { + addFeature(acc, feature, { + item: legend.items.find((i) => i.name === option.name), + value: option.name, + }) + } else if (isUnclassified) { + addFeature(acc, feature, { + item: unclassifiedLegendItem, + value: name, + }) + } else { + addFeature(acc, feature, { + item: noDataLegendItem, + value: i18n.t('Not set'), + }) } - }) + return acc + }, []) return config } From d6ca2680e2f463f4bd278eb34b885a6e6fda9948 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault Date: Thu, 30 Apr 2026 17:17:43 +0200 Subject: [PATCH 05/10] feat: route non-classifiable log/SD values to unclassified [DHIS2-19812] --- src/loaders/thematicLoader.js | 7 +++- src/util/__tests__/classify.spec.js | 40 +++++++++++-------- src/util/__tests__/styleByDataItem.spec.js | 8 +--- src/util/classify.js | 45 ++++++++-------------- src/util/styleByDataItem.js | 33 ++++++---------- 5 files changed, 59 insertions(+), 74 deletions(-) diff --git a/src/loaders/thematicLoader.js b/src/loaders/thematicLoader.js index 824725817..49fdab1d1 100644 --- a/src/loaders/thematicLoader.js +++ b/src/loaders/thematicLoader.js @@ -17,6 +17,8 @@ import { RENDERING_STRATEGY_SINGLE, CLASSIFICATION_PREDEFINED, CLASSIFICATION_SINGLE_COLOR, + CLASSIFICATION_LOGARITHMIC, + CLASSIFICATION_STANDARD_DEVIATION, ORG_UNIT_COLOR, ORG_UNIT_RADIUS_SMALL, } from '../constants/layers.js' @@ -334,7 +336,10 @@ const thematicLoader = async ({ valueFormat, method, legendItems: legend.items, - clamp: !isPredefined, + clamp: + !isPredefined && + method !== CLASSIFICATION_LOGARITHMIC && + method !== CLASSIFICATION_STANDARD_DEVIATION, }) const getFeatureColor = (legendItem, { isNoData, isUnclassified }) => { diff --git a/src/util/__tests__/classify.spec.js b/src/util/__tests__/classify.spec.js index 3da31f588..e185ca236 100644 --- a/src/util/__tests__/classify.spec.js +++ b/src/util/__tests__/classify.spec.js @@ -226,32 +226,38 @@ describe('getLegendItems', () => { expect(items[0].endValue).toBe(items[1].startValue) }) - it('falls back to equal intervals for logarithmic when min <= 0', () => { + it('filters non-positive values for logarithmic classification', () => { const values = [0, 25, 50, 75, 100] - const { items: logItems } = getLegendItems( - values, - CLASSIFICATION_LOGARITHMIC, - { numClasses: 4 } - ) - const { items: equalItems } = getLegendItems( - values, - CLASSIFICATION_EQUAL_INTERVALS, - { numClasses: 4 } - ) - expect(logItems).toEqual(equalItems) + const { items } = getLegendItems(values, CLASSIFICATION_LOGARITHMIC, { + numClasses: 4, + }) + expect(items.length).toBe(4) + expect(items[0].startValue).toBeGreaterThan(0) + expect(items[items.length - 1].endValue).toBe(100) }) - it('returns standard deviation bins spanning [min, max]', () => { + it('returns empty items for logarithmic when all values are non-positive', () => { + const values = [-100, -50, -10, 0] + const { items } = getLegendItems(values, CLASSIFICATION_LOGARITHMIC, { + numClasses: 4, + }) + expect(items).toEqual([]) + }) + + it('returns standard deviation bins with σ-aligned outer bounds', () => { const values = [0, 10, 20, 50, 80, 90, 100] const { items } = getLegendItems( values, CLASSIFICATION_STANDARD_DEVIATION, { numClasses: 5 } ) - expect(items[0].startValue).toBe(0) - expect(items[items.length - 1].endValue).toBe(100) - expect(items.length).toBeGreaterThanOrEqual(1) - expect(items.length).toBeLessThanOrEqual(5) + // Always produces exactly numClasses bins + expect(items.length).toBe(5) + // Outer bounds extend beyond the data range via σ-alignment + expect(items[0].startValue).toBeLessThan(0) + expect(items[items.length - 1].endValue).toBeGreaterThan(100) + // Adjacent bins are contiguous + expect(items[0].endValue).toBe(items[1].startValue) }) it('returns pretty breaks with round boundaries', () => { diff --git a/src/util/__tests__/styleByDataItem.spec.js b/src/util/__tests__/styleByDataItem.spec.js index 7ad4ccce1..17ce9eb84 100644 --- a/src/util/__tests__/styleByDataItem.spec.js +++ b/src/util/__tests__/styleByDataItem.spec.js @@ -299,7 +299,7 @@ describe('styleByDataItem', () => { { properties: { [STYLE_DATA_ITEM_ID]: 1 } }, { properties: { [STYLE_DATA_ITEM_ID]: 2 } }, { properties: {} }, // no data - dropped (no noDataLegend) - { properties: { [STYLE_DATA_ITEM_ID]: SOME_VALUE } }, // non-numeric - included with fallback color + { properties: { [STYLE_DATA_ITEM_ID]: SOME_VALUE } }, // non-numeric - dropped (no unclassifiedLegend) ], method: 2, classes: 3, @@ -312,7 +312,7 @@ describe('styleByDataItem', () => { expect(mockEngine.query).toHaveBeenCalled() - expect(result.data).toHaveLength(4) + expect(result.data).toHaveLength(3) expect(result.data[0].properties).toMatchObject({ value: 0, color: '#ff0000', @@ -325,10 +325,6 @@ describe('styleByDataItem', () => { value: 2, color: '#0000ff', }) - expect(result.data[3].properties).toMatchObject({ - value: SOME_VALUE, - color: EVENT_COLOR, - }) expect(result.legend.items).toHaveLength(3) expect(result.legend.items).toEqual( diff --git a/src/util/classify.js b/src/util/classify.js index c93acb347..53397fe27 100644 --- a/src/util/classify.js +++ b/src/util/classify.js @@ -99,22 +99,18 @@ export const getLegendItems = ( } else if (method === CLASSIFICATION_STANDARD_DEVIATION) { classification = getStandardDeviation(values, { numClasses, precision }) } else if (method === CLASSIFICATION_LOGARITHMIC) { - if (minValue <= 0) { - // Logarithmic scale requires strictly positive values. - // Silently fall back to equal intervals for now. - // TODO: when DHIS2-19812 (unclassified bucket) lands, filter - // non-positive values out of the classification input and route - // them to the unclassified bucket instead of falling back. - classification = getEqualIntervals(minValue, maxValue, { - numClasses, - precision, - }) - } else { - classification = getLogarithmic(minValue, maxValue, { + const positiveValues = values.filter((v) => v > 0) + if (positiveValues.length === 0) { + return { items: [] } + } + classification = getLogarithmic( + positiveValues[0], + positiveValues.at(-1), + { numClasses, precision, - }) - } + } + ) } else if (method === CLASSIFICATION_PRETTY_BREAKS) { classification = getPrettyBreaks(minValue, maxValue, { numClasses, @@ -229,14 +225,6 @@ const getCkMeans = (values, { numClasses, continuous, precision }) => { } 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 - // fewer bins than requested) - // - the outermost kept bins span to min/max rather than the next - // σ boundary, so bin labels no longer mean "Nσ from mean" - // When the unclassified bucket lands, preserve σ-aligned boundaries - // and route values beyond the outermost break to unclassified. const minValue = values[0] const maxValue = values[values.length - 1] const mu = mean(values) @@ -247,21 +235,22 @@ const getStandardDeviation = (values, { numClasses, precision }) => { const valueFormat = getRoundToPrecisionFn(resolvedPrecision) // Place breaks at 1-sigma intervals centered on the mean. - const internalBreaks = [] const isEven = numClasses % 2 === 0 const maxOffset = Math.floor((numClasses - 1) / 2) + const internalBreaks = [] for (let i = 0; i < numClasses - 1; i++) { let offset = i - maxOffset if (!isEven && offset >= 0) { offset += 1 } - const b = mu + offset * sigma - if (b > minValue && b < maxValue) { - internalBreaks.push(b) - } + internalBreaks.push(mu + offset * sigma) } - const allBreaks = [minValue, ...internalBreaks, maxValue] + // Outer bounds are σ-aligned so all class labels mean "Nσ from mean". + // Values outside [lowerBound, upperBound] are routed to unclassified. + const lowerBound = mu - (maxOffset + 1) * sigma + const upperBound = mu + (maxOffset + 1) * sigma + const allBreaks = [lowerBound, ...internalBreaks, upperBound] return { items: allBreaks.slice(0, -1).map((start, i) => ({ startValue: valueFormat(start), diff --git a/src/util/styleByDataItem.js b/src/util/styleByDataItem.js index 8e7e4f33b..0e575db98 100644 --- a/src/util/styleByDataItem.js +++ b/src/util/styleByDataItem.js @@ -3,6 +3,8 @@ import { EVENT_COLOR, EVENT_RADIUS, CLASSIFICATION_PREDEFINED, + CLASSIFICATION_LOGARITHMIC, + CLASSIFICATION_STANDARD_DEVIATION, } from '../constants/layers.js' import { numberValueTypes, booleanValueTypes } from '../constants/valueTypes.js' import { cssColor } from '../util/colors.js' @@ -183,7 +185,6 @@ const styleByNumeric = async (config, engine) => { method, classes, colorScale, - eventPointColor, eventPointRadius, legendDecimalPlaces, legendIsolated, @@ -242,7 +243,10 @@ const styleByNumeric = async (config, engine) => { valueFormat, method, legendItems: legend.items.filter(isRegularLegendItem), - clamp: method !== CLASSIFICATION_PREDEFINED, + clamp: + method !== CLASSIFICATION_PREDEFINED && + method !== CLASSIFICATION_LOGARITHMIC && + method !== CLASSIFICATION_STANDARD_DEVIATION, }) // Add style data value and color to each feature @@ -255,11 +259,8 @@ const styleByNumeric = async (config, engine) => { legendItem = getLegendItem(numericValue) } - const isUnclassified = - method === CLASSIFICATION_PREDEFINED && - hasValue(value) && - !legendItem const isNoData = !hasValue(value) + const isUnclassified = hasValue(value) && !legendItem if (isUnclassified && !unclassifiedLegendItem) { return acc @@ -272,22 +273,10 @@ const styleByNumeric = async (config, engine) => { legendItem ?? (isUnclassified ? unclassifiedLegendItem : noDataLegendItem) - if (activeItem) { - addFeature(acc, feature, { - item: activeItem, - value: hasValue(value) ? value : i18n.t('Not set'), - }) - } else { - // Non-numeric string value in auto mode: include with default color - acc.push({ - ...feature, - properties: { - ...feature.properties, - value, - color: cssColor(eventPointColor) || EVENT_COLOR, - }, - }) - } + addFeature(acc, feature, { + item: activeItem, + value: hasValue(value) ? value : i18n.t('Not set'), + }) return acc }, []) From 68a7d2d9196991ef23e7d73f3bb8ce5f23f56d1c Mon Sep 17 00:00:00 2001 From: Bruno Raimbault Date: Thu, 30 Apr 2026 17:30:39 +0200 Subject: [PATCH 06/10] chore: add tests --- src/util/__tests__/classify.spec.js | 44 ++++++++ src/util/__tests__/favorites.spec.js | 127 +++++++++++++++++++++++ src/util/__tests__/legend.spec.js | 150 +++++++++++++++++++++++++++ 3 files changed, 321 insertions(+) diff --git a/src/util/__tests__/classify.spec.js b/src/util/__tests__/classify.spec.js index e185ca236..c9a8d9eec 100644 --- a/src/util/__tests__/classify.spec.js +++ b/src/util/__tests__/classify.spec.js @@ -145,6 +145,50 @@ describe('getLegendItemForValue', () => { }) ).toEqual(items[1]) }) + + it('returns isolated item when value is within its range', () => { + const items = [ + { startValue: 0, endValue: 10 }, + { startValue: 10, endValue: 20 }, + { startValue: 4, endValue: 8, isIsolated: true }, + ] + expect(getLegendItemForValue({ value: 6, legendItems: items })).toEqual( + items[2] + ) + }) + + it('falls back to regular range items when value is outside isolated range', () => { + const items = [ + { startValue: 0, endValue: 10 }, + { startValue: 10, endValue: 20 }, + { startValue: 4, endValue: 8, isIsolated: true }, + ] + expect( + getLegendItemForValue({ value: 15, legendItems: items }) + ).toEqual(items[1]) + }) + + it('does not return isNoData or isUnclassified items from range lookup', () => { + const items = [ + { startValue: 0, endValue: 10 }, + { isNoData: true, color: 'grey' }, + { isUnclassified: true, color: 'orange' }, + ] + expect(getLegendItemForValue({ value: 5, legendItems: items })).toEqual( + items[0] + ) + }) + + it('clamp targets only regular range items, not isNoData or isUnclassified', () => { + const items = [ + { startValue: 10, endValue: 20 }, + { isNoData: true, color: 'grey' }, + { isUnclassified: true, color: 'orange' }, + ] + expect( + getLegendItemForValue({ value: 0, legendItems: items, clamp: true }) + ).toEqual(items[0]) + }) }) describe('getLegendItems', () => { diff --git a/src/util/__tests__/favorites.spec.js b/src/util/__tests__/favorites.spec.js index 76d9985d6..902544404 100644 --- a/src/util/__tests__/favorites.spec.js +++ b/src/util/__tests__/favorites.spec.js @@ -344,6 +344,133 @@ describe('cleanMapConfig', () => { ) }) + test('serializes noDataLegend into config JSON and writes noDataColor for backward compat', () => { + const cleanedConfig = cleanMapConfig({ + config: { + mapViews: [ + { + layer: 'thematic', + name: 'Test', + opacity: 1, + noDataLegend: { color: '#aaaaaa', name: 'No data' }, + isLoaded: true, + isLoading: false, + isExpanded: true, + isVisible: true, + }, + ], + }, + defaultBasemapId: 'thedefaultBasemap', + }) + const view = cleanedConfig.mapViews[0] + expect(JSON.parse(view.config).noDataLegend).toEqual({ + color: '#aaaaaa', + name: 'No data', + }) + expect(view.noDataColor).toBe('#aaaaaa') + expect(view).not.toHaveProperty('noDataLegend') + }) + + test('serializes unclassifiedLegend into config JSON and removes it from the layer', () => { + const cleanedConfig = cleanMapConfig({ + config: { + mapViews: [ + { + layer: 'thematic', + name: 'Test', + opacity: 1, + unclassifiedLegend: { + color: '#bbbbbb', + name: 'Unclassified', + }, + isLoaded: true, + isLoading: false, + isExpanded: true, + isVisible: true, + }, + ], + }, + defaultBasemapId: 'thedefaultBasemap', + }) + const view = cleanedConfig.mapViews[0] + expect(JSON.parse(view.config).unclassifiedLegend).toEqual({ + color: '#bbbbbb', + name: 'Unclassified', + }) + expect(view).not.toHaveProperty('unclassifiedLegend') + }) + + test('serializes legendIsolated into config JSON and removes it from the layer', () => { + const cleanedConfig = cleanMapConfig({ + config: { + mapViews: [ + { + layer: 'thematic', + name: 'Test', + opacity: 1, + legendIsolated: { + min: 40, + max: 60, + color: '#888888', + name: 'Mid', + }, + isLoaded: true, + isLoading: false, + isExpanded: true, + isVisible: true, + }, + ], + }, + defaultBasemapId: 'thedefaultBasemap', + }) + const view = cleanedConfig.mapViews[0] + expect(JSON.parse(view.config).legendIsolated).toEqual({ + min: 40, + max: 60, + color: '#888888', + name: 'Mid', + }) + expect(view).not.toHaveProperty('legendIsolated') + }) + + test('combines all serializable config fields into a single config JSON', () => { + const cleanedConfig = cleanMapConfig({ + config: { + mapViews: [ + { + layer: 'thematic', + name: 'Test', + opacity: 1, + legendDecimalPlaces: 2, + legendIsolated: { min: 40, max: 60, color: '#888' }, + noDataLegend: { color: '#aaaaaa' }, + unclassifiedLegend: { color: '#bbbbbb' }, + isLoaded: true, + isLoading: false, + isExpanded: true, + isVisible: true, + }, + ], + }, + defaultBasemapId: 'thedefaultBasemap', + }) + const view = cleanedConfig.mapViews[0] + const parsed = JSON.parse(view.config) + expect(parsed.legendDecimalPlaces).toBe(2) + expect(parsed.legendIsolated).toEqual({ + min: 40, + max: 60, + color: '#888', + }) + expect(parsed.noDataLegend).toEqual({ color: '#aaaaaa' }) + expect(parsed.unclassifiedLegend).toEqual({ color: '#bbbbbb' }) + expect(view).not.toHaveProperty('legendDecimalPlaces') + expect(view).not.toHaveProperty('legendIsolated') + expect(view).not.toHaveProperty('noDataLegend') + expect(view).not.toHaveProperty('unclassifiedLegend') + expect(view.noDataColor).toBe('#aaaaaa') + }) + test('does not add config for thematic layer without legendDecimalPlaces', () => { const config = { mapViews: [ diff --git a/src/util/__tests__/legend.spec.js b/src/util/__tests__/legend.spec.js index a2bc9d22c..8ad750b55 100644 --- a/src/util/__tests__/legend.spec.js +++ b/src/util/__tests__/legend.spec.js @@ -15,6 +15,9 @@ import { getAutomaticLegendItems, getRenderingLabel, parseRange, + isRegularLegendItem, + buildIsolatedLegendItem, + legendNamesContainRange, } from '../legend.js' describe('sortLegendItems', () => { @@ -38,6 +41,19 @@ describe('sortLegendItems', () => { expect(sortLegendItems(items).map((i) => i.from)).toEqual([10, 5, 0]) }) + it('places isIsolated items after regular items, before no-range items', () => { + const items = [ + { name: 'no range' }, + { startValue: 0, endValue: 10 }, + { startValue: 4, endValue: 8, isIsolated: true }, + ] + const sorted = sortLegendItems(items) + expect(sorted[0].isIsolated).toBeUndefined() + expect(sorted[0].startValue).toBe(0) + expect(sorted[1].isIsolated).toBe(true) + expect(sorted[2].name).toBe('no range') + }) + it('places items without range keys at the end', () => { const items = [ { startValue: 10, endValue: 20 }, @@ -226,6 +242,140 @@ describe('legend utils', () => { expect(Number.isInteger(item.endValue)).toBe(true) }) }) + + it('prepends isolated item and classifies remaining data when legendIsolated is set', () => { + const colorScale = ['#f00', '#0f0', '#00f', '#ff0', '#f0f'] + const data = [0, 10, 20, 50, 55, 60, 80, 90, 100] + const { items } = getAutomaticLegendItems({ + data, + method: CLASSIFICATION_EQUAL_INTERVALS, + classes: 3, + colorScale, + legendIsolated: { + min: 40, + max: 70, + color: '#888', + name: 'Mid', + }, + }) + expect(items[0]).toEqual( + expect.objectContaining({ isIsolated: true, color: '#888' }) + ) + expect(items.slice(1)).toHaveLength(3) + items + .slice(1) + .forEach((item) => expect(item.isIsolated).toBeUndefined()) + }) + + it('returns only the isolated item when all data falls within isolated range', () => { + const { items } = getAutomaticLegendItems({ + data: [45, 50, 55], + method: CLASSIFICATION_EQUAL_INTERVALS, + classes: 3, + colorScale: defaultColorScale, + legendIsolated: { min: 40, max: 60, color: '#888' }, + }) + expect(items).toHaveLength(1) + expect(items[0].isIsolated).toBe(true) + }) + + it('returns only the isolated item when data array is empty but legendIsolated is set', () => { + const { items } = getAutomaticLegendItems({ + data: [], + method: CLASSIFICATION_EQUAL_INTERVALS, + classes: 3, + colorScale: defaultColorScale, + legendIsolated: { + min: 40, + max: 60, + color: '#888', + name: 'Mid', + }, + }) + expect(items).toHaveLength(1) + expect(items[0].isIsolated).toBe(true) + }) + }) + + describe('legendNamesContainRange', () => { + it('returns true when most item names contain their numeric range', () => { + const items = [ + { name: '0 - 10', startValue: 0, endValue: 10 }, + { name: '10 - 20', startValue: 10, endValue: 20 }, + { name: '20 - 30', startValue: 20, endValue: 30 }, + ] + expect(legendNamesContainRange(items)).toBe(true) + }) + + it('returns false when item names are descriptive labels', () => { + const items = [ + { name: 'Low', startValue: 0, endValue: 10 }, + { name: 'Medium', startValue: 10, endValue: 20 }, + { name: 'High', startValue: 20, endValue: 30 }, + ] + expect(legendNamesContainRange(items)).toBe(false) + }) + + it('returns false when the items array is empty', () => { + expect(legendNamesContainRange([])).toBe(false) + }) + + it('ignores items with non-numeric startValue/endValue', () => { + const items = [{ name: 'Other', startValue: NaN, endValue: NaN }] + expect(legendNamesContainRange(items)).toBe(false) + }) + }) + + describe('isRegularLegendItem', () => { + it('returns true for a plain legend item', () => { + expect( + isRegularLegendItem({ + startValue: 0, + endValue: 10, + color: 'red', + }) + ).toBe(true) + }) + + it('returns false for isNoData item', () => { + expect(isRegularLegendItem({ isNoData: true })).toBe(false) + }) + + it('returns false for isUnclassified item', () => { + expect(isRegularLegendItem({ isUnclassified: true })).toBe(false) + }) + + it('returns false for isIsolated item', () => { + expect(isRegularLegendItem({ isIsolated: true })).toBe(false) + }) + }) + + describe('buildIsolatedLegendItem', () => { + it('creates an item with isIsolated flag and correct range', () => { + const item = buildIsolatedLegendItem({ + min: 50, + max: 100, + color: '#ff0000', + name: 'High', + }) + expect(item).toEqual({ + startValue: 50, + endValue: 100, + color: '#ff0000', + name: 'High', + isIsolated: true, + }) + }) + + it('omits name property when name is not provided', () => { + const item = buildIsolatedLegendItem({ + min: 50, + max: 100, + color: '#ff0000', + }) + expect(item).not.toHaveProperty('name') + expect(item.isIsolated).toBe(true) + }) }) describe('parseRange', () => { From dcf0817f431b66c3e0024766acc1303339f8cfee Mon Sep 17 00:00:00 2001 From: Bruno Raimbault Date: Thu, 30 Apr 2026 17:50:57 +0200 Subject: [PATCH 07/10] chore: fix sonarqube issue --- src/loaders/thematicLoader.js | 84 +++++++++++++++---------------- src/reducers/layerEdit.js | 6 +-- src/util/__tests__/legend.spec.js | 4 +- src/util/styleByDataItem.js | 2 +- 4 files changed, 49 insertions(+), 47 deletions(-) diff --git a/src/loaders/thematicLoader.js b/src/loaders/thematicLoader.js index 49fdab1d1..88a47caf9 100644 --- a/src/loaders/thematicLoader.js +++ b/src/loaders/thematicLoader.js @@ -173,7 +173,7 @@ const thematicLoader = async ({ const [mainFeatures, data, associatedGeometries] = response const valueById = getValueById(data) - const valuesByPeriod = !isSingleMap ? getValuesByPeriod(data) : null // [PATH] null → Single; populated → Timeline / Split (do not creates OrgUnits with no data) + const valuesByPeriod = isSingleMap ? null : getValuesByPeriod(data) // [PATH] null → Single; populated → Timeline / Split (do not creates OrgUnits with no data) const names = getApiResponseNames( periodTypeData?.enabledPeriodTypesData?.metaData @@ -394,13 +394,13 @@ const thematicLoader = async ({ } const countLegendItem = (legendItem, { isNoData, isUnclassified }) => { - const item = - legendItem ?? - (isNoData - ? noDataLegendItem - : isUnclassified - ? unclassifiedLegendItem - : null) + let specialItem = null + if (isNoData) { + specialItem = noDataLegendItem + } else if (isUnclassified) { + specialItem = unclassifiedLegendItem + } + const item = legendItem ?? specialItem if (item) { item.count++ } @@ -409,39 +409,7 @@ const thematicLoader = async ({ // Feature styling - Processing // ----- - if (!isSingleMap) { - const periods = Object.keys(valuesByPeriod) - periods.forEach((period) => { - const orgUnits = Object.keys(valuesByPeriod[period]) - orgUnits.forEach((orgunit) => { - const item = valuesByPeriod[period][orgunit] - const value = Number(item.value) - const legendItem = getLegendItem(value) - const isNoData = !hasValue(item.value) - const isUnclassified = - !isSingleColor && !legendItem && !isNoData - - // No data org units are absent from valuesByPeriod; - if (isUnclassified && !hasUnclassifiedClass) { - Object.assign(item, { isUnclassified: true }) - return - } - // ThematicLayer handles no data and unclassified inclusion/exclusion - - Object.assign(item, { - ...getFeatureColor(legendItem, { - isNoData, - isUnclassified, - }), - ...getFeatureRadius( - legendItem, - { isNoData, isUnclassified }, - value - ), - }) - }) - }) - } else { + if (isSingleMap) { // Style and filter features in place features = features.flatMap(({ id, geometry, properties }) => { const value = valueById[id] @@ -489,6 +457,38 @@ const thematicLoader = async ({ return [{ id, geometry, properties }] }) + } else { + const periods = Object.keys(valuesByPeriod) + periods.forEach((period) => { + const orgUnits = Object.keys(valuesByPeriod[period]) + orgUnits.forEach((orgunit) => { + const item = valuesByPeriod[period][orgunit] + const value = Number(item.value) + const legendItem = getLegendItem(value) + const isNoData = !hasValue(item.value) + const isUnclassified = + !isSingleColor && !legendItem && !isNoData + + // No data org units are absent from valuesByPeriod; + if (isUnclassified && !hasUnclassifiedClass) { + Object.assign(item, { isUnclassified: true }) + return + } + // ThematicLayer handles no data and unclassified inclusion/exclusion + + Object.assign(item, { + ...getFeatureColor(legendItem, { + isNoData, + isUnclassified, + }), + ...getFeatureRadius( + legendItem, + { isNoData, isUnclassified }, + value + ), + }) + }) + }) } return { @@ -512,7 +512,7 @@ const thematicLoader = async ({ // then fetches the full legendSet from the server. Returns null if not found or deleted. const resolveLegendSet = async (config, dataItem, engine) => { const legendSet = - config.legendSet ?? (!config.method ? dataItem.legendSet : null) + config.legendSet ?? (config.method ? null : dataItem.legendSet) if (!legendSet) { return null } diff --git a/src/reducers/layerEdit.js b/src/reducers/layerEdit.js index f00631f93..2d1d72cd7 100644 --- a/src/reducers/layerEdit.js +++ b/src/reducers/layerEdit.js @@ -582,10 +582,10 @@ const layerEdit = (state = null, action) => { case types.LAYER_EDIT_UNCLASSIFIED_LEGEND_SET: newState = { ...state } - if (!action.payload) { - delete newState.unclassifiedLegend - } else { + if (action.payload) { newState.unclassifiedLegend = action.payload + } else { + delete newState.unclassifiedLegend } return newState diff --git a/src/util/__tests__/legend.spec.js b/src/util/__tests__/legend.spec.js index 8ad750b55..299473083 100644 --- a/src/util/__tests__/legend.spec.js +++ b/src/util/__tests__/legend.spec.js @@ -321,7 +321,9 @@ describe('legend utils', () => { }) it('ignores items with non-numeric startValue/endValue', () => { - const items = [{ name: 'Other', startValue: NaN, endValue: NaN }] + const items = [ + { name: 'Other', startValue: Number.NaN, endValue: Number.NaN }, + ] expect(legendNamesContainRange(items)).toBe(false) }) }) diff --git a/src/util/styleByDataItem.js b/src/util/styleByDataItem.js index 0e575db98..4e5914f44 100644 --- a/src/util/styleByDataItem.js +++ b/src/util/styleByDataItem.js @@ -326,7 +326,7 @@ const styleByOptionSet = async (config, engine) => { config.data = config.data.reduce((acc, feature) => { const name = feature.properties[id] const isNoData = !hasValue(name) - const option = !isNoData ? optionsByName[name.toLowerCase()] : null + const option = isNoData ? null : optionsByName[name.toLowerCase()] const isUnclassified = !isNoData && !option if (isUnclassified && !unclassifiedLegendItem) { From 4c828a2731efa5a36eddf6ba08f91dea30a77d1d Mon Sep 17 00:00:00 2001 From: Bruno Raimbault Date: Thu, 30 Apr 2026 18:29:48 +0200 Subject: [PATCH 08/10] chore: fix cypress test --- cypress/elements/event_layer.js | 12 ++++++++++++ cypress/elements/thematic_layer.js | 6 ++++++ cypress/integration/layers/eventlayer.cy.js | 5 ++++- cypress/integration/layers/thematiclayer.cy.js | 11 +++++++---- src/components/dimensions/DimensionSelect.jsx | 2 +- 5 files changed, 30 insertions(+), 6 deletions(-) diff --git a/cypress/elements/event_layer.js b/cypress/elements/event_layer.js index a26ebc6a9..064687825 100644 --- a/cypress/elements/event_layer.js +++ b/cypress/elements/event_layer.js @@ -60,4 +60,16 @@ export class EventLayer extends Layer { return this } + + selectIncludeUnclassifiedEvents() { + cy.contains('Include unclassified events').click() + + return this + } + + selectIncludeNoDataEvents() { + cy.contains('Include events with no data').click() + + return this + } } diff --git a/cypress/elements/thematic_layer.js b/cypress/elements/thematic_layer.js index 5375c8020..d85b54ca6 100644 --- a/cypress/elements/thematic_layer.js +++ b/cypress/elements/thematic_layer.js @@ -151,6 +151,12 @@ export class ThematicLayer extends Layer { return this } + selectIncludeUnclassifiedOU() { + cy.contains('Include unclassified org units').click() + + return this + } + selectIncludeNoDataOU() { cy.contains('Include org units with no data').click() diff --git a/cypress/integration/layers/eventlayer.cy.js b/cypress/integration/layers/eventlayer.cy.js index 6fbacddfb..d4ef82658 100644 --- a/cypress/integration/layers/eventlayer.cy.js +++ b/cypress/integration/layers/eventlayer.cy.js @@ -11,7 +11,7 @@ const programE2E = { name: 'E2E program', stage: 'Stage 1 - Repeatable', de: 'E2E - Yes/no', - options: ['Yes', 'No', 'Other'], + options: ['Yes', 'No', 'Unclassified', 'No data'], } const programIP = { @@ -141,6 +141,9 @@ context('Event Layers', () => { .contains(programE2E.de) .click() + Layer.selectIncludeUnclassifiedEvents() + Layer.selectIncludeNoDataEvents() + Layer.addToMap() Layer.validateDialogClosed(true) diff --git a/cypress/integration/layers/thematiclayer.cy.js b/cypress/integration/layers/thematiclayer.cy.js index dc4ad3b80..ab41c3d2c 100644 --- a/cypress/integration/layers/thematiclayer.cy.js +++ b/cypress/integration/layers/thematiclayer.cy.js @@ -86,8 +86,8 @@ context('Thematic Layers', () => { // Examples of bubble labels: // "10.5" - // "No data" - const bubbleLabelTextPattern = /^(\d+(\.\d+)?|No data)$/ + // "No data (2)" + const bubbleLabelTextPattern = /^(\d+(\.\d+)?|No data)(\s*\(\d+\))?$/ // Choropleth Layer.openDialog('Thematic') @@ -177,7 +177,9 @@ context('Thematic Layers', () => { cy.getByDataTest('dhis2-uicore-checkbox').eq(1).click() - Layer.openOu('Tonkolili').selectOu('Gbonkonlenken').addToMap() + Layer.openOu('Tonkolili').selectOu('Gbonkonlenken') + + Layer.selectTab('Style').selectIncludeUnclassifiedOU().addToMap() getMaps().click('center') @@ -282,7 +284,8 @@ context('Thematic Layers', () => { n: 7, removeAll: false, }) - .addToMap() + + Layer.selectTab('Style').selectIncludeUnclassifiedOU().addToMap() Layer.validateDialogClosed(true) diff --git a/src/components/dimensions/DimensionSelect.jsx b/src/components/dimensions/DimensionSelect.jsx index 5d1f8ebec..c2625ce1a 100644 --- a/src/components/dimensions/DimensionSelect.jsx +++ b/src/components/dimensions/DimensionSelect.jsx @@ -67,7 +67,7 @@ const DimensionSelect = ({ dimension, onChange }) => { >
From eefbdfd236fb66172f3eb3494fd32f74f1b2b596 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault Date: Fri, 1 May 2026 11:35:04 +0200 Subject: [PATCH 09/10] fix: isolated class was skipped in event layer [DHIS2-15514] --- src/loaders/thematicLoader.js | 20 ++++++++++---------- src/util/__tests__/legend.spec.js | 12 ++++++++++++ src/util/legend.js | 6 ++++++ src/util/styleByDataItem.js | 8 ++------ 4 files changed, 30 insertions(+), 16 deletions(-) diff --git a/src/loaders/thematicLoader.js b/src/loaders/thematicLoader.js index 88a47caf9..9a1b5c1b5 100644 --- a/src/loaders/thematicLoader.js +++ b/src/loaders/thematicLoader.js @@ -301,16 +301,6 @@ const thematicLoader = async ({ } } - let noDataLegendItem = null - if (hasNoDataClass) { - noDataLegendItem = { - color: config.noDataLegend.color, - name: config.noDataLegend.name || i18n.t('No data'), - isNoData: true, - } - legend.items.push(noDataLegendItem) - } - let unclassifiedLegendItem = null if (hasUnclassifiedClass) { unclassifiedLegendItem = { @@ -321,6 +311,16 @@ const thematicLoader = async ({ legend.items.push(unclassifiedLegendItem) } + let noDataLegendItem = null + if (hasNoDataClass) { + noDataLegendItem = { + color: config.noDataLegend.color, + name: config.noDataLegend.name || i18n.t('No data'), + isNoData: true, + } + legend.items.push(noDataLegendItem) + } + // Counting for Timeline / Split would be ambiguous if (isSingleMap) { legend.items.forEach((item) => (item.count = 0)) diff --git a/src/util/__tests__/legend.spec.js b/src/util/__tests__/legend.spec.js index 299473083..73ef20ec2 100644 --- a/src/util/__tests__/legend.spec.js +++ b/src/util/__tests__/legend.spec.js @@ -54,6 +54,18 @@ describe('sortLegendItems', () => { expect(sorted[2].name).toBe('no range') }) + it('places isUnclassified before isNoData regardless of insertion order', () => { + const items = [ + { startValue: 0, endValue: 10 }, + { isNoData: true, color: 'grey' }, + { isUnclassified: true, color: 'orange' }, + ] + const sorted = sortLegendItems(items) + expect(sorted[0].startValue).toBe(0) + expect(sorted[1].isUnclassified).toBe(true) + expect(sorted[2].isNoData).toBe(true) + }) + it('places items without range keys at the end', () => { const items = [ { startValue: 10, endValue: 20 }, diff --git a/src/util/legend.js b/src/util/legend.js index af2e0184a..d3c306975 100644 --- a/src/util/legend.js +++ b/src/util/legend.js @@ -60,6 +60,12 @@ export const sortLegendItems = (items) => const bRange = getRange(b) if (!aRange && !bRange) { + if (a.isNoData && !b.isNoData) { + return 1 + } + if (!a.isNoData && b.isNoData) { + return -1 + } return 0 } if (!aRange) { diff --git a/src/util/styleByDataItem.js b/src/util/styleByDataItem.js index 4e5914f44..a6da89dec 100644 --- a/src/util/styleByDataItem.js +++ b/src/util/styleByDataItem.js @@ -10,11 +10,7 @@ import { numberValueTypes, booleanValueTypes } from '../constants/valueTypes.js' import { cssColor } from '../util/colors.js' import { OPTION_SET_QUERY, LEGEND_SET_QUERY } from '../util/requests.js' import { getLegendItemForValue } from './classify.js' -import { - getAutomaticLegendItems, - getPredefinedLegendItems, - isRegularLegendItem, -} from './legend.js' +import { getAutomaticLegendItems, getPredefinedLegendItems } from './legend.js' const hasValue = (value) => value !== undefined && value !== null && value !== '' @@ -242,7 +238,7 @@ const styleByNumeric = async (config, engine) => { value, valueFormat, method, - legendItems: legend.items.filter(isRegularLegendItem), + legendItems: legend.items, clamp: method !== CLASSIFICATION_PREDEFINED && method !== CLASSIFICATION_LOGARITHMIC && From 2f01b867659de95386557e90f7e1bf04ac0a0f99 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault Date: Thu, 14 May 2026 13:13:50 +0200 Subject: [PATCH 10/10] feat: add unclassified class for facility and org unit group set styling [DHIS2-19812] --- src/components/edit/FacilityDialog.jsx | 15 ++ src/components/edit/orgUnit/OrgUnitDialog.jsx | 18 ++ src/loaders/facilityLoader.js | 7 + src/loaders/orgUnitLoader.js | 7 + src/util/__tests__/orgUnits.spec.js | 197 ++++++++++++++++++ src/util/favorites.js | 9 +- src/util/orgUnits.js | 84 ++++---- 7 files changed, 301 insertions(+), 36 deletions(-) diff --git a/src/components/edit/FacilityDialog.jsx b/src/components/edit/FacilityDialog.jsx index 25bf1942c..e00c915b2 100644 --- a/src/components/edit/FacilityDialog.jsx +++ b/src/components/edit/FacilityDialog.jsx @@ -8,6 +8,7 @@ import { setRadiusLow, setOrganisationUnitGroupSet, setOrganisationUnitColor, + setUnclassifiedLegend, } from '../../actions/layerEdit.js' import { ORG_UNIT_COLOR, @@ -24,6 +25,7 @@ import StyleByGroupSet from '../groupSet/StyleByGroupSet.jsx' import OrgUnitSelect from '../orgunits/OrgUnitSelect.jsx' import BufferRadius from './shared/BufferRadius.jsx' import Labels from './shared/Labels.jsx' +import UnclassifiedLegend from './shared/UnclassifiedLegend.jsx' import styles from './styles/LayerDialog.module.css' const QUERY = { @@ -40,6 +42,7 @@ const FacilityDialog = ({ radiusLow, organisationUnitColor, organisationUnitGroupSet, + unclassifiedLegend, orgUnitField, id, validateLayer, @@ -120,6 +123,14 @@ const FacilityDialog = ({ + {organisationUnitGroupSet && ( + + dispatch(setUnclassifiedLegend(v)) + } + /> + )} {!organisationUnitGroupSet && ( <> + {organisationUnitGroupSet && ( + + )}
)} @@ -133,6 +150,7 @@ export default connect( { setRadiusLow, setOrganisationUnitColor, + setUnclassifiedLegend, }, null, { diff --git a/src/loaders/facilityLoader.js b/src/loaders/facilityLoader.js index ec2eeff76..da1f505d2 100644 --- a/src/loaders/facilityLoader.js +++ b/src/loaders/facilityLoader.js @@ -5,6 +5,7 @@ import { CUSTOM_ALERT, } from '../constants/alerts.js' import { getOrgUnitsFromRows } from '../util/analytics.js' +import { parseJsonConfig } from '../util/config.js' import { toGeoJson } from '../util/map.js' import { ORG_UNITS_GROUP_SET_QUERY, @@ -32,6 +33,12 @@ const facilityLoader = async ({ let loadError const alerts = [] + const { unclassifiedLegend } = parseJsonConfig(config.config) + if (unclassifiedLegend) { + config.unclassifiedLegend = unclassifiedLegend + } + delete config.config + const orgUnitIds = orgUnits.map((item) => item.id) let associatedGeometries diff --git a/src/loaders/orgUnitLoader.js b/src/loaders/orgUnitLoader.js index 0c0c8b34f..f3b26e993 100644 --- a/src/loaders/orgUnitLoader.js +++ b/src/loaders/orgUnitLoader.js @@ -6,6 +6,7 @@ import { ERROR_CRITICAL, } from '../constants/alerts.js' import { getOrgUnitsFromRows } from '../util/analytics.js' +import { parseJsonConfig } from '../util/config.js' import { toGeoJson } from '../util/map.js' import { ORG_UNITS_GROUP_SET_QUERY, @@ -32,6 +33,12 @@ const orgUnitLoader = async ({ let loadError const alerts = [] + const { unclassifiedLegend } = parseJsonConfig(config.config) + if (unclassifiedLegend) { + config.unclassifiedLegend = unclassifiedLegend + } + delete config.config + const orgUnitIds = orgUnits.map((item) => item.id) let associatedGeometries const name = i18n.t('Organisation units') diff --git a/src/util/__tests__/orgUnits.spec.js b/src/util/__tests__/orgUnits.spec.js index 074ea7cb1..4ac5e9639 100644 --- a/src/util/__tests__/orgUnits.spec.js +++ b/src/util/__tests__/orgUnits.spec.js @@ -75,4 +75,201 @@ describe('getStyledOrgUnits', () => { items: [{ name: 'Level1' }], }) }) + + it('should filter out unclassified facility when unclassifiedLegend not set', () => { + const features = [ + { + geometry: { type: 'Point' }, + properties: { + hasAdditionalGeometry: false, + dimensions: {}, + }, + }, + ] + const groupSet = { + id: 'gs1', + name: 'GroupSet1', + organisationUnitGroups: [ + { + id: 'g1', + name: 'Group1', + color: '#ff0000', + symbol: '21.png', + }, + ], + } + const config = { radiusLow: 10 } + const result = getStyledOrgUnits({ + features, + groupSet, + config, + baseUrl: '/baseUrl', + }) + + expect(result.styledFeatures).toHaveLength(0) + expect(result.legend.items).not.toContainEqual( + expect.objectContaining({ name: 'Unclassified' }) + ) + }) + + it('should include unclassified facility with unclassifiedLegend color when set', () => { + const unclassifiedColor = '#aabbcc' + const features = [ + { + geometry: { type: 'Point' }, + properties: { + hasAdditionalGeometry: false, + dimensions: {}, + }, + }, + ] + const groupSet = { + id: 'gs1', + name: 'GroupSet1', + organisationUnitGroups: [ + { + id: 'g1', + name: 'Group1', + color: '#ff0000', + symbol: '21.png', + }, + ], + } + const config = { + radiusLow: 10, + unclassifiedLegend: { color: unclassifiedColor }, + } + const result = getStyledOrgUnits({ + features, + groupSet, + config, + baseUrl: '/baseUrl', + }) + + expect(result.styledFeatures).toHaveLength(1) + expect(result.styledFeatures[0].properties.color).toBe( + unclassifiedColor + ) + expect(result.styledFeatures[0].properties.iconUrl).toBeUndefined() + expect(result.legend.items).toContainEqual( + expect.objectContaining({ + name: 'Unclassified', + color: unclassifiedColor, + }) + ) + }) + + it('should use custom name from unclassifiedLegend when provided', () => { + const features = [ + { + geometry: { type: 'Point' }, + properties: { hasAdditionalGeometry: false, dimensions: {} }, + }, + ] + const groupSet = { + id: 'gs1', + name: 'GroupSet1', + organisationUnitGroups: [ + { + id: 'g1', + name: 'Group1', + color: '#ff0000', + symbol: '21.png', + }, + ], + } + const config = { + radiusLow: 10, + unclassifiedLegend: { color: '#cccccc', name: 'Unknown' }, + } + const result = getStyledOrgUnits({ + features, + groupSet, + config, + baseUrl: '/baseUrl', + }) + + expect(result.legend.items).toContainEqual( + expect.objectContaining({ name: 'Unknown' }) + ) + }) + + it('should always add Unclassified legend item when unclassifiedLegend is set, even if all features are matched', () => { + const features = [ + { + geometry: { type: 'Point' }, + properties: { + hasAdditionalGeometry: false, + dimensions: { gs1: 'g1' }, + }, + }, + ] + const groupSet = { + id: 'gs1', + name: 'GroupSet1', + organisationUnitGroups: [ + { + id: 'g1', + name: 'Group1', + color: '#ff0000', + symbol: '21.png', + }, + ], + } + const config = { + radiusLow: 10, + unclassifiedLegend: { color: '#cccccc' }, + } + const result = getStyledOrgUnits({ + features, + groupSet, + config, + baseUrl: '/baseUrl', + }) + + expect(result.legend.items).toContainEqual( + expect.objectContaining({ name: 'Unclassified', color: '#cccccc' }) + ) + }) + + it('should include unclassified orgunit with unclassifiedLegend color when set', () => { + const unclassifiedColor = '#aabbcc' + const features = [ + { + geometry: { type: 'Polygon' }, + properties: { level: 1, dimensions: {} }, + }, + ] + const groupSet = { + id: 'gs1', + name: 'GroupSet1', + organisationUnitGroups: [ + { id: 'g1', name: 'Group1', color: '#ff0000' }, + ], + } + const config = { + organisationUnitColor: 'red', + radiusLow: 10, + unclassifiedLegend: { color: unclassifiedColor }, + } + const orgUnitLevels = { 1: 'Level1' } + const result = getStyledOrgUnits({ + features, + groupSet, + config, + baseUrl: '/baseUrl', + orgUnitLevels, + }) + + expect(result.styledFeatures).toHaveLength(1) + expect(result.styledFeatures[0].properties.color).toBe( + unclassifiedColor + ) + expect(result.legend.items).toContainEqual( + expect.objectContaining({ + name: 'Unclassified', + color: unclassifiedColor, + }) + ) + }) }) diff --git a/src/util/favorites.js b/src/util/favorites.js index 0966ff9a9..e42aebc4a 100644 --- a/src/util/favorites.js +++ b/src/util/favorites.js @@ -2,7 +2,9 @@ import { isNil, omitBy, pick, isObject, omit } from 'lodash/fp' import { EARTH_ENGINE_LAYER, EVENT_LAYER, + FACILITY_LAYER, GEOJSON_URL_LAYER, + ORG_UNIT_LAYER, THEMATIC_LAYER, TRACKED_ENTITY_LAYER, } from '../constants/layers.js' @@ -197,7 +199,12 @@ const models2objects = (layer, cleanMapviewConfig) => { delete layer.relationshipLineColor delete layer.relationshipOutsideProgram delete layer.periodType - } else if (layerType === THEMATIC_LAYER || layerType === EVENT_LAYER) { + } else if ( + layerType === THEMATIC_LAYER || + layerType === EVENT_LAYER || + layerType === FACILITY_LAYER || + layerType === ORG_UNIT_LAYER + ) { if (cleanMapviewConfig) { const configData = {} if (layer.legendDecimalPlaces !== undefined) { diff --git a/src/util/orgUnits.js b/src/util/orgUnits.js index 2217db204..6a1015e1c 100644 --- a/src/util/orgUnits.js +++ b/src/util/orgUnits.js @@ -99,6 +99,7 @@ export const getStyledOrgUnits = ({ config: { organisationUnitColor = ORG_UNIT_COLOR, radiusLow = ORG_UNIT_RADIUS, + unclassifiedLegend, }, baseUrl, orgUnitLevels, @@ -129,46 +130,51 @@ export const getStyledOrgUnits = ({ const useColor = styleType === STYLE_TYPE_COLOR - let styledFeatures = features.map((f) => { - const isPoint = f.geometry.type === 'Point' - const { hasAdditionalGeometry } = f.properties - const { color, symbol } = getOrgUnitStyle( - f.properties.dimensions, - groupSet - ) - let radius - - if (isPoint) { - radius = hasAdditionalGeometry - ? ORG_UNIT_RADIUS_SMALL + 1 - : radiusLow - } + const styledFeatures = features + .map((f) => { + const isPoint = f.geometry.type === 'Point' + const { hasAdditionalGeometry } = f.properties + const { color, symbol } = getOrgUnitStyle( + f.properties.dimensions, + groupSet + ) + const isUnclassified = !!groupSet.id && !color && !symbol + let radius - const properties = { - ...f.properties, - radius, - } + if (isPoint) { + radius = hasAdditionalGeometry + ? ORG_UNIT_RADIUS_SMALL + 1 + : radiusLow + } - if (useColor && color) { - properties.color = hasAdditionalGeometry ? ORG_UNIT_COLOR : color - } else if (symbol) { - properties.iconUrl = `${baseUrl}/images/orgunitgroup/${symbol}` - } + const properties = { + ...f.properties, + radius, + } - if (properties.level && levelWeight) { - properties.weight = levelWeight(f.properties.level) - } + if (isUnclassified && unclassifiedLegend) { + properties.color = unclassifiedLegend.color + } else if (useColor && color) { + properties.color = hasAdditionalGeometry + ? ORG_UNIT_COLOR + : color + } else if (symbol) { + properties.iconUrl = `${baseUrl}/images/orgunitgroup/${symbol}` + } - return { - ...f, - properties, - } - }) + if (properties.level && levelWeight) { + properties.weight = levelWeight(f.properties.level) + } - // Only include facilities having a group membership - if (isFacilityLayer && groupSet.id && !useColor) { - styledFeatures = styledFeatures.filter((f) => f.properties.iconUrl) - } + return { + ...f, + properties, + } + }) + .filter( + (f) => + !groupSet.id || !!f.properties.iconUrl || !!f.properties.color + ) const groupItems = getOrgUnitGroupLegendItems( organisationUnitGroups, @@ -176,6 +182,14 @@ export const getStyledOrgUnits = ({ baseUrl ) + if (unclassifiedLegend && groupSet.id) { + groupItems.push({ + name: unclassifiedLegend.name || i18n.t('Unclassified'), + color: unclassifiedLegend.color, + ...(isFacilityLayer && !useColor ? { radius: radiusLow } : {}), + }) + } + const facilityItems = isFacilityLayer && !groupSet.id ? [