diff --git a/i18n/en.pot b/i18n/en.pot index df97c59df..2b93f9cbd 100644 --- a/i18n/en.pot +++ b/i18n/en.pot @@ -5,8 +5,8 @@ msgstr "" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1)\n" -"POT-Creation-Date: 2026-03-12T18:11:56.380Z\n" -"PO-Revision-Date: 2026-03-12T18:11:56.380Z\n" +"POT-Creation-Date: 2026-04-24T10:52:00.804Z\n" +"PO-Revision-Date: 2026-04-24T10:52:00.804Z\n" msgid "2020" msgstr "2020" @@ -1638,6 +1638,18 @@ msgstr "Equal intervals" msgid "Equal counts" msgstr "Equal counts" +msgid "Natural breaks (intervals)" +msgstr "Natural breaks (intervals)" + +msgid "Natural breaks (clusters)" +msgstr "Natural breaks (clusters)" + +msgid "Pretty breaks" +msgstr "Pretty breaks" + +msgid "Logarithmic scale" +msgstr "Logarithmic scale" + msgid "Symbol" msgstr "Symbol" diff --git a/package.json b/package.json index d413ddf57..96dc22f9d 100644 --- a/package.json +++ b/package.json @@ -73,6 +73,7 @@ "redux": "^4.2.1", "redux-logger": "^3.0.6", "redux-thunk": "^2.4.2", + "simple-statistics": "^7.8.9", "styled-jsx": "^4.0.1", "url-polyfill": "^1.1.14" }, diff --git a/src/components/classification/LegendSetSelect.jsx b/src/components/classification/LegendSetSelect.jsx index 2c5ba1b36..caf170fa9 100644 --- a/src/components/classification/LegendSetSelect.jsx +++ b/src/components/classification/LegendSetSelect.jsx @@ -1,7 +1,7 @@ import { useDataQuery } from '@dhis2/app-runtime' import i18n from '@dhis2/d2-i18n' import PropTypes from 'prop-types' -import React from 'react' +import React, { useEffect } from 'react' import { useSelector, useDispatch } from 'react-redux' import { setLegendSet } from '../../actions/layerEdit.js' import { SelectField } from '../core/index.js' @@ -21,11 +21,22 @@ const style = { width: '100%', } -const LegendSetSelect = ({ legendSetError }) => { +const LegendSetSelect = ({ defaultLegendSet, legendSetError }) => { const legendSet = useSelector((state) => state.layerEdit.legendSet) const dispatch = useDispatch() const { loading, error, data } = useDataQuery(LEGEND_SETS_QUERY) + useEffect(() => { + if (!legendSet && data?.sets.legendSets?.length) { + const legendSets = data.sets.legendSets + const defaultItem = defaultLegendSet + ? legendSets.find((ls) => ls.id === defaultLegendSet.id) ?? + legendSets[0] + : legendSets[0] + dispatch(setLegendSet(defaultItem)) + } + }, [legendSet, data, defaultLegendSet, dispatch]) + return ( { } LegendSetSelect.propTypes = { + defaultLegendSet: PropTypes.shape({ + id: PropTypes.string.isRequired, + }), legendSetError: PropTypes.string, } diff --git a/src/components/classification/LegendTypeSelect.jsx b/src/components/classification/LegendTypeSelect.jsx index 2fa119705..2e6127cea 100644 --- a/src/components/classification/LegendTypeSelect.jsx +++ b/src/components/classification/LegendTypeSelect.jsx @@ -4,8 +4,8 @@ import { connect } from 'react-redux' import { setClassification } from '../../actions/layerEdit.js' import { getLegendTypes, - CLASSIFICATION_EQUAL_INTERVALS, - CLASSIFICATION_EQUAL_COUNTS, + getClassificationTypes, + CLASSIFICATION_AUTO_DEFAULT, } from '../../constants/layers.js' import { Radio, RadioGroup } from '../core/index.js' @@ -13,15 +13,17 @@ import { Radio, RadioGroup } from '../core/index.js' const LegendTypeSelect = ({ mapType, method, setClassification }) => method ? ( id) + .includes(method) + ? CLASSIFICATION_AUTO_DEFAULT : method - } + )} onChange={(method) => setClassification(Number(method))} > {getLegendTypes(mapType === 'BUBBLE').map(({ id, name }) => ( - + ))} ) : null diff --git a/src/components/classification/NumericLegendStyle.jsx b/src/components/classification/NumericLegendStyle.jsx index dfa64caaa..72d869743 100644 --- a/src/components/classification/NumericLegendStyle.jsx +++ b/src/components/classification/NumericLegendStyle.jsx @@ -1,10 +1,10 @@ import PropTypes from 'prop-types' import React, { useEffect } from 'react' import { connect } from 'react-redux' -import { setClassification, setLegendSet } from '../../actions/layerEdit.js' +import { setClassification } from '../../actions/layerEdit.js' import { CLASSIFICATION_PREDEFINED, - CLASSIFICATION_EQUAL_INTERVALS, + CLASSIFICATION_AUTO_DEFAULT, CLASSIFICATION_SINGLE_COLOR, } from '../../constants/layers.js' import Classification from './Classification.jsx' @@ -18,9 +18,7 @@ const NumericLegendStyle = (props) => { mapType, method, dataItem, - legendSet, setClassification, - setLegendSet, legendSetError, style, } = props @@ -35,18 +33,11 @@ const NumericLegendStyle = (props) => { setClassification( dataItem && dataItem.legendSet ? CLASSIFICATION_PREDEFINED - : CLASSIFICATION_EQUAL_INTERVALS + : CLASSIFICATION_AUTO_DEFAULT ) } }, [method, dataItem, setClassification]) - useEffect(() => { - // Set legend set defined for data item in use by default - if (isPredefined && !legendSet && dataItem?.legendSet) { - setLegendSet(dataItem.legendSet) - } - }, [isPredefined, legendSet, dataItem, setLegendSet]) - return (
{ {isSingleColor ? ( ) : isPredefined ? ( - + ) : ( )} @@ -67,9 +61,7 @@ const NumericLegendStyle = (props) => { NumericLegendStyle.propTypes = { setClassification: PropTypes.func.isRequired, - setLegendSet: PropTypes.func.isRequired, dataItem: PropTypes.object, - legendSet: PropTypes.object, legendSetError: PropTypes.string, mapType: PropTypes.string, method: PropTypes.number, @@ -79,7 +71,6 @@ NumericLegendStyle.propTypes = { export default connect( ({ layerEdit }) => ({ method: layerEdit.method, - legendSet: layerEdit.legendSet, }), - { setClassification, setLegendSet } + { setClassification } )(NumericLegendStyle) diff --git a/src/components/legend/Bubbles.jsx b/src/components/legend/Bubbles.jsx index 1e107069f..b535254b8 100644 --- a/src/components/legend/Bubbles.jsx +++ b/src/components/legend/Bubbles.jsx @@ -16,26 +16,27 @@ export const digitWidth = 6.8 export const guideLength = 16 export const textPadding = 4 -const Bubbles = ({ - radiusLow, - radiusHigh, +const filterBubbleText = (bubbles, showNumbers) => { + if (!showNumbers) { + return + } + bubbles.forEach((b, i) => { + if (!showNumbers.includes(i)) { + delete b.text + } + }) +} + +const computeBubbleLayout = ({ + bubbleClasses, color, minValue, maxValue, - classes, - isPlugin, + scale, + radiusLow, + radiusHigh, + legendWidth, }) => { - const legendWidth = isPlugin ? 150 : 245 - const noDataClass = classes.find((c) => c.noData === true) - const bubbleClasses = classes.filter((c) => !c.noData) - - const height = radiusHigh * 2 + 4 - const scale = scaleSqrt().range([radiusLow, radiusHigh]) - - if (isNaN(radiusLow) || isNaN(radiusHigh)) { - return null - } - const bubbles = bubbleClasses.length ? createBubbleItems({ classes: bubbleClasses, @@ -53,32 +54,76 @@ const Bubbles = ({ radiusHigh, }) - const { alternate, offset, showNumbers } = computeLayout({ + const layout = computeLayout({ bubbles, bubbleClasses, radiusHigh, legendWidth, }) + filterBubbleText(bubbles, layout.showNumbers) + + return { bubbles, alternate: layout.alternate, offset: layout.offset } +} - if (showNumbers) { - bubbles.forEach((b, i) => { - if (!showNumbers.includes(i)) { - delete b.text - } - }) +const Bubbles = ({ + radiusLow, + radiusHigh, + color, + minValue, + maxValue, + classes, + isPlugin, +}) => { + 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)) { + return null } + if (!hasDataRange && !noDataClass) { + return null + } + + let bubbles = [] + let alternate = false + let offset = '2' + + if (hasDataRange) { + ;({ bubbles, alternate, offset } = computeBubbleLayout({ + bubbleClasses, + color, + minValue, + maxValue, + scale, + radiusLow, + radiusHigh, + legendWidth, + })) + } + + const xTranslate = alternate ? offset : '2' + return (
- + {bubbles.map((bubble, i) => ( {' '} [ { - id: CLASSIFICATION_EQUAL_INTERVALS, + id: CLASSIFICATION_AUTO_DEFAULT, name: i18n.t('Automatic color legend'), }, { @@ -174,6 +180,26 @@ export const getClassificationTypes = () => [ id: CLASSIFICATION_EQUAL_COUNTS, name: i18n.t('Equal counts'), }, + { + id: CLASSIFICATION_NATURAL_BREAKS_RANGES, + name: i18n.t('Natural breaks (intervals)'), + }, + { + id: CLASSIFICATION_NATURAL_BREAKS_CLUSTERS, + name: i18n.t('Natural breaks (clusters)'), + }, + { + id: CLASSIFICATION_PRETTY_BREAKS, + name: i18n.t('Pretty breaks'), + }, + { + id: CLASSIFICATION_LOGARITHMIC, + name: i18n.t('Logarithmic scale'), + }, + { + id: CLASSIFICATION_STANDARD_DEVIATION, + name: i18n.t('Standard deviation'), + }, ] export const STYLE_TYPE_COLOR = 'COLOR' diff --git a/src/loaders/thematicLoader.js b/src/loaders/thematicLoader.js index abbd3de96..c361576d0 100644 --- a/src/loaders/thematicLoader.js +++ b/src/loaders/thematicLoader.js @@ -228,6 +228,7 @@ const thematicLoader = async ({ getLegendItemForValue({ value, valueFormat, + method, legendItems: legend.items.filter((item) => !item.noData), clamp: method !== CLASSIFICATION_PREDEFINED, }) @@ -236,6 +237,10 @@ const thematicLoader = async ({ 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 getRadiusForValue = scaleSqrt() diff --git a/src/reducers/layerEdit.js b/src/reducers/layerEdit.js index 687f22e65..9474d0c17 100644 --- a/src/reducers/layerEdit.js +++ b/src/reducers/layerEdit.js @@ -2,9 +2,8 @@ import * as types from '../constants/actionTypes.js' import { EVENT_STATUS_ALL } from '../constants/eventStatuses.js' import { CLASSIFICATION_SINGLE_COLOR, - CLASSIFICATION_EQUAL_INTERVALS, - CLASSIFICATION_EQUAL_COUNTS, CLASSIFICATION_PREDEFINED, + getClassificationTypes, THEMATIC_CHOROPLETH, EE_BUFFER, NONE, @@ -279,10 +278,9 @@ const layerEdit = (state = null, action) => { if ( state.method === CLASSIFICATION_SINGLE_COLOR || - ![ - CLASSIFICATION_EQUAL_INTERVALS, - CLASSIFICATION_EQUAL_COUNTS, - ].includes(action.method) + !getClassificationTypes() + .map((t) => t.id) + .includes(action.method) ) { delete newState.colorScale delete newState.classes diff --git a/src/util/__tests__/bubbles.spec.js b/src/util/__tests__/bubbles.spec.js index f6cf5eb15..7e5a16191 100644 --- a/src/util/__tests__/bubbles.spec.js +++ b/src/util/__tests__/bubbles.spec.js @@ -32,6 +32,34 @@ jest.mock('../numbers.js', () => ({ })) describe('createBubbleItems', () => { + it('should return a single bubble when minValue === maxValue', () => { + const mockScale = { domain: jest.fn(() => () => 5) } + const classes = [{ startValue: 7, endValue: 7, color: '#abc' }] + const bubbles = createBubbleItems({ + classes, + minValue: 7, + maxValue: 7, + scale: mockScale, + radiusHigh: 20, + }) + expect(bubbles).toHaveLength(1) + expect(bubbles[0].text).toBe('7') + expect(bubbles[0].color).toBe('#abc') + }) + + it('should not produce NaN text when minValue === maxValue', () => { + const mockScale = { domain: jest.fn(() => () => 5) } + const classes = [{ startValue: 42, endValue: 42, color: '#def' }] + const bubbles = createBubbleItems({ + classes, + minValue: 42, + maxValue: 42, + scale: mockScale, + radiusHigh: 20, + }) + expect(bubbles[0].text).not.toContain('NaN') + }) + it('should create bubble items from class breaks', () => { const mockScale = { domain: jest.fn(() => (value) => value * 2), @@ -57,6 +85,34 @@ describe('createBubbleItems', () => { }) describe('createSingleColorBubbles', () => { + it('should return a single bubble when minValue === maxValue', () => { + const mockScale = { domain: jest.fn(() => () => 5) } + const bubbles = createSingleColorBubbles({ + color: '#abc', + minValue: 50, + maxValue: 50, + scale: mockScale, + radiusLow: 5, + radiusHigh: 20, + }) + expect(bubbles).toHaveLength(1) + expect(bubbles[0].text).toBe('50') + expect(bubbles[0].color).toBe('#abc') + }) + + it('should not produce NaN text when minValue === maxValue', () => { + const mockScale = { domain: jest.fn(() => () => 5) } + const bubbles = createSingleColorBubbles({ + color: '#abc', + minValue: 100, + maxValue: 100, + scale: mockScale, + radiusLow: 5, + radiusHigh: 20, + }) + expect(bubbles[0].text).not.toContain('NaN') + }) + it('should return three bubbles with single color and formatted values', () => { const mockScale = { domain: jest.fn(() => (value) => value * 0.5), diff --git a/src/util/__tests__/classify.spec.js b/src/util/__tests__/classify.spec.js index b22c8ed68..ace09a558 100644 --- a/src/util/__tests__/classify.spec.js +++ b/src/util/__tests__/classify.spec.js @@ -1,6 +1,11 @@ import { CLASSIFICATION_EQUAL_INTERVALS, CLASSIFICATION_EQUAL_COUNTS, + CLASSIFICATION_NATURAL_BREAKS_RANGES, + CLASSIFICATION_NATURAL_BREAKS_CLUSTERS, + CLASSIFICATION_PRETTY_BREAKS, + CLASSIFICATION_LOGARITHMIC, + CLASSIFICATION_STANDARD_DEVIATION, } from '../../constants/layers.js' import { getLegendItemForValue, getLegendItems } from '../classify.js' @@ -76,11 +81,75 @@ describe('getLegendItemForValue', () => { }) ).toEqual(legendItems[1]) }) + + it('matches value equal to a single-value class (startValue === endValue)', () => { + const items = [ + { startValue: 0, endValue: 10 }, + { startValue: 100, endValue: 100 }, + { startValue: 100, endValue: 200 }, + ] + expect( + getLegendItemForValue({ value: 100, legendItems: items }) + ).toEqual(items[1]) + }) + + it('does not match single-value class with a different value', () => { + const items = [ + { startValue: 0, endValue: 10 }, + { startValue: 100, endValue: 100 }, + ] + expect( + getLegendItemForValue({ value: 50, legendItems: items }) + ).toBeUndefined() + }) + + it('matches value at non-last cluster endValue under CLUSTERS method', () => { + const clusterItems = [ + { startValue: 1, endValue: 3 }, + { startValue: 10, endValue: 12 }, + { startValue: 100, endValue: 102 }, + ] + expect( + getLegendItemForValue({ + value: 3, + method: CLASSIFICATION_NATURAL_BREAKS_CLUSTERS, + legendItems: clusterItems, + }) + ).toEqual(clusterItems[0]) + }) + + it('does not match non-last endValue under non-clusters method', () => { + const intervalItems = [ + { startValue: 0, endValue: 10 }, + { startValue: 10, endValue: 20 }, + ] + expect( + getLegendItemForValue({ + value: 10, + method: CLASSIFICATION_EQUAL_INTERVALS, + legendItems: intervalItems, + }) + ).toEqual(intervalItems[1]) + }) + + it('still matches last endValue regardless of method', () => { + const items = [ + { startValue: 0, endValue: 50 }, + { startValue: 50, endValue: 100 }, + ] + expect( + getLegendItemForValue({ + value: 100, + method: CLASSIFICATION_EQUAL_INTERVALS, + legendItems: items, + }) + ).toEqual(items[1]) + }) }) describe('getLegendItems', () => { it('returns equal intervals for CLASSIFICATION_EQUAL_INTERVALS', () => { - const values = [0, 100] + const values = [0, 25, 50, 75, 100] const { items } = getLegendItems( values, CLASSIFICATION_EQUAL_INTERVALS, @@ -117,4 +186,162 @@ describe('getLegendItems', () => { ) expect(typeof valueFormat).toBe('function') }) + + it('returns natural breaks (intervals) as continuous bins', () => { + const values = [1, 2, 3, 10, 11, 12, 100, 101, 102] + const { items } = getLegendItems( + values, + CLASSIFICATION_NATURAL_BREAKS_RANGES, + 3 + ) + expect(items).toHaveLength(3) + expect(items[0].endValue).toBe(items[1].startValue) + expect(items[1].endValue).toBe(items[2].startValue) + expect(items[0].startValue).toBe(1) + expect(items[2].endValue).toBe(102) + }) + + it('returns natural breaks (clusters) with gaps between clusters', () => { + const values = [1, 2, 3, 10, 11, 12, 100, 101, 102] + const { items } = getLegendItems( + values, + CLASSIFICATION_NATURAL_BREAKS_CLUSTERS, + 3 + ) + expect(items).toHaveLength(3) + expect(items[0].endValue).toBeLessThan(items[1].startValue) + expect(items[1].endValue).toBeLessThan(items[2].startValue) + }) + + it('returns logarithmic bins for strictly positive data', () => { + const values = [1, 10, 100, 1000, 10000] + const { items } = getLegendItems(values, CLASSIFICATION_LOGARITHMIC, 4) + expect(items).toHaveLength(4) + expect(items[0].startValue).toBe(1) + expect(items[3].endValue).toBe(10000) + expect(items[0].endValue).toBe(items[1].startValue) + }) + + it('falls back to equal intervals for logarithmic when min <= 0', () => { + const values = [0, 25, 50, 75, 100] + const { items: logItems } = getLegendItems( + values, + CLASSIFICATION_LOGARITHMIC, + 4 + ) + const { items: equalItems } = getLegendItems( + values, + CLASSIFICATION_EQUAL_INTERVALS, + 4 + ) + expect(logItems).toEqual(equalItems) + }) + + it('returns standard deviation bins spanning [min, max]', () => { + const values = [0, 10, 20, 50, 80, 90, 100] + const { items } = getLegendItems( + values, + CLASSIFICATION_STANDARD_DEVIATION, + 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) + }) + + it('returns pretty breaks with round boundaries', () => { + const { items } = getLegendItems( + [0, 100], + CLASSIFICATION_PRETTY_BREAKS, + 5 + ) + expect(items[0].endValue).toBe(20) + }) + + it('removes consecutive duplicate bins', () => { + const values = [5, 5, 5, 5, 5, 10, 10, 10] + const { items } = getLegendItems(values, CLASSIFICATION_EQUAL_COUNTS, 5) + for (let i = 1; i < items.length; i++) { + expect( + items[i].startValue === items[i - 1].startValue && + items[i].endValue === items[i - 1].endValue + ).toBe(false) + } + }) + + it('caps class count for natural breaks (intervals) when fewer distinct values', () => { + const { items } = getLegendItems( + [1, 2, 3], + CLASSIFICATION_NATURAL_BREAKS_RANGES, + 5 + ) + expect(items).toHaveLength(3) + }) + + it('caps class count for natural breaks (clusters) when fewer distinct values', () => { + const { items } = getLegendItems( + [1, 2, 3], + CLASSIFICATION_NATURAL_BREAKS_CLUSTERS, + 5 + ) + expect(items).toHaveLength(3) + }) + + it('caps class count for equal counts when fewer distinct values', () => { + const { items } = getLegendItems( + [1, 2, 3], + CLASSIFICATION_EQUAL_COUNTS, + 5 + ) + expect(items.length).toBeLessThanOrEqual(3) + }) + + it('does not throw for pretty breaks with few distinct values', () => { + expect(() => + getLegendItems([1, 2], CLASSIFICATION_PRETTY_BREAKS, 5) + ).not.toThrow() + }) + + it('does not throw for standard deviation with few distinct values', () => { + expect(() => + getLegendItems([1, 2], CLASSIFICATION_STANDARD_DEVIATION, 5) + ).not.toThrow() + }) + + it('returns single bin when all values are equal', () => { + const { items } = getLegendItems( + [5, 5, 5, 5], + CLASSIFICATION_EQUAL_INTERVALS, + 4 + ) + expect(items).toEqual([{ startValue: 5, endValue: 5 }]) + }) + + it('short-circuits to single bin for all-equal values regardless of method', () => { + const methods = [ + CLASSIFICATION_EQUAL_INTERVALS, + CLASSIFICATION_EQUAL_COUNTS, + CLASSIFICATION_NATURAL_BREAKS_RANGES, + CLASSIFICATION_NATURAL_BREAKS_CLUSTERS, + CLASSIFICATION_STANDARD_DEVIATION, + CLASSIFICATION_LOGARITHMIC, + CLASSIFICATION_PRETTY_BREAKS, + ] + methods.forEach((method) => { + const { items } = getLegendItems([7, 7, 7], method, 5) + expect(items).toEqual([{ startValue: 7, endValue: 7 }]) + }) + }) + + it('all-equal single bin is matched by getLegendItemForValue', () => { + const { items } = getLegendItems( + [7, 7, 7], + CLASSIFICATION_EQUAL_INTERVALS, + 5 + ) + expect(getLegendItemForValue({ value: 7, legendItems: items })).toEqual( + items[0] + ) + }) }) diff --git a/src/util/bubbles.js b/src/util/bubbles.js index 8151d9528..250061cbc 100644 --- a/src/util/bubbles.js +++ b/src/util/bubbles.js @@ -9,6 +9,14 @@ import { getContrastColor } from './colors.js' import { getLongestTextLength } from './helpers.js' import { getRoundToPrecisionFn } from './numbers.js' +const getBubbleValueFormat = ({ minValue, maxValue, divisor }) => { + if (minValue === maxValue) { + return (n) => n.toString() + } + const precision = precisionRound((maxValue - minValue) / divisor, maxValue) + return (n) => getRoundToPrecisionFn(precision)(n).toString() +} + export const createBubbleItems = ({ classes, minValue, @@ -16,9 +24,11 @@ export const createBubbleItems = ({ scale, radiusHigh, }) => { - const binSize = (maxValue - minValue) / classes.length - const precision = precisionRound(binSize, maxValue) - const valueFormat = (n) => getRoundToPrecisionFn(precision)(n).toString() + const valueFormat = getBubbleValueFormat({ + minValue, + maxValue, + divisor: classes.length, + }) const startValue = classes[0].startValue const endValue = classes[classes.length - 1].endValue @@ -31,6 +41,10 @@ export const createBubbleItems = ({ text: valueFormat(c.endValue), })) + if (minValue === maxValue) { + return bubbles + } + bubbles.push({ radius: itemScale(startValue), maxRadius: radiusHigh, @@ -48,12 +62,23 @@ export const createSingleColorBubbles = ({ radiusLow, radiusHigh, }) => { - const binSize = (maxValue - minValue) / 3 - const precision = precisionRound(binSize, maxValue) - const valueFormat = (n) => getRoundToPrecisionFn(precision)(n).toString() + const valueFormat = getBubbleValueFormat({ minValue, maxValue, divisor: 3 }) const stroke = color && getContrastColor(color) const itemScale = scale.domain([minValue, maxValue]) + + if (minValue === maxValue) { + return [ + { + radius: itemScale(minValue), + maxRadius: radiusHigh, + color, + stroke, + text: valueFormat(minValue), + }, + ] + } + const midValue = (maxValue + minValue) / 2 return [ diff --git a/src/util/classify.js b/src/util/classify.js index cd98c3f4a..eb3b3cd5c 100644 --- a/src/util/classify.js +++ b/src/util/classify.js @@ -1,8 +1,14 @@ // Utils for thematic mapping import { precisionRound } from 'd3-format' +import { ckmeans, mean, standardDeviation } from 'simple-statistics' import { CLASSIFICATION_EQUAL_INTERVALS, CLASSIFICATION_EQUAL_COUNTS, + CLASSIFICATION_NATURAL_BREAKS_RANGES, + CLASSIFICATION_NATURAL_BREAKS_CLUSTERS, + CLASSIFICATION_STANDARD_DEVIATION, + CLASSIFICATION_LOGARITHMIC, + CLASSIFICATION_PRETTY_BREAKS, } from '../constants/layers.js' import { hasValue } from './helpers.js' import { getRoundToPrecisionFn } from './numbers.js' @@ -11,6 +17,7 @@ import { getRoundToPrecisionFn } from './numbers.js' export const getLegendItemForValue = ({ value, valueFormat, + method, legendItems, clamp = false, }) => { @@ -30,45 +37,81 @@ export const getLegendItemForValue = ({ } } + const isClusters = method === CLASSIFICATION_NATURAL_BREAKS_CLUSTERS const isLast = (index) => index === legendItems.length - 1 - return legendItems.find( - (item, index) => - value >= item.startValue && - (value < item.endValue || (isLast(index) && value == item.endValue)) + return legendItems.find((item, index) => + item.startValue === item.endValue + ? value === item.startValue + : value >= item.startValue && + (value < item.endValue || + (value === item.endValue && (isClusters || isLast(index)))) ) } export const getLegendItems = (values, method, numClasses) => { const minValue = values[0] const maxValue = values[values.length - 1] + if (minValue === maxValue) { + return { + items: [{ startValue: minValue, endValue: maxValue }], + } + } + + const distinctValues = [...new Set(values)] + const k = Math.min(numClasses, distinctValues.length) + let classification if (method === CLASSIFICATION_EQUAL_INTERVALS) { - classification = getEqualIntervals(minValue, maxValue, numClasses) + classification = getEqualIntervals(minValue, maxValue, { + numClasses: k, + }) } else if (method === CLASSIFICATION_EQUAL_COUNTS) { - classification = getQuantiles(values, numClasses) + classification = getQuantiles(values, { numClasses: k }) + } else if (method === CLASSIFICATION_NATURAL_BREAKS_RANGES) { + classification = getCkMeans(values, { + numClasses: k, + continuous: true, + }) + } else if (method === CLASSIFICATION_NATURAL_BREAKS_CLUSTERS) { + classification = getCkMeans(values, { + numClasses: k, + continuous: false, + }) + } else if (method === CLASSIFICATION_STANDARD_DEVIATION) { + classification = getStandardDeviation(values, { numClasses }) + } 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, + }) + } else { + classification = getLogarithmic(minValue, maxValue, { numClasses }) + } + } else if (method === CLASSIFICATION_PRETTY_BREAKS) { + classification = getPrettyBreaks(minValue, maxValue, { numClasses }) } - return classification ?? {} + if (!classification) { + return {} + } + return { + items: classification.items.filter( + (bin, index, arr) => + index === 0 || + bin.startValue !== arr[index - 1].startValue || + bin.endValue !== arr[index - 1].endValue + ), + valueFormat: classification.valueFormat, + } } -// This function is not in use, but keeping it -// just in case it's needed in the future -// export const getClassBins = (values, method, numClasses) => { -// const minValue = values[0] -// const maxValue = values[values.length - 1] -// let bins - -// if (method === CLASSIFICATION_EQUAL_INTERVALS) { -// bins = getEqualIntervals(minValue, maxValue, numClasses) -// } else if (method === CLASSIFICATION_EQUAL_COUNTS) { -// bins = getQuantiles(values, numClasses) -// } - -// return bins -// } - -const getEqualIntervals = (minValue, maxValue, numClasses) => { +const getEqualIntervals = (minValue, maxValue, { numClasses }) => { const items = [] const classSize = (maxValue - minValue) / numClasses const precision = precisionRound(classSize, maxValue) @@ -87,7 +130,7 @@ const getEqualIntervals = (minValue, maxValue, numClasses) => { return { items, valueFormat } } -const getQuantiles = (values, numClasses) => { +const getQuantiles = (values, { numClasses }) => { const minValue = values[0] const maxValue = values[values.length - 1] const items = [] @@ -107,10 +150,10 @@ const getQuantiles = (values, numClasses) => { } } - // bin can be undefined if few values + // item can be undefined if few values return { items: items - .filter((bin) => bin !== undefined) + .filter((item) => item !== undefined) .map((value, index) => ({ startValue: valueFormat(value), endValue: valueFormat(items[index + 1] || maxValue), @@ -118,3 +161,144 @@ const getQuantiles = (values, numClasses) => { valueFormat, } } + +const getCkMeans = (values, { numClasses, continuous }) => { + const minValue = values[0] + const maxValue = values[values.length - 1] + const precision = precisionRound( + (maxValue - minValue) / numClasses, + maxValue + ) + const valueFormat = getRoundToPrecisionFn(precision) + + const k = Math.min(numClasses, values.length) + const clusters = ckmeans(values, k) + + if (continuous) { + // Continuous: midpoint between adjacent cluster bounds + const boundaries = [ + minValue, + ...clusters + .slice(0, -1) + .map( + (cluster, i) => + (cluster[cluster.length - 1] + clusters[i + 1][0]) / 2 + ), + maxValue, + ] + return { + items: clusters.map((_, index) => ({ + startValue: valueFormat(boundaries[index]), + endValue: valueFormat(boundaries[index + 1]), + })), + valueFormat, + } + } + + // Discrete (clusters): true cluster bounds, gaps allowed + return { + items: clusters.map((cluster) => ({ + startValue: valueFormat(cluster[0]), + endValue: valueFormat(cluster[cluster.length - 1]), + })), + valueFormat, + } +} + +const getStandardDeviation = (values, { numClasses }) => { + // 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) + const sigma = standardDeviation(values) + const precision = precisionRound( + (maxValue - minValue) / numClasses, + maxValue + ) + const valueFormat = getRoundToPrecisionFn(precision) + + // Place breaks at 1-sigma intervals centered on the mean. + const internalBreaks = [] + const isEven = numClasses % 2 === 0 + const maxOffset = Math.floor((numClasses - 1) / 2) + 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) + } + } + + const allBreaks = [minValue, ...internalBreaks, maxValue] + return { + items: allBreaks.slice(0, -1).map((start, i) => ({ + startValue: valueFormat(start), + endValue: valueFormat(allBreaks[i + 1]), + })), + valueFormat, + } +} + +const getLogarithmic = (minValue, maxValue, { numClasses }) => { + const logMin = Math.log(minValue) + const logMax = Math.log(maxValue) + const logStep = (logMax - logMin) / numClasses + const precision = precisionRound( + (maxValue - minValue) / numClasses, + maxValue + ) + const valueFormat = getRoundToPrecisionFn(precision) + + const items = [] + for (let i = 0; i < numClasses; i++) { + const startValue = Math.exp(logMin + i * logStep) + const endValue = + i < numClasses - 1 ? Math.exp(logMin + (i + 1) * logStep) : maxValue + items.push({ + startValue: valueFormat(startValue), + endValue: valueFormat(endValue), + }) + } + + return { items, valueFormat } +} + +const getPrettyBreaks = (minValue, maxValue, { numClasses }) => { + const range = maxValue - minValue + const roughStep = range / numClasses + const magnitude = Math.pow(10, Math.floor(Math.log10(roughStep))) + const niceSteps = [1, 2, 5].map((n) => n * magnitude) + const niceStep = niceSteps.findLast((s) => s <= roughStep) ?? niceSteps[0] + + const precision = precisionRound(niceStep, maxValue) + const valueFormat = getRoundToPrecisionFn(precision) + + const internalBreaks = [] + let b = Math.ceil(minValue / niceStep) * niceStep + if (b === minValue) { + b += niceStep + } + while (b < maxValue && internalBreaks.length < numClasses - 1) { + internalBreaks.push(b) + b += niceStep + } + + const allBreaks = [minValue, ...internalBreaks, maxValue] + return { + items: allBreaks.slice(0, -1).map((start, i) => ({ + startValue: valueFormat(start), + endValue: valueFormat(allBreaks[i + 1]), + })), + valueFormat, + } +} diff --git a/src/util/styleByDataItem.js b/src/util/styleByDataItem.js index 9ff5091c7..c58c89991 100644 --- a/src/util/styleByDataItem.js +++ b/src/util/styleByDataItem.js @@ -192,6 +192,7 @@ const styleByNumeric = async (config, engine) => { getLegendItemForValue({ value, valueFormat, + method, legendItems: legend.items.filter((item) => !item.noData), clamp: method !== CLASSIFICATION_PREDEFINED, }) diff --git a/yarn.lock b/yarn.lock index 7a5c7183d..01bfd691c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -14134,6 +14134,11 @@ simple-concat@^1.0.0: resolved "https://registry.yarnpkg.com/simple-concat/-/simple-concat-1.0.1.tgz#f46976082ba35c2263f1c8ab5edfe26c41c9552f" integrity sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q== +simple-statistics@^7.8.9: + version "7.8.9" + resolved "https://registry.yarnpkg.com/simple-statistics/-/simple-statistics-7.8.9.tgz#daa0f089d88ab47a4d6187ace534c459be05742f" + integrity sha512-YT6MLqYsz7y1rQZOLFlOCCgSRpCi6bqY417yhoOLI7aVoBi29dD39EPrOE03W9DY25H0J0jizVsHZnkLzyGJFg== + slash@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/slash/-/slash-2.0.0.tgz#de552851a1759df3a8f206535442f5ec4ddeab44"