From bb0b416e589f641ee4a16118607b2c36c42036a2 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault Date: Thu, 10 Sep 2026 14:05:43 +0200 Subject: [PATCH 1/2] mockup: DBSCAN density clustering for event layers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mockup for DHIS2-21461. Adds a third event-clustering mode alongside the existing grid and donut clustering: density-based clustering via @turf/clusters-dbscan, which groups nearby events by density rather than by map grid cell and explicitly separates dense clusters from noise (sporadic events that join no cluster). Entirely client-side, built on the existing "events" layer type in maps-gl — no maps-gl changes needed, since its point/label rendering already supports per-feature radius/color/name overrides. Settings (enabled, neighborhood radius, minimum cluster size) are gated behind and persisted in the layer's config JSON blob, so a saved map round-trips the DBSCAN settings for sharing. DO NOT MERGE Co-Authored-By: Claude Sonnet 5 --- i18n/en.pot | 22 ++- package.json | 1 + public/images/dbscan.png | Bin 0 -> 3284 bytes src/actions/layerEdit.js | 18 +++ src/components/edit/event/EventDialog.jsx | 76 +++++++++- src/components/map/layers/EventLayer.jsx | 160 ++++++++++++++++++++-- src/constants/actionTypes.js | 5 + src/constants/layers.js | 11 ++ src/loaders/eventLoader.js | 8 ++ src/reducers/layerEdit.js | 20 +++ src/util/favorites.js | 11 ++ yarn.lock | 58 ++++++++ 12 files changed, 371 insertions(+), 19 deletions(-) create mode 100644 public/images/dbscan.png diff --git a/i18n/en.pot b/i18n/en.pot index 72d26d6fad..3a09c53b98 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-09-10T12:01:26.772Z\n" +"PO-Revision-Date: 2026-09-10T12:01:26.773Z\n" msgid "2020" msgstr "2020" @@ -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" @@ -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" diff --git a/package.json b/package.json index 83ec322d06..69ecd97d99 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/clusters-dbscan": "^7.3.5", "abortcontroller-polyfill": "^1.7.8", "array-move": "^4.0.0", "classnames": "^2.5.1", diff --git a/public/images/dbscan.png b/public/images/dbscan.png new file mode 100644 index 0000000000000000000000000000000000000000..0dc45ffd71394df88f064f213d0be68697b43b10 GIT binary patch literal 3284 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 ({ 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, diff --git a/src/components/edit/event/EventDialog.jsx b/src/components/edit/event/EventDialog.jsx index 5302dd24e5..d1c1d3a57f 100644 --- a/src/components/edit/event/EventDialog.jsx +++ b/src/components/edit/event/EventDialog.jsx @@ -11,6 +11,9 @@ import { setEventStatus, setEventCoordinateField, setEventClustering, + setDbscanClustering, + setDbscanEps, + setDbscanMinPoints, setEventPointColor, setEventPointRadius, // setFallbackCoordinateField, @@ -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, @@ -73,6 +82,9 @@ const EventDialog = ({ columns = DEFAULT_NO_COLUMNS, countEventsOutsideOrgUnits, countFeaturesWithoutCoordinates, + dbscanClustering, + dbscanEps, + dbscanMinPoints, endDate, eventClustering, eventCoordinateField, @@ -461,10 +473,67 @@ const EventDialog = ({ onClick={() => dispatch(setEventClustering(false)) } - isSelected={!eventClustering} + isSelected={ + !eventClustering && + !dbscanClustering + } + className={styles.flexInnerColumn} + /> + + dispatch(setDbscanClustering(true)) + } + isSelected={dbscanClustering} className={styles.flexInnerColumn} + data-test="eventdialog-dbscan" /> + {dbscanClustering && ( +
+ + dispatch(setDbscanEps(val)) + } + helpText={i18n.t( + 'Events within this distance of each other can join the same cluster' + )} + /> + + dispatch( + setDbscanMinPoints(val) + ) + } + helpText={i18n.t( + 'Fewer nearby events than this are shown as individual, unclustered events' + )} + /> +
+ )}
@@ -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, diff --git a/src/components/map/layers/EventLayer.jsx b/src/components/map/layers/EventLayer.jsx index b132e09e74..d3fefe57b2 100644 --- a/src/components/map/layers/EventLayer.jsx +++ b/src/components/map/layers/EventLayer.jsx @@ -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' @@ -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' @@ -43,6 +50,9 @@ class EventLayer extends Layer { data, engine, eventClustering, + dbscanClustering, + dbscanEps, + dbscanMinPoints, eventCoordinateField, eventPointColor, eventPointRadius, @@ -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({ @@ -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 ( + + + + + + + + +
{i18n.t('Events in cluster')} + {popup.feature.properties.dbscanCount} +
+
+ ) + } + + return displayItems ? ( { return { ...state, eventClustering: action.checked, + ...(action.checked && { dbscanClustering: false }), + } + + case types.LAYER_EDIT_DBSCAN_CLUSTERING_SET: + return { + ...state, + dbscanClustering: action.checked, + ...(action.checked && { eventClustering: false }), + } + + case types.LAYER_EDIT_DBSCAN_EPS_SET: + return { + ...state, + dbscanEps: action.eps, + } + + case types.LAYER_EDIT_DBSCAN_MIN_POINTS_SET: + return { + ...state, + dbscanMinPoints: action.minPoints, } case types.LAYER_EDIT_COUNT_FEATURES_WITHOUT_COORDS_SET: diff --git a/src/util/favorites.js b/src/util/favorites.js index 520c919873..3f4da867c2 100644 --- a/src/util/favorites.js +++ b/src/util/favorites.js @@ -58,6 +58,9 @@ const validLayerProperties = [ 'labelTemplate', 'countFeaturesWithoutCoordinates', 'countEventsOutsideOrgUnits', + 'dbscanClustering', // mockup for DHIS2-21461, stored in layer config + 'dbscanEps', + 'dbscanMinPoints', 'legendDecimalPlaces', 'legendIsolated', 'lastUpdated', @@ -180,6 +183,11 @@ const buildCommonLayerConfigData = (layer) => { if (layer.labelDataItem) { configData.labelDataItem = layer.labelDataItem } + if (layer.dbscanClustering) { + configData.dbscanClustering = true + configData.dbscanEps = layer.dbscanEps + configData.dbscanMinPoints = layer.dbscanMinPoints + } return configData } @@ -194,6 +202,9 @@ const deleteCommonLayerConfigProps = (layer) => { delete layer.countFeaturesWithoutCoordinates delete layer.countEventsOutsideOrgUnits delete layer.labelDataItem + delete layer.dbscanClustering + delete layer.dbscanEps + delete layer.dbscanMinPoints } const buildEarthEngineLayerConfigData = (layer) => { diff --git a/yarn.lock b/yarn.lock index 90512c02d9..ec7193d176 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3968,6 +3968,28 @@ "@types/geojson" "^7946.0.10" tslib "^2.8.1" +"@turf/clone@7.4.0": + version "7.4.0" + resolved "https://registry.yarnpkg.com/@turf/clone/-/clone-7.4.0.tgz#61a2c7c7c72ad656b668b08b88035cb13f88eddf" + integrity sha512-IfYnuil7XYJauy3crzIYEr26QkmBiTgFdGfYwUUe3S6dawX+lyb3vhuaYyssEPcCJq83g0G7hwxZy7141Mmzjg== + dependencies: + "@turf/helpers" "7.4.0" + "@types/geojson" "^7946.0.10" + tslib "^2.8.1" + +"@turf/clusters-dbscan@^7.3.5": + version "7.4.0" + resolved "https://registry.yarnpkg.com/@turf/clusters-dbscan/-/clusters-dbscan-7.4.0.tgz#3fca7b0605a8c938a4171f7f83dd4ff47ff1bd0b" + integrity sha512-6uKPUvL7VNx3MueOqgGTx/pnGe5p5rgy/tA/6HOgRFLlDu0oYUTUdNg2JE0ylYOc0HW5hxCcmsRR0lz6fxw1KA== + dependencies: + "@turf/clone" "7.4.0" + "@turf/distance" "7.4.0" + "@turf/helpers" "7.4.0" + "@turf/meta" "7.4.0" + "@types/geojson" "^7946.0.10" + rbush "^3.0.1" + tslib "^2.8.1" + "@turf/convex@7.3.5": version "7.3.5" resolved "https://registry.yarnpkg.com/@turf/convex/-/convex-7.3.5.tgz#8781b7a53855195224f02e003df6205c3ad3afc2" @@ -3999,6 +4021,16 @@ "@types/geojson" "^7946.0.10" tslib "^2.8.1" +"@turf/distance@7.4.0": + version "7.4.0" + resolved "https://registry.yarnpkg.com/@turf/distance/-/distance-7.4.0.tgz#7ddbc863f6e510749b60f02780f3c96fb8d2db06" + integrity sha512-ODkopQDG1m/U6Mx7OMmShncaCvneomwX3lZY1CDA7x40bhDdTTRYP/n/6LicZVyIO4/oK+aXkov4NOtWYthA2g== + dependencies: + "@turf/helpers" "7.4.0" + "@turf/invariant" "7.4.0" + "@types/geojson" "^7946.0.10" + tslib "^2.8.1" + "@turf/helpers@7.3.5": version "7.3.5" resolved "https://registry.yarnpkg.com/@turf/helpers/-/helpers-7.3.5.tgz#051928c03cdf9ffcc7ae36581c317afc49bfd999" @@ -4007,6 +4039,14 @@ "@types/geojson" "^7946.0.10" tslib "^2.8.1" +"@turf/helpers@7.4.0": + version "7.4.0" + resolved "https://registry.yarnpkg.com/@turf/helpers/-/helpers-7.4.0.tgz#5589f04812ac95f8985e9396db01d9e70f4a35d5" + integrity sha512-7PAwLZqOdRzTI5g9bHvUwlloAXPDH/mlajtryk0tw4ZwGMtmXsAyF4QundsAMfy4u48Fyd3AUqMXY0SMxqJGWg== + dependencies: + "@types/geojson" "^7946.0.10" + tslib "^2.8.1" + "@turf/invariant@7.3.5": version "7.3.5" resolved "https://registry.yarnpkg.com/@turf/invariant/-/invariant-7.3.5.tgz#5619d0e0ef3755e2be69855bad47e10158a1820b" @@ -4016,6 +4056,15 @@ "@types/geojson" "^7946.0.10" tslib "^2.8.1" +"@turf/invariant@7.4.0": + version "7.4.0" + resolved "https://registry.yarnpkg.com/@turf/invariant/-/invariant-7.4.0.tgz#b96eb3a1965cca126fcf15571058d126cc7a32e2" + integrity sha512-OAsc3qdNx+tRqzWmMNFMnVlWWACIqnYqIejZKvb2oKkKPYJJrURPXZ6OdrGD58ljJMolLoqwIZGSx3VndaEjxg== + dependencies: + "@turf/helpers" "7.4.0" + "@types/geojson" "^7946.0.10" + tslib "^2.8.1" + "@turf/jsts@^2.7.1": version "2.7.2" resolved "https://registry.yarnpkg.com/@turf/jsts/-/jsts-2.7.2.tgz#b327aa54ef065cbf1768ad69bd6ae1087385d32b" @@ -4043,6 +4092,15 @@ "@types/geojson" "^7946.0.10" tslib "^2.8.1" +"@turf/meta@7.4.0": + version "7.4.0" + resolved "https://registry.yarnpkg.com/@turf/meta/-/meta-7.4.0.tgz#99c3b3af4c2e5a7ef0b4f99c9f42120e82a91e62" + integrity sha512-3cLUvlEyDuSnMSzrjhaLAEiYR8xhbfWyTVQlrlEw40xL81d4KF4PqUWbjTXKpXZStdYbet2GCurl79KMypNw6g== + dependencies: + "@turf/helpers" "7.4.0" + "@types/geojson" "^7946.0.10" + tslib "^2.8.1" + "@turf/projection@7.3.5": version "7.3.5" resolved "https://registry.yarnpkg.com/@turf/projection/-/projection-7.3.5.tgz#5de65db16cf4f9e789b2beec1f25e8482981f958" From a67bcd7d013f6a8392826ab0cdb3a4b1ec18fb25 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault Date: Thu, 17 Sep 2026 15:57:28 +0200 Subject: [PATCH 2/2] mockup: polish DBSCAN cluster rendering Fixes and iterates on the DBSCAN density-clustering event layer mockup: - Fix a bug where every noise point rendered the literal label text "undefined": the DBSCAN branch force-set a {name} label template but never guaranteed noise points actually had a name property. - Give each cluster a distinct color instead of one fixed orange, cycling through a curated 7-color subset of the Data Visualizer default palette (excluding its two greys, already reserved for noise, and its palest entries). Colors are assigned by greedy graph coloring against real-world proximity (not cluster index), so clusters that are actually near each other on the map get different colors; distant clusters can safely reuse one. - Default noise points to a muted grey/smaller radius instead of the sitewide near-black default, without overriding an explicit user color/radius choice. - Add zoom-driven declustering: a cluster's aggregate bubble swaps for its individual (color-tagged) member points once eps, converted to on-screen pixels at the current zoom, crosses a threshold -- shared by every cluster so they all switch together, rather than each cluster's own on-screen spread deciding independently. Membership itself (DBSCAN eps/minPoints) never changes with zoom. - Add a concave hull per cluster (own maps-gl layer, so its opacity can be tuned independently of the events layer's points, which have no such control and paint above the hull). Concave rather than convex specifically because a convex hull fills in the concave notches of a chain-shaped cluster, visually enclosing unrelated noise points that fall in that gap. - Show the cluster number in its popup, and make clicking the hull open the same popup as clicking the bubble/points. Still a mockup for DHIS2-21461, not intended to merge as-is. Co-Authored-By: Claude Sonnet 5 --- package.json | 1 + src/components/map/layers/EventLayer.jsx | 392 ++++++++++++++++++++--- src/constants/layers.js | 23 +- src/util/colors.js | 44 +++ yarn.lock | 41 ++- 5 files changed, 460 insertions(+), 41 deletions(-) diff --git a/package.json b/package.json index 69ecd97d99..da99f6c18a 100644 --- a/package.json +++ b/package.json @@ -58,6 +58,7 @@ "@turf/boolean-point-in-polygon": "^7.3.5", "@turf/centroid": "^7.3.5", "@turf/clusters-dbscan": "^7.3.5", + "@turf/concave": "^7.3.5", "abortcontroller-polyfill": "^1.7.8", "array-move": "^4.0.0", "classnames": "^2.5.1", diff --git a/src/components/map/layers/EventLayer.jsx b/src/components/map/layers/EventLayer.jsx index d3fefe57b2..0052398ece 100644 --- a/src/components/map/layers/EventLayer.jsx +++ b/src/components/map/layers/EventLayer.jsx @@ -1,19 +1,24 @@ import { Analytics } from '@dhis2/analytics' import i18n from '@dhis2/d2-i18n' import { clustersDbscan } from '@turf/clusters-dbscan' +import { concave } from '@turf/concave' 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, + EVENT_DBSCAN_NOISE_COLOR, + EVENT_DBSCAN_NOISE_RADIUS, + EVENT_DBSCAN_REVEAL_PIXEL_RADIUS, + EVENT_DBSCAN_HULL_OPACITY_FACTOR, + GEOJSON_LAYER, LABEL_TEMPLATE_NAME_ONLY, LABEL_TEMPLATE_TOOLTIP_ONLY, } from '../../../constants/layers.js' -import { getContrastColor } from '../../../util/colors.js' +import { getClusterColor, getContrastColor } from '../../../util/colors.js' import { loadEventCoordinateFieldName } from '../../../util/coordinatesName.js' import { getAnalyticsRequest, @@ -31,6 +36,18 @@ import Popup from '../Popup.jsx' import EventPopup from './EventPopup.jsx' import Layer from './Layer.js' +// Equirectangular approximation, not a proper haversine/turf distance — +// plenty accurate at the local/regional scale a single DBSCAN cluster +// layer operates at, without pulling in another @turf/* dependency just +// for this one greedy-color-assignment heuristic (see setupDbscanClusters) +const approxDistanceMeters = ([lng1, lat1], [lng2, lat2]) => { + const EARTH_RADIUS = 6371000 + const dLat = ((lat2 - lat1) * Math.PI) / 180 + const dLng = ((lng2 - lng1) * Math.PI) / 180 + const x = dLng * Math.cos(((lat1 + lat2) / 2) * (Math.PI / 180)) + return Math.sqrt(x * x + dLat * dLat) * EARTH_RADIUS +} + class EventLayer extends Layer { clusterCount = 0 @@ -84,12 +101,19 @@ class EventLayer extends Layer { ? '#' + eventPointColor : eventPointColor - const fillColor = color || EVENT_COLOR + // Noise points (DBSCAN mode) default to a muted grey rather than the + // sitewide near-black EVENT_COLOR, so they read as neutral against + // clustered points' own distinct colors — but an explicit user + // color choice still wins, same as any other event style mode + const fillColor = + color || (dbscanClustering ? EVENT_DBSCAN_NOISE_COLOR : EVENT_COLOR) const strokeColor = !styleDataItem ? getContrastColor(fillColor) : undefined const countColor = strokeColor - const radius = eventPointRadius || EVENT_RADIUS + const radius = + eventPointRadius || + (dbscanClustering ? EVENT_DBSCAN_NOISE_RADIUS : EVENT_RADIUS) const map = this.context.map @@ -155,10 +179,11 @@ class EventLayer extends Layer { // 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, { + this.setupDbscanClusters(labeledData, { eps: dbscanEps || EVENT_DBSCAN_EPS_DEFAULT, minPoints: dbscanMinPoints || EVENT_DBSCAN_MIN_POINTS_DEFAULT, }) + config.data = this.renderDbscanFeatures() config.label = LABEL_TEMPLATE_NAME_ONLY config.labelStyle = getLabelStyle({ labelFontColor, @@ -194,16 +219,79 @@ class EventLayer extends Layer { }) } - // Create and add event layer based on config object - this.layer = map.createLayer(config) + // Create and add event layer based on config object. DBSCAN mode + // wraps it in a group alongside a separate cluster-hull layer, so + // the hull's opacity/z-order can be controlled independently of + // the events layer's own points (see EVENT_DBSCAN_HULL_OPACITY_FACTOR). + if (dbscanClustering) { + this.layer = map.createLayer({ + type: 'group', + id, + index, + opacity, + isVisible, + }) + this.layer.addLayer({ + type: GEOJSON_LAYER, + id: `${id}-dbscan-hulls`, + // A plain feature array, like the main config's own data + // below — maps-gl's Layer.setFeatures() expects that, not + // a wrapped FeatureCollection (that shape is only what the + // raw maplibre-gl source's own setData() takes, used in + // updateDbscanFeatures for the *other* sub-layer) + data: this.dbscanHulls, + style: { opacityFactor: EVENT_DBSCAN_HULL_OPACITY_FACTOR }, + // Clicking the hull opens the same cluster popup as + // clicking its bubble/points — hull features carry the + // same isDbscanCluster/dbscanCount/clusterNumber, see + // setupDbscanClusters + onClick: this.onEventClick.bind(this), + }) + this.layer.addLayer(config) + } else { + this.layer = map.createLayer(config) + } map.addLayer(this.layer) this.setLayerVisibility() + // Re-render (not re-cluster) on zoom: a cluster's aggregate bubble + // can swap for its individual member points once they're spread + // far enough apart on screen — see renderDbscanFeatures. Cluster + // membership itself was already fixed above in setupDbscanClusters. + if (dbscanClustering) { + // maps-gl's Layer assigns each instance its own random id + // internally (unrelated to the id/config we pass in), so the + // events sub-layer's actual GeoJSON source id can only be read + // off the real instance the group just created — LayerGroup + // queues configs and only turns them into instances (in the + // same order) once map.addLayer(this.layer) runs, just above. + // Hulls were queued first, so events is the last instance. + const eventsLayer = this.layer._layers?.at(-1) + this.dbscanEventsLayerId = eventsLayer?.getId() + + // 'zoom' (continuous, fires through the gesture) rather than + // 'zoomend' (fires once, only after the gesture settles) — with + // only 'zoomend', reveal/collapse only catches up once zooming + // stops, so a fast scroll-wheel zoom-out sails straight past + // the actual threshold and only resolves once you stop, + // making it feel like it needs far more zoom than it actually does + this.onDbscanZoom = () => this.updateDbscanFeatures() + map.getMapGL().on('zoom', this.onDbscanZoom) + } + // Fit map to layer bounds once (when first created) this.fitBoundsOnce() } + async removeLayer() { + if (this.onDbscanZoom) { + this.context.map.getMapGL().off('zoom', this.onDbscanZoom) + this.onDbscanZoom = null + } + await super.removeLayer() + } + // Mutates config in place: server/client/donut clustering, or a buffer. applyClusteringConfig( config, @@ -278,23 +366,36 @@ 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 }) { + // groups each dense group of "core"/"edge" points into a cluster, each + // assigned its own distinct color (cluster *identity* is the point of + // DBSCAN, unlike grid clustering where only density/count matters). + // Membership is computed once here and never revisited: eps is a + // real-world distance in meters, not a pixel radius, so which points + // belong together is a property of the data, not the current zoom — + // recomputing it per zoom would contradict the whole premise of + // density-based (as opposed to grid-based) clustering. "Noise" points + // (events too sparse to join a cluster) are stored separately and + // always render as individual events. + setupDbscanClusters(data, { eps, minPoints }) { + // Kept for the reveal-threshold check in shouldRevealDbscanClusters, + // which needs the same eps every cluster was actually clustered with + this.dbscanEps = eps + const pointFeatures = data.filter( (feature) => feature.geometry?.type === 'Point' ) - if (!pointFeatures.length) { - return data - } - - const nonPointFeatures = data.filter( + this.dbscanNonPointFeatures = data.filter( (feature) => feature.geometry?.type !== 'Point' ) + if (!pointFeatures.length) { + this.dbscanClusters = [] + this.dbscanNoiseFeatures = [] + this.dbscanHulls = [] + return + } + const clustered = clustersDbscan( { type: 'FeatureCollection', @@ -308,23 +409,33 @@ class EventLayer extends Layer { { units: 'meters', minPoints } ) - const clusterGroups = {} - const output = [...nonPointFeatures] + const groups = {} + const noise = [] clustered.features.forEach(({ properties }, index) => { const originalFeature = pointFeatures[index] if (properties.dbscan !== 'core' && properties.dbscan !== 'edge') { - output.push(originalFeature) + noise.push({ + ...originalFeature, + properties: { + ...originalFeature.properties, + // Never leave this undefined: the {name} label + // template would otherwise render the literal + // string "undefined" for every noise point + name: originalFeature.properties.name ?? '', + }, + }) return } - const group = (clusterGroups[properties.cluster] = - clusterGroups[properties.cluster] || []) + const group = (groups[properties.cluster] = + groups[properties.cluster] || []) group.push(originalFeature) }) - Object.values(clusterGroups).forEach((members, clusterIndex) => { + this.dbscanNoiseFeatures = noise + const clusters = Object.values(groups).map((members, clusterIndex) => { const [lng, lat] = members .reduce( ([sumLng, sumLat], feature) => [ @@ -334,26 +445,222 @@ class EventLayer extends Layer { [0, 0] ) .map((sum) => sum / members.length) + const centroid = [lng, lat] - output.push({ - type: 'Feature', + return { id: `dbscan-cluster-${clusterIndex}`, - geometry: { type: 'Point', coordinates: [lng, lat] }, + // 1-based: a human-facing label (shown in the popup), + // not the internal id string above + clusterNumber: clusterIndex + 1, + centroid, + members, + // How far this cluster's own members actually reach from + // its centroid, in meters — used below to decide which + // *other* clusters are close enough on the map to need a + // different color, not just an on-screen pixel check + // (which would depend on zoom) + extentRadius: members.reduce( + (max, feature) => + Math.max( + max, + approxDistanceMeters( + centroid, + feature.geometry.coordinates + ) + ), + 0 + ), + radius: Math.min( + EVENT_DBSCAN_CLUSTER_MAX_RADIUS, + EVENT_DBSCAN_CLUSTER_MIN_RADIUS + + Math.sqrt(members.length) * 4 + ), + } + }) + + // Greedy color assignment: give each cluster the first curated + // color (see getClusterColor) not already used by any *nearby* + // cluster processed so far, rather than a flat index%colors cycle + // — which, with only a handful of colors, frequently landed two + // adjacent clusters on the same color purely by coincidence of + // their cluster index, unrelated to their actual position. + // "Nearby" = footprints (extentRadius) overlapping plus a buffer, + // so clusters merely close on the map, not just literally + // touching, still get told apart. + const neighborBuffer = eps * 3 + clusters.forEach((cluster, i) => { + const usedByNeighbors = clusters + .slice(0, i) + .filter( + (other) => + approxDistanceMeters(cluster.centroid, other.centroid) < + cluster.extentRadius + + other.extentRadius + + neighborBuffer + ) + .map((other) => other.color) + + cluster.color = getClusterColor(i, usedByNeighbors) + }) + + this.dbscanClusters = clusters + + // A concave hull per cluster, showing its actual spatial extent — + // rendered in its own layer (see the group layer built in + // createLayer), not updated on zoom since the underlying geography + // doesn't change. A convex hull would be simpler, but it fills in + // concave notches of a chain-shaped cluster, visually enclosing + // other, unrelated noise points that happen to fall in that gap — + // maxEdge is tied to eps (the same real-world distance clusters + // were actually built from) since a boundary edge longer than + // that isn't really part of the cluster's own shape either — but + // needs real slack above eps itself: two points can be in the same + // cluster via a *chain* of eps-hops through other members while + // sitting further than eps apart directly, and concave's Delaunay + // triangulation drops (orphans outside the hull) any point whose + // every edge exceeds maxEdge. Too tight and real members get + // excluded; this is a pragmatic multiplier, not an exact bound. + // Needs at least 3 non-collinear members to form a polygon at all; + // clusters too small/degenerate for that just skip a hull, still + // rendering normally otherwise. + this.dbscanHulls = this.dbscanClusters.flatMap((cluster) => { + if (cluster.members.length < 3) { + return [] + } + + const hull = concave( + { + type: 'FeatureCollection', + features: cluster.members.map((feature) => ({ + type: 'Feature', + properties: {}, + geometry: feature.geometry, + })), + }, + { + maxEdge: (this.dbscanEps * 2.5) / 1000, + units: 'kilometers', + } + ) + + if (!hull) { + return [] + } + + // A tight maxEdge can split the hull into a MultiPolygon + // (disconnected parts of the same cluster) — the polygon + // layer only renders plain Polygon geometry, so each part + // becomes its own feature, all sharing the cluster's color + const polygons = + hull.geometry.type === 'MultiPolygon' + ? hull.geometry.coordinates.map((coordinates) => ({ + type: 'Polygon', + coordinates, + })) + : [hull.geometry] + + // Same identifying properties as the aggregate bubble (see + // renderDbscanFeatures) so clicking a hull opens the same + // cluster popup as clicking its bubble/points does + return polygons.map((geometry) => ({ + type: 'Feature', + geometry, properties: { + color: cluster.color, 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), + dbscanCount: cluster.members.length, + clusterNumber: cluster.clusterNumber, }, - }) + })) }) + } - return output + // Builds the layer's current feature set from the fixed cluster + // membership computed in setupDbscanClusters. Every cluster renders as + // either an aggregate bubble or its individual member points, in + // lockstep — see shouldRevealDbscanClusters — tagged with that + // cluster's own color so its identity stays visible even once + // "unpacked". + renderDbscanFeatures() { + const mapGL = this.context.map.getMapGL() + const reveal = this.shouldRevealDbscanClusters(mapGL) + + const clusterFeatures = (this.dbscanClusters || []).flatMap( + (cluster) => { + if (reveal) { + return cluster.members.map((feature) => ({ + ...feature, + properties: { + ...feature.properties, + color: cluster.color, + name: feature.properties.name ?? '', + }, + })) + } + + return [ + { + type: 'Feature', + id: cluster.id, + geometry: { + type: 'Point', + coordinates: cluster.centroid, + }, + properties: { + isDbscanCluster: true, + dbscanCount: cluster.members.length, + clusterNumber: cluster.clusterNumber, + radius: cluster.radius, + color: cluster.color, + name: formatCount(cluster.members.length), + }, + }, + ] + } + ) + + return [ + ...(this.dbscanNonPointFeatures || []), + ...(this.dbscanNoiseFeatures || []), + ...clusterFeatures, + ] + } + + // Re-renders (not re-clusters) the DBSCAN layer after a zoom change — + // see renderDbscanFeatures/shouldRevealDbscanClusters. + updateDbscanFeatures() { + if (!this.layer || !this.dbscanClusters) { + return + } + + const source = this.context.map + .getMapGL() + .getSource(this.dbscanEventsLayerId) + + if (source) { + source.setData({ + type: 'FeatureCollection', + features: this.renderDbscanFeatures(), + }) + } + } + + // One shared reveal/collapse decision for every cluster, based only on + // the current zoom (via eps converted to on-screen pixels at the + // map's current center), rather than each cluster's own member + // spread — so all clusters switch between aggregate bubble and + // individual points together, at the same zoom level, instead of + // staggered by how spread out each one happens to be. eps is the same + // real-world distance every cluster was actually built from, so + // converting *that* to pixels is what makes this shared and consistent. + shouldRevealDbscanClusters(mapGL) { + const { lat } = mapGL.getCenter() + const metersPerPixel = + (156543.03392 * Math.cos((lat * Math.PI) / 180)) / + Math.pow(2, mapGL.getZoom()) + const epsPixels = this.dbscanEps / metersPerPixel + + return epsPixels >= EVENT_DBSCAN_REVEAL_PIXEL_RADIUS } render() { @@ -369,15 +676,22 @@ class EventLayer extends Layer { // looked up through the usual event popup's tracker/events query. if (popup.feature.properties.isDbscanCluster) { return ( - + - + + + + +
{i18n.t('Events in cluster')}{i18n.t('Cluster')} - {popup.feature.properties.dbscanCount} + {popup.feature.properties.clusterNumber}
{i18n.t('Events in cluster')}{popup.feature.properties.dbscanCount}
diff --git a/src/constants/layers.js b/src/constants/layers.js index 1b3dde01ef..d6a1ed0705 100644 --- a/src/constants/layers.js +++ b/src/constants/layers.js @@ -128,9 +128,30 @@ export const EVENT_DBSCAN_EPS_MAX = 10000 export const EVENT_DBSCAN_MIN_POINTS_DEFAULT = 3 export const EVENT_DBSCAN_MIN_POINTS_MIN = 2 export const EVENT_DBSCAN_MIN_POINTS_MAX = 50 -export const EVENT_DBSCAN_CLUSTER_COLOR = '#E6550D' export const EVENT_DBSCAN_CLUSTER_MIN_RADIUS = 10 export const EVENT_DBSCAN_CLUSTER_MAX_RADIUS = 40 +// Muted/neutral default for noise points (events too sparse to join any +// cluster) — grey rather than the sitewide EVENT_COLOR default, so noise +// visually recedes against clustered points' own distinct colors, per the +// standard DBSCAN convention (e.g. scikit-learn's reference plot, which +// renders noise in a flat neutral tone rather than a "real" data color) +export const EVENT_DBSCAN_NOISE_COLOR = '#757575' +export const EVENT_DBSCAN_NOISE_RADIUS = 4 +// On-screen radius (px) a cluster's own member spread needs to reach +// before its aggregate bubble is swapped for individual member points — +// see EventLayer's zoom-driven declustering. Clustering membership itself +// (DBSCAN eps/minPoints) never changes with zoom, only this rendering +// choice does. +export const EVENT_DBSCAN_REVEAL_PIXEL_RADIUS = 20 +// Convex hull around each cluster's members, showing its actual spatial +// extent (the more "statistically honest" DBSCAN convention, per e.g. R's +// dbscan::hullplot()) rather than only a centroid+radius bubble. Rendered +// as its own maps-gl layer, entirely separate from the main events layer, +// specifically so its fill/outline opacity can be turned down — the +// events layer's own polygon rendering has no such per-feature opacity +// control and paints above points, which would otherwise bury the +// cluster bubbles/member points a hull is drawn around. +export const EVENT_DBSCAN_HULL_OPACITY_FACTOR = 0.35 /* TEI LAYER */ export const TEI_COLOR = '#BB0000' diff --git a/src/util/colors.js b/src/util/colors.js index 810f5a4799..1c3c2711f4 100644 --- a/src/util/colors.js +++ b/src/util/colors.js @@ -1,3 +1,4 @@ +import { COLOR_SET_DEFAULT, colorSets } from '@dhis2/analytics' import { hcl } from 'd3-color' import { isString } from 'lodash/fp' import colorbrewer from '../constants/colorbrewer.js' @@ -82,6 +83,49 @@ export const cssColor = (color) => { return (/(^[0-9A-F]{6}$)|(^[0-9A-F]{3}$)/i.test(color) ? '#' : '') + color } +// A curated subset of the Data Visualizer default palette, rather than +// cycling through the full ~19-color set: with many clusters, that many +// hues on screen at once reads as noisy, and most of that variety is +// wasted anyway since color only needs to disambiguate *nearby* clusters +// — two clusters on opposite sides of the map reusing a color causes no +// real confusion. Picked (as a literal allowlist, not a formula) for +// maximum mutual distinctiveness — spread across hue, excluding the two +// greys (see EVENT_DBSCAN_NOISE_COLOR, grey is already reserved for +// noise) and the palette's very pale, washed-out entries. The filter +// against the actual default palette (rather than just hardcoding these +// hex values) is what guarantees every one of these really is a member +// of that palette, not a color that merely looks similar to it. +const CLUSTER_COLOR_ALLOWLIST = [ + '#518cc3', // blue + '#d74554', // red + '#ff9e21', // orange + '#47792c', // green + '#45beae', // teal + '#ba3ba1', // magenta + '#6b2dd4', // violet +] +const clusterColors = colorSets[COLOR_SET_DEFAULT].colors.filter((color) => + CLUSTER_COLOR_ALLOWLIST.includes(color) +) + +// Mockup for DHIS2-21461: a distinct color per DBSCAN cluster (identity, +// not density, is what a cluster index encodes), cycling through the +// curated subset above so an arbitrary number of clusters always gets a +// color rather than being capped by palette size. excludeColors lets a +// caller avoid handing out the same color to two *nearby* clusters (see +// EventLayer's greedy assignment) — falls back to the plain cycled color +// if every option is excluded (rare: only when a cluster has more close +// neighbors than there are colors), rather than returning nothing. +export const getClusterColor = (index, excludeColors = []) => { + for (let offset = 0; offset < clusterColors.length; offset++) { + const color = clusterColors[(index + offset) % clusterColors.length] + if (!excludeColors.includes(color)) { + return color + } + } + return clusterColors[index % clusterColors.length] +} + // Returns an unique color (first from an array, then random but still unique) export const getUniqueColor = (defaultColors) => { const colors = [...defaultColors] diff --git a/yarn.lock b/yarn.lock index ec7193d176..ff220d52d1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3990,6 +3990,22 @@ rbush "^3.0.1" tslib "^2.8.1" +"@turf/concave@^7.3.5": + version "7.4.0" + resolved "https://registry.yarnpkg.com/@turf/concave/-/concave-7.4.0.tgz#7bc0378f8603c03f09fd406bcc20b83103be381e" + integrity sha512-EzCHDEbGRVbo9vR/HJriC8U7mpxgTYgyluBDNzcaT7kX53eH7/eS1U7rp/Q36e5RXMmlAWBknqtVseGMvKE5tw== + dependencies: + "@turf/clone" "7.4.0" + "@turf/distance" "7.4.0" + "@turf/helpers" "7.4.0" + "@turf/invariant" "7.4.0" + "@turf/meta" "7.4.0" + "@turf/tin" "7.4.0" + "@types/geojson" "^7946.0.10" + topojson-client "3.x" + topojson-server "3.x" + tslib "^2.8.1" + "@turf/convex@7.3.5": version "7.3.5" resolved "https://registry.yarnpkg.com/@turf/convex/-/convex-7.3.5.tgz#8781b7a53855195224f02e003df6205c3ad3afc2" @@ -4112,6 +4128,15 @@ "@types/geojson" "^7946.0.10" tslib "^2.8.1" +"@turf/tin@7.4.0": + version "7.4.0" + resolved "https://registry.yarnpkg.com/@turf/tin/-/tin-7.4.0.tgz#4073ef896faeb7ca986cda6e669dde17af5d1cec" + integrity sha512-54uBeNKIP8GufZCdeMUMy33e0blR4w0JoE6sYO8I6ZPshNRbVbI24ck+kREVMjzNyeuhL+l7DUrNEn/pcDgBRg== + dependencies: + "@turf/helpers" "7.4.0" + "@types/geojson" "^7946.0.10" + tslib "^2.8.1" + "@types/aria-query@^5.0.1": version "5.0.1" resolved "https://registry.yarnpkg.com/@types/aria-query/-/aria-query-5.0.1.tgz#3286741fb8f1e1580ac28784add4c7a1d49bdfbc" @@ -6212,7 +6237,7 @@ comlink@^4.4.2: resolved "https://registry.yarnpkg.com/comlink/-/comlink-4.4.2.tgz#cbbcd82742fbebc06489c28a183eedc5c60a2bca" integrity sha512-OxGdvBmJuNKSCMO4NTl1L47VRp6xn2wG4F/2hYzB6tiCb709otOxtEYCSvK80PtjODfXXZu8ds+Nw5kVCjqd2g== -commander@^2.19.0, commander@^2.20.0: +commander@2, commander@^2.19.0, commander@^2.20.0: version "2.20.3" resolved "https://registry.yarnpkg.com/commander/-/commander-2.20.3.tgz#fd485e84c03eb4881c20722ba48035e8531aeb33" integrity sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ== @@ -15050,6 +15075,20 @@ to-through@^2.0.0: dependencies: through2 "^2.0.3" +topojson-client@3.x: + version "3.1.0" + resolved "https://registry.yarnpkg.com/topojson-client/-/topojson-client-3.1.0.tgz#22e8b1ed08a2b922feeb4af6f53b6ef09a467b99" + integrity sha512-605uxS6bcYxGXw9qi62XyrV6Q3xwbndjachmNxu8HWTtVPxZfEJN9fd/SZS1Q54Sn2y0TMyMxFj/cJINqGHrKw== + dependencies: + commander "2" + +topojson-server@3.x: + version "3.0.1" + resolved "https://registry.yarnpkg.com/topojson-server/-/topojson-server-3.0.1.tgz#d2b3ec095b6732299be76a48406111b3201a34f5" + integrity sha512-/VS9j/ffKr2XAOjlZ9CgyyeLmgJ9dMwq6Y0YEON8O7p/tGGk+dCWnrE03zEdu7i4L7YsFZLEPZPzCvcB7lEEXw== + dependencies: + commander "2" + tough-cookie@^4.0.0: version "4.1.2" resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-4.1.2.tgz#e53e84b85f24e0b65dd526f46628db6c85f6b874"