Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 20 additions & 2 deletions i18n/en.pot
Original file line number Diff line number Diff line change
Expand Up @@ -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-09-10T12:01:26.772Z\n"
"PO-Revision-Date: 2026-09-10T12:01:26.773Z\n"

msgid "2020"
msgstr "2020"
Expand Down Expand Up @@ -437,6 +437,21 @@ msgstr "Group events"
msgid "View all events"
msgstr "View all events"

msgid "Density clusters"
msgstr "Density clusters"

msgid "Neighborhood radius (m)"
msgstr "Neighborhood radius (m)"

msgid "Events within this distance of each other can join the same cluster"
msgstr "Events within this distance of each other can join the same cluster"

msgid "Minimum cluster size"
msgstr "Minimum cluster size"

msgid "Fewer nearby events than this are shown as individual, unclustered events"
msgstr "Fewer nearby events than this are shown as individual, unclustered events"

msgid "Radius"
msgstr "Radius"

Expand Down Expand Up @@ -811,6 +826,9 @@ msgstr ""
msgid "Basemap could not be added: {{message}}"
msgstr "Basemap could not be added: {{message}}"

msgid "Events in cluster"
msgstr "Events in cluster"

msgid "Could not retrieve event data"
msgstr "Could not retrieve event data"

Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@
"@dnd-kit/utilities": "^3.2.2",
"@turf/boolean-point-in-polygon": "^7.3.5",
"@turf/centroid": "^7.3.5",
"@turf/clusters-dbscan": "^7.3.5",
"abortcontroller-polyfill": "^1.7.8",
"array-move": "^4.0.0",
"classnames": "^2.5.1",
Expand Down
Binary file added public/images/dbscan.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
18 changes: 18 additions & 0 deletions src/actions/layerEdit.js
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,24 @@ export const setEventClustering = (checked) => ({
checked,
})

// Set if DBSCAN density clustering should be used (event) — mockup for DHIS2-21461
export const setDbscanClustering = (checked) => ({
type: types.LAYER_EDIT_DBSCAN_CLUSTERING_SET,
checked,
})

// Set the DBSCAN neighborhood radius in meters (event)
export const setDbscanEps = (eps) => ({
type: types.LAYER_EDIT_DBSCAN_EPS_SET,
eps,
})

// Set the DBSCAN minimum cluster size (event)
export const setDbscanMinPoints = (minPoints) => ({
type: types.LAYER_EDIT_DBSCAN_MIN_POINTS_SET,
minPoints,
})

// Set if features without coordinates should be counted and added to data table
export const setCountFeaturesWithoutCoordinates = (checked) => ({
type: types.LAYER_EDIT_COUNT_FEATURES_WITHOUT_COORDS_SET,
Expand Down
76 changes: 74 additions & 2 deletions src/components/edit/event/EventDialog.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ import {
setEventStatus,
setEventCoordinateField,
setEventClustering,
setDbscanClustering,
setDbscanEps,
setDbscanMinPoints,
setEventPointColor,
setEventPointRadius,
// setFallbackCoordinateField,
Expand All @@ -26,6 +29,12 @@ import {
EVENT_COLOR,
EVENT_RADIUS,
EVENT_BUFFER,
EVENT_DBSCAN_EPS_DEFAULT,
EVENT_DBSCAN_EPS_MIN,
EVENT_DBSCAN_EPS_MAX,
EVENT_DBSCAN_MIN_POINTS_DEFAULT,
EVENT_DBSCAN_MIN_POINTS_MIN,
EVENT_DBSCAN_MIN_POINTS_MAX,
CLASSIFICATION_PREDEFINED,
MIN_RADIUS,
MAX_RADIUS,
Expand Down Expand Up @@ -73,6 +82,9 @@ const EventDialog = ({
columns = DEFAULT_NO_COLUMNS,
countEventsOutsideOrgUnits,
countFeaturesWithoutCoordinates,
dbscanClustering,
dbscanEps,
dbscanMinPoints,
endDate,
eventClustering,
eventCoordinateField,
Expand Down Expand Up @@ -461,10 +473,67 @@ const EventDialog = ({
onClick={() =>
dispatch(setEventClustering(false))
}
isSelected={!eventClustering}
isSelected={
!eventClustering &&
!dbscanClustering
}
className={styles.flexInnerColumn}
/>
<ImageSelect
id="dbscan"
img="images/dbscan.png"
title={i18n.t('Density clusters')}
onClick={() =>
dispatch(setDbscanClustering(true))
}
isSelected={dbscanClustering}
className={styles.flexInnerColumn}
data-test="eventdialog-dbscan"
/>
</div>
{dbscanClustering && (
<div
className={styles.flexInnerColumnFlow}
data-test="eventdialog-dbscanoptions"
>
<NumberField
label={i18n.t(
'Neighborhood radius (m)'
)}
min={EVENT_DBSCAN_EPS_MIN}
max={EVENT_DBSCAN_EPS_MAX}
value={
dbscanEps ??
EVENT_DBSCAN_EPS_DEFAULT
}
onChange={(val) =>
dispatch(setDbscanEps(val))
}
helpText={i18n.t(
'Events within this distance of each other can join the same cluster'
)}
/>
<NumberField
label={i18n.t(
'Minimum cluster size'
)}
min={EVENT_DBSCAN_MIN_POINTS_MIN}
max={EVENT_DBSCAN_MIN_POINTS_MAX}
value={
dbscanMinPoints ??
EVENT_DBSCAN_MIN_POINTS_DEFAULT
}
onChange={(val) =>
dispatch(
setDbscanMinPoints(val)
)
}
helpText={i18n.t(
'Fewer nearby events than this are shown as individual, unclustered events'
)}
/>
</div>
)}
<div
className={cx(
styles.flexInnerColumnFlow,
Expand Down Expand Up @@ -494,7 +563,7 @@ const EventDialog = ({
</div>
<GeometryCentroid tab={'style'} />
<BufferRadius
disabled={eventClustering}
disabled={eventClustering || dbscanClustering}
defaultRadius={EVENT_BUFFER}
/>
<LabelFieldSelect />
Expand Down Expand Up @@ -555,6 +624,9 @@ EventDialog.propTypes = {
columns: PropTypes.array,
countEventsOutsideOrgUnits: PropTypes.bool,
countFeaturesWithoutCoordinates: PropTypes.bool,
dbscanClustering: PropTypes.bool,
dbscanEps: PropTypes.number,
dbscanMinPoints: PropTypes.number,
endDate: PropTypes.string,
eventClustering: PropTypes.bool,
eventCoordinateField: PropTypes.string,
Expand Down
160 changes: 145 additions & 15 deletions src/components/map/layers/EventLayer.jsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,15 @@
import { Analytics } from '@dhis2/analytics'
import i18n from '@dhis2/d2-i18n'
import { clustersDbscan } from '@turf/clusters-dbscan'
import React from 'react'
import {
EVENT_COLOR,
EVENT_RADIUS,
EVENT_DBSCAN_EPS_DEFAULT,
EVENT_DBSCAN_MIN_POINTS_DEFAULT,
EVENT_DBSCAN_CLUSTER_COLOR,
EVENT_DBSCAN_CLUSTER_MIN_RADIUS,
EVENT_DBSCAN_CLUSTER_MAX_RADIUS,
LABEL_TEMPLATE_NAME_ONLY,
LABEL_TEMPLATE_TOOLTIP_ONLY,
} from '../../../constants/layers.js'
Expand All @@ -21,6 +27,7 @@ import { getLabelStyle } from '../../../util/labels.js'
import { sortLegendItems } from '../../../util/legend.js'
import { formatCount } from '../../../util/numbers.js'
import { OPTION_SET_QUERY } from '../../../util/requests.js'
import Popup from '../Popup.jsx'
import EventPopup from './EventPopup.jsx'
import Layer from './Layer.js'

Expand All @@ -43,6 +50,9 @@ class EventLayer extends Layer {
data,
engine,
eventClustering,
dbscanClustering,
dbscanEps,
dbscanMinPoints,
eventCoordinateField,
eventPointColor,
eventPointRadius,
Expand Down Expand Up @@ -142,20 +152,36 @@ class EventLayer extends Layer {
}),
}

this.applyClusteringConfig(config, {
eventClustering,
serverCluster,
bounds,
areaRadius,
color,
styleDataItem,
legend,
id,
nameProperty,
engine,
analyticsEngine,
geometryCentroid,
})
// DBSCAN density clustering — mockup for DHIS2-21461, computed
// client-side since it needs the full loaded point set at once
if (dbscanClustering) {
config.data = this.buildDbscanFeatures(labeledData, {
eps: dbscanEps || EVENT_DBSCAN_EPS_DEFAULT,
minPoints: dbscanMinPoints || EVENT_DBSCAN_MIN_POINTS_DEFAULT,
})
config.label = LABEL_TEMPLATE_NAME_ONLY
config.labelStyle = getLabelStyle({
labelFontColor,
labelFontSize,
labelFontWeight,
labelFontStyle,
})
} else {
this.applyClusteringConfig(config, {
eventClustering,
serverCluster,
bounds,
areaRadius,
color,
styleDataItem,
legend,
id,
nameProperty,
engine,
analyticsEngine,
geometryCentroid,
})
}

if (program && programStage) {
this.loadDisplayItems({
Expand Down Expand Up @@ -249,12 +275,116 @@ class EventLayer extends Layer {
}
}

// DBSCAN density clustering — mockup for DHIS2-21461.
// Runs client-side over all loaded Point features (needs the full point
// set at once, unlike grid clustering's per-tile aggregation) and
// replaces each dense group of "core"/"edge" points with one synthetic
// summary feature, sized and labeled by member count. "Noise" points
// (events too sparse to join a cluster) pass through unchanged, so they
// keep rendering — and remain clickable — as regular individual events.
buildDbscanFeatures(data, { eps, minPoints }) {
const pointFeatures = data.filter(
(feature) => feature.geometry?.type === 'Point'
)

if (!pointFeatures.length) {
return data
}

const nonPointFeatures = data.filter(
(feature) => feature.geometry?.type !== 'Point'
)

const clustered = clustersDbscan(
{
type: 'FeatureCollection',
features: pointFeatures.map((feature) => ({
type: 'Feature',
geometry: feature.geometry,
properties: {},
})),
},
eps,
{ units: 'meters', minPoints }
)

const clusterGroups = {}
const output = [...nonPointFeatures]

clustered.features.forEach(({ properties }, index) => {
const originalFeature = pointFeatures[index]

if (properties.dbscan !== 'core' && properties.dbscan !== 'edge') {
output.push(originalFeature)
return
}

const group = (clusterGroups[properties.cluster] =
clusterGroups[properties.cluster] || [])
group.push(originalFeature)
})

Object.values(clusterGroups).forEach((members, clusterIndex) => {
const [lng, lat] = members
.reduce(
([sumLng, sumLat], feature) => [
sumLng + feature.geometry.coordinates[0],
sumLat + feature.geometry.coordinates[1],
],
[0, 0]
)
.map((sum) => sum / members.length)

output.push({
type: 'Feature',
id: `dbscan-cluster-${clusterIndex}`,
geometry: { type: 'Point', coordinates: [lng, lat] },
properties: {
isDbscanCluster: true,
dbscanCount: members.length,
radius: Math.min(
EVENT_DBSCAN_CLUSTER_MAX_RADIUS,
EVENT_DBSCAN_CLUSTER_MIN_RADIUS +
Math.sqrt(members.length) * 4
),
color: EVENT_DBSCAN_CLUSTER_COLOR,
name: formatCount(members.length),
},
})
})

return output
}

render() {
const { styleDataItem, nameProperty, keyAnalysisDigitGroupSeparator } =
this.props
const { popup, displayItems, eventCoordinateFieldName } = this.state

return popup && displayItems ? (
if (!popup) {
return null
}

// DBSCAN cluster summary points aren't real events, so they can't be
// looked up through the usual event popup's tracker/events query.
if (popup.feature.properties.isDbscanCluster) {
return (
<Popup coordinates={popup.coordinates} onClose={this.onPopupClose}>
<table>
<tbody>
<tr>
<th>{i18n.t('Events in cluster')}</th>
<td>
{popup.feature.properties.dbscanCount}
</td>
</tr>
</tbody>
</table>
</Popup>
)
}

return displayItems ? (
<EventPopup
{...popup}
styleDataItem={styleDataItem}
Expand Down
Loading
Loading