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..da99f6c18a 100644 --- a/package.json +++ b/package.json @@ -57,6 +57,8 @@ "@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", + "@turf/concave": "^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 0000000000..0dc45ffd71 Binary files /dev/null and b/public/images/dbscan.png differ diff --git a/src/actions/layerEdit.js b/src/actions/layerEdit.js index 988c73484e..cd0f77880a 100644 --- a/src/actions/layerEdit.js +++ b/src/actions/layerEdit.js @@ -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, 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..0052398ece 100644 --- a/src/components/map/layers/EventLayer.jsx +++ b/src/components/map/layers/EventLayer.jsx @@ -1,13 +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_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, @@ -21,9 +32,22 @@ 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' +// 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 @@ -43,6 +67,9 @@ class EventLayer extends Layer { data, engine, eventClustering, + dbscanClustering, + dbscanEps, + dbscanMinPoints, eventCoordinateField, eventPointColor, eventPointRadius, @@ -74,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 @@ -142,20 +176,37 @@ 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) { + 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, + labelFontSize, + labelFontWeight, + labelFontStyle, + }) + } else { + this.applyClusteringConfig(config, { + eventClustering, + serverCluster, + bounds, + areaRadius, + color, + styleDataItem, + legend, + id, + nameProperty, + engine, + analyticsEngine, + geometryCentroid, + }) + } if (program && programStage) { this.loadDisplayItems({ @@ -168,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, @@ -249,12 +363,342 @@ 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 + // 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' + ) + + this.dbscanNonPointFeatures = data.filter( + (feature) => feature.geometry?.type !== 'Point' + ) + + if (!pointFeatures.length) { + this.dbscanClusters = [] + this.dbscanNoiseFeatures = [] + this.dbscanHulls = [] + return + } + + const clustered = clustersDbscan( + { + type: 'FeatureCollection', + features: pointFeatures.map((feature) => ({ + type: 'Feature', + geometry: feature.geometry, + properties: {}, + })), + }, + eps, + { units: 'meters', minPoints } + ) + + const groups = {} + const noise = [] + + clustered.features.forEach(({ properties }, index) => { + const originalFeature = pointFeatures[index] + + if (properties.dbscan !== 'core' && properties.dbscan !== 'edge') { + 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 = (groups[properties.cluster] = + groups[properties.cluster] || []) + group.push(originalFeature) + }) + + this.dbscanNoiseFeatures = noise + const clusters = Object.values(groups).map((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) + const centroid = [lng, lat] + + return { + id: `dbscan-cluster-${clusterIndex}`, + // 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: cluster.members.length, + clusterNumber: cluster.clusterNumber, + }, + })) + }) + } + + // 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() { 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('Cluster')} + {popup.feature.properties.clusterNumber} +
{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/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/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..ff220d52d1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3968,6 +3968,44 @@ "@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/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" @@ -3999,6 +4037,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 +4055,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 +4072,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 +4108,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" @@ -4054,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" @@ -6154,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== @@ -14992,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"