Skip to content

feat: new classification methods and edge-case handling [PR2] [DHIS2-18242]#3646

Open
BRaimbault wants to merge 10 commits into
feat/DHIS2-18242-PR1from
feat/DHIS2-18242-PR2
Open

feat: new classification methods and edge-case handling [PR2] [DHIS2-18242]#3646
BRaimbault wants to merge 10 commits into
feat/DHIS2-18242-PR1from
feat/DHIS2-18242-PR2

Conversation

@BRaimbault
Copy link
Copy Markdown
Collaborator

@BRaimbault BRaimbault commented Apr 24, 2026

Parent

Implements

  • DHIS2-21142: Add new classification methods (natural breaks, pretty breaks, logarithmic, standard deviation)
  • DHIS2-12860: Equal counts distribution is not ideal when many values are the same
  • DHIS2-8478: Automatic legends with few data points
  • DHIS2-20818: Thematic layer, bubble style — legends shows NaN when all mapped values are equal
  • DHIS2-21356: Saving event layer without choosing predefined legend set breaks layer

Overview

Second PR in the series extracted from the parent epic. Expands the classification engine with five new methods and hardens edge-case handling across all methods. Builds on the valueFormat plumbing from PR 1.

Changes

Five new classification methods DHIS2-21142

Added to the "Classification" dropdown, in addition to the existing Equal intervals and Equal counts:

  • Natural breaks (intervals) — ckmeans clustering with midpoint boundaries between clusters; bins are continuous and span the full data range
  • Natural breaks (clusters) — ckmeans clustering with true cluster bounds; bins may have gaps, useful for data with distinct groupings
  • Pretty breaks — bins aligned to round numbers (multiples of 1, 2, or 5 × 10ⁿ) for more readable legend boundaries
  • Logarithmic scale — log-spaced bins; falls back to equal intervals when data contains non-positive values (TODO in code to route these to the unclassified bucket once DHIS2-19812 lands)
  • Standard deviation — breaks at 1σ intervals centered on the mean, symmetric around the mean (even class counts include μ as a break; odd class counts skip it)

Natural breaks (clusters) is the only method where non-last bin endValues are inclusive, since clusters can have gaps. getLegendItemForValue now accepts a method parameter to handle this correctly.

New dependency: simple-statistics@^7.8.9 for ckmeans, mean, standardDeviation.

Also fixes the "Automatic" radio button highlight in LegendTypeSelect. Master only mapped EQUAL_COUNTS back to the EQUAL_INTERVALS id, so when a user picked any of the new auto methods (natural breaks, pretty breaks, logarithmic, std-dev) the radio appeared deselected. The fix uses getClassificationTypes() membership to detect any auto method and maps all of them to a single CLASSIFICATION_AUTO_DEFAULT constant — also serving as the default classification method when no method is set, so changing the default updates both consistently.

Also fixes LAYER_EDIT_CLASSIFICATION_SET in the reducer, which previously preserved colorScale/classes only when switching between EQUAL_INTERVALS and EQUAL_COUNTS; it now uses getClassificationTypes() so all auto methods are treated consistently.

Files: src/util/classify.js, src/constants/layers.js, src/loaders/thematicLoader.js, src/util/styleByDataItem.js, src/components/classification/LegendTypeSelect.jsx, src/reducers/layerEdit.js, package.json

Single-value class matching and duplicate bin removal DHIS2-12860

Two changes in classify.js to handle duplicate-heavy data:

  • getLegendItemForValue now matches classes where startValue === endValue via equality rather than range comparison, so single-value classes (e.g. {startValue: 100, endValue: 100}) are actually reachable.
  • getLegendItems filters consecutive duplicate bins from the dispatcher output, preventing equal-counts classification from producing unreachable [n, n] bins when many values are identical.

Files: src/util/classify.js

Handle classifications with few distinct values DHIS2-8478

Two guards added to the getLegendItems dispatcher:

  • All values equal: short-circuit and return a single bin [v, v] regardless of method. Protects downstream methods (notably getPrettyBreaks, where Math.log10(0) = -Infinity would otherwise propagate NaN) and guarantees a sensible legend when a layer filters to a uniform-value dataset.
  • Fewer distinct values than requested classes: cap the effective class count (k = min(numClasses, distinctValues.length)) for methods that partition the data (equal counts, natural breaks variants). Methods that derive bins from the data range (equal intervals, logarithmic, std-dev, pretty breaks) are unaffected.

