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/i18n/en.pot b/i18n/en.pot
index 332c55c5f..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:12:19.709Z\n"
-"PO-Revision-Date: 2026-04-27T11:12:19.709Z\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"
@@ -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"
@@ -101,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}}"
@@ -356,18 +374,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 +410,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"
@@ -446,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"
@@ -717,9 +738,6 @@ msgstr "Groups"
msgid "Parent unit"
msgstr "Parent unit"
-msgid "No data"
-msgstr "No data"
-
msgid "Not set"
msgstr "Not set"
@@ -1816,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 eaf16f49d..458f5e557 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,
@@ -352,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/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/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/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/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 }) => {
>
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/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 0043c53e3..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,
@@ -75,6 +78,7 @@ const ThematicDialog = ({
radiusHigh,
method,
thematicMapType,
+ legendIsolated,
}) => {
const dispatch = useDispatch()
const {
@@ -301,6 +305,7 @@ const ThematicDialog = ({
renderingStrategy,
method,
periods,
+ legendIsolated,
})
}, [
dataItem,
@@ -314,6 +319,7 @@ const ThematicDialog = ({
renderingStrategy,
method,
periods,
+ legendIsolated,
])
// Run layer validation
@@ -577,9 +583,15 @@ const ThematicDialog = ({
legendSetError={errors.legendSetError}
className={styles.select}
/>
- dispatch(setNoDataColor(v))}
+
+ dispatch(setUnclassifiedLegend(v))
+ }
+ />
+ dispatch(setNoDataLegend(v))}
/>
@@ -597,9 +609,13 @@ ThematicDialog.propTypes = {
eventStatus: PropTypes.string,
filters: PropTypes.array,
id: PropTypes.string,
+ 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,
@@ -610,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/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..ca903d3da 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,39 @@ 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.isNoData)
+ const isolatedClass = classes.find((c) => c.isIsolated)
+ const unclassifiedClass = classes.find((c) => c.isUnclassified)
+ const bubbleClasses = classes.filter(
+ (c) => !c.isNoData && !c.isIsolated && !c.isUnclassified
+ )
+
+ const hasDataRange = minValue != null && maxValue != null
+ if (!hasDataRange && !noDataClass && !isolatedClass && !unclassifiedClass) {
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 yUnclassified = yIsolated + (isolatedClass ? extraRowHeight : 0)
+ const yNoData = yUnclassified + (unclassifiedClass ? 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 +185,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 (
-
diff --git a/src/components/legend/Legend.jsx b/src/components/legend/Legend.jsx
index 6ad671885..252c42409 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,81 +21,92 @@ 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)
+ const getShowRange = (item) =>
+ item.isIsolated
+ ? !legendNamesContainRange([item])
+ : !item.name || showRange
+
+ return (
+
+ {description && (
+ {description}
+ )}
+ {groups && (
+
+ {groups.multiple === false ? (
+ <>{groups.list[0].name}>
+ ) : (
+ <>
+ {groups.label}
+ {groups.list.map(({ id, name }) => (
+
{name}
+ ))}
+ >
+ )}
+
+ )}
+ {unit && items && {unit}
}
+ {bubbles ? (
+
+ ) : (
+ Array.isArray(items) && (
+
+
+ {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 = ({
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 dac41a921..c09e25f77 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 =
@@ -143,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 03797a9c5..d49d5ce3d 100644
--- a/src/loaders/eventLoader.js
+++ b/src/loaders/eventLoader.js
@@ -102,10 +102,31 @@ const loadEventLayer = async ({
periodTypeData,
loadExtended,
}) => {
- const { legendDecimalPlaces } = parseJsonConfig(config.config)
+ const {
+ legendDecimalPlaces,
+ legendIsolated,
+ unclassifiedLegend: unclassifiedLegendFromConfig,
+ noDataLegend: noDataLegendFromConfig,
+ } = parseJsonConfig(config.config)
if (legendDecimalPlaces !== undefined) {
config.legendDecimalPlaces = legendDecimalPlaces
}
+ 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 {
@@ -180,11 +201,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/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/loaders/thematicLoader.js b/src/loaders/thematicLoader.js
index d5f472a1f..9a1b5c1b5 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'
@@ -33,9 +35,14 @@ import { hasValue } from '../util/helpers.js'
import {
getPredefinedLegendItems,
getAutomaticLegendItems,
+ buildIsolatedLegendItem,
+ isRegularLegendItem,
} 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,
@@ -52,6 +59,9 @@ const thematicLoader = async ({
analyticsEngine,
periodTypeData,
}) => {
+ // Config parsing
+ // -----
+
const {
columns,
radiusLow = THEMATIC_RADIUS_LOW,
@@ -60,20 +70,64 @@ const thematicLoader = async ({
colorScale,
renderingStrategy = RENDERING_STRATEGY_SINGLE,
thematicMapType,
- noDataColor,
} = config
- const { legendDecimalPlaces } = 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) {
+ 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,
@@ -114,11 +168,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 ? null : getValuesByPeriod(data) // [PATH] null → Single; populated → Timeline / Split (do not creates OrgUnits with no data)
+
const names = getApiResponseNames(
periodTypeData?.enabledPeriodTypesData?.metaData
? {
@@ -133,61 +189,73 @@ 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 (!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,
+ })
+ }
- if (legendSet) {
- const result = await engine.query(LEGEND_SET_QUERY, {
- variables: { id: config.legendSet.id },
+ if (coordinateField && !associatedGeometries.length) {
+ alerts.push({
+ code: WARNING_NO_GEOMETRY_COORD,
+ message: coordinateField.name,
})
- legendSet = result.legendSet
}
+ // Legend
+ // -----
+
let legendItems = []
let valueFormat
- if (!isSingleColor) {
- if (legendSet) {
- legendItems = getPredefinedLegendItems(legendSet)
- } else {
- const classification = getAutomaticLegendItems({
- data: orderedValues,
- method,
- classes,
- colorScale,
- legendDecimalPlaces: config.legendDecimalPlaces,
- })
- legendItems = classification.items
- valueFormat = classification.valueFormat
+ 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(
+ (v) => v < min || v > max
+ )
+ if (nonIsolatedValues.length > 0) {
+ minValue = nonIsolatedValues[0]
+ maxValue = nonIsolatedValues.at(-1)
}
}
@@ -201,9 +269,7 @@ const thematicLoader = async ({
getDateArray(config.endDate)
),
items: legendItems,
- ...(config.legendDecimalPlaces !== undefined && {
- decimalPlaces: config.legendDecimalPlaces,
- }),
+ decimalPlaces: config.legendDecimalPlaces,
}
if (dimensions && dimensions.length) {
@@ -215,18 +281,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,
@@ -236,25 +290,91 @@ 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 unclassifiedLegendItem = null
+ if (hasUnclassifiedClass) {
+ unclassifiedLegendItem = {
+ color: config.unclassifiedLegend.color,
+ name: config.unclassifiedLegend.name || i18n.t('Unclassified'),
+ isUnclassified: true,
+ }
+ 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))
}
+ // 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 &&
+ method !== CLASSIFICATION_LOGARITHMIC &&
+ method !== CLASSIFICATION_STANDARD_DEVIATION,
})
- 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()
@@ -262,96 +382,118 @@ const thematicLoader = async ({
.domain([minValue, maxValue])
.clamp(true)
- 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,
- })
+ const getFeatureRadius = (
+ legendItem,
+ { isNoData, isUnclassified },
+ value
+ ) => {
+ if (legendItem?.isIsolated || isNoData || isUnclassified) {
+ return { radius: THEMATIC_RADIUS_DEFAULT }
}
+ return { radius: getRadiusForValue(value) || THEMATIC_RADIUS_DEFAULT }
}
- if (coordinateField && !associatedGeometries.length) {
- alerts.push({
- code: WARNING_NO_GEOMETRY_COORD,
- message: coordinateField.name,
- })
+ const countLegendItem = (legendItem, { isNoData, isUnclassified }) => {
+ let specialItem = null
+ if (isNoData) {
+ specialItem = noDataLegendItem
+ } else if (isUnclassified) {
+ specialItem = unclassifiedLegendItem
+ }
+ const item = legendItem ?? specialItem
+ if (item) {
+ item.count++
+ }
}
- if (valuesByPeriod) {
- 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)
-
- if (isSingleColor) {
- item.color = colorScale
- } else if (legendItem) {
- item.color = legendItem.color
- }
+ // Feature styling - Processing
+ // -----
- item.radius = getRadiusForValue(value)
- })
- })
- } else {
- const noDataLegendItem = legend.items.find((i) => i.noData === true)
- valueFeatures.forEach(({ id, geometry, properties }) => {
+ if (isSingleMap) {
+ // 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 = hasValue(value)
- ? colorScale
- : noDataLegendItem?.color
- } 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,
- keyAnalysisDigitGroupSeparator,
- { precision: config.legendDecimalPlaces }
- )}` // Shown in data table
- }
+ Object.assign(properties, {
+ ...getFeatureColor(legendItem, { isNoData, isUnclassified }),
+ ...getFeatureLegend(legendItem, { isNoData, isUnclassified }),
+ ...getFeatureRadius(
+ legendItem,
+ {
+ 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
- : getRadiusForValue(value) || THEMATIC_RADIUS_DEFAULT
+ 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 {
...config,
- data: valueFeatures,
+ data: features,
periods,
valuesByPeriod,
name,
@@ -366,6 +508,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 ? null : dataItem.legendSet)
+ 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 a5885cadf..2d1d72cd7 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 }
@@ -558,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) {
+ newState.unclassifiedLegend = action.payload
+ } else {
+ delete newState.unclassifiedLegend
+ }
return newState
case types.LAYER_EDIT_EARTH_ENGINE_PERIOD_SET:
diff --git a/src/util/__tests__/classify.spec.js b/src/util/__tests__/classify.spec.js
index 3da31f588..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', () => {
@@ -226,32 +270,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__/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 c7492620c..73ef20ec2 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,31 @@ 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 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 },
@@ -129,7 +157,7 @@ describe('legend utils', () => {
})
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 +173,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')
})
})
@@ -226,6 +254,142 @@ 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: Number.NaN, endValue: Number.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', () => {
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/__tests__/styleByDataItem.spec.js b/src/util/__tests__/styleByDataItem.spec.js
index b09386fdc..17ce9eb84 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 - dropped (no unclassifiedLegend)
],
method: 2,
classes: 3,
@@ -235,6 +312,7 @@ describe('styleByDataItem', () => {
expect(mockEngine.query).toHaveBeenCalled()
+ expect(result.data).toHaveLength(3)
expect(result.data[0].properties).toMatchObject({
value: 0,
color: '#ff0000',
@@ -247,15 +325,8 @@ describe('styleByDataItem', () => {
value: 2,
color: '#0000ff',
})
- expect(result.data[3].properties).toMatchObject({
- value: NOTSET_VALUE,
- color: EVENT_COLOR,
- })
- expect(result.data[4].properties).toMatchObject({
- value: SOME_VALUE,
- color: EVENT_COLOR,
- })
+ expect(result.legend.items).toHaveLength(3)
expect(result.legend.items).toEqual(
expect.arrayContaining([
expect.objectContaining({
@@ -279,15 +350,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 +407,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 +418,7 @@ describe('styleByDataItem', () => {
expect(mockEngine.query).toHaveBeenCalled()
+ expect(result.data).toHaveLength(2)
expect(result.data[0].properties).toMatchObject({
value: 'Yes',
color: 'red',
@@ -319,15 +427,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 +443,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 +506,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 +517,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 +526,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 +542,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 5cf68d202..53397fe27 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,27 +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.isIsolated &&
+ formattedValue >= item.startValue &&
+ formattedValue <= item.endValue
+ )
+ if (isolatedItem) {
+ return isolatedItem
}
- if (clamp) {
- if (value < legendItems[0].startValue) {
- return legendItems[0]
+ const rangeItems = legendItems.filter(isRegularLegendItem)
+
+ if (clamp && rangeItems.length > 0) {
+ if (formattedValue < rangeItems[0].startValue) {
+ return rangeItems[0]
}
- if (value > legendItems[legendItems.length - 1].endValue) {
- return legendItems[legendItems.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)))
)
}
@@ -87,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,
@@ -217,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)
@@ -235,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/favorites.js b/src/util/favorites.js
index c729cec26..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'
@@ -55,6 +57,7 @@ const validLayerProperties = [
'labelFontColor',
'labelTemplate',
'legendDecimalPlaces',
+ 'legendIsolated',
'lastUpdated',
'layer',
'layerId',
@@ -62,6 +65,8 @@ const validLayerProperties = [
'method',
'name',
'noDataColor',
+ 'noDataLegend',
+ 'unclassifiedLegend',
'opacity',
'organisationUnitColor',
'organisationUnitGroupSet',
@@ -194,16 +199,36 @@ 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) {
- layer.config = JSON.stringify({
- legendDecimalPlaces: layer.legendDecimalPlaces,
- })
+ configData.legendDecimalPlaces = layer.legendDecimalPlaces
+ }
+ if (layer.legendIsolated !== undefined) {
+ configData.legendIsolated = layer.legendIsolated
+ }
+ if (layer.unclassifiedLegend) {
+ configData.unclassifiedLegend = layer.unclassifiedLegend
+ }
+ if (layer.noDataLegend) {
+ layer.noDataColor = layer.noDataLegend.color // 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)
}
}
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 390d3f561..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) {
@@ -69,6 +75,13 @@ export const sortLegendItems = (items) =>
return -1
}
+ if (a.isIsolated && !b.isIsolated) {
+ return 1
+ }
+ if (!a.isIsolated && b.isIsolated) {
+ return -1
+ }
+
return bRange.start === aRange.start
? bRange.end - aRange.end
: bRange.start - aRange.start
@@ -143,38 +156,61 @@ 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'])
- 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 buildIsolatedLegendItem = ({ min, max, color, name }) => ({
+ startValue: min,
+ endValue: max,
+ color,
+ isIsolated: 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,
}
}
@@ -186,3 +222,35 @@ export const getRenderingLabel = (strategy) => {
}
return map[strategy] ? ' • ' + map[strategy] : null
}
+
+const normalize = (str) => String(str).replaceAll(/[\s,]/g, '')
+
+const nameContainsValue = (name, val) => {
+ const normalizedName = normalize(name)
+ const normalizedVal = normalize(val)
+ return new RegExp(String.raw`(?
+ (String(startValue) !== '' && nameContainsValue(name, startValue)) ||
+ (String(endValue) !== '' && nameContainsValue(name, endValue))
+
+export const legendNamesContainRange = (items) => {
+ const numericItems = items.filter(
+ ({ startValue, endValue }) =>
+ !Number.isNaN(startValue) && !Number.isNaN(endValue)
+ )
+
+ if (!numericItems.length) {
+ return false
+ }
+
+ const itemsWithRange = numericItems.filter(
+ ({ name = '', startValue, endValue }) =>
+ rangeInName(name, startValue, endValue)
+ )
+
+ return itemsWithRange.length / numericItems.length >= 0.5
+}
diff --git a/src/util/numbers.js b/src/util/numbers.js
index 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/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
? [
diff --git a/src/util/styleByDataItem.js b/src/util/styleByDataItem.js
index b3d3505ad..a6da89dec 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'
@@ -13,6 +15,50 @@ import { getAutomaticLegendItems, getPredefinedLegendItems } 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 +78,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]
+
+ const { noDataLegendItem } = addSpecialLegendItems(legend, { noDataLegend })
+ stampLegendItems(legend.items, eventPointRadius)
- config.data = data.map((feature) => {
+ 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
}
@@ -135,9 +181,11 @@ const styleByNumeric = async (config, engine) => {
method,
classes,
colorScale,
- eventPointColor,
eventPointRadius,
legendDecimalPlaces,
+ legendIsolated,
+ noDataLegend,
+ unclassifiedLegend,
} = config
let valueFormat
@@ -172,22 +220,17 @@ const styleByNumeric = async (config, engine) => {
classes,
colorScale,
legendDecimalPlaces,
+ legendIsolated,
})
legend.items = classification.items
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) =>
@@ -195,12 +238,15 @@ const styleByNumeric = async (config, engine) => {
value,
valueFormat,
method,
- legendItems: legend.items.filter((item) => !item.noData),
- clamp: method !== CLASSIFICATION_PREDEFINED,
+ legendItems: legend.items,
+ clamp:
+ method !== CLASSIFICATION_PREDEFINED &&
+ method !== CLASSIFICATION_LOGARITHMIC &&
+ method !== CLASSIFICATION_STANDARD_DEVIATION,
})
// 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
@@ -209,29 +255,38 @@ const styleByNumeric = async (config, engine) => {
legendItem = getLegendItem(numericValue)
}
- if (legendItem) {
- legendItem.count++
- } else {
- legend.items[legend.items.length - 1].count++
- }
+ const isNoData = !hasValue(value)
+ const isUnclassified = hasValue(value) && !legendItem
- return {
- ...feature,
- properties: {
- ...feature.properties,
- value: hasValue(value) ? value : i18n.t('Not set'),
- color: legendItem
- ? legendItem.color
- : cssColor(eventPointColor) || EVENT_COLOR,
- },
+ if (isUnclassified && !unclassifiedLegendItem) {
+ return acc
}
- })
+ if (isNoData && !noDataLegendItem) {
+ return acc
+ }
+
+ const activeItem =
+ legendItem ??
+ (isUnclassified ? unclassifiedLegendItem : noDataLegendItem)
+
+ addFeature(acc, feature, {
+ item: activeItem,
+ value: hasValue(value) ? value : i18n.t('Not set'),
+ })
+ 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
@@ -247,16 +302,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
@@ -267,38 +319,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 ? null : optionsByName[name.toLowerCase()]
+ 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
}