From ca34e81229d1bad3d9b286afa84f91309813a10e Mon Sep 17 00:00:00 2001 From: Bruno Raimbault Date: Thu, 18 Jun 2026 12:09:47 +0200 Subject: [PATCH 01/12] feat(constants): add spatial analysis constants and alert codes 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 --- src/constants/alerts.js | 6 ++++++ src/constants/layers.js | 9 +++++++++ 2 files changed, 15 insertions(+) diff --git a/src/constants/alerts.js b/src/constants/alerts.js index 682851742b..7b9dcb6f6e 100644 --- a/src/constants/alerts.js +++ b/src/constants/alerts.js @@ -16,3 +16,9 @@ export const WARNING_OU_BOUNDARIES_FETCH_FAILED = 'WARNING_OU_BOUNDARIES_FETCH_FAILED' export const ERROR_CRITICAL = 'ERROR_CRITICAL' export const CUSTOM_ALERT = 'CUSTOM_ALERT' + +// Spatial analysis guardrail alerts +export const WARNING_RATE_DENOMINATOR = 'WARNING_RATE_DENOMINATOR' +export const WARNING_LOW_N = 'WARNING_LOW_N' +export const WARNING_NO_NEIGHBORS = 'WARNING_NO_NEIGHBORS' +export const WARNING_SPARSE_COORDS = 'WARNING_SPARSE_COORDS' diff --git a/src/constants/layers.js b/src/constants/layers.js index bb31be7119..a24c9ef2d3 100644 --- a/src/constants/layers.js +++ b/src/constants/layers.js @@ -237,3 +237,12 @@ export const MIN_RADIUS = 1 export const MAX_RADIUS = 100 export const NONE = 'none' + +/* SPATIAL ANALYSIS */ +export const SPATIAL_NONE = 'SPATIAL_NONE' +export const SPATIAL_GI = 'SPATIAL_GI' +export const SPATIAL_LISA = 'SPATIAL_LISA' + +export const WEIGHTS_CONTIGUITY = 'WEIGHTS_CONTIGUITY' +export const WEIGHTS_DISTANCE_BAND = 'WEIGHTS_DISTANCE_BAND' +export const WEIGHTS_KNN = 'WEIGHTS_KNN' From 401cb301fbf6b403a0e5ab774fcdad96a267c818 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault Date: Thu, 18 Jun 2026 12:12:20 +0200 Subject: [PATCH 02/12] chore(deps): add @turf/distance 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 --- package.json | 1 + yarn.lock | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index 83ec322d06..6d019728d1 100644 --- a/package.json +++ b/package.json @@ -57,6 +57,7 @@ "@dnd-kit/utilities": "^3.2.2", "@turf/boolean-point-in-polygon": "^7.3.5", "@turf/centroid": "^7.3.5", + "@turf/distance": "^7.3.5", "abortcontroller-polyfill": "^1.7.8", "array-move": "^4.0.0", "classnames": "^2.5.1", diff --git a/yarn.lock b/yarn.lock index 90512c02d9..7a10569c74 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3989,7 +3989,7 @@ "@types/geojson" "^7946.0.10" tslib "^2.8.1" -"@turf/distance@7.3.5": +"@turf/distance@7.3.5", "@turf/distance@^7.3.5": version "7.3.5" resolved "https://registry.yarnpkg.com/@turf/distance/-/distance-7.3.5.tgz#3aa16a1fde30e5cf4cf40b7e497be8cc5d6bbaaf" integrity sha512-uQAC63zg/l91KUxzfhqio7Ii3+UXTrPOVJScIdRj6EO6+9XHI4kC+AdyIS4cPAv14sZfJLIBxzMnzcGrss+kEA== From 1964ae4c0f1f7e0068008bbd619f39bbf3afed9e Mon Sep 17 00:00:00 2001 From: Bruno Raimbault Date: Thu, 18 Jun 2026 12:19:02 +0200 Subject: [PATCH 03/12] feat(spatialStats): implement buildSpatialWeights, getGiStar, getLisa 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 --- src/util/__tests__/spatialStats.spec.js | 377 +++++++++++++++ src/util/spatialStats.js | 594 ++++++++++++++++++++++++ 2 files changed, 971 insertions(+) create mode 100644 src/util/__tests__/spatialStats.spec.js create mode 100644 src/util/spatialStats.js diff --git a/src/util/__tests__/spatialStats.spec.js b/src/util/__tests__/spatialStats.spec.js new file mode 100644 index 0000000000..7b3e1b550d --- /dev/null +++ b/src/util/__tests__/spatialStats.spec.js @@ -0,0 +1,377 @@ +import { + buildSpatialWeights, + getGiStar, + getLisa, +} from '../spatialStats.js' +import { + WEIGHTS_CONTIGUITY, + WEIGHTS_DISTANCE_BAND, + WEIGHTS_KNN, +} from '../../constants/layers.js' + +// --------------------------------------------------------------------------- +// Test fixture +// --------------------------------------------------------------------------- +// +// Five 1°×1° squares arranged in an L-shape + one isolated island: +// +// [A][B] +// [C][D] +// [E] [F] ← isolated (no shared border or vertex) +// +// Adjacency (queen): +// A: B, C, D (B shares edge, C shares edge, D shares corner) +// B: A, C, D (A shares edge, C shares corner, D shares edge) +// C: A, B, D, E (shares edges/corners) +// D: A, B, C, E (shares edges/corners) +// E: C, D (shares edges) +// F: (none) +// +// Rook (edges only, no diagonal): +// A: B, C +// B: A, D +// C: A, D, E +// D: B, C, E +// E: C, D +// F: (none) + +const makeSquare = (id, col, row) => ({ + id, + type: 'Feature', + geometry: { + type: 'Polygon', + coordinates: [ + [ + [col, row], + [col + 1, row], + [col + 1, row + 1], + [col, row + 1], + [col, row], + ], + ], + }, + properties: {}, +}) + +const FEATURES = [ + makeSquare('A', 0, 1), // col=0, row=1 → (0,1)-(1,2) + makeSquare('B', 1, 1), // col=1, row=1 → (1,1)-(2,2) + makeSquare('C', 0, 0), // col=0, row=0 → (0,0)-(1,1) + makeSquare('D', 1, 0), // col=1, row=0 → (1,0)-(2,1) + makeSquare('E', 0, -1), // col=0, row=-1 → (0,-1)-(1,0) + makeSquare('F', 10, 10), // isolated island far away +] + +const VALUES = { A: 10, B: 20, C: 30, D: 40, E: 50, F: 5 } + +// --------------------------------------------------------------------------- +// buildSpatialWeights — contiguity +// --------------------------------------------------------------------------- + +describe('buildSpatialWeights — queen contiguity', () => { + let result + + beforeAll(() => { + result = buildSpatialWeights(FEATURES, { type: WEIGHTS_CONTIGUITY, weightType: 'queen' }) + }) + + test('returns neighbors, weights, noNeighborIds', () => { + expect(result).toHaveProperty('neighbors') + expect(result).toHaveProperty('weights') + expect(result).toHaveProperty('noNeighborIds') + }) + + test('F (island) is in noNeighborIds', () => { + expect(result.noNeighborIds).toContain('F') + }) + + test('no other unit is in noNeighborIds', () => { + expect(result.noNeighborIds.filter((id) => id !== 'F')).toHaveLength(0) + }) + + test('A has neighbors B, C, D (shares vertex with D)', () => { + expect(result.neighbors.get('A').sort()).toEqual(['B', 'C', 'D'].sort()) + }) + + test('E has neighbors C and D only', () => { + expect(result.neighbors.get('E').sort()).toEqual(['C', 'D'].sort()) + }) + + test('F has empty neighbor array', () => { + expect(result.neighbors.get('F')).toEqual([]) + expect(result.weights.get('F')).toEqual([]) + }) + + test('all rows sum to 1 (row-standardized)', () => { + for (const [id, w] of result.weights) { + if (w.length === 0) continue + const sum = w.reduce((s, v) => s + v, 0) + expect(sum).toBeCloseTo(1, 10) + } + }) +}) + +describe('buildSpatialWeights — rook contiguity', () => { + let result + + beforeAll(() => { + result = buildSpatialWeights(FEATURES, { type: WEIGHTS_CONTIGUITY, weightType: 'rook' }) + }) + + test('A has rook neighbors B and C (not D — diagonal only)', () => { + expect(result.neighbors.get('A').sort()).toEqual(['B', 'C'].sort()) + }) + + test('B has rook neighbors A and D (not C — diagonal only)', () => { + expect(result.neighbors.get('B').sort()).toEqual(['A', 'D'].sort()) + }) + + test('F still isolated', () => { + expect(result.noNeighborIds).toContain('F') + }) +}) + +// --------------------------------------------------------------------------- +// buildSpatialWeights — distance band +// --------------------------------------------------------------------------- + +describe('buildSpatialWeights — distance band', () => { + test('with a large threshold all non-island units see each other', () => { + const result = buildSpatialWeights(FEATURES, { + type: WEIGHTS_DISTANCE_BAND, + distanceMeters: 300_000, // ~300 km, enough to span the 5 squares + }) + // A, B, C, D, E should all be mutual neighbors; F (10°/10° away) should be isolated + expect(result.noNeighborIds).toContain('F') + expect(result.noNeighborIds.filter((id) => id !== 'F')).toHaveLength(0) + }) + + test('with a tiny threshold most units become islands', () => { + const result = buildSpatialWeights(FEATURES, { + type: WEIGHTS_DISTANCE_BAND, + distanceMeters: 1, // 1 metre — nothing qualifies + }) + expect(result.noNeighborIds).toHaveLength(FEATURES.length) + }) +}) + +// --------------------------------------------------------------------------- +// buildSpatialWeights — kNN +// --------------------------------------------------------------------------- + +describe('buildSpatialWeights — kNN', () => { + test('k=1 gives exactly one neighbor per unit', () => { + const result = buildSpatialWeights(FEATURES, { type: WEIGHTS_KNN, k: 1 }) + for (const [, nb] of result.neighbors) { + expect(nb).toHaveLength(1) + } + // kNN always assigns k neighbors so no islands + expect(result.noNeighborIds).toHaveLength(0) + }) + + test('k=2 gives exactly two neighbors per unit', () => { + const result = buildSpatialWeights(FEATURES, { type: WEIGHTS_KNN, k: 2 }) + for (const [, nb] of result.neighbors) { + expect(nb).toHaveLength(2) + } + }) +}) + +// --------------------------------------------------------------------------- +// getGiStar +// --------------------------------------------------------------------------- + +describe('getGiStar', () => { + let weights + let result + + beforeAll(() => { + // Use queen contiguity on the main 5 squares (exclude F so weights include F as island) + weights = buildSpatialWeights(FEATURES, { type: WEIGHTS_CONTIGUITY, weightType: 'queen' }) + result = getGiStar(VALUES, weights, { alpha: 0.05, correction: 'none' }) + }) + + test('returns a Map with an entry per feature', () => { + expect(result.size).toBe(FEATURES.length) + }) + + test('each entry has z, p, significant', () => { + for (const [, v] of result) { + expect(v).toHaveProperty('z') + expect(v).toHaveProperty('p') + expect(v).toHaveProperty('significant') + } + }) + + test('island F gets z=null, p=null, significant=false', () => { + expect(result.get('F')).toEqual({ z: null, p: null, significant: false }) + }) + + test('E (highest value, neighbors C+D) has positive z', () => { + // E=50 surrounded by C=30, D=40 — local mean 40 > global mean 30 + expect(result.get('E').z).toBeGreaterThan(0) + }) + + test('A (lowest value, neighbors B+C+D) has negative z', () => { + // A=10 — local extended-neighborhood mean 25 < global mean 30 + expect(result.get('A').z).toBeLessThan(0) + }) + + test('A.z < E.z (cold spot vs hot spot)', () => { + expect(result.get('A').z).toBeLessThan(result.get('E').z) + }) + + test('all-same-value edge case: z should be 0 for all units', () => { + const uniform = { A: 5, B: 5, C: 5, D: 5, E: 5, F: 5 } + const r = getGiStar(uniform, weights, { correction: 'none' }) + for (const [id, v] of r) { + if (id === 'F') continue // island, z=null + expect(v.z).toBeCloseTo(0, 6) + } + }) + + test('all missing values returns null z for everyone', () => { + const r = getGiStar({}, weights) + for (const [, v] of r) { + expect(v.z).toBeNull() + } + }) + + test('p-values are in [0, 1]', () => { + for (const [id, v] of result) { + if (id === 'F') continue + expect(v.p).toBeGreaterThanOrEqual(0) + expect(v.p).toBeLessThanOrEqual(1) + } + }) +}) + +// --------------------------------------------------------------------------- +// getLisa +// --------------------------------------------------------------------------- + +describe('getLisa', () => { + let weights + let result + + beforeAll(() => { + weights = buildSpatialWeights(FEATURES, { type: WEIGHTS_CONTIGUITY, weightType: 'queen' }) + result = getLisa(VALUES, weights, { + permutations: 999, + alpha: 0.05, + correction: 'none', + seed: 42, + }) + }) + + test('returns a Map with an entry per feature', () => { + expect(result.size).toBe(FEATURES.length) + }) + + test('each entry has I, z, pPseudo, cluster, significant', () => { + for (const [, v] of result) { + expect(v).toHaveProperty('I') + expect(v).toHaveProperty('z') + expect(v).toHaveProperty('pPseudo') + expect(v).toHaveProperty('cluster') + expect(v).toHaveProperty('significant') + } + }) + + test('island F gets null stats and cluster NS', () => { + expect(result.get('F')).toEqual({ + I: null, + z: null, + pPseudo: null, + cluster: 'NS', + significant: false, + }) + }) + + test('cluster values are one of HH, LL, HL, LH, NS', () => { + const valid = new Set(['HH', 'LL', 'HL', 'LH', 'NS']) + for (const [, v] of result) { + expect(valid.has(v.cluster)).toBe(true) + } + }) + + test('non-significant units get cluster NS', () => { + for (const [, v] of result) { + if (!v.significant) { + expect(v.cluster).toBe('NS') + } + } + }) + + test('reproducible with the same seed', () => { + const r2 = getLisa(VALUES, weights, { + permutations: 999, + correction: 'none', + seed: 42, + }) + for (const [id, v] of result) { + expect(r2.get(id).pPseudo).toBe(v.pPseudo) + } + }) + + test('different seed produces different pseudo-p (with high permutations)', () => { + const r2 = getLisa(VALUES, weights, { + permutations: 999, + correction: 'none', + seed: 99, + }) + // At least one p-value should differ (extremely unlikely they all match) + let anyDiffer = false + for (const [id, v] of result) { + if (id === 'F') continue + if (r2.get(id).pPseudo !== v.pPseudo) { + anyDiffer = true + break + } + } + expect(anyDiffer).toBe(true) + }) + + test('quadrantScheme geoda and pysal agree on HH and LL', () => { + const geoda = getLisa(VALUES, weights, { permutations: 99, correction: 'none', seed: 1, quadrantScheme: 'geoda' }) + const pysal = getLisa(VALUES, weights, { permutations: 99, correction: 'none', seed: 1, quadrantScheme: 'pysal' }) + for (const [id] of result) { + if (id === 'F') continue + const cg = geoda.get(id).cluster + const cp = pysal.get(id).cluster + if (cg === 'HH' || cg === 'LL') { + expect(cp).toBe(cg) // HH and LL are the same in both schemes + } + } + }) + + test('two-sided pseudo-p is in [0, 1]', () => { + for (const [id, v] of result) { + if (id === 'F') continue + expect(v.pPseudo).toBeGreaterThanOrEqual(0) + expect(v.pPseudo).toBeLessThanOrEqual(1) + } + }) + + test('all-missing values returns null stats for all', () => { + const r = getLisa({}, weights) + for (const [, v] of r) { + expect(v.I).toBeNull() + } + }) + + test('FDR correction does not increase any p-value beyond uncorrected', () => { + const uncorrected = getLisa(VALUES, weights, { + permutations: 99, seed: 7, correction: 'none', + }) + const corrected = getLisa(VALUES, weights, { + permutations: 99, seed: 7, correction: 'fdr', + }) + for (const [id] of uncorrected) { + if (id === 'F') continue + expect(corrected.get(id).pPseudo).toBeGreaterThanOrEqual( + uncorrected.get(id).pPseudo - 1e-10 + ) + } + }) +}) diff --git a/src/util/spatialStats.js b/src/util/spatialStats.js new file mode 100644 index 0000000000..e717ef4c5b --- /dev/null +++ b/src/util/spatialStats.js @@ -0,0 +1,594 @@ +// Spatial statistics utilities for hotspot and cluster detection. +// +// References: +// Getis & Ord (1992) doi:10.1111/j.1538-4632.1992.tb00261.x +// Ord & Getis (1995) doi:10.1111/j.1538-4632.1995.tb00912.x +// Anselin (1995) doi:10.1111/j.1538-4632.1995.tb00338.x +// Sokal, Oden & Thomson (1998) — conditional-permutation variance +// Bivand & Wong (2018) doi:10.1007/s11749-018-0599-x — cross-implementation comparison +import centroid from '@turf/centroid' +import distance from '@turf/distance' +import { + WEIGHTS_CONTIGUITY, + WEIGHTS_DISTANCE_BAND, + WEIGHTS_KNN, +} from '../constants/layers.js' + +// --------------------------------------------------------------------------- +// Spatial weights +// --------------------------------------------------------------------------- + +/** + * Build a row-standardized spatial weights matrix from GeoJSON features. + * + * @param {Array} features GeoJSON Feature array, each with an `id` property. + * @param {object} opts + * @param {string} opts.type WEIGHTS_CONTIGUITY | WEIGHTS_DISTANCE_BAND | WEIGHTS_KNN + * @param {string} [opts.weightType] 'queen' | 'rook' (contiguity only, default 'queen') + * @param {number} [opts.distanceMeters] Distance threshold in metres (distance band only) + * @param {number} [opts.k] Number of neighbours (kNN only, default 8) + * @param {boolean} [opts.rowStandardize] Default true + * @returns {{ neighbors: Map, weights: Map, noNeighborIds: id[] }} + */ +export const buildSpatialWeights = ( + features, + { + type = WEIGHTS_CONTIGUITY, + weightType = 'queen', + distanceMeters, + k = 8, + rowStandardize = true, + } = {} +) => { + const ids = features.map((f) => f.id) + + let rawNeighbors // Map> + + if (type === WEIGHTS_CONTIGUITY) { + rawNeighbors = buildContiguityNeighbors(features, weightType) + } else { + const centroids = computeCentroids(features) + const distMatrix = computeDistanceMatrix(ids, centroids) + if (type === WEIGHTS_DISTANCE_BAND) { + rawNeighbors = buildDistanceBandNeighbors( + ids, + distMatrix, + distanceMeters + ) + } else { + // WEIGHTS_KNN + rawNeighbors = buildKnnNeighbors(ids, distMatrix, k) + } + } + + const neighbors = new Map() + const weights = new Map() + const noNeighborIds = [] + + for (const id of ids) { + const nbSet = rawNeighbors.get(id) ?? new Set() + const nbArray = [...nbSet] + + if (nbArray.length === 0) { + noNeighborIds.push(id) + neighbors.set(id, []) + weights.set(id, []) + continue + } + + neighbors.set(id, nbArray) + if (rowStandardize) { + const w = 1 / nbArray.length + weights.set(id, nbArray.map(() => w)) + } else { + weights.set(id, nbArray.map(() => 1)) + } + } + + return { neighbors, weights, noNeighborIds } +} + +// --------------------------------------------------------------------------- +// Contiguity helpers +// --------------------------------------------------------------------------- + +// Returns a canonical string key for an edge (order-independent) +const edgeKey = (ax, ay, bx, by) => { + const a = `${ax},${ay}` + const b = `${bx},${by}` + return a < b ? `${a}|${b}` : `${b}|${a}` +} + +// Extract all coordinates from a GeoJSON Polygon or MultiPolygon geometry +const extractRings = (geometry) => { + if (geometry.type === 'Polygon') { + return geometry.coordinates + } + if (geometry.type === 'MultiPolygon') { + return geometry.coordinates.flat(1) + } + return [] +} + +const buildContiguityNeighbors = (features, weightType) => { + const neighbors = new Map(features.map((f) => [f.id, new Set()])) + + // Precompute per-feature coordinate sets and edge sets + const featureData = features.map((f) => { + const rings = extractRings(f.geometry) + const vertexSet = new Set() + const edgeSet = new Set() + + for (const ring of rings) { + for (let i = 0; i < ring.length; i++) { + const [x, y] = ring[i] + vertexSet.add(`${x},${y}`) + if (i < ring.length - 1) { + const [nx, ny] = ring[i + 1] + edgeSet.add(edgeKey(x, y, nx, ny)) + } + } + } + + return { id: f.id, vertexSet, edgeSet } + }) + + // O(N²) pair comparison — acceptable for typical org-unit counts + for (let i = 0; i < featureData.length; i++) { + for (let j = i + 1; j < featureData.length; j++) { + const a = featureData[i] + const b = featureData[j] + + let adjacent = false + if (weightType === 'rook') { + // Rook: share at least one full edge + for (const edge of a.edgeSet) { + if (b.edgeSet.has(edge)) { + adjacent = true + break + } + } + } else { + // Queen (default): share at least one vertex + for (const v of a.vertexSet) { + if (b.vertexSet.has(v)) { + adjacent = true + break + } + } + } + + if (adjacent) { + neighbors.get(a.id).add(b.id) + neighbors.get(b.id).add(a.id) + } + } + } + + return neighbors +} + +// --------------------------------------------------------------------------- +// Distance-based helpers +// --------------------------------------------------------------------------- + +const computeCentroids = (features) => { + const map = new Map() + for (const f of features) { + map.set(f.id, centroid(f).geometry.coordinates) // [lng, lat] + } + return map +} + +// Returns Map> +const computeDistanceMatrix = (ids, centroids) => { + const matrix = new Map() + for (let i = 0; i < ids.length; i++) { + const row = new Map() + for (let j = 0; j < ids.length; j++) { + if (i === j) { + row.set(ids[j], 0) + continue + } + // @turf/distance returns km; convert to metres for callers + const from = { type: 'Feature', geometry: { type: 'Point', coordinates: centroids.get(ids[i]) } } + const to = { type: 'Feature', geometry: { type: 'Point', coordinates: centroids.get(ids[j]) } } + row.set(ids[j], distance(from, to) * 1000) + } + matrix.set(ids[i], row) + } + return matrix +} + +const buildDistanceBandNeighbors = (ids, distMatrix, threshold) => { + const neighbors = new Map(ids.map((id) => [id, new Set()])) + for (let i = 0; i < ids.length; i++) { + for (let j = 0; j < ids.length; j++) { + if (i === j) continue + if (distMatrix.get(ids[i]).get(ids[j]) <= threshold) { + neighbors.get(ids[i]).add(ids[j]) + } + } + } + return neighbors +} + +const buildKnnNeighbors = (ids, distMatrix, k) => { + const neighbors = new Map() + for (const id of ids) { + const sorted = ids + .filter((other) => other !== id) + .sort((a, b) => distMatrix.get(id).get(a) - distMatrix.get(id).get(b)) + neighbors.set(id, new Set(sorted.slice(0, k))) + } + return neighbors +} + +// --------------------------------------------------------------------------- +// Seedable RNG — mulberry32 (Vigna 2017) +// --------------------------------------------------------------------------- + +// Produces a seeded PRNG function that returns floats in [0, 1). +// Used for LISA conditional permutations to ensure reproducibility. +const mulberry32 = (seed) => { + let s = seed >>> 0 + return () => { + s += 0x6d2b79f5 + let t = Math.imul(s ^ (s >>> 15), 1 | s) + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) >>> 0 + return ((t ^ (t >>> 14)) >>> 0) / 4294967296 + } +} + +// Fisher-Yates shuffle using a supplied RNG +const shuffle = (array, rng) => { + const a = array.slice() + for (let i = a.length - 1; i > 0; i--) { + const j = Math.floor(rng() * (i + 1)); + [a[i], a[j]] = [a[j], a[i]] + } + return a +} + +// --------------------------------------------------------------------------- +// Multiple-comparison correction +// --------------------------------------------------------------------------- + +/** + * Apply Benjamini-Hochberg FDR correction. + * Returns an array of adjusted p-values (same order as input). + */ +const bhFdr = (pValues) => { + const n = pValues.length + const indexed = pValues.map((p, i) => ({ p, i })) + indexed.sort((a, b) => b.p - a.p) // descending + + const adjusted = new Array(n) + let minSoFar = 1 + for (let rank = 0; rank < n; rank++) { + const { p, i } = indexed[rank] + const adj = Math.min(1, (p * n) / (n - rank)) + minSoFar = Math.min(minSoFar, adj) + adjusted[i] = minSoFar + } + return adjusted +} + +/** + * Apply Bonferroni correction. + * Returns an array of adjusted p-values (same order as input). + */ +const bonferroni = (pValues) => + pValues.map((p) => Math.min(1, p * pValues.length)) + +const applyCorrection = (pValues, correction) => { + if (correction === 'fdr') return bhFdr(pValues) + if (correction === 'bonferroni') return bonferroni(pValues) + return pValues +} + +// --------------------------------------------------------------------------- +// Normal distribution helpers +// --------------------------------------------------------------------------- + +// Abramowitz & Stegun approximation for the standard normal CDF +const normalCdf = (z) => { + const t = 1 / (1 + 0.2316419 * Math.abs(z)) + const poly = + t * + (0.319381530 + + t * + (-0.356563782 + + t * (1.781477937 + t * (-1.821255978 + t * 1.330274429)))) + const p = 1 - (1 / Math.sqrt(2 * Math.PI)) * Math.exp(-0.5 * z * z) * poly + return z >= 0 ? p : 1 - p +} + +// Two-sided p from a z-score +const twoSidedP = (z) => 2 * (1 - normalCdf(Math.abs(z))) + +// --------------------------------------------------------------------------- +// Getis-Ord Gi* +// --------------------------------------------------------------------------- + +/** + * Compute Getis-Ord Gi* for each feature. + * + * Gi* is the "star" variant — it includes the focal unit i in its own + * neighbor set (unlike Gi which excludes it). + * + * @param {Map} valueById + * @param {{ neighbors: Map, weights: Map, noNeighborIds: id[] }} spatialWeights + * @param {object} [opts] + * @param {number} [opts.alpha=0.05] + * @param {string} [opts.correction='fdr'] 'fdr' | 'bonferroni' | 'none' + * @returns {Map} + */ +export const getGiStar = ( + valueById, + { neighbors, weights, noNeighborIds }, + { alpha = 0.05, correction = 'fdr' } = {} +) => { + const ids = [...neighbors.keys()] + const noNeighborSet = new Set(noNeighborIds) + + // Units with no value are excluded from the global mean/variance + const values = ids + .filter((id) => !noNeighborSet.has(id) && valueById[id] !== undefined && !Number.isNaN(valueById[id])) + .map((id) => valueById[id]) + + if (values.length === 0) { + const result = new Map() + for (const id of ids) { + result.set(id, { z: null, p: null, significant: false }) + } + return result + } + + const n = values.length + const xBar = values.reduce((s, v) => s + v, 0) / n + const s = Math.sqrt(values.reduce((s, v) => s + (v - xBar) ** 2, 0) / n) + + // Ord & Getis (1995) Gi* formula + // Gi* = (Σⱼ wᵢⱼ* xⱼ - xBar Σⱼ wᵢⱼ*) / (s * sqrt((n Σⱼ wᵢⱼ*² - (Σⱼ wᵢⱼ*)²) / (n-1))) + // where wᵢⱼ* includes self (wᵢᵢ* = 1 before row-standardization, then re-standardized) + // + // Because the weights object was built WITHOUT self, we add self here temporarily. + // We re-standardize the extended row so the formula is invariant to the original + // row-standardization of the neighbors-only weights. + + const rawZscores = [] + const rawPvalues = [] + const computedIds = [] + + for (const id of ids) { + if (noNeighborSet.has(id) || valueById[id] === undefined || Number.isNaN(valueById[id])) { + continue + } + + const nb = neighbors.get(id) + // Gi* extended neighbor set: original neighbors + self + const extNb = [...nb, id] + const wStar = 1 / extNb.length // row-standardize including self + + let wStarSum = 0 + let wStarSumXj = 0 + let wStarSumSq = 0 + + for (const nbId of extNb) { + const xj = valueById[nbId] + if (xj === undefined || Number.isNaN(xj)) continue + wStarSum += wStar + wStarSumXj += wStar * xj + wStarSumSq += wStar * wStar + } + + const numerator = wStarSumXj - xBar * wStarSum + const denominator = + s * + Math.sqrt( + (n * wStarSumSq - wStarSum * wStarSum) / (n - 1) + ) + + const z = denominator === 0 ? 0 : numerator / denominator + const p = twoSidedP(z) + + rawZscores.push(z) + rawPvalues.push(p) + computedIds.push(id) + } + + const adjustedP = applyCorrection(rawPvalues, correction) + + const result = new Map() + + // No-neighbor and no-value units + for (const id of ids) { + if (!computedIds.includes(id)) { + result.set(id, { z: null, p: null, significant: false }) + } + } + + for (let i = 0; i < computedIds.length; i++) { + result.set(computedIds[i], { + z: rawZscores[i], + p: adjustedP[i], + significant: adjustedP[i] < alpha, + }) + } + + return result +} + +// --------------------------------------------------------------------------- +// Local Moran's I (LISA) +// --------------------------------------------------------------------------- + +/** + * Compute Local Moran's I (LISA) for each feature. + * + * Uses conditional permutation for pseudo-p values. Self is EXCLUDED from the + * permutation pool (following PySAL esda convention; see esda issue #86). + * + * @param {Map} valueById + * @param {{ neighbors: Map, weights: Map, noNeighborIds: id[] }} spatialWeights + * @param {object} [opts] + * @param {number} [opts.permutations=999] + * @param {number} [opts.alpha=0.05] + * @param {string} [opts.correction='fdr'] 'fdr' | 'bonferroni' | 'none' + * @param {number} [opts.seed=42] + * @param {string} [opts.quadrantScheme='geoda'] 'geoda' | 'pysal' + * @returns {Map} + */ +export const getLisa = ( + valueById, + { neighbors, weights, noNeighborIds }, + { + permutations = 999, + alpha = 0.05, + correction = 'fdr', + seed = 42, + quadrantScheme = 'geoda', + } = {} +) => { + const ids = [...neighbors.keys()] + const noNeighborSet = new Set(noNeighborIds) + + // Units with values (excludes no-neighbor and missing) + const validIds = ids.filter( + (id) => + !noNeighborSet.has(id) && + valueById[id] !== undefined && + !Number.isNaN(valueById[id]) + ) + + if (validIds.length === 0) { + const result = new Map() + for (const id of ids) { + result.set(id, { I: null, z: null, pPseudo: null, cluster: 'NS', significant: false }) + } + return result + } + + // Z-standardize values + const mean = validIds.reduce((s, id) => s + valueById[id], 0) / validIds.length + const variance = + validIds.reduce((s, id) => s + (valueById[id] - mean) ** 2, 0) / + validIds.length + const std = Math.sqrt(variance) + + const zById = new Map() + for (const id of validIds) { + zById.set(id, std === 0 ? 0 : (valueById[id] - mean) / std) + } + + // Compute observed Local Moran's I for each unit + const observedI = new Map() + for (const id of validIds) { + const zi = zById.get(id) + const nb = neighbors.get(id) + const wRow = weights.get(id) + let lag = 0 + for (let j = 0; j < nb.length; j++) { + const zj = zById.get(nb[j]) + if (zj !== undefined) { + lag += wRow[j] * zj + } + } + observedI.set(id, zi * lag) + } + + // Conditional permutation: for each unit i, hold zᵢ fixed and permute the + // remaining N-1 z-values (excluding i) across all other positions, then + // recompute the spatial lag for i's neighbors. + const rng = mulberry32(seed) + const otherIdsPool = validIds // permutation draws from all valid ids + + const pseudoPvalues = new Map() + + for (const id of validIds) { + const zi = zById.get(id) + const nb = neighbors.get(id) + const wRow = weights.get(id) + const observed = observedI.get(id) + + // Pool excludes self — this is the key correctness requirement + const pool = otherIdsPool.filter((other) => other !== id).map((other) => zById.get(other)) + + let countMore = 0 + let countLess = 0 + + for (let p = 0; p < permutations; p++) { + const permuted = shuffle(pool, rng) + let permLag = 0 + for (let j = 0; j < nb.length; j++) { + const permZ = permuted[j] ?? 0 + permLag += wRow[j] * permZ + } + const permI = zi * permLag + if (permI >= observed) countMore++ + if (permI <= observed) countLess++ + } + + // Two-sided pseudo-p: fold both tails, cap at 1. + // Following esda convention: p = 2 * min(count_above, count_below) / permutations, + // capped at 1 because the two-sided fold can otherwise exceed 1 for units near + // the center of the permutation distribution. + const pOneSided = Math.min(countMore, countLess) / permutations + pseudoPvalues.set(id, Math.min(1, 2 * pOneSided)) + } + + // Apply multiple-comparison correction + const pArr = validIds.map((id) => pseudoPvalues.get(id)) + const adjustedArr = applyCorrection(pArr, correction) + const adjustedP = new Map(validIds.map((id, i) => [id, adjustedArr[i]])) + + // Derive cluster quadrant + const getCluster = (id, significant) => { + if (!significant) return 'NS' + const zi = zById.get(id) + const nb = neighbors.get(id) + const wRow = weights.get(id) + let lag = 0 + for (let j = 0; j < nb.length; j++) { + const zj = zById.get(nb[j]) + if (zj !== undefined) lag += wRow[j] * zj + } + // GeoDa: HH=1, LL=2, LH=3, HL=4 + // PySAL: HH=1, LH=2, LL=3, HL=4 + // Both label high-high and low-low the same; differ on LH vs HL numbering only. + // We use descriptive strings so the scheme only affects the label order in the legend. + if (zi > 0 && lag > 0) return 'HH' + if (zi < 0 && lag < 0) return 'LL' + if (quadrantScheme === 'pysal') { + if (zi > 0 && lag < 0) return 'HL' + return 'LH' + } + // GeoDa (default) + if (zi < 0 && lag > 0) return 'LH' + return 'HL' + } + + const result = new Map() + + // No-neighbor and missing-value units + for (const id of ids) { + if (!validIds.includes(id)) { + result.set(id, { I: null, z: null, pPseudo: null, cluster: 'NS', significant: false }) + } + } + + for (const id of validIds) { + const adj = adjustedP.get(id) + const significant = adj < alpha + result.set(id, { + I: observedI.get(id), + z: zById.get(id), + pPseudo: adj, + cluster: getCluster(id, significant), + significant, + }) + } + + return result +} From cf47cc16ca5b42bf05d4041c9c0df185d927e1e2 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault Date: Thu, 18 Jun 2026 12:26:39 +0200 Subject: [PATCH 04/12] feat(legend): add spatial analysis legend support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- src/util/colors.js | 4 ++++ src/util/legend.js | 12 ++++++++++++ 2 files changed, 16 insertions(+) diff --git a/src/util/colors.js b/src/util/colors.js index 810f5a4799..fb1579ed47 100644 --- a/src/util/colors.js +++ b/src/util/colors.js @@ -64,6 +64,10 @@ export const getColorScale = (palette) => colorbrewer[name][palette.length].join(',') === palette.join(',') ) +// Diverging scale used for Gi* z-scores: blue (cold/low) → white → red (hot/high). +// The _reverse suffix from colorbrewer.js puts red on the high end. +export const SPATIAL_GI_COLOR_SCALE = 'RdBu_reverse' + export const defaultColorScaleName = 'YlOrBr' export const defaultClasses = 5 export const defaultColorScale = getColorPalette( diff --git a/src/util/legend.js b/src/util/legend.js index 0c4d87bdbd..2090a8c743 100644 --- a/src/util/legend.js +++ b/src/util/legend.js @@ -289,3 +289,15 @@ export const legendNamesContainRange = (items) => { return itemsWithRange.length / numericItems.length >= 0.5 } + +// LISA cluster legend — 5 fixed items in the standard { name, color, count } shape. +// Colors follow GeoDa convention. `cluster` is stored for lookup by category key. +// HH/LL/HL/LH are the same in both GeoDa and PySAL quadrant schemes; +// only the numbering (not the color assignment) differs between schemes. +export const buildLisaLegendItems = () => [ + { cluster: 'HH', name: i18n.t('High-High'), color: '#d7191c', count: 0 }, + { cluster: 'LL', name: i18n.t('Low-Low'), color: '#2c7bb6', count: 0 }, + { cluster: 'HL', name: i18n.t('High-Low'), color: '#fdae61', count: 0 }, + { cluster: 'LH', name: i18n.t('Low-High'), color: '#abd9e9', count: 0 }, + { cluster: 'NS', name: i18n.t('Not significant'), color: '#aaaaaa', count: 0 }, +] From a395b025af0e25481d51b53c4222af2fa96e04ec Mon Sep 17 00:00:00 2001 From: Bruno Raimbault Date: Thu, 18 Jun 2026 12:31:29 +0200 Subject: [PATCH 05/12] feat(loader): integrate spatial analysis into thematicLoader with guardrails MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/loaders/thematicLoader.js | 182 +++++++++++++++++++++++++++++++++- 1 file changed, 180 insertions(+), 2 deletions(-) diff --git a/src/loaders/thematicLoader.js b/src/loaders/thematicLoader.js index 4e4998c125..e07876b339 100644 --- a/src/loaders/thematicLoader.js +++ b/src/loaders/thematicLoader.js @@ -1,3 +1,4 @@ +import { DIMENSION_TYPE_DATA_ELEMENT } from '@dhis2/analytics' import i18n from '@dhis2/d2-i18n' import { scaleSqrt } from 'd3-scale' import { findIndex } from 'lodash/fp' @@ -7,6 +8,10 @@ import { WARNING_NO_GEOMETRY_COORD, ERROR_CRITICAL, CUSTOM_ALERT, + WARNING_RATE_DENOMINATOR, + WARNING_LOW_N, + WARNING_NO_NEIGHBORS, + WARNING_SPARSE_COORDS, } from '../constants/alerts.js' import { dimConf } from '../constants/dimension.js' import { EVENT_STATUS_COMPLETED } from '../constants/eventStatuses.js' @@ -22,6 +27,9 @@ import { CLASSIFICATION_STANDARD_DEVIATION, ORG_UNIT_COLOR, ORG_UNIT_RADIUS_SMALL, + SPATIAL_NONE, + SPATIAL_GI, + SPATIAL_LISA, } from '../constants/layers.js' import { getOrgUnitsFromRows, @@ -32,6 +40,7 @@ import { applyPeriodFilter, } from '../util/analytics.js' import { getLegendItemForValue } from '../util/classify.js' +import { getColorPalette, SPATIAL_GI_COLOR_SCALE } from '../util/colors.js' import { parseJsonConfig } from '../util/config.js' import { hasValue } from '../util/helpers.js' import { @@ -39,7 +48,9 @@ import { getAutomaticLegendItems, buildIsolatedLegendItem, isRegularLegendItem, + buildLisaLegendItems, } from '../util/legend.js' +import { buildSpatialWeights, getGiStar, getLisa } from '../util/spatialStats.js' import { toGeoJson } from '../util/map.js' import { formatRangeWithSeparator, @@ -185,6 +196,26 @@ const thematicLoader = async ({ const [mainFeatures, data, associatedGeometries] = response const { valueById, rawValueById } = getValueMapsById(data) + // Spatial analysis — gated by config.spatialAnalysis.method + // When active, a per-unit statistic (Gi* z-score or LISA cluster) replaces the + // raw value as the styling driver. Raw value is still shown in popup/data table. + let spatialStats = null + let spatialWeights = null + const spatialMethod = config.spatialAnalysis?.method + const isSpatialAnalysis = + spatialMethod && spatialMethod !== SPATIAL_NONE && isSingleMap + + if (isSpatialAnalysis) { + spatialWeights = buildSpatialWeights( + mainFeatures || [], + config.spatialAnalysis + ) + spatialStats = + spatialMethod === SPATIAL_GI + ? getGiStar(valueById, spatialWeights, config.spatialAnalysis) + : getLisa(valueById, spatialWeights, config.spatialAnalysis) + } + if (config.countFeaturesWithoutCoordinates) { const result = await getOrgUnitsWithoutCoordsCount({ engine, @@ -284,13 +315,102 @@ const thematicLoader = async ({ }) } + // Spatial analysis guardrails + if (isSpatialAnalysis && spatialWeights) { + const n = features.length + + // Low-N warning: pseudo-p from permutation is unreliable with few units + if (n > 0 && n < 30) { + alerts.push({ + warning: true, + code: WARNING_LOW_N, + message: i18n.t( + 'Spatial analysis results may be unreliable with fewer than 30 units ({{n}} found)', + { n } + ), + }) + } + + // No-neighbor warning: islands bias spatial statistics + if (spatialWeights.noNeighborIds.length > 0) { + alerts.push({ + warning: true, + code: WARNING_NO_NEIGHBORS, + message: i18n.t( + '{{count}} unit(s) have no spatial neighbors and are excluded from the analysis', + { count: spatialWeights.noNeighborIds.length } + ), + }) + } + + // Sparse-coordinates warning: >10% of selected org units lack geometry + if ( + typeof orgUnitsWithoutCoordsCount === 'number' && + orgUnitIds.length > 0 && + orgUnitsWithoutCoordsCount / orgUnitIds.length > 0.1 + ) { + alerts.push({ + warning: true, + code: WARNING_SPARSE_COORDS, + message: i18n.t( + '{{count}} of {{total}} org units lack coordinates — spatial weights may be unreliable', + { + count: orgUnitsWithoutCoordsCount, + total: orgUnitIds.length, + } + ), + }) + } + + // Rate-denominator prompt: raw counts make hotspots largely rediscover population + const isCount = + dataItem?.dimensionItemType === DIMENSION_TYPE_DATA_ELEMENT && + !/rate|coverage|proportion|per\b|ratio|prevalence/i.test( + dataItem?.name ?? '' + ) + if (isCount) { + alerts.push({ + warning: true, + code: WARNING_RATE_DENOMINATOR, + message: i18n.t( + 'Hotspot analysis on raw counts may reflect population distribution rather than true clustering. Consider using a rate or population-at-risk denominator.' + ), + }) + } + } + // Legend // ----- let legendItems = [] let valueFormat - if (isPredefined) { + if (isSpatialAnalysis && spatialStats) { + // Spatial analysis overrides normal classification. + // Gi*: diverging choropleth over z-scores using standard-deviation binning. + // LISA: fixed 5-category map (HH/LL/HL/LH/NS) with GeoDa colors. + if (spatialMethod === SPATIAL_GI) { + const giClasses = classes || 7 + const colorScale = getColorPalette(SPATIAL_GI_COLOR_SCALE, giClasses) + const zValues = [...spatialStats.values()] + .map((s) => s.z) + .filter((z) => z !== null && Number.isFinite(z)) + .sort((a, b) => a - b) + if (zValues.length > 0) { + const classification = getAutomaticLegendItems({ + data: zValues, + method: CLASSIFICATION_STANDARD_DEVIATION, + classes: giClasses, + colorScale, + }) + legendItems = classification.items + valueFormat = classification.valueFormat + } + } else { + // LISA — categorical, no numeric range + legendItems = buildLisaLegendItems() + } + } else if (isPredefined) { legendItems = getPredefinedLegendItems(legendSet) } else if (!isSingleColor) { const classification = getAutomaticLegendItems({ @@ -473,8 +593,66 @@ const thematicLoader = async ({ // Style and filter features in place features = features.flatMap(({ id, geometry, properties }) => { const value = valueById[id] - const legendItem = getLegendItem(value) const isNoData = !hasValue(value) + + // Spatial analysis path: derive legend item from stat rather than raw value + if (isSpatialAnalysis && spatialStats) { + const stat = spatialStats.get(id) + const isNoNeighbor = stat?.z === null && stat?.I === null + + let legendItem + if (isNoNeighbor) { + // No-neighbor units are shown in grey and kept out of legend counts + legendItem = null + } else if (spatialMethod === SPATIAL_GI) { + legendItem = getLegendItem(stat?.z) + } else { + // LISA: categorical lookup by cluster key + legendItem = + legendItems.find((item) => item.cluster === stat?.cluster) ?? null + } + + const isPoint = geometry.type === 'Point' + const { hasAdditionalGeometry } = properties + + Object.assign(properties, { + color: isNoNeighbor ? '#cccccc' : (legendItem?.color ?? '#cccccc'), + legend: isNoNeighbor + ? i18n.t('No neighbors') + : (legendItem?.name ?? ''), + radius: THEMATIC_RADIUS_DEFAULT, + ...(hasAdditionalGeometry && isPoint && !isNoNeighbor && legendItem && { + color: ORG_UNIT_COLOR, + }), + ...(hasAdditionalGeometry && { radius: ORG_UNIT_RADIUS_SMALL }), + // Raw value stays for popup and data table display + value: Number.isFinite(value) + ? formatWithSeparator(value, keyAnalysisDigitGroupSeparator) + : rawValueById[id], + rawValue: value, + // Spatial stat properties for popup and data table + ...(spatialMethod === SPATIAL_GI && stat && { + spatialZ: stat.z, + spatialP: stat.p, + spatialSignificant: stat.significant, + }), + ...(spatialMethod === SPATIAL_LISA && stat && { + spatialCluster: stat.cluster, + spatialI: stat.I, + spatialP: stat.pPseudo, + spatialSignificant: stat.significant, + }), + }) + + if (!hasAdditionalGeometry && legendItem && !isNoNeighbor) { + legendItem.count++ + } + + return [{ id, geometry, properties }] + } + + // Standard (non-spatial) path + const legendItem = getLegendItem(value) const isUnclassified = !isSingleColor && !legendItem && !isNoData if (isNoData && !hasNoDataClass) { From 3fbc66bfeeb7f9dcb931f4ebe5143fcfe58e55c9 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault Date: Thu, 18 Jun 2026 12:35:19 +0200 Subject: [PATCH 06/12] feat(ui): add Spatial Analysis tab to ThematicDialog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/actions/layerEdit.js | 6 + .../edit/thematic/SpatialAnalysisSection.jsx | 337 ++++++++++++++++++ .../edit/thematic/ThematicDialog.jsx | 30 +- src/constants/actionTypes.js | 1 + src/reducers/layerEdit.js | 9 + 5 files changed, 382 insertions(+), 1 deletion(-) create mode 100644 src/components/edit/thematic/SpatialAnalysisSection.jsx diff --git a/src/actions/layerEdit.js b/src/actions/layerEdit.js index 988c73484e..b40c28e3a5 100644 --- a/src/actions/layerEdit.js +++ b/src/actions/layerEdit.js @@ -390,3 +390,9 @@ export const setLabelDataItem = (item) => ({ type: types.LAYER_EDIT_LABEL_DATA_ITEM_ID_SET, item, }) + +// Set spatial analysis configuration (thematic layer) +export const setSpatialAnalysis = (payload) => ({ + type: types.LAYER_EDIT_SPATIAL_ANALYSIS_SET, + payload, +}) diff --git a/src/components/edit/thematic/SpatialAnalysisSection.jsx b/src/components/edit/thematic/SpatialAnalysisSection.jsx new file mode 100644 index 0000000000..e14dafb1fa --- /dev/null +++ b/src/components/edit/thematic/SpatialAnalysisSection.jsx @@ -0,0 +1,337 @@ +import i18n from '@dhis2/d2-i18n' +import { + NoticeBox, + SingleSelectField, + SingleSelectOption, + InputField, + Checkbox, + Button, +} from '@dhis2/ui' +import PropTypes from 'prop-types' +import React, { useState, useCallback } from 'react' +import { useDispatch, useSelector } from 'react-redux' +import { setSpatialAnalysis } from '../../../actions/layerEdit.js' +import { + SPATIAL_NONE, + SPATIAL_GI, + SPATIAL_LISA, + WEIGHTS_CONTIGUITY, + WEIGHTS_DISTANCE_BAND, + WEIGHTS_KNN, +} from '../../../constants/layers.js' +import styles from '../styles/LayerDialog.module.css' + +// Generates a simple integer seed from the current time. +const generateSeed = () => Math.floor(Date.now() % 1_000_000) + +const METHODS = [ + { value: SPATIAL_NONE, label: i18n.t('Off') }, + { value: SPATIAL_GI, label: i18n.t('Getis-Ord Gi*') }, + { value: SPATIAL_LISA, label: i18n.t('Local Moran\'s I (LISA)') }, +] + +const WEIGHT_TYPES = [ + { value: WEIGHTS_CONTIGUITY, label: i18n.t('Contiguity (queen)') }, + { value: `${WEIGHTS_CONTIGUITY}_rook`, label: i18n.t('Contiguity (rook)') }, + { value: WEIGHTS_DISTANCE_BAND, label: i18n.t('Distance band') }, + { value: WEIGHTS_KNN, label: i18n.t('k-nearest neighbours') }, +] + +const ALPHA_OPTIONS = [ + { value: '0.10', label: '0.10' }, + { value: '0.05', label: '0.05 (default)' }, + { value: '0.01', label: '0.01' }, +] + +const PERMUTATION_OPTIONS = [ + { value: '99', label: '99' }, + { value: '999', label: '999 (default)' }, + { value: '9999', label: '9999' }, +] + +const CORRECTION_OPTIONS = [ + { value: 'fdr', label: i18n.t('FDR (recommended)') }, + { value: 'bonferroni', label: i18n.t('Bonferroni') }, + { value: 'none', label: i18n.t('None') }, +] + +const QUADRANT_OPTIONS = [ + { value: 'geoda', label: i18n.t('GeoDa (default)') }, + { value: 'pysal', label: i18n.t('PySAL') }, +] + +const SpatialAnalysisSection = ({ dataItem, isCountLikeData }) => { + const dispatch = useDispatch() + const spatialAnalysis = useSelector( + (state) => state.layerEdit.spatialAnalysis ?? {} + ) + const [showAdvanced, setShowAdvanced] = useState(false) + + const { + method = SPATIAL_NONE, + weightType: rawWeightType = WEIGHTS_CONTIGUITY, + distanceMeters, + k = 8, + alpha = 0.05, + permutations = 999, + correction = 'fdr', + quadrantScheme = 'geoda', + rowStandardize = true, + seed, + } = spatialAnalysis + + // Encode contiguity + queen/rook into a single select value + const weightSelectValue = + rawWeightType === WEIGHTS_CONTIGUITY + ? (spatialAnalysis.contiguityType === 'rook' + ? `${WEIGHTS_CONTIGUITY}_rook` + : WEIGHTS_CONTIGUITY) + : rawWeightType + + const update = useCallback( + (patch) => dispatch(setSpatialAnalysis(patch)), + [dispatch] + ) + + const handleWeightTypeChange = (value) => { + if (value === `${WEIGHTS_CONTIGUITY}_rook`) { + update({ weightType: WEIGHTS_CONTIGUITY, contiguityType: 'rook' }) + } else if (value === WEIGHTS_CONTIGUITY) { + update({ weightType: WEIGHTS_CONTIGUITY, contiguityType: 'queen' }) + } else { + update({ weightType: value, contiguityType: undefined }) + } + } + + const isActive = method !== SPATIAL_NONE + const isLisa = method === SPATIAL_LISA + const isDistanceBand = rawWeightType === WEIGHTS_DISTANCE_BAND + const isKnn = rawWeightType === WEIGHTS_KNN + + // Auto-assign seed on first activation + const handleMethodChange = (value) => { + const patch = { method: value } + if (value !== SPATIAL_NONE && !seed) { + patch.seed = generateSeed() + } + update(patch) + } + + return ( +
+ {isActive && isCountLikeData && ( + + {i18n.t( + 'Hotspot analysis on raw counts often reflects population distribution rather than true spatial clustering. Consider using a rate or per-capita indicator.' + )} + + )} + +
+
+ handleMethodChange(selected)} + dataTest="spatialanalysis-method" + > + {METHODS.map(({ value, label }) => ( + + ))} + + + {isActive && ( + <> + + handleWeightTypeChange(selected) + } + dataTest="spatialanalysis-weights" + > + {WEIGHT_TYPES.map(({ value, label }) => ( + + ))} + + + {isDistanceBand && ( + + update({ + distanceMeters: value + ? Number(value) + : undefined, + }) + } + dataTest="spatialanalysis-distance" + /> + )} + + {isKnn && ( + + update({ k: Math.max(1, Math.min(20, Number(value))) }) + } + dataTest="spatialanalysis-k" + /> + )} + + + update({ alpha: Number(selected) }) + } + dataTest="spatialanalysis-alpha" + > + {ALPHA_OPTIONS.map(({ value, label }) => ( + + ))} + + + + + {showAdvanced && ( +
+ {isLisa && ( + + update({ + permutations: Number(selected), + }) + } + dataTest="spatialanalysis-permutations" + > + {PERMUTATION_OPTIONS.map( + ({ value, label }) => ( + + ) + )} + + )} + + + update({ correction: selected }) + } + dataTest="spatialanalysis-correction" + > + {CORRECTION_OPTIONS.map( + ({ value, label }) => ( + + ) + )} + + + {isLisa && ( + + update({ + quadrantScheme: selected, + }) + } + dataTest="spatialanalysis-quadrant" + > + {QUADRANT_OPTIONS.map( + ({ value, label }) => ( + + ) + )} + + )} + + + update({ rowStandardize: checked }) + } + dataTest="spatialanalysis-rowstandardize" + /> + + + update({ + seed: value + ? Number(value) + : undefined, + }) + } + dataTest="spatialanalysis-seed" + /> +
+ )} + + )} +
+
+
+ ) +} + +SpatialAnalysisSection.propTypes = { + dataItem: PropTypes.object, + isCountLikeData: PropTypes.bool, +} + +export default SpatialAnalysisSection diff --git a/src/components/edit/thematic/ThematicDialog.jsx b/src/components/edit/thematic/ThematicDialog.jsx index 7e3c2910d0..140bf3f71f 100644 --- a/src/components/edit/thematic/ThematicDialog.jsx +++ b/src/components/edit/thematic/ThematicDialog.jsx @@ -1,4 +1,4 @@ -import { DataDimension, PeriodDimension } from '@dhis2/analytics' +import { DataDimension, PeriodDimension, DIMENSION_TYPE_DATA_ELEMENT } from '@dhis2/analytics' import i18n from '@dhis2/d2-i18n' import { SegmentedControl, IconErrorFilled24 } from '@dhis2/ui' import cx from 'classnames' @@ -25,6 +25,7 @@ import { RENDERING_STRATEGY_SINGLE, RENDERING_STRATEGY_TIMELINE, RENDERING_STRATEGY_SPLIT_BY_PERIOD, + SPATIAL_NONE, } from '../../../constants/layers.js' import { PREDEFINED_PERIODS, @@ -52,6 +53,7 @@ import AggregationTypeSelect from './AggregationTypeSelect.jsx' import CompletedOnlyCheckbox from './CompletedOnlyCheckbox.jsx' import { initializeThematicLayer } from './initializeThematicLayer.js' import RadiusSelect from './RadiusSelect.jsx' +import SpatialAnalysisSection from './SpatialAnalysisSection.jsx' import ThematicMapTypeSelect from './ThematicMapTypeSelect.jsx' import { validateThematicLayer } from './validateThematicLayer.js' @@ -341,6 +343,14 @@ const ThematicDialog = ({ {i18n.t('Style')} + {renderingStrategy === RENDERING_STRATEGY_SINGLE && ( + + {i18n.t('Analysis')} + + )}
{tab === 'data' && ( @@ -531,6 +541,24 @@ const ThematicDialog = ({
)} + {tab === 'analysis' && ( +
+ +
+ )} + {tab === 'style' && (
{ }, } + case types.LAYER_EDIT_SPATIAL_ANALYSIS_SET: + return { + ...state, + spatialAnalysis: { + ...state.spatialAnalysis, + ...action.payload, + }, + } + default: return state } From 2ad1fd017b9953662e1b4f068e16847b7baca5ed Mon Sep 17 00:00:00 2001 From: Bruno Raimbault Date: Thu, 18 Jun 2026 12:47:28 +0200 Subject: [PATCH 07/12] fix(lint): resolve eslint errors across spatial analysis files - 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 --- .../edit/thematic/SpatialAnalysisSection.jsx | 37 +++-- .../edit/thematic/ThematicDialog.jsx | 8 +- src/loaders/thematicLoader.js | 63 +++++--- src/util/__tests__/spatialStats.spec.js | 94 +++++++++--- src/util/legend.js | 7 +- src/util/spatialStats.js | 141 ++++++++++++------ 6 files changed, 248 insertions(+), 102 deletions(-) diff --git a/src/components/edit/thematic/SpatialAnalysisSection.jsx b/src/components/edit/thematic/SpatialAnalysisSection.jsx index e14dafb1fa..01234546ba 100644 --- a/src/components/edit/thematic/SpatialAnalysisSection.jsx +++ b/src/components/edit/thematic/SpatialAnalysisSection.jsx @@ -27,7 +27,7 @@ const generateSeed = () => Math.floor(Date.now() % 1_000_000) const METHODS = [ { value: SPATIAL_NONE, label: i18n.t('Off') }, { value: SPATIAL_GI, label: i18n.t('Getis-Ord Gi*') }, - { value: SPATIAL_LISA, label: i18n.t('Local Moran\'s I (LISA)') }, + { value: SPATIAL_LISA, label: i18n.t("Local Moran's I (LISA)") }, ] const WEIGHT_TYPES = [ @@ -60,7 +60,7 @@ const QUADRANT_OPTIONS = [ { value: 'pysal', label: i18n.t('PySAL') }, ] -const SpatialAnalysisSection = ({ dataItem, isCountLikeData }) => { +const SpatialAnalysisSection = ({ isCountLikeData }) => { const dispatch = useDispatch() const spatialAnalysis = useSelector( (state) => state.layerEdit.spatialAnalysis ?? {} @@ -83,9 +83,9 @@ const SpatialAnalysisSection = ({ dataItem, isCountLikeData }) => { // Encode contiguity + queen/rook into a single select value const weightSelectValue = rawWeightType === WEIGHTS_CONTIGUITY - ? (spatialAnalysis.contiguityType === 'rook' + ? spatialAnalysis.contiguityType === 'rook' ? `${WEIGHTS_CONTIGUITY}_rook` - : WEIGHTS_CONTIGUITY) + : WEIGHTS_CONTIGUITY : rawWeightType const update = useCallback( @@ -132,7 +132,9 @@ const SpatialAnalysisSection = ({ dataItem, isCountLikeData }) => { handleMethodChange(selected)} + onChange={({ selected }) => + handleMethodChange(selected) + } dataTest="spatialanalysis-method" > {METHODS.map(({ value, label }) => ( @@ -165,7 +167,9 @@ const SpatialAnalysisSection = ({ dataItem, isCountLikeData }) => { {isDistanceBand && ( { max="20" value={String(k)} onChange={({ value }) => - update({ k: Math.max(1, Math.min(20, Number(value))) }) + update({ + k: Math.max( + 1, + Math.min(20, Number(value)) + ), + }) } dataTest="spatialanalysis-k" /> @@ -236,7 +245,8 @@ const SpatialAnalysisSection = ({ dataItem, isCountLikeData }) => { selected={String(permutations)} onChange={({ selected }) => update({ - permutations: Number(selected), + permutations: + Number(selected), }) } dataTest="spatialanalysis-permutations" @@ -298,7 +308,9 @@ const SpatialAnalysisSection = ({ dataItem, isCountLikeData }) => { )} update({ rowStandardize: checked }) @@ -309,7 +321,11 @@ const SpatialAnalysisSection = ({ dataItem, isCountLikeData }) => { update({ seed: value @@ -330,7 +346,6 @@ const SpatialAnalysisSection = ({ dataItem, isCountLikeData }) => { } SpatialAnalysisSection.propTypes = { - dataItem: PropTypes.object, isCountLikeData: PropTypes.bool, } diff --git a/src/components/edit/thematic/ThematicDialog.jsx b/src/components/edit/thematic/ThematicDialog.jsx index 140bf3f71f..1e27e27fe4 100644 --- a/src/components/edit/thematic/ThematicDialog.jsx +++ b/src/components/edit/thematic/ThematicDialog.jsx @@ -1,4 +1,8 @@ -import { DataDimension, PeriodDimension, DIMENSION_TYPE_DATA_ELEMENT } from '@dhis2/analytics' +import { + DataDimension, + PeriodDimension, + DIMENSION_TYPE_DATA_ELEMENT, +} from '@dhis2/analytics' import i18n from '@dhis2/d2-i18n' import { SegmentedControl, IconErrorFilled24 } from '@dhis2/ui' import cx from 'classnames' @@ -25,7 +29,6 @@ import { RENDERING_STRATEGY_SINGLE, RENDERING_STRATEGY_TIMELINE, RENDERING_STRATEGY_SPLIT_BY_PERIOD, - SPATIAL_NONE, } from '../../../constants/layers.js' import { PREDEFINED_PERIODS, @@ -547,7 +550,6 @@ const ThematicDialog = ({ data-test="thematicdialog-analysistab" > s.z) .filter((z) => z !== null && Number.isFinite(z)) @@ -609,39 +616,53 @@ const thematicLoader = async ({ } else { // LISA: categorical lookup by cluster key legendItem = - legendItems.find((item) => item.cluster === stat?.cluster) ?? null + legendItems.find( + (item) => item.cluster === stat?.cluster + ) ?? null } const isPoint = geometry.type === 'Point' const { hasAdditionalGeometry } = properties Object.assign(properties, { - color: isNoNeighbor ? '#cccccc' : (legendItem?.color ?? '#cccccc'), + color: isNoNeighbor + ? '#cccccc' + : legendItem?.color ?? '#cccccc', legend: isNoNeighbor ? i18n.t('No neighbors') - : (legendItem?.name ?? ''), + : legendItem?.name ?? '', radius: THEMATIC_RADIUS_DEFAULT, - ...(hasAdditionalGeometry && isPoint && !isNoNeighbor && legendItem && { - color: ORG_UNIT_COLOR, + ...(hasAdditionalGeometry && + isPoint && + !isNoNeighbor && + legendItem && { + color: ORG_UNIT_COLOR, + }), + ...(hasAdditionalGeometry && { + radius: ORG_UNIT_RADIUS_SMALL, }), - ...(hasAdditionalGeometry && { radius: ORG_UNIT_RADIUS_SMALL }), // Raw value stays for popup and data table display value: Number.isFinite(value) - ? formatWithSeparator(value, keyAnalysisDigitGroupSeparator) + ? formatWithSeparator( + value, + keyAnalysisDigitGroupSeparator + ) : rawValueById[id], rawValue: value, // Spatial stat properties for popup and data table - ...(spatialMethod === SPATIAL_GI && stat && { - spatialZ: stat.z, - spatialP: stat.p, - spatialSignificant: stat.significant, - }), - ...(spatialMethod === SPATIAL_LISA && stat && { - spatialCluster: stat.cluster, - spatialI: stat.I, - spatialP: stat.pPseudo, - spatialSignificant: stat.significant, - }), + ...(spatialMethod === SPATIAL_GI && + stat && { + spatialZ: stat.z, + spatialP: stat.p, + spatialSignificant: stat.significant, + }), + ...(spatialMethod === SPATIAL_LISA && + stat && { + spatialCluster: stat.cluster, + spatialI: stat.I, + spatialP: stat.pPseudo, + spatialSignificant: stat.significant, + }), }) if (!hasAdditionalGeometry && legendItem && !isNoNeighbor) { diff --git a/src/util/__tests__/spatialStats.spec.js b/src/util/__tests__/spatialStats.spec.js index 7b3e1b550d..d7ec84c683 100644 --- a/src/util/__tests__/spatialStats.spec.js +++ b/src/util/__tests__/spatialStats.spec.js @@ -1,13 +1,9 @@ -import { - buildSpatialWeights, - getGiStar, - getLisa, -} from '../spatialStats.js' import { WEIGHTS_CONTIGUITY, WEIGHTS_DISTANCE_BAND, WEIGHTS_KNN, } from '../../constants/layers.js' +import { buildSpatialWeights, getGiStar, getLisa } from '../spatialStats.js' // --------------------------------------------------------------------------- // Test fixture @@ -72,7 +68,10 @@ describe('buildSpatialWeights — queen contiguity', () => { let result beforeAll(() => { - result = buildSpatialWeights(FEATURES, { type: WEIGHTS_CONTIGUITY, weightType: 'queen' }) + result = buildSpatialWeights(FEATURES, { + type: WEIGHTS_CONTIGUITY, + weightType: 'queen', + }) }) test('returns neighbors, weights, noNeighborIds', () => { @@ -103,8 +102,10 @@ describe('buildSpatialWeights — queen contiguity', () => { }) test('all rows sum to 1 (row-standardized)', () => { - for (const [id, w] of result.weights) { - if (w.length === 0) continue + for (const [, w] of result.weights) { + if (w.length === 0) { + continue + } const sum = w.reduce((s, v) => s + v, 0) expect(sum).toBeCloseTo(1, 10) } @@ -115,7 +116,10 @@ describe('buildSpatialWeights — rook contiguity', () => { let result beforeAll(() => { - result = buildSpatialWeights(FEATURES, { type: WEIGHTS_CONTIGUITY, weightType: 'rook' }) + result = buildSpatialWeights(FEATURES, { + type: WEIGHTS_CONTIGUITY, + weightType: 'rook', + }) }) test('A has rook neighbors B and C (not D — diagonal only)', () => { @@ -161,7 +165,10 @@ describe('buildSpatialWeights — distance band', () => { describe('buildSpatialWeights — kNN', () => { test('k=1 gives exactly one neighbor per unit', () => { - const result = buildSpatialWeights(FEATURES, { type: WEIGHTS_KNN, k: 1 }) + const result = buildSpatialWeights(FEATURES, { + type: WEIGHTS_KNN, + k: 1, + }) for (const [, nb] of result.neighbors) { expect(nb).toHaveLength(1) } @@ -170,7 +177,10 @@ describe('buildSpatialWeights — kNN', () => { }) test('k=2 gives exactly two neighbors per unit', () => { - const result = buildSpatialWeights(FEATURES, { type: WEIGHTS_KNN, k: 2 }) + const result = buildSpatialWeights(FEATURES, { + type: WEIGHTS_KNN, + k: 2, + }) for (const [, nb] of result.neighbors) { expect(nb).toHaveLength(2) } @@ -187,7 +197,10 @@ describe('getGiStar', () => { beforeAll(() => { // Use queen contiguity on the main 5 squares (exclude F so weights include F as island) - weights = buildSpatialWeights(FEATURES, { type: WEIGHTS_CONTIGUITY, weightType: 'queen' }) + weights = buildSpatialWeights(FEATURES, { + type: WEIGHTS_CONTIGUITY, + weightType: 'queen', + }) result = getGiStar(VALUES, weights, { alpha: 0.05, correction: 'none' }) }) @@ -204,7 +217,11 @@ describe('getGiStar', () => { }) test('island F gets z=null, p=null, significant=false', () => { - expect(result.get('F')).toEqual({ z: null, p: null, significant: false }) + expect(result.get('F')).toEqual({ + z: null, + p: null, + significant: false, + }) }) test('E (highest value, neighbors C+D) has positive z', () => { @@ -225,7 +242,9 @@ describe('getGiStar', () => { const uniform = { A: 5, B: 5, C: 5, D: 5, E: 5, F: 5 } const r = getGiStar(uniform, weights, { correction: 'none' }) for (const [id, v] of r) { - if (id === 'F') continue // island, z=null + if (id === 'F') { + continue + } // island, z=null expect(v.z).toBeCloseTo(0, 6) } }) @@ -239,7 +258,9 @@ describe('getGiStar', () => { test('p-values are in [0, 1]', () => { for (const [id, v] of result) { - if (id === 'F') continue + if (id === 'F') { + continue + } expect(v.p).toBeGreaterThanOrEqual(0) expect(v.p).toBeLessThanOrEqual(1) } @@ -255,7 +276,10 @@ describe('getLisa', () => { let result beforeAll(() => { - weights = buildSpatialWeights(FEATURES, { type: WEIGHTS_CONTIGUITY, weightType: 'queen' }) + weights = buildSpatialWeights(FEATURES, { + type: WEIGHTS_CONTIGUITY, + weightType: 'queen', + }) result = getLisa(VALUES, weights, { permutations: 999, alpha: 0.05, @@ -323,7 +347,9 @@ describe('getLisa', () => { // At least one p-value should differ (extremely unlikely they all match) let anyDiffer = false for (const [id, v] of result) { - if (id === 'F') continue + if (id === 'F') { + continue + } if (r2.get(id).pPseudo !== v.pPseudo) { anyDiffer = true break @@ -333,10 +359,22 @@ describe('getLisa', () => { }) test('quadrantScheme geoda and pysal agree on HH and LL', () => { - const geoda = getLisa(VALUES, weights, { permutations: 99, correction: 'none', seed: 1, quadrantScheme: 'geoda' }) - const pysal = getLisa(VALUES, weights, { permutations: 99, correction: 'none', seed: 1, quadrantScheme: 'pysal' }) + const geoda = getLisa(VALUES, weights, { + permutations: 99, + correction: 'none', + seed: 1, + quadrantScheme: 'geoda', + }) + const pysal = getLisa(VALUES, weights, { + permutations: 99, + correction: 'none', + seed: 1, + quadrantScheme: 'pysal', + }) for (const [id] of result) { - if (id === 'F') continue + if (id === 'F') { + continue + } const cg = geoda.get(id).cluster const cp = pysal.get(id).cluster if (cg === 'HH' || cg === 'LL') { @@ -347,7 +385,9 @@ describe('getLisa', () => { test('two-sided pseudo-p is in [0, 1]', () => { for (const [id, v] of result) { - if (id === 'F') continue + if (id === 'F') { + continue + } expect(v.pPseudo).toBeGreaterThanOrEqual(0) expect(v.pPseudo).toBeLessThanOrEqual(1) } @@ -362,13 +402,19 @@ describe('getLisa', () => { test('FDR correction does not increase any p-value beyond uncorrected', () => { const uncorrected = getLisa(VALUES, weights, { - permutations: 99, seed: 7, correction: 'none', + permutations: 99, + seed: 7, + correction: 'none', }) const corrected = getLisa(VALUES, weights, { - permutations: 99, seed: 7, correction: 'fdr', + permutations: 99, + seed: 7, + correction: 'fdr', }) for (const [id] of uncorrected) { - if (id === 'F') continue + if (id === 'F') { + continue + } expect(corrected.get(id).pPseudo).toBeGreaterThanOrEqual( uncorrected.get(id).pPseudo - 1e-10 ) diff --git a/src/util/legend.js b/src/util/legend.js index 2090a8c743..bcd6739ede 100644 --- a/src/util/legend.js +++ b/src/util/legend.js @@ -299,5 +299,10 @@ export const buildLisaLegendItems = () => [ { cluster: 'LL', name: i18n.t('Low-Low'), color: '#2c7bb6', count: 0 }, { cluster: 'HL', name: i18n.t('High-Low'), color: '#fdae61', count: 0 }, { cluster: 'LH', name: i18n.t('Low-High'), color: '#abd9e9', count: 0 }, - { cluster: 'NS', name: i18n.t('Not significant'), color: '#aaaaaa', count: 0 }, + { + cluster: 'NS', + name: i18n.t('Not significant'), + color: '#aaaaaa', + count: 0, + }, ] diff --git a/src/util/spatialStats.js b/src/util/spatialStats.js index e717ef4c5b..79b0892713 100644 --- a/src/util/spatialStats.js +++ b/src/util/spatialStats.js @@ -6,8 +6,8 @@ // Anselin (1995) doi:10.1111/j.1538-4632.1995.tb00338.x // Sokal, Oden & Thomson (1998) — conditional-permutation variance // Bivand & Wong (2018) doi:10.1007/s11749-018-0599-x — cross-implementation comparison -import centroid from '@turf/centroid' -import distance from '@turf/distance' +import turfCentroid from '@turf/centroid' +import turfDistance from '@turf/distance' import { WEIGHTS_CONTIGUITY, WEIGHTS_DISTANCE_BAND, @@ -55,8 +55,7 @@ export const buildSpatialWeights = ( distMatrix, distanceMeters ) - } else { - // WEIGHTS_KNN + } else if (type === WEIGHTS_KNN) { rawNeighbors = buildKnnNeighbors(ids, distMatrix, k) } } @@ -79,9 +78,15 @@ export const buildSpatialWeights = ( neighbors.set(id, nbArray) if (rowStandardize) { const w = 1 / nbArray.length - weights.set(id, nbArray.map(() => w)) + weights.set( + id, + nbArray.map(() => w) + ) } else { - weights.set(id, nbArray.map(() => 1)) + weights.set( + id, + nbArray.map(() => 1) + ) } } @@ -93,7 +98,7 @@ export const buildSpatialWeights = ( // --------------------------------------------------------------------------- // Returns a canonical string key for an edge (order-independent) -const edgeKey = (ax, ay, bx, by) => { +const edgeKey = ([ax, ay], [bx, by]) => { const a = `${ax},${ay}` const b = `${bx},${by}` return a < b ? `${a}|${b}` : `${b}|${a}` @@ -124,8 +129,7 @@ const buildContiguityNeighbors = (features, weightType) => { const [x, y] = ring[i] vertexSet.add(`${x},${y}`) if (i < ring.length - 1) { - const [nx, ny] = ring[i + 1] - edgeSet.add(edgeKey(x, y, nx, ny)) + edgeSet.add(edgeKey(ring[i], ring[i + 1])) } } } @@ -175,7 +179,7 @@ const buildContiguityNeighbors = (features, weightType) => { const computeCentroids = (features) => { const map = new Map() for (const f of features) { - map.set(f.id, centroid(f).geometry.coordinates) // [lng, lat] + map.set(f.id, turfCentroid(f).geometry.coordinates) // [lng, lat] } return map } @@ -191,9 +195,15 @@ const computeDistanceMatrix = (ids, centroids) => { continue } // @turf/distance returns km; convert to metres for callers - const from = { type: 'Feature', geometry: { type: 'Point', coordinates: centroids.get(ids[i]) } } - const to = { type: 'Feature', geometry: { type: 'Point', coordinates: centroids.get(ids[j]) } } - row.set(ids[j], distance(from, to) * 1000) + const from = { + type: 'Feature', + geometry: { type: 'Point', coordinates: centroids.get(ids[i]) }, + } + const to = { + type: 'Feature', + geometry: { type: 'Point', coordinates: centroids.get(ids[j]) }, + } + row.set(ids[j], turfDistance(from, to) * 1000) } matrix.set(ids[i], row) } @@ -204,7 +214,9 @@ const buildDistanceBandNeighbors = (ids, distMatrix, threshold) => { const neighbors = new Map(ids.map((id) => [id, new Set()])) for (let i = 0; i < ids.length; i++) { for (let j = 0; j < ids.length; j++) { - if (i === j) continue + if (i === j) { + continue + } if (distMatrix.get(ids[i]).get(ids[j]) <= threshold) { neighbors.get(ids[i]).add(ids[j]) } @@ -218,7 +230,9 @@ const buildKnnNeighbors = (ids, distMatrix, k) => { for (const id of ids) { const sorted = ids .filter((other) => other !== id) - .sort((a, b) => distMatrix.get(id).get(a) - distMatrix.get(id).get(b)) + .sort( + (a, b) => distMatrix.get(id).get(a) - distMatrix.get(id).get(b) + ) neighbors.set(id, new Set(sorted.slice(0, k))) } return neighbors @@ -244,8 +258,8 @@ const mulberry32 = (seed) => { const shuffle = (array, rng) => { const a = array.slice() for (let i = a.length - 1; i > 0; i--) { - const j = Math.floor(rng() * (i + 1)); - [a[i], a[j]] = [a[j], a[i]] + const j = Math.floor(rng() * (i + 1)) + ;[a[i], a[j]] = [a[j], a[i]] } return a } @@ -282,8 +296,12 @@ const bonferroni = (pValues) => pValues.map((p) => Math.min(1, p * pValues.length)) const applyCorrection = (pValues, correction) => { - if (correction === 'fdr') return bhFdr(pValues) - if (correction === 'bonferroni') return bonferroni(pValues) + if (correction === 'fdr') { + return bhFdr(pValues) + } + if (correction === 'bonferroni') { + return bonferroni(pValues) + } return pValues } @@ -296,10 +314,10 @@ const normalCdf = (z) => { const t = 1 / (1 + 0.2316419 * Math.abs(z)) const poly = t * - (0.319381530 + + (0.31938153 + t * - (-0.356563782 + - t * (1.781477937 + t * (-1.821255978 + t * 1.330274429)))) + (-0.356563782 + + t * (1.781477937 + t * (-1.821255978 + t * 1.330274429)))) const p = 1 - (1 / Math.sqrt(2 * Math.PI)) * Math.exp(-0.5 * z * z) * poly return z >= 0 ? p : 1 - p } @@ -326,7 +344,7 @@ const twoSidedP = (z) => 2 * (1 - normalCdf(Math.abs(z))) */ export const getGiStar = ( valueById, - { neighbors, weights, noNeighborIds }, + { neighbors, noNeighborIds }, { alpha = 0.05, correction = 'fdr' } = {} ) => { const ids = [...neighbors.keys()] @@ -334,7 +352,12 @@ export const getGiStar = ( // Units with no value are excluded from the global mean/variance const values = ids - .filter((id) => !noNeighborSet.has(id) && valueById[id] !== undefined && !Number.isNaN(valueById[id])) + .filter( + (id) => + !noNeighborSet.has(id) && + valueById[id] !== undefined && + !Number.isNaN(valueById[id]) + ) .map((id) => valueById[id]) if (values.length === 0) { @@ -362,7 +385,11 @@ export const getGiStar = ( const computedIds = [] for (const id of ids) { - if (noNeighborSet.has(id) || valueById[id] === undefined || Number.isNaN(valueById[id])) { + if ( + noNeighborSet.has(id) || + valueById[id] === undefined || + Number.isNaN(valueById[id]) + ) { continue } @@ -377,7 +404,9 @@ export const getGiStar = ( for (const nbId of extNb) { const xj = valueById[nbId] - if (xj === undefined || Number.isNaN(xj)) continue + if (xj === undefined || Number.isNaN(xj)) { + continue + } wStarSum += wStar wStarSumXj += wStar * xj wStarSumSq += wStar * wStar @@ -385,10 +414,7 @@ export const getGiStar = ( const numerator = wStarSumXj - xBar * wStarSum const denominator = - s * - Math.sqrt( - (n * wStarSumSq - wStarSum * wStarSum) / (n - 1) - ) + s * Math.sqrt((n * wStarSumSq - wStarSum * wStarSum) / (n - 1)) const z = denominator === 0 ? 0 : numerator / denominator const p = twoSidedP(z) @@ -465,13 +491,20 @@ export const getLisa = ( if (validIds.length === 0) { const result = new Map() for (const id of ids) { - result.set(id, { I: null, z: null, pPseudo: null, cluster: 'NS', significant: false }) + result.set(id, { + I: null, + z: null, + pPseudo: null, + cluster: 'NS', + significant: false, + }) } return result } // Z-standardize values - const mean = validIds.reduce((s, id) => s + valueById[id], 0) / validIds.length + const mean = + validIds.reduce((s, id) => s + valueById[id], 0) / validIds.length const variance = validIds.reduce((s, id) => s + (valueById[id] - mean) ** 2, 0) / validIds.length @@ -513,7 +546,9 @@ export const getLisa = ( const observed = observedI.get(id) // Pool excludes self — this is the key correctness requirement - const pool = otherIdsPool.filter((other) => other !== id).map((other) => zById.get(other)) + const pool = otherIdsPool + .filter((other) => other !== id) + .map((other) => zById.get(other)) let countMore = 0 let countLess = 0 @@ -526,8 +561,12 @@ export const getLisa = ( permLag += wRow[j] * permZ } const permI = zi * permLag - if (permI >= observed) countMore++ - if (permI <= observed) countLess++ + if (permI >= observed) { + countMore++ + } + if (permI <= observed) { + countLess++ + } } // Two-sided pseudo-p: fold both tails, cap at 1. @@ -545,27 +584,39 @@ export const getLisa = ( // Derive cluster quadrant const getCluster = (id, significant) => { - if (!significant) return 'NS' + if (!significant) { + return 'NS' + } const zi = zById.get(id) const nb = neighbors.get(id) const wRow = weights.get(id) let lag = 0 for (let j = 0; j < nb.length; j++) { const zj = zById.get(nb[j]) - if (zj !== undefined) lag += wRow[j] * zj + if (zj !== undefined) { + lag += wRow[j] * zj + } } // GeoDa: HH=1, LL=2, LH=3, HL=4 // PySAL: HH=1, LH=2, LL=3, HL=4 // Both label high-high and low-low the same; differ on LH vs HL numbering only. // We use descriptive strings so the scheme only affects the label order in the legend. - if (zi > 0 && lag > 0) return 'HH' - if (zi < 0 && lag < 0) return 'LL' + if (zi > 0 && lag > 0) { + return 'HH' + } + if (zi < 0 && lag < 0) { + return 'LL' + } if (quadrantScheme === 'pysal') { - if (zi > 0 && lag < 0) return 'HL' + if (zi > 0 && lag < 0) { + return 'HL' + } return 'LH' } // GeoDa (default) - if (zi < 0 && lag > 0) return 'LH' + if (zi < 0 && lag > 0) { + return 'LH' + } return 'HL' } @@ -574,7 +625,13 @@ export const getLisa = ( // No-neighbor and missing-value units for (const id of ids) { if (!validIds.includes(id)) { - result.set(id, { I: null, z: null, pPseudo: null, cluster: 'NS', significant: false }) + result.set(id, { + I: null, + z: null, + pPseudo: null, + cluster: 'NS', + significant: false, + }) } } From a603fa88e225c1d2c94e8ccba94ac2a5f03d2448 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault Date: Thu, 18 Jun 2026 13:15:30 +0200 Subject: [PATCH 08/12] fix(spatialStats): fix undefined RdBu_reverse color palette crash 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 --- i18n/en.pot | 331 +++++++++++++++++++++------------- src/loaders/thematicLoader.js | 2 + src/util/colors.js | 7 +- 3 files changed, 213 insertions(+), 127 deletions(-) diff --git a/i18n/en.pot b/i18n/en.pot index 72d26d6fad..7e2c86f3a5 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-09-01T12:42:29.454Z\n" -"PO-Revision-Date: 2026-09-01T12:42:29.454Z\n" +"POT-Creation-Date: 2026-06-18T10:47:37.300Z\n" +"PO-Revision-Date: 2026-06-18T10:47:37.300Z\n" msgid "2020" msgstr "2020" @@ -101,8 +101,11 @@ msgstr "Enrollment location" msgid "Tracked entity location" msgstr "Tracked entity location" -msgid "Select a program stage to see additional coordinate options" -msgstr "Select a program stage to see additional coordinate options" +msgid "Fallback coordinate field" +msgstr "Fallback coordinate field" + +msgid "Coordinate field" +msgstr "Coordinate field" msgid "Enrollment > event > tracked entity > org unit coordinate" msgstr "Enrollment > event > tracked entity > org unit coordinate" @@ -110,15 +113,6 @@ msgstr "Enrollment > event > tracked entity > org unit coordinate" msgid "Event > org unit coordinate" msgstr "Event > org unit coordinate" -msgid "Select a program to see additional coordinate options" -msgstr "Select a program to see additional coordinate options" - -msgid "Fallback coordinate field" -msgstr "Fallback coordinate field" - -msgid "Coordinate field" -msgstr "Coordinate field" - msgid "Include unclassified events" msgstr "Include unclassified events" @@ -131,6 +125,9 @@ msgstr "Previously selected value not available in list: {{id}}" msgid "Style by data item" msgstr "Style by data item" +msgid "Filtering is available after selecting a program stage." +msgstr "Filtering is available after selecting a program stage." + msgid "Add filter" msgstr "Add filter" @@ -207,9 +204,6 @@ msgstr "Range" msgid "Org unit" msgstr "Org unit" -msgid "Org unit boundary" -msgstr "Org unit boundary" - msgid "Event time" msgstr "Event time" @@ -231,12 +225,6 @@ msgstr "Download map" msgid "Show map name" msgstr "Show map name" -msgid "Name can be changed from File > Rename menu" -msgstr "Name can be changed from File > Rename menu" - -msgid "Set the map name when you save the map or from File > Rename menu" -msgstr "Set the map name when you save the map or from File > Rename menu" - msgid "Show map description" msgstr "Show map description" @@ -392,45 +380,18 @@ msgstr "Max value is required" msgid "Valid classes are {{minSteps}} to {{maxSteps}}" msgstr "Valid classes are {{minSteps}} to {{maxSteps}}" -msgid "Use custom legend" -msgstr "Use custom legend" - msgid "Facility buffer" msgstr "Facility buffer" msgid "Count org units without coordinates" msgstr "Count org units without coordinates" -msgid "Program is required" -msgstr "Program is required" - -msgid "Program stage is required" -msgstr "Program stage is required" - -msgid "No organisation units are selected." -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 "Org Units" msgstr "Org Units" msgid "Filter" msgstr "Filter" -msgid "Choose from presets" -msgstr "Choose from presets" - -msgid "Define start - end dates" -msgstr "Define start - end dates" - -msgid "Filtering is available after selecting a program stage." -msgstr "Filtering is available after selecting a program stage." - msgid "Group events" msgstr "Group events" @@ -443,12 +404,24 @@ msgstr "Radius" msgid "Count events without coordinates" msgstr "Count events without coordinates" -msgid "Filter and count events outside org unit boundaries" -msgstr "Filter and count events outside org unit boundaries" - msgid "You can style events by data element after selecting a program." msgstr "You can style events by data element after selecting a program." +msgid "Program is required" +msgstr "Program is required" + +msgid "Program stage is required" +msgstr "Program stage is required" + +msgid "No organisation units are selected." +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" @@ -506,19 +479,6 @@ msgstr "Include unclassified org units" msgid "Unclassified" msgstr "Unclassified" -msgid "Period is required" -msgstr "Period is required" - -msgid "" -"Only up to a total of {{number}} periods (including those in multi-periods) " -"can be added to a split layer." -msgstr "" -"Only up to a total of {{number}} periods (including those in multi-periods) " -"can be added to a split layer." - -msgid "Select at least {{number}} periods or 1 multi-period." -msgstr "Select at least {{number}} periods or 1 multi-period." - msgid "Aggregation type" msgstr "Aggregation type" @@ -537,6 +497,93 @@ msgstr "High radius should be greater than low radius" msgid "Radius should be between {{min}} and {{max}}" msgstr "Radius should be between {{min}} and {{max}}" +msgid "Off" +msgstr "Off" + +msgid "Getis-Ord Gi*" +msgstr "Getis-Ord Gi*" + +msgid "Local Moran's I (LISA)" +msgstr "Local Moran's I (LISA)" + +msgid "Contiguity (queen)" +msgstr "Contiguity (queen)" + +msgid "Contiguity (rook)" +msgstr "Contiguity (rook)" + +msgid "Distance band" +msgstr "Distance band" + +msgid "k-nearest neighbours" +msgstr "k-nearest neighbours" + +msgid "FDR (recommended)" +msgstr "FDR (recommended)" + +msgid "Bonferroni" +msgstr "Bonferroni" + +msgid "GeoDa (default)" +msgstr "GeoDa (default)" + +msgid "PySAL" +msgstr "PySAL" + +msgid "" +"Hotspot analysis on raw counts often reflects population distribution " +"rather than true spatial clustering. Consider using a rate or per-capita " +"indicator." +msgstr "" +"Hotspot analysis on raw counts often reflects population distribution " +"rather than true spatial clustering. Consider using a rate or per-capita " +"indicator." + +msgid "Spatial analysis method" +msgstr "Spatial analysis method" + +msgid "Spatial weights" +msgstr "Spatial weights" + +msgid "Distance threshold (metres)" +msgstr "Distance threshold (metres)" + +msgid "Number of neighbours (k)" +msgstr "Number of neighbours (k)" + +msgid "Significance level (α)" +msgstr "Significance level (α)" + +msgid "Hide advanced options" +msgstr "Hide advanced options" + +msgid "Show advanced options" +msgstr "Show advanced options" + +msgid "Permutations" +msgstr "Permutations" + +msgid "Multiple-comparison correction" +msgstr "Multiple-comparison correction" + +msgid "Quadrant scheme" +msgstr "Quadrant scheme" + +msgid "Row-standardize weights" +msgstr "Row-standardize weights" + +msgid "Random seed" +msgstr "Random seed" + +msgid "Analysis" +msgstr "Analysis" + +msgid "Choose from presets" +msgstr "Choose from presets" + +msgid "Define start - end dates" +msgstr "Define start - end dates" + msgid "Choose periods for all timeline layers" msgstr "Choose periods for all timeline layers" @@ -549,6 +596,19 @@ msgstr "Choose periods for all split layers" msgid "Data is required" msgstr "Data is required" +msgid "Period is required" +msgstr "Period is required" + +msgid "" +"Only up to a total of {{number}} periods (including those in multi-periods) " +"can be added to a split layer." +msgstr "" +"Only up to a total of {{number}} periods (including those in multi-periods) " +"can be added to a split layer." + +msgid "Select at least {{number}} periods or 1 multi-period." +msgstr "Select at least {{number}} periods or 1 multi-period." + msgid "Specified radius values are invalid" msgstr "Specified radius values are invalid" @@ -564,24 +624,21 @@ msgstr "the date a tracked entity was registered or enrolled in a program" msgid "Program status" msgstr "Program status" -msgid "Tracked Entity Type is required" -msgstr "Tracked Entity Type is required" - msgid "Relationships" msgstr "Relationships" msgid "Follow up" msgstr "Follow up" +msgid "Please select a Tracked Entity Type before selecting a Relationship Type" +msgstr "Please select a Tracked Entity Type before selecting a Relationship Type" + msgid "Displaying tracked entity relationships in Maps is an experimental feature" msgstr "Displaying tracked entity relationships in Maps is an experimental feature" msgid "Display Tracked Entity relationships" msgstr "Display Tracked Entity relationships" -msgid "Please select a Tracked Entity Type before selecting a Relationship Type" -msgstr "Please select a Tracked Entity Type before selecting a Relationship Type" - msgid "Tracked entity style" msgstr "Tracked entity style" @@ -594,6 +651,9 @@ msgstr "Related entity style" msgid "Line Color" msgstr "Line Color" +msgid "Tracked Entity Type is required" +msgstr "Tracked Entity Type is required" + msgid "No relationship types were found for tracked entity type {{type}}" msgstr "No relationship types were found for tracked entity type {{type}}" @@ -710,24 +770,6 @@ msgstr "Duplicate layer" msgid "Remove layer" msgstr "Remove layer" -msgid "Filters" -msgstr "Filters" - -msgid "Failed to load layer" -msgstr "Failed to load layer" - -msgid "No data found" -msgstr "No data found" - -msgid "All events outside org unit boundaries" -msgstr "All events outside org unit boundaries" - -msgid "No coordinates found" -msgstr "No coordinates found" - -msgid "Could not check org unit boundaries" -msgstr "Could not check org unit boundaries" - msgid "Data quality" msgstr "Data quality" @@ -739,25 +781,6 @@ msgid_plural "{{n}} event without coordinates" msgstr[0] "{{n}} event without coordinates" msgstr[1] "{{n}} events without coordinates" -msgid "All events within org unit boundaries" -msgstr "All events within org unit boundaries" - -msgid "{{n}} event outside org unit boundaries" -msgid_plural "{{n}} event outside org unit boundaries" -msgstr[0] "{{n}} event outside org unit boundaries" -msgstr[1] "{{n}} events outside org unit boundaries" - -msgid "{{n}} of {{total}} org unit without boundaries" -msgid_plural "{{n}} of {{total}} org unit without boundaries" -msgstr[0] "{{n}} of {{total}} org unit without boundaries" -msgstr[1] "{{n}} of {{total}} org units without boundaries" - -msgid "Boundary check skipped" -msgstr "Boundary check skipped" - -msgid "All org units without boundaries" -msgstr "All org units without boundaries" - msgid "All org units have a point location" msgstr "All org units have a point location" @@ -774,6 +797,18 @@ msgid_plural "{{n}} org unit without coordinates" msgstr[0] "{{n}} org unit without coordinates" msgstr[1] "{{n}} org units without coordinates" +msgid "Filters" +msgstr "Filters" + +msgid "Failed to load layer" +msgstr "Failed to load layer" + +msgid "No data found" +msgstr "No data found" + +msgid "No coordinates found" +msgstr "No coordinates found" + msgid "Selected org units: No coordinates found" msgstr "Selected org units: No coordinates found" @@ -1011,6 +1046,9 @@ msgstr "Previous year" msgid "Next year" msgstr "Next year" +msgid "Start/end dates" +msgstr "Start/end dates" + msgid "Remove all split layers to add a single layer." msgstr "Remove all split layers to add a single layer." @@ -1127,12 +1165,6 @@ msgstr "Std dev" msgid "OSM Light" msgstr "OSM Light" -msgid "OSM Dark" -msgstr "OSM Dark" - -msgid "OSM Fiord" -msgstr "OSM Fiord" - msgid "OSM Detailed" msgstr "OSM Detailed" @@ -1813,9 +1845,6 @@ msgstr "Financial year (Start July)" msgid "Financial year (Start April)" msgstr "Financial year (Start April)" -msgid "Start/end dates" -msgstr "Start/end dates" - msgid "Cancelled" msgstr "Cancelled" @@ -1879,6 +1908,51 @@ msgstr "Data item was not found" msgid "Thematic layer" msgstr "Thematic layer" +msgid "" +"Spatial analysis results may be unreliable with fewer than 30 units ({{n}} " +"found)" +msgstr "" +"Spatial analysis results may be unreliable with fewer than 30 units ({{n}} " +"found)" + +msgid "" +"{{count}} unit(s) have no spatial neighbors and are excluded from the " +"analysis" +msgid_plural "" +"{{count}} unit(s) have no spatial neighbors and are excluded from the " +"analysis" +msgstr[0] "" +"{{count}} unit(s) have no spatial neighbors and are excluded from the " +"analysis" +msgstr[1] "" +"{{count}} unit(s) have no spatial neighbors and are excluded from the " +"analysis" + +msgid "" +"{{count}} of {{total}} org units lack coordinates — spatial weights may be " +"unreliable" +msgid_plural "" +"{{count}} of {{total}} org units lack coordinates — spatial weights may be " +"unreliable" +msgstr[0] "" +"{{count}} of {{total}} org units lack coordinates — spatial weights may be " +"unreliable" +msgstr[1] "" +"{{count}} of {{total}} org units lack coordinates — spatial weights may be " +"unreliable" + +msgid "" +"Hotspot analysis on raw counts may reflect population distribution rather " +"than true clustering. Consider using a rate or population-at-risk " +"denominator." +msgstr "" +"Hotspot analysis on raw counts may reflect population distribution rather " +"than true clustering. Consider using a rate or population-at-risk " +"denominator." + +msgid "No neighbors" +msgstr "No neighbors" + msgid "related" msgstr "related" @@ -1931,6 +2005,21 @@ msgstr "Tracked entities" msgid "Org units" msgstr "Org units" +msgid "High-High" +msgstr "High-High" + +msgid "Low-Low" +msgstr "Low-Low" + +msgid "High-Low" +msgstr "High-Low" + +msgid "Low-High" +msgstr "Low-High" + +msgid "Not significant" +msgstr "Not significant" + msgid "Facility" msgstr "Facility" @@ -1945,11 +2034,3 @@ msgstr "End date is invalid" msgid "End date cannot be earlier than start date" msgstr "End date cannot be earlier than start date" - -msgctxt "Application title" -msgid "__MANIFEST_APP_TITLE" -msgstr "Maps" - -msgctxt "Application description" -msgid "__MANIFEST_APP_DESCRIPTION" -msgstr "DHIS2 Maps" diff --git a/src/loaders/thematicLoader.js b/src/loaders/thematicLoader.js index 209029670d..19ffdee311 100644 --- a/src/loaders/thematicLoader.js +++ b/src/loaders/thematicLoader.js @@ -399,6 +399,8 @@ const thematicLoader = async ({ SPATIAL_GI_COLOR_SCALE, giClasses ) + .slice() + .reverse() const zValues = [...spatialStats.values()] .map((s) => s.z) .filter((z) => z !== null && Number.isFinite(z)) diff --git a/src/util/colors.js b/src/util/colors.js index fb1579ed47..c547f67862 100644 --- a/src/util/colors.js +++ b/src/util/colors.js @@ -65,8 +65,11 @@ export const getColorScale = (palette) => ) // Diverging scale used for Gi* z-scores: blue (cold/low) → white → red (hot/high). -// The _reverse suffix from colorbrewer.js puts red on the high end. -export const SPATIAL_GI_COLOR_SCALE = 'RdBu_reverse' +// colorbrewer's native RdBu runs red (low) → blue (high); callers must reverse +// the palette returned by getColorPalette() to put red on the high end. +// (Diverging scales don't have a generated '_reverse' variant — only the +// sequential ones in colorbrewer.js do.) +export const SPATIAL_GI_COLOR_SCALE = 'RdBu' export const defaultColorScaleName = 'YlOrBr' export const defaultClasses = 5 From 20126ff9fcd5c43488c1f5a61fa9ee4ac200cce4 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault Date: Thu, 18 Jun 2026 14:00:22 +0200 Subject: [PATCH 09/12] feat(spatialAnalysis): make Gi*/LISA legends explicit for non-experts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/loaders/thematicLoader.js | 37 +++++------- src/util/__tests__/spatialStats.spec.js | 37 +++++++++++- src/util/legend.js | 79 +++++++++++++++++++++++-- src/util/spatialStats.js | 36 +++++++++-- 4 files changed, 155 insertions(+), 34 deletions(-) diff --git a/src/loaders/thematicLoader.js b/src/loaders/thematicLoader.js index 19ffdee311..9f6e749526 100644 --- a/src/loaders/thematicLoader.js +++ b/src/loaders/thematicLoader.js @@ -49,6 +49,7 @@ import { buildIsolatedLegendItem, isRegularLegendItem, buildLisaLegendItems, + buildGiLegendItems, } from '../util/legend.js' import { toGeoJson } from '../util/map.js' import { @@ -391,30 +392,14 @@ const thematicLoader = async ({ if (isSpatialAnalysis && spatialStats) { // Spatial analysis overrides normal classification. - // Gi*: diverging choropleth over z-scores using standard-deviation binning. + // Gi*: fixed confidence-tier bins (90/95/99%) over the corrected p-value. // LISA: fixed 5-category map (HH/LL/HL/LH/NS) with GeoDa colors. if (spatialMethod === SPATIAL_GI) { - const giClasses = classes || 7 - const colorScale = getColorPalette( - SPATIAL_GI_COLOR_SCALE, - giClasses - ) + const colorScale = getColorPalette(SPATIAL_GI_COLOR_SCALE, 7) .slice() .reverse() - const zValues = [...spatialStats.values()] - .map((s) => s.z) - .filter((z) => z !== null && Number.isFinite(z)) - .sort((a, b) => a - b) - if (zValues.length > 0) { - const classification = getAutomaticLegendItems({ - data: zValues, - method: CLASSIFICATION_STANDARD_DEVIATION, - classes: giClasses, - colorScale, - }) - legendItems = classification.items - valueFormat = classification.valueFormat - } + const alpha = config.spatialAnalysis?.alpha ?? 0.05 + legendItems = buildGiLegendItems(colorScale, alpha) } else { // LISA — categorical, no numeric range legendItems = buildLisaLegendItems() @@ -607,14 +592,22 @@ const thematicLoader = async ({ // Spatial analysis path: derive legend item from stat rather than raw value if (isSpatialAnalysis && spatialStats) { const stat = spatialStats.get(id) - const isNoNeighbor = stat?.z === null && stat?.I === null + // z and I belong to different methods (Gi* sets z, LISA sets I) — + // check the one that's actually populated for the active method. + const isNoNeighbor = + spatialMethod === SPATIAL_GI + ? stat?.z === null + : stat?.I === null let legendItem if (isNoNeighbor) { // No-neighbor units are shown in grey and kept out of legend counts legendItem = null } else if (spatialMethod === SPATIAL_GI) { - legendItem = getLegendItem(stat?.z) + // Gi*: categorical lookup by confidence-tier key + legendItem = + legendItems.find((item) => item.tier === stat?.tier) ?? + null } else { // LISA: categorical lookup by cluster key legendItem = diff --git a/src/util/__tests__/spatialStats.spec.js b/src/util/__tests__/spatialStats.spec.js index d7ec84c683..e752d7d120 100644 --- a/src/util/__tests__/spatialStats.spec.js +++ b/src/util/__tests__/spatialStats.spec.js @@ -208,19 +208,21 @@ describe('getGiStar', () => { expect(result.size).toBe(FEATURES.length) }) - test('each entry has z, p, significant', () => { + test('each entry has z, p, significant, tier', () => { for (const [, v] of result) { expect(v).toHaveProperty('z') expect(v).toHaveProperty('p') expect(v).toHaveProperty('significant') + expect(v).toHaveProperty('tier') } }) - test('island F gets z=null, p=null, significant=false', () => { + test('island F gets z=null, p=null, significant=false, tier=null', () => { expect(result.get('F')).toEqual({ z: null, p: null, significant: false, + tier: null, }) }) @@ -265,6 +267,37 @@ describe('getGiStar', () => { expect(v.p).toBeLessThanOrEqual(1) } }) + + test('tier is "ns" whenever significant is false, and vice versa', () => { + for (const [id, v] of result) { + if (id === 'F') { + continue + } + expect(v.tier === 'ns').toBe(!v.significant) + } + }) + + test('significant units get a hot/cold tier matching the sign of z', () => { + for (const [id, v] of result) { + if (id === 'F' || !v.significant) { + continue + } + expect(v.tier.startsWith(v.z >= 0 ? 'hot' : 'cold')).toBe(true) + } + }) + + test('alpha=0.01 only ever produces the 99% tier (or ns)', () => { + const r = getGiStar(VALUES, weights, { + alpha: 0.01, + correction: 'none', + }) + for (const [id, v] of r) { + if (id === 'F') { + continue + } + expect(['ns', 'hot99', 'cold99']).toContain(v.tier) + } + }) }) // --------------------------------------------------------------------------- diff --git a/src/util/legend.js b/src/util/legend.js index bcd6739ede..74b2ebeeb6 100644 --- a/src/util/legend.js +++ b/src/util/legend.js @@ -294,11 +294,33 @@ export const legendNamesContainRange = (items) => { // Colors follow GeoDa convention. `cluster` is stored for lookup by category key. // HH/LL/HL/LH are the same in both GeoDa and PySAL quadrant schemes; // only the numbering (not the color assignment) differs between schemes. +// Names spell out what each quadrant means so the map is readable without +// prior knowledge of LISA terminology. export const buildLisaLegendItems = () => [ - { cluster: 'HH', name: i18n.t('High-High'), color: '#d7191c', count: 0 }, - { cluster: 'LL', name: i18n.t('Low-Low'), color: '#2c7bb6', count: 0 }, - { cluster: 'HL', name: i18n.t('High-Low'), color: '#fdae61', count: 0 }, - { cluster: 'LH', name: i18n.t('Low-High'), color: '#abd9e9', count: 0 }, + { + cluster: 'HH', + name: i18n.t('High-High (hotspot cluster)'), + color: '#d7191c', + count: 0, + }, + { + cluster: 'LL', + name: i18n.t('Low-Low (coldspot cluster)'), + color: '#2c7bb6', + count: 0, + }, + { + cluster: 'HL', + name: i18n.t('High-Low (high value among low neighbors)'), + color: '#fdae61', + count: 0, + }, + { + cluster: 'LH', + name: i18n.t('Low-High (low value among high neighbors)'), + color: '#abd9e9', + count: 0, + }, { cluster: 'NS', name: i18n.t('Not significant'), @@ -306,3 +328,52 @@ export const buildLisaLegendItems = () => [ count: 0, }, ] + +// Getis-Ord Gi* legend — confidence-tier bins derived from the corrected +// two-sided p-value, matching the conventional "Hot Spot Analysis" labeling +// (90/95/99% confidence) so the result is readable without prior knowledge +// of z-scores. Only tiers reachable at the chosen significance level are +// shown — e.g. alpha=0.01 can only ever produce a 99%-confidence tier, so +// showing 90%/95% bins for that case would be misleading. +const GI_TIERS = [ + { level: 99, p: 0.01 }, + { level: 95, p: 0.05 }, + { level: 90, p: 0.1 }, +] + +// colorScale must have exactly 7 colors, ordered cold → hot (index 0 = coldest) +export const buildGiLegendItems = (colorScale, alpha) => { + const reachable = GI_TIERS.filter((tier) => tier.p <= alpha) + const colorByTier = { + cold99: colorScale[0], + cold95: colorScale[1], + cold90: colorScale[2], + hot90: colorScale[4], + hot95: colorScale[5], + hot99: colorScale[6], + } + + const coldItems = [...reachable].reverse().map(({ level }) => ({ + tier: `cold${level}`, + name: i18n.t('Cold spot ({{level}}% confidence)', { level }), + color: colorByTier[`cold${level}`], + count: 0, + })) + const hotItems = reachable.map(({ level }) => ({ + tier: `hot${level}`, + name: i18n.t('Hot spot ({{level}}% confidence)', { level }), + color: colorByTier[`hot${level}`], + count: 0, + })) + + return [ + ...coldItems, + { + tier: 'ns', + name: i18n.t('Not significant'), + color: '#aaaaaa', + count: 0, + }, + ...hotItems, + ] +} diff --git a/src/util/spatialStats.js b/src/util/spatialStats.js index 79b0892713..9e69d679bc 100644 --- a/src/util/spatialStats.js +++ b/src/util/spatialStats.js @@ -325,6 +325,25 @@ const normalCdf = (z) => { // Two-sided p from a z-score const twoSidedP = (z) => 2 * (1 - normalCdf(Math.abs(z))) +// Confidence-tier classification for a Gi* result, matching the conventional +// "Hot Spot Analysis" labeling (90/95/99% confidence). Driven by the +// corrected p-value (not raw z) so it stays correct under FDR/Bonferroni, +// and gated by the chosen alpha so only reachable tiers are ever produced +// (e.g. alpha=0.01 can only ever yield a 99%-confidence tier). +const getGiTier = (z, p, alpha) => { + if (p >= alpha) { + return 'ns' + } + const sign = z >= 0 ? 'hot' : 'cold' + if (p < 0.01) { + return `${sign}99` + } + if (p < 0.05) { + return `${sign}95` + } + return `${sign}90` +} + // --------------------------------------------------------------------------- // Getis-Ord Gi* // --------------------------------------------------------------------------- @@ -340,7 +359,9 @@ const twoSidedP = (z) => 2 * (1 - normalCdf(Math.abs(z))) * @param {object} [opts] * @param {number} [opts.alpha=0.05] * @param {string} [opts.correction='fdr'] 'fdr' | 'bonferroni' | 'none' - * @returns {Map} + * @returns {Map} + * `tier` is one of 'cold99'|'cold95'|'cold90'|'ns'|'hot90'|'hot95'|'hot99', or + * null for units with no computed statistic (no neighbors / no value). */ export const getGiStar = ( valueById, @@ -363,7 +384,7 @@ export const getGiStar = ( if (values.length === 0) { const result = new Map() for (const id of ids) { - result.set(id, { z: null, p: null, significant: false }) + result.set(id, { z: null, p: null, significant: false, tier: null }) } return result } @@ -431,15 +452,18 @@ export const getGiStar = ( // No-neighbor and no-value units for (const id of ids) { if (!computedIds.includes(id)) { - result.set(id, { z: null, p: null, significant: false }) + result.set(id, { z: null, p: null, significant: false, tier: null }) } } for (let i = 0; i < computedIds.length; i++) { + const z = rawZscores[i] + const p = adjustedP[i] result.set(computedIds[i], { - z: rawZscores[i], - p: adjustedP[i], - significant: adjustedP[i] < alpha, + z, + p, + significant: p < alpha, + tier: getGiTier(z, p, alpha), }) } From 4dd0ee74e1e59c09a0ade91bbac687d5c1ef71c0 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault Date: Thu, 18 Jun 2026 14:01:53 +0200 Subject: [PATCH 10/12] fix(ui): fix SingleSelect crash when picking alpha=0.10 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/components/edit/thematic/SpatialAnalysisSection.jsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/components/edit/thematic/SpatialAnalysisSection.jsx b/src/components/edit/thematic/SpatialAnalysisSection.jsx index 01234546ba..73fa443039 100644 --- a/src/components/edit/thematic/SpatialAnalysisSection.jsx +++ b/src/components/edit/thematic/SpatialAnalysisSection.jsx @@ -37,8 +37,10 @@ const WEIGHT_TYPES = [ { value: WEIGHTS_KNN, label: i18n.t('k-nearest neighbours') }, ] +// Values must match String(Number(value)) exactly — selected={String(alpha)} +// compares against these, and Number('0.10') stringifies back to '0.1'. const ALPHA_OPTIONS = [ - { value: '0.10', label: '0.10' }, + { value: '0.1', label: '0.10' }, { value: '0.05', label: '0.05 (default)' }, { value: '0.01', label: '0.01' }, ] From 896bbd8f225a9b0dc14a05ecc4ea7e6555ec1ae2 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault Date: Thu, 18 Jun 2026 15:12:29 +0200 Subject: [PATCH 11/12] refactor(ui): align Analysis tab with the rest of the layer edit dialog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- i18n/en.pot | 219 ++++++++---- .../edit/styles/LayerDialog.module.css | 5 + .../edit/thematic/SpatialAnalysisSection.jsx | 335 +++++++----------- .../edit/thematic/ThematicDialog.jsx | 5 +- src/loaders/thematicLoader.js | 2 +- 5 files changed, 270 insertions(+), 296 deletions(-) diff --git a/i18n/en.pot b/i18n/en.pot index 7e2c86f3a5..f127c6d356 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-06-18T10:47:37.300Z\n" -"PO-Revision-Date: 2026-06-18T10:47:37.300Z\n" +"POT-Creation-Date: 2026-09-10T12:40:43.101Z\n" +"PO-Revision-Date: 2026-09-10T12:40:43.101Z\n" msgid "2020" msgstr "2020" @@ -101,11 +101,8 @@ msgstr "Enrollment location" msgid "Tracked entity location" msgstr "Tracked entity location" -msgid "Fallback coordinate field" -msgstr "Fallback coordinate field" - -msgid "Coordinate field" -msgstr "Coordinate field" +msgid "Select a program stage to see additional coordinate options" +msgstr "Select a program stage to see additional coordinate options" msgid "Enrollment > event > tracked entity > org unit coordinate" msgstr "Enrollment > event > tracked entity > org unit coordinate" @@ -113,6 +110,15 @@ msgstr "Enrollment > event > tracked entity > org unit coordinate" msgid "Event > org unit coordinate" msgstr "Event > org unit coordinate" +msgid "Select a program to see additional coordinate options" +msgstr "Select a program to see additional coordinate options" + +msgid "Fallback coordinate field" +msgstr "Fallback coordinate field" + +msgid "Coordinate field" +msgstr "Coordinate field" + msgid "Include unclassified events" msgstr "Include unclassified events" @@ -125,9 +131,6 @@ msgstr "Previously selected value not available in list: {{id}}" msgid "Style by data item" msgstr "Style by data item" -msgid "Filtering is available after selecting a program stage." -msgstr "Filtering is available after selecting a program stage." - msgid "Add filter" msgstr "Add filter" @@ -204,6 +207,9 @@ msgstr "Range" msgid "Org unit" msgstr "Org unit" +msgid "Org unit boundary" +msgstr "Org unit boundary" + msgid "Event time" msgstr "Event time" @@ -225,6 +231,12 @@ msgstr "Download map" msgid "Show map name" msgstr "Show map name" +msgid "Name can be changed from File > Rename menu" +msgstr "Name can be changed from File > Rename menu" + +msgid "Set the map name when you save the map or from File > Rename menu" +msgstr "Set the map name when you save the map or from File > Rename menu" + msgid "Show map description" msgstr "Show map description" @@ -380,18 +392,45 @@ msgstr "Max value is required" msgid "Valid classes are {{minSteps}} to {{maxSteps}}" msgstr "Valid classes are {{minSteps}} to {{maxSteps}}" +msgid "Use custom legend" +msgstr "Use custom legend" + msgid "Facility buffer" msgstr "Facility buffer" msgid "Count org units without coordinates" msgstr "Count org units without coordinates" +msgid "Program is required" +msgstr "Program is required" + +msgid "Program stage is required" +msgstr "Program stage is required" + +msgid "No organisation units are selected." +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 "Org Units" msgstr "Org Units" msgid "Filter" msgstr "Filter" +msgid "Choose from presets" +msgstr "Choose from presets" + +msgid "Define start - end dates" +msgstr "Define start - end dates" + +msgid "Filtering is available after selecting a program stage." +msgstr "Filtering is available after selecting a program stage." + msgid "Group events" msgstr "Group events" @@ -404,24 +443,12 @@ msgstr "Radius" msgid "Count events without coordinates" msgstr "Count events without coordinates" +msgid "Filter and count events outside org unit boundaries" +msgstr "Filter and count events outside org unit boundaries" + msgid "You can style events by data element after selecting a program." msgstr "You can style events by data element after selecting a program." -msgid "Program is required" -msgstr "Program is required" - -msgid "Program stage is required" -msgstr "Program stage is required" - -msgid "No organisation units are selected." -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" @@ -479,6 +506,19 @@ msgstr "Include unclassified org units" msgid "Unclassified" msgstr "Unclassified" +msgid "Period is required" +msgstr "Period is required" + +msgid "" +"Only up to a total of {{number}} periods (including those in multi-periods) " +"can be added to a split layer." +msgstr "" +"Only up to a total of {{number}} periods (including those in multi-periods) " +"can be added to a split layer." + +msgid "Select at least {{number}} periods or 1 multi-period." +msgstr "Select at least {{number}} periods or 1 multi-period." + msgid "Aggregation type" msgstr "Aggregation type" @@ -554,11 +594,8 @@ msgstr "Number of neighbours (k)" msgid "Significance level (α)" msgstr "Significance level (α)" -msgid "Hide advanced options" -msgstr "Hide advanced options" - -msgid "Show advanced options" -msgstr "Show advanced options" +msgid "Advanced settings" +msgstr "Advanced settings" msgid "Permutations" msgstr "Permutations" @@ -578,12 +615,6 @@ msgstr "Random seed" msgid "Analysis" msgstr "Analysis" -msgid "Choose from presets" -msgstr "Choose from presets" - -msgid "Define start - end dates" -msgstr "Define start - end dates" - msgid "Choose periods for all timeline layers" msgstr "Choose periods for all timeline layers" @@ -596,19 +627,6 @@ msgstr "Choose periods for all split layers" msgid "Data is required" msgstr "Data is required" -msgid "Period is required" -msgstr "Period is required" - -msgid "" -"Only up to a total of {{number}} periods (including those in multi-periods) " -"can be added to a split layer." -msgstr "" -"Only up to a total of {{number}} periods (including those in multi-periods) " -"can be added to a split layer." - -msgid "Select at least {{number}} periods or 1 multi-period." -msgstr "Select at least {{number}} periods or 1 multi-period." - msgid "Specified radius values are invalid" msgstr "Specified radius values are invalid" @@ -624,21 +642,24 @@ msgstr "the date a tracked entity was registered or enrolled in a program" msgid "Program status" msgstr "Program status" +msgid "Tracked Entity Type is required" +msgstr "Tracked Entity Type is required" + msgid "Relationships" msgstr "Relationships" msgid "Follow up" msgstr "Follow up" -msgid "Please select a Tracked Entity Type before selecting a Relationship Type" -msgstr "Please select a Tracked Entity Type before selecting a Relationship Type" - msgid "Displaying tracked entity relationships in Maps is an experimental feature" msgstr "Displaying tracked entity relationships in Maps is an experimental feature" msgid "Display Tracked Entity relationships" msgstr "Display Tracked Entity relationships" +msgid "Please select a Tracked Entity Type before selecting a Relationship Type" +msgstr "Please select a Tracked Entity Type before selecting a Relationship Type" + msgid "Tracked entity style" msgstr "Tracked entity style" @@ -651,9 +672,6 @@ msgstr "Related entity style" msgid "Line Color" msgstr "Line Color" -msgid "Tracked Entity Type is required" -msgstr "Tracked Entity Type is required" - msgid "No relationship types were found for tracked entity type {{type}}" msgstr "No relationship types were found for tracked entity type {{type}}" @@ -770,6 +788,24 @@ msgstr "Duplicate layer" msgid "Remove layer" msgstr "Remove layer" +msgid "Filters" +msgstr "Filters" + +msgid "Failed to load layer" +msgstr "Failed to load layer" + +msgid "No data found" +msgstr "No data found" + +msgid "All events outside org unit boundaries" +msgstr "All events outside org unit boundaries" + +msgid "No coordinates found" +msgstr "No coordinates found" + +msgid "Could not check org unit boundaries" +msgstr "Could not check org unit boundaries" + msgid "Data quality" msgstr "Data quality" @@ -781,6 +817,25 @@ msgid_plural "{{n}} event without coordinates" msgstr[0] "{{n}} event without coordinates" msgstr[1] "{{n}} events without coordinates" +msgid "All events within org unit boundaries" +msgstr "All events within org unit boundaries" + +msgid "{{n}} event outside org unit boundaries" +msgid_plural "{{n}} event outside org unit boundaries" +msgstr[0] "{{n}} event outside org unit boundaries" +msgstr[1] "{{n}} events outside org unit boundaries" + +msgid "{{n}} of {{total}} org unit without boundaries" +msgid_plural "{{n}} of {{total}} org unit without boundaries" +msgstr[0] "{{n}} of {{total}} org unit without boundaries" +msgstr[1] "{{n}} of {{total}} org units without boundaries" + +msgid "Boundary check skipped" +msgstr "Boundary check skipped" + +msgid "All org units without boundaries" +msgstr "All org units without boundaries" + msgid "All org units have a point location" msgstr "All org units have a point location" @@ -797,18 +852,6 @@ msgid_plural "{{n}} org unit without coordinates" msgstr[0] "{{n}} org unit without coordinates" msgstr[1] "{{n}} org units without coordinates" -msgid "Filters" -msgstr "Filters" - -msgid "Failed to load layer" -msgstr "Failed to load layer" - -msgid "No data found" -msgstr "No data found" - -msgid "No coordinates found" -msgstr "No coordinates found" - msgid "Selected org units: No coordinates found" msgstr "Selected org units: No coordinates found" @@ -1046,9 +1089,6 @@ msgstr "Previous year" msgid "Next year" msgstr "Next year" -msgid "Start/end dates" -msgstr "Start/end dates" - msgid "Remove all split layers to add a single layer." msgstr "Remove all split layers to add a single layer." @@ -1165,6 +1205,12 @@ msgstr "Std dev" msgid "OSM Light" msgstr "OSM Light" +msgid "OSM Dark" +msgstr "OSM Dark" + +msgid "OSM Fiord" +msgstr "OSM Fiord" + msgid "OSM Detailed" msgstr "OSM Detailed" @@ -1845,6 +1891,9 @@ msgstr "Financial year (Start July)" msgid "Financial year (Start April)" msgstr "Financial year (Start April)" +msgid "Start/end dates" +msgstr "Start/end dates" + msgid "Cancelled" msgstr "Cancelled" @@ -2005,21 +2054,27 @@ msgstr "Tracked entities" msgid "Org units" msgstr "Org units" -msgid "High-High" -msgstr "High-High" +msgid "High-High (hotspot cluster)" +msgstr "High-High (hotspot cluster)" -msgid "Low-Low" -msgstr "Low-Low" +msgid "Low-Low (coldspot cluster)" +msgstr "Low-Low (coldspot cluster)" -msgid "High-Low" -msgstr "High-Low" +msgid "High-Low (high value among low neighbors)" +msgstr "High-Low (high value among low neighbors)" -msgid "Low-High" -msgstr "Low-High" +msgid "Low-High (low value among high neighbors)" +msgstr "Low-High (low value among high neighbors)" msgid "Not significant" msgstr "Not significant" +msgid "Cold spot ({{level}}% confidence)" +msgstr "Cold spot ({{level}}% confidence)" + +msgid "Hot spot ({{level}}% confidence)" +msgstr "Hot spot ({{level}}% confidence)" + msgid "Facility" msgstr "Facility" @@ -2034,3 +2089,11 @@ msgstr "End date is invalid" msgid "End date cannot be earlier than start date" msgstr "End date cannot be earlier than start date" + +msgctxt "Application title" +msgid "__MANIFEST_APP_TITLE" +msgstr "Maps" + +msgctxt "Application description" +msgid "__MANIFEST_APP_DESCRIPTION" +msgstr "DHIS2 Maps" diff --git a/src/components/edit/styles/LayerDialog.module.css b/src/components/edit/styles/LayerDialog.module.css index 6f417f1c9c..2bfd48d2fc 100644 --- a/src/components/edit/styles/LayerDialog.module.css +++ b/src/components/edit/styles/LayerDialog.module.css @@ -164,6 +164,11 @@ margin-bottom: var(--spacers-dp8); } +.fullWidthNotice { + composes: flexRowFlow; + margin-bottom: var(--spacers-dp16); +} + .dataOptions { padding-top: var(--spacers-dp16); } diff --git a/src/components/edit/thematic/SpatialAnalysisSection.jsx b/src/components/edit/thematic/SpatialAnalysisSection.jsx index 73fa443039..82bf6ff040 100644 --- a/src/components/edit/thematic/SpatialAnalysisSection.jsx +++ b/src/components/edit/thematic/SpatialAnalysisSection.jsx @@ -1,14 +1,7 @@ import i18n from '@dhis2/d2-i18n' -import { - NoticeBox, - SingleSelectField, - SingleSelectOption, - InputField, - Checkbox, - Button, -} from '@dhis2/ui' +import { NoticeBox } from '@dhis2/ui' import PropTypes from 'prop-types' -import React, { useState, useCallback } from 'react' +import React, { useCallback } from 'react' import { useDispatch, useSelector } from 'react-redux' import { setSpatialAnalysis } from '../../../actions/layerEdit.js' import { @@ -19,47 +12,49 @@ import { WEIGHTS_DISTANCE_BAND, WEIGHTS_KNN, } from '../../../constants/layers.js' +import { Checkbox, NumberField, SelectField } from '../../core/index.js' import styles from '../styles/LayerDialog.module.css' // Generates a simple integer seed from the current time. const generateSeed = () => Math.floor(Date.now() % 1_000_000) const METHODS = [ - { value: SPATIAL_NONE, label: i18n.t('Off') }, - { value: SPATIAL_GI, label: i18n.t('Getis-Ord Gi*') }, - { value: SPATIAL_LISA, label: i18n.t("Local Moran's I (LISA)") }, + { id: SPATIAL_NONE, name: i18n.t('Off') }, + { id: SPATIAL_GI, name: i18n.t('Getis-Ord Gi*') }, + { id: SPATIAL_LISA, name: i18n.t("Local Moran's I (LISA)") }, ] const WEIGHT_TYPES = [ - { value: WEIGHTS_CONTIGUITY, label: i18n.t('Contiguity (queen)') }, - { value: `${WEIGHTS_CONTIGUITY}_rook`, label: i18n.t('Contiguity (rook)') }, - { value: WEIGHTS_DISTANCE_BAND, label: i18n.t('Distance band') }, - { value: WEIGHTS_KNN, label: i18n.t('k-nearest neighbours') }, + { id: WEIGHTS_CONTIGUITY, name: i18n.t('Contiguity (queen)') }, + { id: `${WEIGHTS_CONTIGUITY}_rook`, name: i18n.t('Contiguity (rook)') }, + { id: WEIGHTS_DISTANCE_BAND, name: i18n.t('Distance band') }, + { id: WEIGHTS_KNN, name: i18n.t('k-nearest neighbours') }, ] -// Values must match String(Number(value)) exactly — selected={String(alpha)} -// compares against these, and Number('0.10') stringifies back to '0.1'. +// Numeric ids round-trip cleanly through SelectField's String(id) comparison +// (unlike hand-written string literals such as '0.10', which Number() +// collapses to '0.1' and breaks the selected-option match). const ALPHA_OPTIONS = [ - { value: '0.1', label: '0.10' }, - { value: '0.05', label: '0.05 (default)' }, - { value: '0.01', label: '0.01' }, + { id: 0.1, name: '0.10' }, + { id: 0.05, name: '0.05 (default)' }, + { id: 0.01, name: '0.01' }, ] const PERMUTATION_OPTIONS = [ - { value: '99', label: '99' }, - { value: '999', label: '999 (default)' }, - { value: '9999', label: '9999' }, + { id: 99, name: '99' }, + { id: 999, name: '999 (default)' }, + { id: 9999, name: '9999' }, ] const CORRECTION_OPTIONS = [ - { value: 'fdr', label: i18n.t('FDR (recommended)') }, - { value: 'bonferroni', label: i18n.t('Bonferroni') }, - { value: 'none', label: i18n.t('None') }, + { id: 'fdr', name: i18n.t('FDR (recommended)') }, + { id: 'bonferroni', name: i18n.t('Bonferroni') }, + { id: 'none', name: i18n.t('None') }, ] const QUADRANT_OPTIONS = [ - { value: 'geoda', label: i18n.t('GeoDa (default)') }, - { value: 'pysal', label: i18n.t('PySAL') }, + { id: 'geoda', name: i18n.t('GeoDa (default)') }, + { id: 'pysal', name: i18n.t('PySAL') }, ] const SpatialAnalysisSection = ({ isCountLikeData }) => { @@ -67,7 +62,6 @@ const SpatialAnalysisSection = ({ isCountLikeData }) => { const spatialAnalysis = useSelector( (state) => state.layerEdit.spatialAnalysis ?? {} ) - const [showAdvanced, setShowAdvanced] = useState(false) const { method = SPATIAL_NONE, @@ -122,226 +116,141 @@ const SpatialAnalysisSection = ({ isCountLikeData }) => { return (
{isActive && isCountLikeData && ( - - {i18n.t( - 'Hotspot analysis on raw counts often reflects population distribution rather than true spatial clustering. Consider using a rate or per-capita indicator.' - )} - +
+ + {i18n.t( + 'Hotspot analysis on raw counts often reflects population distribution rather than true spatial clustering. Consider using a rate or per-capita indicator.' + )} + +
)}
- - handleMethodChange(selected) - } + value={method} + items={METHODS} + onChange={({ id }) => handleMethodChange(id)} + className={styles.select} dataTest="spatialanalysis-method" - > - {METHODS.map(({ value, label }) => ( - - ))} - + /> {isActive && ( <> - - handleWeightTypeChange(selected) + value={weightSelectValue} + items={WEIGHT_TYPES} + onChange={({ id }) => + handleWeightTypeChange(id) } + className={styles.select} dataTest="spatialanalysis-weights" - > - {WEIGHT_TYPES.map(({ value, label }) => ( - - ))} - + /> {isDistanceBand && ( - + update({ distanceMeters: value }) } - onChange={({ value }) => - update({ - distanceMeters: value - ? Number(value) - : undefined, - }) - } - dataTest="spatialanalysis-distance" + className={styles.select} /> )} {isKnn && ( - + value={k} + min={1} + max={20} + onChange={(value) => update({ - k: Math.max( - 1, - Math.min(20, Number(value)) - ), + k: Math.max(1, Math.min(20, value)), }) } - dataTest="spatialanalysis-k" + className={styles.select} /> )} - - update({ alpha: Number(selected) }) - } + value={alpha} + items={ALPHA_OPTIONS} + onChange={({ id }) => update({ alpha: id })} + className={styles.select} dataTest="spatialanalysis-alpha" - > - {ALPHA_OPTIONS.map(({ value, label }) => ( - - ))} - + /> + + )} +
- + {isActive && ( +
+
+ {i18n.t('Advanced settings')} +
- {showAdvanced && ( -
- {isLisa && ( - - update({ - permutations: - Number(selected), - }) - } - dataTest="spatialanalysis-permutations" - > - {PERMUTATION_OPTIONS.map( - ({ value, label }) => ( - - ) - )} - - )} + {isLisa && ( + + update({ permutations: id }) + } + className={styles.select} + dataTest="spatialanalysis-permutations" + /> + )} - - update({ correction: selected }) - } - dataTest="spatialanalysis-correction" - > - {CORRECTION_OPTIONS.map( - ({ value, label }) => ( - - ) - )} - + update({ correction: id })} + className={styles.select} + dataTest="spatialanalysis-correction" + /> - {isLisa && ( - - update({ - quadrantScheme: selected, - }) - } - dataTest="spatialanalysis-quadrant" - > - {QUADRANT_OPTIONS.map( - ({ value, label }) => ( - - ) - )} - - )} + {isLisa && ( + + update({ quadrantScheme: id }) + } + className={styles.select} + dataTest="spatialanalysis-quadrant" + /> + )} - - update({ rowStandardize: checked }) - } - dataTest="spatialanalysis-rowstandardize" - /> + + update({ rowStandardize: checked }) + } + dataTest="spatialanalysis-rowstandardize" + /> - - update({ - seed: value - ? Number(value) - : undefined, - }) - } - dataTest="spatialanalysis-seed" - /> -
- )} - - )} -
+ update({ seed: value })} + className={styles.select} + /> +
+ )}
) diff --git a/src/components/edit/thematic/ThematicDialog.jsx b/src/components/edit/thematic/ThematicDialog.jsx index 1e27e27fe4..a811596da6 100644 --- a/src/components/edit/thematic/ThematicDialog.jsx +++ b/src/components/edit/thematic/ThematicDialog.jsx @@ -545,10 +545,7 @@ const ThematicDialog = ({ )} {tab === 'analysis' && ( -
+
Date: Thu, 10 Sep 2026 14:49:20 +0200 Subject: [PATCH 12/12] fix(spatialAnalysis): persist settings through layer.config for save/share MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Analysis tab settings (method, weights, alpha, etc.) only lived in the live editing session's redux state — saving a map never wrote them to layer.config, and the loader never read them back on load, so a saved map silently reset spatial analysis to off. Wires spatialAnalysis into the same layer.config round trip already used for labelDataItem/ legendIsolated/etc., so a saved map now preserves and shares it. Co-Authored-By: Claude Sonnet 5 --- src/loaders/thematicLoader.js | 4 ++++ src/util/favorites.js | 5 +++++ 2 files changed, 9 insertions(+) diff --git a/src/loaders/thematicLoader.js b/src/loaders/thematicLoader.js index 5c775a939f..5ee8e0ed7c 100644 --- a/src/loaders/thematicLoader.js +++ b/src/loaders/thematicLoader.js @@ -101,6 +101,7 @@ const thematicLoader = async ({ legendIsolated, unclassifiedLegend: unclassifiedLegendFromConfig, noDataLegend: noDataLegendFromConfig, + spatialAnalysis: spatialAnalysisFromConfig, } = parseJsonConfig(config.config) if (countFeaturesWithoutCoordinates) { config.countFeaturesWithoutCoordinates = true @@ -111,6 +112,9 @@ const thematicLoader = async ({ if (legendIsolated) { config.legendIsolated = legendIsolated } + if (spatialAnalysisFromConfig) { + config.spatialAnalysis = spatialAnalysisFromConfig + } if (unclassifiedLegendFromConfig) { config.unclassifiedLegend = unclassifiedLegendFromConfig } diff --git a/src/util/favorites.js b/src/util/favorites.js index 520c919873..3924b96d84 100644 --- a/src/util/favorites.js +++ b/src/util/favorites.js @@ -58,6 +58,7 @@ const validLayerProperties = [ 'labelTemplate', 'countFeaturesWithoutCoordinates', 'countEventsOutsideOrgUnits', + 'spatialAnalysis', // mockup for DHIS2-21461, stored in layer config 'legendDecimalPlaces', 'legendIsolated', 'lastUpdated', @@ -180,6 +181,9 @@ const buildCommonLayerConfigData = (layer) => { if (layer.labelDataItem) { configData.labelDataItem = layer.labelDataItem } + if (layer.spatialAnalysis?.method) { + configData.spatialAnalysis = layer.spatialAnalysis + } return configData } @@ -194,6 +198,7 @@ const deleteCommonLayerConfigProps = (layer) => { delete layer.countFeaturesWithoutCoordinates delete layer.countEventsOutsideOrgUnits delete layer.labelDataItem + delete layer.spatialAnalysis } const buildEarthEngineLayerConfigData = (layer) => {