No silent method switching — user-selected method is preserved, result size simply adapts to the data.

Files: src/util/classify.js

Fix NaN in bubble legend for equal values DHIS2-20818

When a thematic bubble layer has all values equal, the bubble radius calculation hit precisionRound(0, maxValue)NaN, rendering the legend with "NaN" labels.

Extracted a getBubbleValueFormat helper that bypasses precisionRound for the equal-values case. Both createBubbleItems and createSingleColorBubbles now produce a single bubble matching the map's rendered size.

Composes with DHIS2-8478's single-bin short-circuit: classification returns one class, bubble layer renders one bubble, legend shows one bubble. Consistent end-to-end.

Also guards Bubbles against undefined minValue/maxValue — a crash path introduced by the minValue === maxValue shortcut in getBubbleValueFormat (undefined === undefined is true, returning (n) => n.toString() which throws on undefined). When no data range is present but noDataColor is set, the component now renders a compact legend with just the no-data circle.

Files: src/util/bubbles.js, src/components/legend/Bubbles.jsx

Auto-select legend set when switching to predefined type DHIS2-21356

When a user switched the "Legend type" to "Predefined" without explicitly picking a legend set, the layer would save with legendSet: null and break on reload. The pre-existing useEffect in NumericLegendStyle only fired when the data item had an associated legend set — leaving the no-association case unhandled.

Moved legend-set auto-selection into LegendSetSelect, where the available list is guaranteed to be loaded. Pass dataItem?.legendSet down as defaultLegendSet; the component prefers that, falls back to the first available legend set when the association is missing or the associated set isn't in the user's list.

Files: src/components/classification/LegendSetSelect.jsx, src/components/classification/NumericLegendStyle.jsx

Tests update

  • src/util/__tests__/bubbles.spec.js
  • src/util/__tests__/classify.spec.js

Manual testing

Netlify: https://pr-3646.maps.netlify.dhis2.org/ + Instance: https://dev.im.dhis2.org/maps-app-42-3

image

 

image

 

image

 

  • DHIS2-20818: Thematic layer, bubble style — legends shows NaN when all mapped values are equal
image

 

  • DHIS2-21356: Saving event layer without choosing predefined legend set breaks layer

Quality checklist

Add N/A to items that are not applicable.

  • Jest tests added/updated
  • Docs added N/A
  • d2-ci dependencies replaced N/A
  • Include plugin in testing
  • Tester approved (@edoardo)

@dhis2-bot
Copy link
Copy Markdown
Contributor

dhis2-bot commented Apr 24, 2026

🚀 Deployed on https://pr-3646.maps.netlify.dhis2.org

@dhis2-bot dhis2-bot temporarily deployed to netlify April 24, 2026 13:24 Inactive
@dhis2-bot dhis2-bot temporarily deployed to netlify April 24, 2026 13:32 Inactive
Copy link
Copy Markdown
Collaborator Author

@BRaimbault BRaimbault left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ready for review.

@BRaimbault BRaimbault marked this pull request as ready for review April 24, 2026 14:00
@dhis2-bot dhis2-bot temporarily deployed to netlify April 26, 2026 09:32 Inactive
@BRaimbault BRaimbault changed the title feat: new classification methods and edge-case handling [DHIS2-18242] feat: new classification methods and edge-case handling [PR2] [DHIS2-18242] Apr 26, 2026
@BRaimbault BRaimbault requested a review from a team April 27, 2026 08:39
@dhis2-bot dhis2-bot temporarily deployed to netlify April 27, 2026 12:48 Inactive
@dhis2-bot dhis2-bot temporarily deployed to netlify April 27, 2026 14:19 Inactive
@BRaimbault BRaimbault force-pushed the feat/DHIS2-18242-PR2 branch from 60a3f6b to 9b7bc5b Compare April 27, 2026 14:26
@dhis2-bot dhis2-bot temporarily deployed to netlify April 27, 2026 14:28 Inactive
@BRaimbault BRaimbault force-pushed the feat/DHIS2-18242-PR2 branch from 9b7bc5b to 60caa87 Compare April 27, 2026 14:44
@dhis2-bot dhis2-bot temporarily deployed to netlify April 27, 2026 14:46 Inactive
@sonarqubecloud
Copy link
Copy Markdown

@dhis2-bot dhis2-bot temporarily deployed to netlify May 14, 2026 11:31 Inactive
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants