Skip to content

mockup: spatial hotspot analysis for thematic layers#3684

Draft
BRaimbault wants to merge 11 commits into
masterfrom
feat/spatial-analysis
Draft

mockup: spatial hotspot analysis for thematic layers#3684
BRaimbault wants to merge 11 commits into
masterfrom
feat/spatial-analysis

Conversation

@BRaimbault

@BRaimbault BRaimbault commented Jun 18, 2026

Copy link
Copy Markdown
Collaborator

Mockup for DHIS2-21461

DO NOT MERGE

Description

What: Adds inferential spatial statistics — Getis-Ord Gi* and Local Moran's I (LISA) — as an optional "Analysis" tab on the thematic layer edit dialog. Gated entirely behind config.spatialAnalysis, so existing saved maps are unaffected.

  • Spatial weights: contiguity (queen/rook), distance band, or k-nearest neighbors, row-standardized
  • Gi*: z-score hotspot/coldspot detection with FDR/Bonferroni correction; legend shows fixed confidence-tier bins (90/95/99%) so results read without prior stats knowledge
  • LISA: cluster classification (High-High, Low-Low, High-Low, Low-High) via conditional permutation, seedable for reproducibility
  • Data-quality guardrails: warnings for low sample size, islands with no neighbors, sparse coordinates, and raw-count denominators (which distort hotspot results)
  • New Analysis tab follows the existing dialog's component/layout conventions (core/ field wrappers, two-column style)

This is a proof of concept for the first slice of a larger epic — heatmap/DBSCAN clustering for events and multidimensional thematic maps are planned as follow-ups, not included here.


Screenshots

image image image image

BRaimbault and others added 11 commits June 18, 2026 12:09
Add SPATIAL_NONE/GI/LISA method constants, WEIGHTS_CONTIGUITY/DISTANCE_BAND/KNN
weight type constants, and four guardrail alert codes (WARNING_RATE_DENOMINATOR,
WARNING_LOW_N, WARNING_NO_NEIGHBORS, WARNING_SPARSE_COORDS).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Needed for distance-band and kNN spatial weight construction in the
upcoming spatial analysis feature. @turf/centroid is already present.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
… with tests

- buildSpatialWeights: queen/rook contiguity (coordinate-set comparison),
  distance-band and kNN (via @turf/centroid + @turf/distance), row-standardized;
  islands go into noNeighborIds with empty rows rather than zero-rows.

- getGiStar (Ord & Getis 1995): includes self in extended neighbor set (Gi* variant),
  two-sided normal p-value, FDR (BH) or Bonferroni correction.

- getLisa (Anselin 1995): conditional permutation (999 default) with mulberry32
  seeded RNG for reproducibility; self EXCLUDED from permutation pool (esda issue #86);
  two-sided pseudo-p capped at 1; BH/Bonferroni correction; GeoDa/PySAL quadrant schemes.

Tests cover: all weight types, island detection, row-standardization, z-score
direction, p-value bounds, reproducibility, quadrant scheme agreement on HH/LL,
FDR monotonicity, and degenerate inputs (all-missing, all-same-value).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- colors.js: SPATIAL_GI_COLOR_SCALE = 'RdBu_reverse' — diverging palette for Gi*
  z-scores (blue=cold/negative, red=hot/positive). The loader uses this with the
  existing getColorPalette + getAutomaticLegendItems + CLASSIFICATION_STANDARD_DEVIATION,
  so no new classification path is needed.

- legend.js: buildLisaLegendItems() — returns 5 fixed items (HH/LL/HL/LH/NS) in
  the standard { name, color, count } shape using GeoDa colors. Lookup at render
  time is legendItems.find(item => item.cluster === cluster).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…rdrails

Gated by config.spatialAnalysis.method (SPATIAL_GI or SPATIAL_LISA); no-op
when absent or set to SPATIAL_NONE, so existing maps are unaffected.

Spatial step (single-map only):
- buildSpatialWeights → getGiStar or getLisa after valueById is built.
- Gi*: diverging choropleth via getAutomaticLegendItems + CLASSIFICATION_STANDARD_DEVIATION
  over z-scores with the RdBu_reverse palette.
- LISA: categorical 5-item legend (buildLisaLegendItems) with cluster-key lookup.
- Feature properties carry spatialZ/spatialI/spatialP/spatialSignificant alongside
  raw value and formatted value for popup and data table display.
- No-neighbor units rendered in grey with "No neighbors" label, excluded from counts.

Guardrails (push onto alerts array when spatial is active):
- WARNING_LOW_N: fewer than 30 units.
- WARNING_NO_NEIGHBORS: any island units detected by buildSpatialWeights.
- WARNING_SPARSE_COORDS: >10% of org units lack geometry.
- WARNING_RATE_DENOMINATOR: DATA_ELEMENT data item whose name does not contain
  rate/coverage/proportion/per/ratio/prevalence keywords.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
New "Analysis" tab (single-map only — hidden for Timeline/Split) with:

Primary controls: method (Off / Gi* / LISA), spatial weights type, distance
threshold (distance band), k (kNN), significance α.

Advanced section (collapsed by default): permutations (LISA), multiple-comparison
correction, quadrant scheme (LISA), row-standardize toggle, random seed.

Denominator warning: NoticeBox shown when method is active and the selected
data item is a DATA_ELEMENT whose name lacks rate/coverage/proportion/ratio/
prevalence keywords — nudges users toward per-capita indicators.

Redux: LAYER_EDIT_SPATIAL_ANALYSIS_SET action + reducer merge-patch on
state.spatialAnalysis. Config round-trips via LayerEdit's {...layer} spread
so saved maps reproduce exactly. Seed is auto-generated on first activation
and persisted so results are reproducible when the map is shared.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Rename centroid/distance imports to turfCentroid/turfDistance to avoid
  name collision with local variables
- Make WEIGHTS_KNN branch explicit (else if) to satisfy no-else-return
- Refactor edgeKey to take two array params (max-params lint rule)
- Remove unused `weights` destructuring from getGiStar
- Remove unused `id` binding in spatialStats.spec.js loop
- Remove unused SPATIAL_NONE import from ThematicDialog
- Remove unused dataItem prop from SpatialAnalysisSection

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
colorbrewer.js only auto-generates '_reverse' variants for sequential
scales, not diverging ones like RdBu, so getColorPalette('RdBu_reverse', n)
returned undefined and crashed on classes lookup. Use the native 'RdBu'
scale and reverse the returned array in the loader so cold (low z) stays
blue and hot (high z) stays red.

Also regenerate i18n/en.pot to pick up translatable strings added by the
spatial analysis UI in earlier commits.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Gi* previously labeled bins with raw sigma-of-z-score ranges (e.g.
"-1.5 – 1.5"), which is meaningless without statistics background and was
also computed from the sample distribution of z-scores rather than the
calibrated standard normal — the displayed bins didn't actually correspond
to any significance threshold.

Replace it with the conventional "Hot Spot Analysis" labeling: fixed
Cold/Hot spot bins at 90/95/99% confidence, derived directly from each
unit's corrected (FDR/Bonferroni) p-value via a new `tier` field on the
Gi* result. Only tiers reachable under the chosen alpha are shown, so e.g.
alpha=0.01 never displays an unreachable 90%/95% bin. LISA quadrant names
(High-High etc.) get a parenthetical explaining what each one means.

Also fixes a latent bug where Gi* no-neighbor (island) units never showed
"No neighbors" in the legend: the check required both `stat.z` and
`stat.I` to be null, but those fields belong to different methods (Gi*
only sets z, LISA only sets I), so the Gi* branch could never match.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
ALPHA_OPTIONS used value: '0.10', but the selected option is computed as
String(alpha) where alpha is stored as a Number — Number('0.10') stringifies
back to '0.1', not '0.10', so the SingleSelect could no longer find a
matching option and crashed with "There is no option with the value: 0.1".

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
SpatialAnalysisSection used raw @dhis2/ui components (SingleSelectField,
InputField, Checkbox, Button) instead of this app's core/ wrappers
(SelectField, NumberField, Checkbox) that every other tab in this dialog
is built from. That meant manual value<->string conversions the rest of
the codebase doesn't need to do — and was the direct cause of the
"There is no option with the value" crash from hand-written option
strings like '0.10' silently drifting from Number('0.10').toString().
Switching to SelectField (which takes {id, name} items and compares via
a single consistent String(id)) removes that whole class of bug.

Also:
- Replace the "Show/Hide advanced options" Button toggle — not a pattern
  used anywhere else in this dialog — with a second always-visible
  column ("Advanced settings"), matching the Style tab's two-column
  layout and TrackedEntityDialog's section-header convention.
- Fix the data-quality NoticeBox overflowing the dialog: it was using
  the `.notice` class (vertical margin only), so it inherited the
  `.tabContent` bleed margin with no horizontal inset to compensate,
  unlike `.flexColumn` siblings. New `.fullWidthNotice` composes the
  existing `.flexRowFlow` (already used for full-width rows elsewhere
  in this dialog) instead.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@sonarqubecloud

Copy link
Copy Markdown

Quality Gate Failed Quality Gate failed

Failed conditions
9 New issues
8 New Critical Issues (required ≤ 0)
D Reliability Rating on New Code (required ≥ A)
4 New Bugs (required ≤ 0)
5 New Code Smells (required ≤ 0)

See analysis details on SonarQube Cloud

Catch issues before they fail your Quality Gate with our IDE extension SonarQube for IDE

@dhis2-bot

Copy link
Copy Markdown
Contributor

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

@dhis2-bot dhis2-bot temporarily deployed to netlify June 18, 2026 13:30 Inactive
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants