From 253eba92528fd08169174c62d5f46671fa7cd8b8 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault Date: Thu, 10 Sep 2026 14:28:24 +0200 Subject: [PATCH 1/5] mockup: chart map (donut/bar) for thematic layers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mockup for DHIS2-21461. Adds a third thematic rendering type — Chart — alongside Choropleth and Bubble: each org unit renders a small donut or bar chart marker sized by total value and broken into one segment per selected data item, instead of being classified into a single threshold bucket. Multi-item selection reuses DHIS2 analytics' existing multi-id dx dimension support (columns already stored dx as an items array; only the single-item default was hardcoded), so the loader change is a parallel loadChartMapLayer path rather than a rewrite of the classification pipeline. Rendering adds maplibre-gl as a direct dependency and places plain maplibre-gl Markers with inline SVG chart content — no maps-gl changes needed, since donut/bar markers are just DOM elements positioned on the underlying map, not a new maps-gl layer type. Chart type (donut/bar) persists through the layer's config JSON blob, and the selected series persist natively through columns, so a saved map round-trips the full chart setup for sharing. DO NOT MERGE Co-Authored-By: Claude Sonnet 5 --- i18n/en.pot | 29 ++- package.json | 1 + public/images/chart.png | Bin 0 -> 3043 bytes src/actions/layerEdit.js | 12 + .../edit/thematic/ThematicDialog.jsx | 214 +++++++++++++----- src/components/map/layers/ThematicLayer.jsx | 121 +++++++++- src/constants/actionTypes.js | 3 + src/constants/layers.js | 13 ++ src/loaders/thematicLoader.js | 162 ++++++++++++- src/reducers/layerEdit.js | 14 ++ src/util/analytics.js | 18 ++ src/util/chartMarker.js | 98 ++++++++ src/util/colors.js | 4 + src/util/favorites.js | 5 + 14 files changed, 633 insertions(+), 61 deletions(-) create mode 100644 public/images/chart.png create mode 100644 src/util/chartMarker.js diff --git a/i18n/en.pot b/i18n/en.pot index 72d26d6fad..4e428aafc5 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:26:25.386Z\n" +"PO-Revision-Date: 2026-09-10T12:26:25.386Z\n" msgid "2020" msgstr "2020" @@ -546,6 +546,15 @@ msgstr "Selection is initialized from shared periods" msgid "Choose periods for all split layers" msgstr "Choose periods for all split layers" +msgid "Donut" +msgstr "Donut" + +msgid "Bar" +msgstr "Bar" + +msgid "Select at least one data item on the Data tab" +msgstr "Select at least one data item on the Data tab" + msgid "Data is required" msgstr "Data is required" @@ -1738,6 +1747,9 @@ msgstr "Choropleth" msgid "Bubble map" msgstr "Bubble map" +msgid "Chart map" +msgstr "Chart map" + msgid "Automatic color legend" msgstr "Automatic color legend" @@ -1879,6 +1891,19 @@ msgstr "Data item was not found" msgid "Thematic layer" msgstr "Thematic layer" +msgid "" +"Chart maps are hard to read with more than {{max}} series ({{count}} " +"selected)" +msgid_plural "" +"Chart maps are hard to read with more than {{max}} series ({{count}} " +"selected)" +msgstr[0] "" +"Chart maps are hard to read with more than {{max}} series ({{count}} " +"selected)" +msgstr[1] "" +"Chart maps are hard to read with more than {{max}} series ({{count}} " +"selected)" + msgid "related" msgstr "related" diff --git a/package.json b/package.json index 83ec322d06..50aa3e4e32 100644 --- a/package.json +++ b/package.json @@ -73,6 +73,7 @@ "html-to-image": "^1.11.13", "lodash": "^4.18.1", "loglevel": "^1.9.2", + "maplibre-gl": "^5.24.0", "prop-types": "^15.8.1", "query-string": "^9.4.1", "react-redux": "^9.3.0", diff --git a/public/images/chart.png b/public/images/chart.png new file mode 100644 index 0000000000000000000000000000000000000000..12ff6333bd8b265e7ae7a1fc633999ea2ed7dee6 GIT binary patch literal 3043 zcmd5;4@?th6hEq+U+=V>t}Q5-^>jLiV|H`?ArWn_EesVrwJ5eaZJdZ8tQ%DkCt!D1 zbZX`zn{z7Cv1vtzbz7w&D0N-tE^F)GK0o`{i=NNo!UMso4WYEXPv!6iX6>3h#*Ao zr&F{b>}ir-@dX{$XA#;qZdBb{D=H@uHHFP-TCjD;q4$WA1T7di|8TC}y}k&4byPtU zLn*?DHxq&=>0VoepQ94BQ8GOjmG zNf=5VMwDnD>oVyGKN*TrD#+MSl<11+xbfi9wE;OY`a% z%I%HpCQ*&{v)e_LC#0!%%rt+Z;<$M#9Yf8Lk)kTH@f2bU^&Bx(s%Is(nOA>eJlPn7 ztK;c0!l_`AY|9AR3@$i5K1aO8?Jxt{X-kqu9i$|XK1qB-;{Xe==<+Pr!zBbB!L}%u zHj?e9VSkwV0i=33og8y!H*YD;2nebUQYZK3g%*W^OY( zyyGRh2(MVM42#O~!Df%3m9a|?U*V(QP_nv?{akclYix8r${S1T*@j-9^G28VXkQuZ zaZKc=M`Cl^rXz%cR6?qWVKgO8<)#2X_swywAdc7V4v65N`yjnrf!>8Q!W>`pHhCkCCUq7+&xm-mjUIWWg&fx3qmPq-W|A33wnV zF>U313@oJxWvpaQPT^uwDX>~GgoDB6Ohg4n5NQw-{$_6T2rM9&_bN@gSkViZo{9G` zDU*fR9$v03j}{1lMn6vj7aUa%=tresgE!;p&g(6Azf6}wqYD=pLXvB_Pn?K{gT%S8 z{{)KcPZrKkhW7{dV?N)ZCy|x!^&|Y;Ow7OXgO3pt<90me6l3Q`ZSbMOY4hBL;&H8$ zK~?nIgo52zHxDqjVtjc<;P%W)2du_TvI`iPNc<3OhxdkCnyKN|J7sVUxo%lAz>n3a z(6@8_v$4*twOdSjjci>~Qt-*&&TqvLN;}8S0AA0&jgXrK9U_MUaGj;E<89XxmkIoa*of z?tu1ohQj8_lm>7%AEu}p@|6Sfs;t2?UD2(>DJfIG2nPDuU>qiFw^wu1SraUP0o={f zTrPe0e!DQgOzXGVcbn>F@%^B{xZ2S>AEt{zwz_S(!Eorhetua&@P!MrN5NuoHJQc2 zUGTH*#tAourV1B>-D8|^`J#;9RX4Nnv%H4=GsL9jTZE-H!|99brxM2OX3XL`s8F$G zusK|hq3}*Y=OP7Tx4&J+Zb&iqapB#NX82rmW`p{ph5i=V-58<-3A?%eLorAGO0E&^ zy`TL>Rk5C5K$oiZAY~Sw7O!R;{Y;whYGApR3_CXvxEq=(#yP9YJXbKcXkMceOkL*j zUFc&oI?ehFbtl?Bkl%i|RVK0bEtEv$LyFU<)17?53y1jeuqNr ollifQlgQI4G>-hga`kqUwPi=IEtYEm_~RW(GNu?R$pwzT06epChyVZp literal 0 HcmV?d00001 diff --git a/src/actions/layerEdit.js b/src/actions/layerEdit.js index 988c73484e..18689ee492 100644 --- a/src/actions/layerEdit.js +++ b/src/actions/layerEdit.js @@ -57,6 +57,18 @@ export const setDataItem = (dataItem, dimension) => ({ dimension, }) +// Set chart series data items (thematic chart map) — mockup for DHIS2-21461 +export const setChartDataItems = (dataItems) => ({ + type: types.LAYER_EDIT_CHART_DATA_ITEMS_SET, + dataItems, +}) + +// Set chart type: donut or bar (thematic chart map) +export const setChartType = (chartType) => ({ + type: types.LAYER_EDIT_CHART_TYPE_SET, + chartType, +}) + // Set data element used for styling (event) export const setStyleDataItem = (dataItem) => ({ type: types.LAYER_EDIT_STYLE_DATA_ITEM_SET, diff --git a/src/components/edit/thematic/ThematicDialog.jsx b/src/components/edit/thematic/ThematicDialog.jsx index 7e3c2910d0..072bc57f63 100644 --- a/src/components/edit/thematic/ThematicDialog.jsx +++ b/src/components/edit/thematic/ThematicDialog.jsx @@ -8,6 +8,8 @@ import { useDispatch, useSelector } from 'react-redux' import { setClassification, setDataItem, + setChartDataItems, + setChartType, setLegendSet, setNoDataLegend, setUnclassifiedLegend, @@ -25,6 +27,10 @@ import { RENDERING_STRATEGY_SINGLE, RENDERING_STRATEGY_TIMELINE, RENDERING_STRATEGY_SPLIT_BY_PERIOD, + THEMATIC_CHART, + THEMATIC_CHART_MAX_SERIES, + CHART_TYPE_DONUT, + CHART_TYPE_BAR, } from '../../../constants/layers.js' import { PREDEFINED_PERIODS, @@ -35,9 +41,11 @@ import useLayersPeriodSync from '../../../hooks/useLayersPeriodSync.js' import usePrevious from '../../../hooks/usePrevious.js' import { getDataItemFromColumns, + getDataItemsFromColumns, getPeriodsFromFilters, getDimensionsFromFilters, } from '../../../util/analytics.js' +import { getChartSeriesColors } from '../../../util/colors.js' import NumericLegendStyle from '../../classification/NumericLegendStyle.jsx' import { Tab, Tabs, Checkbox } from '../../core/index.js' import DimensionFilter from '../../dimensions/DimensionFilter.jsx' @@ -79,6 +87,7 @@ const ThematicDialog = ({ radiusHigh, method, thematicMapType, + chartType, legendIsolated, }) => { const dispatch = useDispatch() @@ -107,7 +116,12 @@ const ThematicDialog = ({ const prevEndDate = usePrevious(endDate) const prevValidateLayer = usePrevious(validateLayer) + const isChartMap = thematicMapType === THEMATIC_CHART const dataItem = useMemo(() => getDataItemFromColumns(columns), [columns]) + const chartDataItems = useMemo( + () => getDataItemsFromColumns(columns), + [columns] + ) const periods = useMemo(() => getPeriodsFromFilters(filters), [filters]) const dimensions = useMemo( () => getDimensionsFromFilters(filters), @@ -346,39 +360,61 @@ const ThematicDialog = ({ {tab === 'data' && (
- { - const selected = items.at(-1) ?? {} - const legendSet = await fetchLegendSet( - selected - ) - dispatch( - setDataItem( - { ...selected, legendSet }, - selected.type + {isChartMap ? ( + ({ + ...item, + type: item.dimensionItemType, + }) + )} + onSelect={({ items }) => + dispatch(setChartDataItems(items)) + } + height="408px" + heightCalculation="375px" + maxSelections={THEMATIC_CHART_MAX_SERIES} + /> + ) : ( + { + const selected = items.at(-1) ?? {} + const legendSet = await fetchLegendSet( + selected ) - ) - }} - onCalculationSave={(items) => - dispatch(setDataItem(items, items.type)) - } - height="408px" - heightCalculation="375px" - maxSelections={1} - /> + dispatch( + setDataItem( + { ...selected, legendSet }, + selected.type + ) + ) + }} + onCalculationSave={(items) => + dispatch( + setDataItem(items, items.type) + ) + } + height="408px" + heightCalculation="375px" + maxSelections={1} + /> + )}
@@ -538,14 +574,42 @@ const ThematicDialog = ({ >
-
- -
+ {isChartMap ? ( +
+ + dispatch(setChartType(value)) + } + /> +
+ ) : ( +
+ +
+ )}
- - - dispatch(setUnclassifiedLegend(v)) - } - /> - dispatch(setNoDataLegend(v))} - /> + {isChartMap ? ( +
+ {chartDataItems.length === 0 && ( +
+ + {errors.dataError || + i18n.t( + 'Select at least one data item on the Data tab' + )} +
+ )} + {getChartSeriesColors( + chartDataItems.length + ).map((color, index) => ( +
+ + {chartDataItems[index].name} +
+ ))} +
+ ) : ( + <> + + + dispatch(setUnclassifiedLegend(v)) + } + /> + + dispatch(setNoDataLegend(v)) + } + /> + + )}
)} @@ -588,6 +689,7 @@ const ThematicDialog = ({ ThematicDialog.propTypes = { backupPeriodsDates: PropTypes.object, + chartType: PropTypes.string, columns: PropTypes.array, currentUser: PropTypes.object, endDate: PropTypes.string, diff --git a/src/components/map/layers/ThematicLayer.jsx b/src/components/map/layers/ThematicLayer.jsx index adf4bfbfe9..e864055383 100644 --- a/src/components/map/layers/ThematicLayer.jsx +++ b/src/components/map/layers/ThematicLayer.jsx @@ -1,4 +1,6 @@ import i18n from '@dhis2/d2-i18n' +import { scaleSqrt } from 'd3-scale' +import { Marker } from 'maplibre-gl' import React, { Fragment } from 'react' import { RENDERING_STRATEGY_SINGLE, @@ -6,6 +8,10 @@ import { RENDERING_STRATEGY_SPLIT_BY_PERIOD, THEMATIC_CHOROPLETH, THEMATIC_BUBBLE, + THEMATIC_CHART, + CHART_TYPE_DONUT, + THEMATIC_CHART_MARKER_MIN_SIZE, + THEMATIC_CHART_MARKER_MAX_SIZE, BOUNDARY_LAYER, ORG_UNIT_COLOR, ORG_UNIT_RADIUS_SMALL, @@ -13,6 +19,7 @@ import { PADDING_TIMELINE, DURATION_TIMELINE, } from '../../../constants/layers.js' +import { createChartMarkerElement } from '../../../util/chartMarker.js' import { filterData } from '../../../util/filter.js' import { getLabelStyle } from '../../../util/labels.js' import { @@ -53,6 +60,7 @@ class ThematicLayer extends Layer { const { isPlugin, map } = this.context const bubbleMap = thematicMapType === THEMATIC_BUBBLE + const isChartMap = thematicMapType === THEMATIC_CHART const filteredData = this.buildPeriodData() @@ -100,6 +108,34 @@ class ThematicLayer extends Layer { }) this.layer.addLayer(config) + } else if (isChartMap) { + // Chart map mockup for DHIS2-21461: boundaries only through + // maps-gl, donut/bar markers are plain maplibre-gl Markers + // managed directly by this component (see buildChartMarkers) + this.layer = map.createLayer({ + type: 'group', + id, + index, + opacity, + isVisible, + }) + + this.layer.addLayer({ + type: BOUNDARY_LAYER, + data: data.map((f) => ({ + ...f, + properties: { + ...f.properties, + style: { + color: ORG_UNIT_COLOR, + weight: 0.5, + }, + }, + })), + style: {}, + }) + + this.buildChartMarkers(filteredData) } else { this.layer = map.createLayer(config) } @@ -122,6 +158,61 @@ class ThematicLayer extends Layer { } } + // Chart map mockup for DHIS2-21461: places one donut/bar SVG marker per + // feature directly on the underlying maplibre-gl map, sized by each + // feature's series total (sqrt scale, so area — not radius — tracks + // the value, matching how bubble maps already scale their radius) + buildChartMarkers(features) { + const { chartType = CHART_TYPE_DONUT } = this.props + const mapGL = this.context.map.getMapGL() + + this.removeChartMarkers() + + const chartFeatures = features.filter((f) => f.properties.chartValues) + const totals = chartFeatures.map((f) => + f.properties.chartValues.reduce((sum, s) => sum + s.value, 0) + ) + const getSize = scaleSqrt() + .domain([0, Math.max(...totals, 1)]) + .range([ + THEMATIC_CHART_MARKER_MIN_SIZE, + THEMATIC_CHART_MARKER_MAX_SIZE, + ]) + .clamp(true) + + this.chartMarkers = chartFeatures.map((feature) => { + const total = feature.properties.chartValues.reduce( + (sum, s) => sum + s.value, + 0 + ) + const coordinates = poleOfInaccessibility(feature.geometry) + const el = createChartMarkerElement( + chartType, + feature.properties.chartValues, + getSize(total) + ) + + el.addEventListener('click', (evt) => { + evt.stopPropagation() + this.onFeatureClick({ feature, coordinates }) + }) + + return new Marker({ element: el }) + .setLngLat(coordinates) + .addTo(mapGL) + }) + } + + removeChartMarkers() { + this.chartMarkers?.forEach((marker) => marker.remove()) + this.chartMarkers = [] + } + + async removeLayer() { + this.removeChartMarkers() + await super.removeLayer() + } + // Set initial period setPeriod(callback) { const { @@ -161,7 +252,31 @@ class ThematicLayer extends Layer { const { columns, aggregationType, legend, externalPeriod } = this.props const { popup } = this.state const { coordinates, feature } = popup - const { id, name, value } = feature.properties + const { id, name, value, chartValues } = feature.properties + + if (chartValues) { + return ( + +
{name}
+ + + {chartValues.map((series) => ( + + + + + ))} + +
{series.name}{series.value}
+
+ ) + } + const indicator = columns[0].items[0].name || '' const periodName = externalPeriod ? externalPeriod.name : legend.period @@ -219,13 +334,15 @@ class ThematicLayer extends Layer { // Rebuild the period-specific data the same way as in createLayer const bubbleMap = thematicMapType === THEMATIC_BUBBLE + const isChartMap = thematicMapType === THEMATIC_CHART const filteredData = this.buildPeriodData() // If the underlying map layer supports incremental updates, use it. - // For group/bubble layers we fall back to recreating the layer. + // For group/bubble/chart layers we fall back to recreating the layer. if ( this.layer && !bubbleMap && + !isChartMap && typeof this.layer.setData === 'function' ) { try { diff --git a/src/constants/actionTypes.js b/src/constants/actionTypes.js index 04bc55e885..1f5da91394 100644 --- a/src/constants/actionTypes.js +++ b/src/constants/actionTypes.js @@ -89,6 +89,9 @@ export const LAYER_EDIT_LEGEND_DECIMAL_PLACES_SET = 'LAYER_EDIT_LEGEND_DECIMAL_PLACES_SET' export const LAYER_EDIT_LEGEND_ISOLATED_SET = 'LAYER_EDIT_LEGEND_ISOLATED_SET' export const LAYER_EDIT_DATA_ITEM_SET = 'LAYER_EDIT_DATA_ITEM_SET' +export const LAYER_EDIT_CHART_DATA_ITEMS_SET = + 'LAYER_EDIT_CHART_DATA_ITEMS_SET' +export const LAYER_EDIT_CHART_TYPE_SET = 'LAYER_EDIT_CHART_TYPE_SET' export const LAYER_EDIT_EVENT_STATUS_SET = 'LAYER_EDIT_EVENT_STATUS_SET' export const LAYER_EDIT_EVENT_COORDINATE_FIELD_SET = 'LAYER_EDIT_EVENT_COORDINATE_FIELD_SET' diff --git a/src/constants/layers.js b/src/constants/layers.js index bb31be7119..9bd752dcd6 100644 --- a/src/constants/layers.js +++ b/src/constants/layers.js @@ -84,6 +84,14 @@ export const THEMATIC_RADIUS_MAX = 50 export const THEMATIC_COLOR = '#558CC0' export const THEMATIC_BUFFER = 5000 +// Mockup for DHIS2-21461: chart map (donut/bar), gated by thematicMapType +export const THEMATIC_CHART = 'CHART' +export const CHART_TYPE_DONUT = 'donut' +export const CHART_TYPE_BAR = 'bar' +export const THEMATIC_CHART_MAX_SERIES = 8 +export const THEMATIC_CHART_MARKER_MIN_SIZE = 30 +export const THEMATIC_CHART_MARKER_MAX_SIZE = 90 + export const getThematicMapTypes = () => [ { id: THEMATIC_CHOROPLETH, @@ -95,6 +103,11 @@ export const getThematicMapTypes = () => [ name: i18n.t('Bubble map'), image: 'images/bubble.png', }, + { + id: THEMATIC_CHART, + name: i18n.t('Chart map'), + image: 'images/chart.png', + }, ] /* EVENT LAYER */ diff --git a/src/loaders/thematicLoader.js b/src/loaders/thematicLoader.js index 4e4998c125..705f624712 100644 --- a/src/loaders/thematicLoader.js +++ b/src/loaders/thematicLoader.js @@ -12,6 +12,8 @@ import { dimConf } from '../constants/dimension.js' import { EVENT_STATUS_COMPLETED } from '../constants/eventStatuses.js' import { THEMATIC_BUBBLE, + THEMATIC_CHART, + THEMATIC_CHART_MAX_SERIES, THEMATIC_RADIUS_DEFAULT, THEMATIC_RADIUS_LOW, THEMATIC_RADIUS_HIGH, @@ -28,10 +30,12 @@ import { getPeriodsFromFilters, getValidDimensionsFromFilters, getDataItemFromColumns, + getDataItemsFromColumns, getApiResponseNames, applyPeriodFilter, } from '../util/analytics.js' import { getLegendItemForValue } from '../util/classify.js' +import { getChartSeriesColors } from '../util/colors.js' import { parseJsonConfig } from '../util/config.js' import { hasValue } from '../util/helpers.js' import { @@ -85,6 +89,7 @@ const thematicLoader = async ({ legendIsolated, unclassifiedLegend: unclassifiedLegendFromConfig, noDataLegend: noDataLegendFromConfig, + chartType: chartTypeFromConfig, } = parseJsonConfig(config.config) if (countFeaturesWithoutCoordinates) { config.countFeaturesWithoutCoordinates = true @@ -95,6 +100,9 @@ const thematicLoader = async ({ if (legendIsolated) { config.legendIsolated = legendIsolated } + if (chartTypeFromConfig) { + config.chartType = chartTypeFromConfig + } if (unclassifiedLegendFromConfig) { config.unclassifiedLegend = unclassifiedLegendFromConfig } @@ -110,6 +118,20 @@ const thematicLoader = async ({ delete config.noDataColor delete config.config + // Chart map mockup for DHIS2-21461: multi-series donut/bar per org unit. + // Kept as a separate path since it has no classification/legend-set + // pipeline (series are categorical, not thresholds). + if (thematicMapType === THEMATIC_CHART) { + return await loadChartMapLayer({ + config, + dataItems: getDataItemsFromColumns(columns), + engine, + keyAnalysisDisplayProperty, + userId, + analyticsEngine, + }) + } + const orgUnitIds = getOrgUnitsFromRows(config.rows).map((item) => item.id) let orgUnitsWithoutCoordsCount = null @@ -631,6 +653,22 @@ export const getValueMapsById = (data) => { ) } +// Chart map mockup for DHIS2-21461: groups values by org unit, then by dx +// item, since a multi-item dx request returns one row per (dx, ou) pair +export const getMultiValueMapsById = (data) => { + const { headers, rows } = data + const ouIndex = findIndex(['name', 'ou'], headers) + const dxIndex = findIndex(['name', 'dx'], headers) + const valueIndex = findIndex(['name', 'value'], headers) + + return rows.reduce((valuesById, row) => { + const ouId = row[ouIndex] + valuesById[ouId] = valuesById[ouId] || {} + valuesById[ouId][row[dxIndex]] = Number.parseFloat(row[valueIndex]) + return valuesById + }, {}) +} + // Returns an array of ordered values const getOrderedValues = (data) => { const { headers, rows } = data @@ -669,6 +707,7 @@ const loadData = async ({ const coordinateField = getCoordinateField(config) const isOperand = columns[0].dimension === dimConf.operand.objectName const isSingleMap = renderingStrategy === RENDERING_STRATEGY_SINGLE + const isChartMap = config.thematicMapType === THEMATIC_CHART const orgUnitIds = orgUnits.map((item) => item.id) let dataDimension = isOperand ? dataItem.id.split('.')[0] : dataItem.id @@ -678,7 +717,12 @@ const loadData = async ({ let analyticsRequest = new analyticsEngine.request() .addOrgUnitDimension(orgUnits.map((ou) => ou.id)) - .addDataDimension(dataDimension) + .addDataDimension( + // Chart map mockup for DHIS2-21461: one dx item per chart series + isChartMap + ? getDataItemsFromColumns(columns).map((item) => item.id) + : dataDimension + ) .withDisplayProperty(keyAnalysisDisplayProperty) // name/shortName if (!isSingleMap) { @@ -761,4 +805,120 @@ const loadData = async ({ ] } +// Chart map mockup for DHIS2-21461: loads one value per (org unit, series) +// pair and attaches them to each feature as chartValues, for ThematicLayer +// to render as donut/bar markers. No classification/legend-set pipeline — +// series are categorical, not value thresholds. +const loadChartMapLayer = async ({ + config, + dataItems, + engine, + keyAnalysisDisplayProperty, + userId, + analyticsEngine, +}) => { + const coordinateField = getCoordinateField(config) + const chartMapName = i18n.t('Chart map') + + let loadError + const response = dataItems.length + ? await loadData({ + config, + engine, + keyAnalysisDisplayProperty, + userId, + analyticsEngine, + }).catch((err) => { + loadError = err + if (err.message) { + loadError = + err.errorCode === 'E7124' && err.message.includes('dx') + ? i18n.t('Data item was not found') + : err.message + } + }) + : null + + if (!response) { + return { + ...config, + ...(loadError + ? { + alerts: [{ code: ERROR_CRITICAL, message: loadError }], + } + : {}), + name: chartMapName, + data: [], + legend: null, + isLoaded: true, + isLoading: false, + loadError, + } + } + + const [mainFeatures, data, associatedGeometries] = response + const valuesById = getMultiValueMapsById(data) + const features = addAssociatedGeometries(mainFeatures, associatedGeometries) + + const alerts = [] + if (!features.length) { + alerts.push({ code: WARNING_NO_OU_COORD, message: chartMapName }) + } else if (!data.rows.length) { + alerts.push({ code: WARNING_NO_DATA, message: chartMapName }) + } + if (coordinateField && !associatedGeometries?.length) { + alerts.push({ + code: WARNING_NO_GEOMETRY_COORD, + message: coordinateField.name, + }) + } + if (dataItems.length > THEMATIC_CHART_MAX_SERIES) { + alerts.push({ + warning: true, + code: CUSTOM_ALERT, + message: i18n.t( + 'Chart maps are hard to read with more than {{max}} series ({{count}} selected)', + { max: THEMATIC_CHART_MAX_SERIES, count: dataItems.length } + ), + }) + } + + const seriesColors = getChartSeriesColors(dataItems.length) + const legend = { + title: chartMapName, + items: dataItems.map((item, index) => ({ + name: item.name, + color: seriesColors[index], + })), + } + + const styledFeatures = features.map(({ id, geometry, properties }) => ({ + id, + geometry, + properties: { + ...properties, + chartValues: dataItems.map((item, index) => ({ + id: item.id, + name: item.name, + color: seriesColors[index], + value: Number.isFinite(valuesById[id]?.[item.id]) + ? valuesById[id][item.id] + : 0, + })), + }, + })) + + return { + ...config, + data: styledFeatures, + name: chartMapName, + legend, + alerts, + isLoaded: true, + isLoading: false, + isExpanded: true, + loadError, + } +} + export default thematicLoader diff --git a/src/reducers/layerEdit.js b/src/reducers/layerEdit.js index 35ed32059d..1669928c8d 100644 --- a/src/reducers/layerEdit.js +++ b/src/reducers/layerEdit.js @@ -12,6 +12,7 @@ import { START_END_DATES } from '../constants/periods.js' import { setFiltersFromPeriods, setDataItemInColumns, + setDataItemsInColumns, setOrgUnitPathInRows, removePeriodFromFilters, changeDimensionInFilters, @@ -65,6 +66,19 @@ const layerEdit = (state = null, action) => { name: null, } + case types.LAYER_EDIT_CHART_DATA_ITEMS_SET: + return { + ...state, + columns: setDataItemsInColumns(action.dataItems), + name: null, + } + + case types.LAYER_EDIT_CHART_TYPE_SET: + return { + ...state, + chartType: action.chartType, + } + case types.LAYER_EDIT_PERIOD_NAME_SET: return { ...state, diff --git a/src/util/analytics.js b/src/util/analytics.js index c403222a0d..47d58eb440 100644 --- a/src/util/analytics.js +++ b/src/util/analytics.js @@ -30,6 +30,24 @@ const getDimensionItems = (dimension, arr) => { export const getDataItemFromColumns = (columns = []) => getDimensionItems('dx', columns)[0] +// Mockup for DHIS2-21461: chart map, one dx item per chart series +export const getDataItemsFromColumns = (columns = []) => + getDimensionItems('dx', columns) + +export const setDataItemsInColumns = (dataItems = []) => [ + createDimension( + 'dx', + dataItems.map((dataItem) => ({ + id: dataItem.id, + name: dataItem.name, + expression: dataItem.expression, + dimensionItemType: dataItem.dimensionItemType || dataItem.type, + legendSet: dataItem.legendSet, + })), + { objectName: 'dx' } + ), +] + export const setDataItemInColumns = (dataItem, dimension) => { const dim = dimConf[dimension] return dim diff --git a/src/util/chartMarker.js b/src/util/chartMarker.js new file mode 100644 index 0000000000..b9978d11ac --- /dev/null +++ b/src/util/chartMarker.js @@ -0,0 +1,98 @@ +import { CHART_TYPE_BAR } from '../constants/layers.js' + +// Mockup for DHIS2-21461: donut/bar chart markers for thematic chart maps. +// Builds a small standalone SVG string per feature — segments is an array +// of { name, color, value }. + +const polarToCartesian = ({ cx, cy, r, angleRad }) => [ + cx + r * Math.cos(angleRad), + cy + r * Math.sin(angleRad), +] + +// SVG path for one donut wedge, start/end as a fraction (0-1) of the circle +const donutWedgePath = ({ start, end, r, r0, cx, cy }) => { + const a0 = 2 * Math.PI * start - Math.PI / 2 + const a1 = 2 * Math.PI * end - Math.PI / 2 + const largeArc = end - start > 0.5 ? 1 : 0 + const [x0, y0] = polarToCartesian({ cx, cy, r, angleRad: a0 }) + const [x1, y1] = polarToCartesian({ cx, cy, r, angleRad: a1 }) + const [x2, y2] = polarToCartesian({ cx, cy, r: r0, angleRad: a1 }) + const [x3, y3] = polarToCartesian({ cx, cy, r: r0, angleRad: a0 }) + + return [ + `M ${x0} ${y0}`, + `A ${r} ${r} 0 ${largeArc} 1 ${x1} ${y1}`, + `L ${x2} ${y2}`, + `A ${r0} ${r0} 0 ${largeArc} 0 ${x3} ${y3}`, + 'Z', + ].join(' ') +} + +export const buildDonutSvg = (segments, size) => { + const r = size / 2 + const r0 = r * 0.55 + const positive = segments.filter((s) => s.value > 0) + const total = positive.reduce((sum, s) => sum + s.value, 0) + + if (!total) { + return `` + } + + // A single segment covering everything can't be drawn as an SVG arc + // (start === end after a full turn), so fall back to plain rings + if (positive.length === 1) { + return `` + } + + let offset = 0 + const wedges = positive + .map((segment) => { + const path = donutWedgePath({ + start: offset / total, + end: (offset + segment.value) / total, + r, + r0, + cx: r, + cy: r, + }) + offset += segment.value + return `` + }) + .join('') + + return `${wedges}` +} + +export const buildBarSvg = (segments, size) => { + const height = size + const width = size + const max = Math.max(...segments.map((s) => s.value), 1) + const gap = 2 + const barWidth = (width - gap * (segments.length + 1)) / segments.length + + const bars = segments + .map((segment, index) => { + const barHeight = (Math.max(segment.value, 0) / max) * (height - 4) + const x = gap + index * (barWidth + gap) + const y = height - barHeight + return `` + }) + .join('') + + return `${bars}` +} + +// Returns a positioned, clickable DOM element for a maplibre-gl Marker +export const createChartMarkerElement = (chartType, segments, size) => { + const el = document.createElement('div') + el.style.cursor = 'pointer' + el.style.filter = 'drop-shadow(0 0 2px rgba(0,0,0,0.4))' + el.innerHTML = + chartType === CHART_TYPE_BAR + ? buildBarSvg(segments, size) + : buildDonutSvg(segments, size) + return el +} diff --git a/src/util/colors.js b/src/util/colors.js index 810f5a4799..f3c6e1dfb1 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(',') ) +// Mockup for DHIS2-21461: distinct series colors for a chart map's segments +export const getChartSeriesColors = (count) => + getColorPalette('Set1', Math.max(3, Math.min(count, 9))).slice(0, count) + export const defaultColorScaleName = 'YlOrBr' export const defaultClasses = 5 export const defaultColorScale = getColorPalette( diff --git a/src/util/favorites.js b/src/util/favorites.js index 520c919873..bcd997ab01 100644 --- a/src/util/favorites.js +++ b/src/util/favorites.js @@ -58,6 +58,7 @@ const validLayerProperties = [ 'labelTemplate', 'countFeaturesWithoutCoordinates', 'countEventsOutsideOrgUnits', + 'chartType', // 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.chartType) { + configData.chartType = layer.chartType + } return configData } @@ -192,6 +196,7 @@ const deleteCommonLayerConfigProps = (layer) => { delete layer.noDataLegend delete layer.unclassifiedLegend delete layer.countFeaturesWithoutCoordinates + delete layer.chartType delete layer.countEventsOutsideOrgUnits delete layer.labelDataItem } From 71de6be9b537a71a181076f7f8d9960498df64b2 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault Date: Thu, 10 Sep 2026 14:56:02 +0200 Subject: [PATCH 2/5] fix(chartMap): stop sending an invalid thematicMapType to the API Saving a chart map crashed the server with a 500: the mapView schema's ThematicMapType enum only accepts BUBBLE/CHOROPLETH, so our new "CHART" value failed Jackson deserialization on save. Chart mode is now flagged via config.isChartMap in the layer's config JSON blob (the same mechanism already used for chartType), while the persisted thematicMapType itself stays a valid CHOROPLETH. The loader restores thematicMapType to CHART from config.isChartMap on load, so rendering and re-editing still see chart mode correctly. Co-Authored-By: Claude Sonnet 5 --- src/loaders/thematicLoader.js | 9 +++++++-- src/util/favorites.js | 11 +++++++++++ 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/src/loaders/thematicLoader.js b/src/loaders/thematicLoader.js index 705f624712..b6fae54bca 100644 --- a/src/loaders/thematicLoader.js +++ b/src/loaders/thematicLoader.js @@ -90,6 +90,7 @@ const thematicLoader = async ({ unclassifiedLegend: unclassifiedLegendFromConfig, noDataLegend: noDataLegendFromConfig, chartType: chartTypeFromConfig, + isChartMap: isChartMapFromConfig, } = parseJsonConfig(config.config) if (countFeaturesWithoutCoordinates) { config.countFeaturesWithoutCoordinates = true @@ -120,8 +121,12 @@ const thematicLoader = async ({ // Chart map mockup for DHIS2-21461: multi-series donut/bar per org unit. // Kept as a separate path since it has no classification/legend-set - // pipeline (series are categorical, not thresholds). - if (thematicMapType === THEMATIC_CHART) { + // pipeline (series are categorical, not thresholds). Saved maps store + // thematicMapType as CHOROPLETH (the server's ThematicMapType enum only + // accepts BUBBLE/CHOROPLETH) and flag chart mode via config.isChartMap + // instead — see favorites.js. + if (thematicMapType === THEMATIC_CHART || isChartMapFromConfig) { + config.thematicMapType = THEMATIC_CHART return await loadChartMapLayer({ config, dataItems: getDataItemsFromColumns(columns), diff --git a/src/util/favorites.js b/src/util/favorites.js index bcd997ab01..62aff732cb 100644 --- a/src/util/favorites.js +++ b/src/util/favorites.js @@ -6,6 +6,8 @@ import { GEOJSON_URL_LAYER, ORG_UNIT_LAYER, THEMATIC_LAYER, + THEMATIC_CHART, + THEMATIC_CHOROPLETH, TRACKED_ENTITY_LAYER, } from '../constants/layers.js' @@ -184,6 +186,9 @@ const buildCommonLayerConfigData = (layer) => { if (layer.chartType) { configData.chartType = layer.chartType } + if (layer.thematicMapType === THEMATIC_CHART) { + configData.isChartMap = true + } return configData } @@ -191,6 +196,12 @@ const deleteCommonLayerConfigProps = (layer) => { if (layer.noDataLegend) { layer.noDataColor = layer.noDataLegend.color // noDataColor is the DHIS2 API schema field — store color there for backward compatibility } + if (layer.thematicMapType === THEMATIC_CHART) { + // The server's ThematicMapType enum only accepts BUBBLE/CHOROPLETH — + // chart mode is flagged via config.isChartMap instead (see above) + // and restored by thematicLoader.js on load + layer.thematicMapType = THEMATIC_CHOROPLETH + } delete layer.legendDecimalPlaces delete layer.legendIsolated delete layer.noDataLegend From cdd7b34a49503a87860da9a3ee9629f79cce40d2 Mon Sep 17 00:00:00 2001 From: Bruno Raimbault Date: Thu, 17 Sep 2026 13:58:02 +0200 Subject: [PATCH 3/5] mockup: polish chart map donut/bar marker visuals Iterates on the chart map mockup's marker rendering for legibility and consistency, in place of a rewrite: - Donut markers gain per-wedge percentage labels and an always-visible center total, a translucent background, and separators between wedges. Donut area is now exactly proportional to value (the size scale previously started its range at a non-zero floor, which broke that proportionality). - Bar markers get a fixed-width layout so bar width no longer varies with an org unit's total, a shared global height scale so a given value is the same bar height everywhere on the map, and a minimal y-axis (0 plus this chart's own peak value). - Hover interactions: zoom in on a marker (more for small donuts, whose labels benefit most), highlight the org unit's boundary the same way bubble maps do, and show a tooltip via the same showLabel/hideLabel mechanism choropleth/bubble layers use, instead of unreliable native SVG tooltips. Boundary stroke width is pre-compensated so it doesn't visually thicken along with the hover zoom. - Series colors reuse the Data Visualizer's default color set from @dhis2/analytics instead of a one-off ColorBrewer palette. Still a mockup for DHIS2-21461, not intended to merge as-is. Co-Authored-By: Claude Sonnet 5 --- src/components/map/layers/ThematicLayer.jsx | 124 +++++++- src/util/chartMarker.js | 323 ++++++++++++++++++-- src/util/colors.js | 7 +- 3 files changed, 416 insertions(+), 38 deletions(-) diff --git a/src/components/map/layers/ThematicLayer.jsx b/src/components/map/layers/ThematicLayer.jsx index e864055383..3e1ab3c9e8 100644 --- a/src/components/map/layers/ThematicLayer.jsx +++ b/src/components/map/layers/ThematicLayer.jsx @@ -1,5 +1,5 @@ import i18n from '@dhis2/d2-i18n' -import { scaleSqrt } from 'd3-scale' +import { scaleSqrt, scaleLinear } from 'd3-scale' import { Marker } from 'maplibre-gl' import React, { Fragment } from 'react' import { @@ -19,7 +19,11 @@ import { PADDING_TIMELINE, DURATION_TIMELINE, } from '../../../constants/layers.js' -import { createChartMarkerElement } from '../../../util/chartMarker.js' +import { + createChartMarkerElement, + buildChartTooltipHtml, + TOOLTIP_OFFSET_X, +} from '../../../util/chartMarker.js' import { filterData } from '../../../util/filter.js' import { getLabelStyle } from '../../../util/labels.js' import { @@ -164,7 +168,8 @@ class ThematicLayer extends Layer { // the value, matching how bubble maps already scale their radius) buildChartMarkers(features) { const { chartType = CHART_TYPE_DONUT } = this.props - const mapGL = this.context.map.getMapGL() + const { map } = this.context + const mapGL = map.getMapGL() this.removeChartMarkers() @@ -172,13 +177,49 @@ class ThematicLayer extends Layer { const totals = chartFeatures.map((f) => f.properties.chartValues.reduce((sum, s) => sum + s.value, 0) ) - const getSize = scaleSqrt() + // Range starts at 0 (not THEMATIC_CHART_MARKER_MIN_SIZE) so diameter + // is a pure sqrt(total) curve through the origin — that's what + // makes *area* exactly proportional to total. Starting the range at + // a non-zero floor instead (as a plain scaleSqrt().range([MIN,MAX]) + // would) skews that relationship: area stops tracking value once a + // constant offset is baked into the interpolation. The floor is + // instead applied afterwards, per feature, so it only inflates the + // very smallest markers for visibility without distorting the + // scale for everything else — see getSize below. Because a donut's + // wedges are cut from this same circle by each segment's share of + // the feature's own total, a wedge's colored area ends up exactly + // proportional to that segment's absolute value too, on the same + // scale as every other marker on the map. + const rawSize = scaleSqrt() .domain([0, Math.max(...totals, 1)]) - .range([ - THEMATIC_CHART_MARKER_MIN_SIZE, - THEMATIC_CHART_MARKER_MAX_SIZE, - ]) + .range([0, THEMATIC_CHART_MARKER_MAX_SIZE]) .clamp(true) + const getSize = (total) => + Math.max(rawSize(total), THEMATIC_CHART_MARKER_MIN_SIZE) + + // Bar heights use one fixed value-to-pixel scale for the whole + // layer (rather than each marker normalizing to its own max) so a + // given value is the same bar height everywhere on the map — bars + // stay comparable across org units, not just within one marker. + // The ceiling is rounded up to a "nice" axis number (10, 50, 100, + // 500, 1k, ...) via d3's own tick algorithm. Each chart still shows + // its own peak value as a label (see buildBarSvg) positioned at + // that bar's actual height on this shared scale, for local + // context, without the height encoding itself varying per marker. + const maxSegmentValue = scaleLinear() + .domain([ + 0, + Math.max( + ...chartFeatures.flatMap((f) => + f.properties.chartValues.map((s) => s.value) + ), + 1 + ), + ]) + .nice() + .domain()[1] + + this.chartTooltipTimers = [] this.chartMarkers = chartFeatures.map((feature) => { const total = feature.properties.chartValues.reduce( @@ -189,7 +230,7 @@ class ThematicLayer extends Layer { const el = createChartMarkerElement( chartType, feature.properties.chartValues, - getSize(total) + { size: getSize(total), maxValue: maxSegmentValue } ) el.addEventListener('click', (evt) => { @@ -197,15 +238,80 @@ class ThematicLayer extends Layer { this.onFeatureClick({ feature, coordinates }) }) + // Chart markers are plain maplibre-gl Markers, outside the + // vector-tile hover pipeline, so hovering one doesn't reach the + // boundary polygon beneath it or trigger a tooltip the way it + // does for bubble/choropleth — both are wired up by hand here, + // the same way maps-gl's DonutCluster does it for its own + // marker-based layer (map's showLabel/hideLabel + setHoverState). + // The map's own mousemove handling is disabled meanwhile: the + // boundary polygon under the marker is still interactive, so + // without this its onMouseMove keeps re-querying that polygon + // on every pixel of movement and calling hideLabel(), which + // fights with and immediately cancels our own showLabel above. + let showTimer + let hideTimer + el.addEventListener('mouseenter', () => { + map.setMouseMoveEnabled(false) + this.highlightChartFeature(feature.properties.id) + + clearTimeout(hideTimer) + clearTimeout(showTimer) + showTimer = setTimeout(() => { + // Hovering scales the marker up (see + // createChartMarkerElement), so the tooltip is anchored + // to a point pushed sideways from the marker's true + // center rather than the center itself — otherwise the + // now larger marker can grow past the tooltip and + // overlap it + const point = mapGL.project(coordinates) + const labelLngLat = mapGL.unproject([ + point.x + TOOLTIP_OFFSET_X, + point.y, + ]) + map.showLabel( + buildChartTooltipHtml(feature.properties.chartValues), + labelLngLat, + { isHTML: true } + ) + }, 150) + this.chartTooltipTimers.push(showTimer) + }) + el.addEventListener('mouseleave', () => { + map.setMouseMoveEnabled(true) + this.highlightChartFeature(null) + + clearTimeout(showTimer) + hideTimer = setTimeout(() => map.hideLabel(), 150) + this.chartTooltipTimers.push(hideTimer) + }) + return new Marker({ element: el }) .setLngLat(coordinates) .addTo(mapGL) }) } + // Highlights the boundary polygon for an org unit the same way bubble + // maps' feature-state hover does (see Boundary layer in maps-gl), + // since chart markers don't go through the map's own mousemove/ + // queryRenderedFeatures pipeline that normally drives that highlight + highlightChartFeature(id) { + const { map } = this.context + map.setHoverState(id ? this.layer.getFeaturesById(id) : null) + } + removeChartMarkers() { + this.chartTooltipTimers?.forEach((timer) => clearTimeout(timer)) + this.chartTooltipTimers = [] this.chartMarkers?.forEach((marker) => marker.remove()) this.chartMarkers = [] + this.context.map?.hideLabel() + // Guards against markers being rebuilt (e.g. a period change) while + // the cursor sits over one mid-hover, which would otherwise leave + // mousemove permanently disabled with no mouseleave left to fire + this.context.map?.setMouseMoveEnabled(true) + this.highlightChartFeature(null) } async removeLayer() { diff --git a/src/util/chartMarker.js b/src/util/chartMarker.js index b9978d11ac..c13f6f7fa0 100644 --- a/src/util/chartMarker.js +++ b/src/util/chartMarker.js @@ -1,9 +1,69 @@ -import { CHART_TYPE_BAR } from '../constants/layers.js' +import i18n from '@dhis2/d2-i18n' +import { + CHART_TYPE_BAR, + THEMATIC_CHART_MARKER_MIN_SIZE, + THEMATIC_CHART_MARKER_MAX_SIZE, +} from '../constants/layers.js' +import { getContrastColor } from './colors.js' // Mockup for DHIS2-21461: donut/bar chart markers for thematic chart maps. // Builds a small standalone SVG string per feature — segments is an array // of { name, color, value }. +const MARKER_BG = 'var(--marker-bg, rgba(255, 255, 255, 0.82))' +const MARKER_SHADOW = 'drop-shadow(0 1px 3px rgba(0,0,0,0.45))' +const MARKER_SHADOW_HOVER = 'drop-shadow(0 3px 6px rgba(0,0,0,0.55))' +const LABEL_COLOR = '#33373d' + +// Escapes text going into the hover tooltip's HTML (segment names come +// from user-entered data item metadata) +const escapeHtml = (str) => + String(str).replace( + /[&<>]/g, + (c) => ({ '&': '&', '<': '<', '>': '>' }[c]) + ) + +const round1 = (n) => { + const rounded = Math.round(n * 10) / 10 + return rounded % 1 === 0 ? rounded.toFixed(0) : rounded.toFixed(1) +} + +// Compact number for on-marker labels (10.5k, 1.2M), full precision is +// still available in the tooltip +const formatCompact = (value) => { + const abs = Math.abs(value) + if (abs >= 1e6) { + return `${round1(value / 1e6)}M` + } + if (abs >= 1e3) { + return `${round1(value / 1e3)}k` + } + return String(Math.round(value)) +} + +// HTML shown in the map's hover label (see Map#showLabel in @dhis2/maps-gl, +// the same mechanism choropleth/bubble thematic layers use for hover +// tooltips) — one swatch+value row per positive segment +export const buildChartTooltipHtml = (segments) => { + const positive = segments.filter((s) => s.value > 0) + + if (!positive.length) { + return `
${escapeHtml(i18n.t('No data'))}
` + } + + return `
${positive + .map( + (segment) => + '
' + + `` + + `${escapeHtml( + segment.name + )}: ${segment.value.toLocaleString()}` + + '
' + ) + .join('')}
` +} + const polarToCartesian = ({ cx, cy, r, angleRad }) => [ cx + r * Math.cos(angleRad), cy + r * Math.sin(angleRad), @@ -28,71 +88,280 @@ const donutWedgePath = ({ start, end, r, r0, cx, cy }) => { ].join(' ') } +// Total value in the donut hole. Always rendered, even on markers too +// small to hold it legibly at rest — same reasoning as the percentage +// labels in buildDonutSvg: a font-size floor plus the size-dependent hover +// zoom (see getDonutHoverScale) is what makes it readable on small +// markers, rather than hiding it outright below some size +const donutCenterLabel = (r, r0, total) => { + const fontSize = Math.max(Math.min(r0 * 0.78, 13), 6) + return `${formatCompact( + total + )}` +} + +// Fixed zoom for bar markers — their layout doesn't vary continuously with +// a scalar size (width comes from category count, not size; see +// buildBarSvg), so there's no size to scale the zoom amount against. +const BAR_HOVER_SCALE = 1.35 + +// Donuts get a bigger hover zoom the smaller they are: a marker at +// THEMATIC_CHART_MARKER_MIN_SIZE is where percentage labels are most +// cramped and benefit most from zooming in, while a marker already at +// THEMATIC_CHART_MARKER_MAX_SIZE is legible enough at rest that it only +// needs the same modest zoom bar markers get. +const DONUT_HOVER_SCALE_AT_MIN_SIZE = 2.5 +const DONUT_HOVER_SCALE_AT_MAX_SIZE = BAR_HOVER_SCALE + +const getDonutHoverScale = (size) => { + const span = THEMATIC_CHART_MARKER_MAX_SIZE - THEMATIC_CHART_MARKER_MIN_SIZE + const t = Math.min( + Math.max((size - THEMATIC_CHART_MARKER_MIN_SIZE) / span, 0), + 1 + ) + return ( + DONUT_HOVER_SCALE_AT_MIN_SIZE - + t * (DONUT_HOVER_SCALE_AT_MIN_SIZE - DONUT_HOVER_SCALE_AT_MAX_SIZE) + ) +} + export const buildDonutSvg = (segments, size) => { const r = size / 2 + const ring = r - 0.75 // leave room for the outer stroke inside the viewBox const r0 = r * 0.55 const positive = segments.filter((s) => s.value > 0) const total = positive.reduce((sum, s) => sum + s.value, 0) + // The CSS transform that zooms a marker on hover (see + // createChartMarkerElement) scales everything uniformly, strokes + // included, so a boundary drawn at a fixed width would visually + // thicken right along with the zoom. Pre-dividing by that marker's own + // hover scale here means the stroke ends up back at its intended + // width once zoomed, instead of ballooning — most noticeable on small + // donuts, which also zoom the most (see getDonutHoverScale). + const strokeWidth = 1.5 / getDonutHoverScale(size) + if (!total) { - return `` + return `` } // A single segment covering everything can't be drawn as an SVG arc // (start === end after a full turn), so fall back to plain rings if (positive.length === 1) { - return `` + return `${donutCenterLabel( + r, + r0, + total + )}` } + // Percentage labels sit mid-ring on every wedge wide enough to hold one + // without overlapping its neighbors. They always render, even on small + // markers where they'd be too small to read at rest, because hovering + // a marker scales it up (see createChartMarkerElement) — a floor on + // the font size means that zoom reliably makes them legible rather + // than relying on text that may not have been drawn at all + const percentFontSize = Math.max(Math.min((ring - r0) * 0.6, 10), 6) + const midRadius = (r0 + ring) / 2 + + // Built as two separate passes — all wedges, then all percent labels — + // rather than interleaved per segment, so every label paints on top of + // every wedge's boundary stroke. SVG paints in document order, so an + // interleaved path+label, path+label, ... sequence lets a later + // wedge's stroke cut across an earlier wedge's label wherever the two + // happen to overlap near a shared boundary. let offset = 0 + const percentLabels = [] const wedges = positive .map((segment) => { + const start = offset / total + const end = (offset + segment.value) / total const path = donutWedgePath({ - start: offset / total, - end: (offset + segment.value) / total, - r, + start, + end, + r: ring, r0, cx: r, cy: r, }) offset += segment.value - return `` + + const fraction = end - start + if (fraction >= 0.04) { + const mid = (start + end) / 2 + const angle = 2 * Math.PI * mid - Math.PI / 2 + const [x, y] = polarToCartesian({ + cx: r, + cy: r, + r: midRadius, + angleRad: angle, + }) + percentLabels.push( + `${Math.round(fraction * 100)}%` + ) + } + + // A thin border in the marker background color separates + // adjacent wedges so segments stay legible when colors clash + return `` }) .join('') - return `${wedges}` + // The hole background circle paints before the percent labels (not + // after) so a label that happens to sit right at the inner ring edge + // never gets clipped by it — text always needs to be the topmost layer + return `${wedges}${percentLabels.join( + '' + )}${donutCenterLabel(r, r0, total)}` } -export const buildBarSvg = (segments, size) => { - const height = size - const width = size - const max = Math.max(...segments.map((s) => s.value), 1) - const gap = 2 - const barWidth = (width - gap * (segments.length + 1)) / segments.length +// Bar layout is entirely fixed in pixels — width per bar, gap, and max +// height are all constants, not derived from a feature's total. Width only +// grows with the number of categories (more bars), height comes from one +// linear value-to-pixel scale shared across the whole layer (maxValue). +// Together this keeps every dimension of a bar marker strictly about the +// data it encodes rather than the org unit's overall magnitude, so bar +// charts are visually comparable from one org unit to the next — a "500" +// bar is the same width and height everywhere on the map. +const BAR_WIDTH = 7 +const BAR_GAP = 3 +const BAR_MAX_HEIGHT = 56 +const BAR_PAD_RIGHT = 5 +const BAR_PAD_BOTTOM = 5 +const BAR_LABEL_FONT_SIZE = 11 +const BAR_PAD_TOP = BAR_LABEL_FONT_SIZE + 5 +const BAR_CARD_RADIUS = 4 +// Reserved column on the left for the y-axis's two labels (0 and this +// chart's own peak value), so a bar's height can be read as an actual +// number instead of only compared by eye against other bars. Wide enough +// for the longest label formatCompact can produce (e.g. "999.9k") without +// it clipping against the marker's left edge. +const BAR_AXIS_WIDTH = 26 +const BAR_AXIS_FONT_SIZE = 7 +const BAR_AXIS_COLOR = '#6b7280' + +// maxValue is expected to already be a "nice" round ceiling (see +// ThematicLayer's use of d3's scaleLinear().nice()) shared across every +// marker in the layer, so a given value is always the same bar height +// everywhere on the map +export const buildBarSvg = (segments, maxValue) => { + const barsWidth = + segments.length * BAR_WIDTH + (segments.length - 1) * BAR_GAP + const width = BAR_AXIS_WIDTH + barsWidth + BAR_PAD_RIGHT + const height = BAR_PAD_TOP + BAR_MAX_HEIGHT + BAR_PAD_BOTTOM + const scaleMax = Math.max(maxValue, 1) + const baselineY = BAR_PAD_TOP + BAR_MAX_HEIGHT + // Pre-divided by the fixed hover zoom so the card border and baseline + // return to their intended width once zoomed, instead of thickening + // along with it (see the matching comment in buildDonutSvg) + const strokeWidth = 1 / BAR_HOVER_SCALE const bars = segments .map((segment, index) => { - const barHeight = (Math.max(segment.value, 0) / max) * (height - 4) - const x = gap + index * (barWidth + gap) - const y = height - barHeight - return `` + const rawHeight = + (Math.max(segment.value, 0) / scaleMax) * BAR_MAX_HEIGHT + // Keep small positive values visible as a short stub rather + // than disappearing into the baseline + const barHeight = segment.value > 0 ? Math.max(rawHeight, 2) : 0 + if (!barHeight) { + return '' + } + const x = BAR_AXIS_WIDTH + index * (BAR_WIDTH + BAR_GAP) + const y = baselineY - barHeight + return `` }) .join('') - return `${bars}` + const total = segments.reduce((sum, s) => sum + Math.max(s.value, 0), 0) + const label = `${formatCompact( + total + )}` + + // The top axis label sits at this chart's own peak bar height (on the + // shared scale), not at the scale's global ceiling — so it reads as + // "here's this chart's own highest value" rather than a distant number + // that may belong to some much larger org unit elsewhere on the map. + // Skipped when that peak is too close to the baseline to avoid + // overlapping the "0" label. + const peakValue = Math.max(...segments.map((s) => Math.max(s.value, 0))) + const peakHeight = Math.min( + (peakValue / scaleMax) * BAR_MAX_HEIGHT, + BAR_MAX_HEIGHT + ) + const axisLabelX = BAR_AXIS_WIDTH - 3 + const axis = + (peakValue > 0 && peakHeight >= 10 + ? `${formatCompact( + peakValue + )}` + : '') + + `0` + + // A faint card behind the bars anchors them to a baseline instead of + // leaving them floating on top of the basemap, kept tight and mostly + // transparent so it reads as a chart, not a UI placeholder + return `${label}${axis}${bars}` } -// Returns a positioned, clickable DOM element for a maplibre-gl Marker -export const createChartMarkerElement = (chartType, segments, size) => { +// Used by ThematicLayer to push the hover tooltip's anchor sideways so +// the zoomed-in marker doesn't grow past it and overlap +export const TOOLTIP_OFFSET_X = 40 + +// Returns a positioned, clickable DOM element for a maplibre-gl Marker. +// The chart itself lives in an inner wrapper so hover styling (transform, +// filter) never touches the outer element's transform, which maplibre-gl +// overwrites directly to position the marker on the map. size only applies +// to donuts (bar layout is fixed, see buildBarSvg); maxValue only to bars. +export const createChartMarkerElement = ( + chartType, + segments, + { size, maxValue } +) => { const el = document.createElement('div') el.style.cursor = 'pointer' - el.style.filter = 'drop-shadow(0 0 2px rgba(0,0,0,0.4))' - el.innerHTML = + + const hoverScale = chartType === CHART_TYPE_BAR - ? buildBarSvg(segments, size) + ? BAR_HOVER_SCALE + : getDonutHoverScale(size) + + const inner = document.createElement('div') + inner.style.filter = MARKER_SHADOW + inner.style.transition = 'transform 120ms ease-out, filter 120ms ease-out' + inner.style.transformOrigin = 'center' + inner.innerHTML = + chartType === CHART_TYPE_BAR + ? buildBarSvg(segments, maxValue) : buildDonutSvg(segments, size) + + el.addEventListener('mouseenter', () => { + // Many markers render small; scaling up on hover (rather than only + // relying on the tooltip) is what makes fine detail like donut + // percentages actually readable without zooming the whole map + inner.style.transform = `scale(${hoverScale})` + inner.style.filter = MARKER_SHADOW_HOVER + el.style.zIndex = 1 + }) + el.addEventListener('mouseleave', () => { + inner.style.transform = '' + inner.style.filter = MARKER_SHADOW + el.style.zIndex = '' + }) + + el.appendChild(inner) return el } diff --git a/src/util/colors.js b/src/util/colors.js index f3c6e1dfb1..a72355161d 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' @@ -64,9 +65,11 @@ export const getColorScale = (palette) => colorbrewer[name][palette.length].join(',') === palette.join(',') ) -// Mockup for DHIS2-21461: distinct series colors for a chart map's segments +// Mockup for DHIS2-21461: distinct series colors for a chart map's segments. +// Reuses the Data Visualizer's default color set so chart maps match the +// rest of DHIS2 analytics rather than introducing a separate palette. export const getChartSeriesColors = (count) => - getColorPalette('Set1', Math.max(3, Math.min(count, 9))).slice(0, count) + colorSets[COLOR_SET_DEFAULT].colors.slice(0, count) export const defaultColorScaleName = 'YlOrBr' export const defaultClasses = 5 From 9c5781f14a129be09a3e2f19cf39da2384fe1f4d Mon Sep 17 00:00:00 2001 From: Bruno Raimbault Date: Thu, 17 Sep 2026 14:09:22 +0200 Subject: [PATCH 4/5] mockup: match chart marker background to the hover tooltip's MARKER_BG now uses the same rgba(255, 255, 255, 0.9) the tooltip's .dhis2-map-label .maplibregl-popup-content already uses in @dhis2/maps-gl's Label.css, instead of a separately chosen value. The tooltip is the reference here, so a chart marker and the tooltip it opens on hover read as the same translucent surface. Co-Authored-By: Claude Sonnet 5 --- src/util/chartMarker.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/util/chartMarker.js b/src/util/chartMarker.js index c13f6f7fa0..f51fa61332 100644 --- a/src/util/chartMarker.js +++ b/src/util/chartMarker.js @@ -10,7 +10,11 @@ import { getContrastColor } from './colors.js' // Builds a small standalone SVG string per feature — segments is an array // of { name, color, value }. -const MARKER_BG = 'var(--marker-bg, rgba(255, 255, 255, 0.82))' +// Matches the hover tooltip's own background exactly (see .dhis2-map-label +// .maplibregl-popup-content in @dhis2/maps-gl's Label.css) — the tooltip +// is the reference, not the other way around, so a chart marker and the +// tooltip it opens read as the same translucent surface +const MARKER_BG = 'var(--marker-bg, rgba(255, 255, 255, 0.9))' const MARKER_SHADOW = 'drop-shadow(0 1px 3px rgba(0,0,0,0.45))' const MARKER_SHADOW_HOVER = 'drop-shadow(0 3px 6px rgba(0,0,0,0.55))' const LABEL_COLOR = '#33373d' From 05e549edb1d167b498255828adc687eb3d42f01d Mon Sep 17 00:00:00 2001 From: Bruno Raimbault Date: Thu, 17 Sep 2026 14:31:29 +0200 Subject: [PATCH 5/5] mockup: replace chart marker drop-shadow with an outline filter: drop-shadow() paints its shadow directly behind the marker's own content in the same pass. On a marker this small, that shadow showed through the translucent MARKER_BG fill across nearly the whole shape rather than just its edges, visibly darkening chart backgrounds below the hover tooltip's own reference shade even after several rounds of softening the shadow. Drops the filter entirely and relies on a thin outline instead (MARKER_OUTLINE, rgba(0,0,0,0.22)) for edge definition: the donut's outer ring in all three render paths, and the bar chart's card border (previously removed in favor of the shadow, now brought back as the primary definition mechanism since there is no shadow left to rely on). Co-Authored-By: Claude Sonnet 5 --- src/util/chartMarker.js | 41 +++++++++++++++++++++++++++-------------- 1 file changed, 27 insertions(+), 14 deletions(-) diff --git a/src/util/chartMarker.js b/src/util/chartMarker.js index f51fa61332..2ccc2a02b5 100644 --- a/src/util/chartMarker.js +++ b/src/util/chartMarker.js @@ -15,8 +15,13 @@ import { getContrastColor } from './colors.js' // is the reference, not the other way around, so a chart marker and the // tooltip it opens read as the same translucent surface const MARKER_BG = 'var(--marker-bg, rgba(255, 255, 255, 0.9))' -const MARKER_SHADOW = 'drop-shadow(0 1px 3px rgba(0,0,0,0.45))' -const MARKER_SHADOW_HOVER = 'drop-shadow(0 3px 6px rgba(0,0,0,0.55))' +// A thin outer outline is what gives the marker definition against the +// basemap, not a shadow: filter: drop-shadow() paints its shadow directly +// behind the marker's own content in the same pass, and on a marker this +// small that shadow shows through the translucent MARKER_BG fill across +// nearly the whole shape rather than just its edges, visibly darkening it +// below the tooltip's own reference shade +const MARKER_OUTLINE = 'rgba(0,0,0,0.22)' const LABEL_COLOR = '#33373d' // Escapes text going into the hover tooltip's HTML (segment names come @@ -146,7 +151,7 @@ export const buildDonutSvg = (segments, size) => { const strokeWidth = 1.5 / getDonutHoverScale(size) if (!total) { - return `` + return `` } // A single segment covering everything can't be drawn as an SVG arc @@ -154,7 +159,7 @@ export const buildDonutSvg = (segments, size) => { if (positive.length === 1) { return `${donutCenterLabel( + }" stroke="${MARKER_OUTLINE}" stroke-width="${strokeWidth}" />${donutCenterLabel( r, r0, total @@ -217,10 +222,18 @@ export const buildDonutSvg = (segments, size) => { // The hole background circle paints before the percent labels (not // after) so a label that happens to sit right at the inner ring edge - // never gets clipped by it — text always needs to be the topmost layer + // never gets clipped by it — text always needs to be the topmost layer. + // The outer outline is a separate stroke-only circle on top of the + // wedges rather than being added to each wedge's own stroke, since + // wedges already use their stroke for the (differently-colored) + // separator between adjacent segments. return `${wedges}${percentLabels.join( '' - )}${donutCenterLabel(r, r0, total)}` + )}${donutCenterLabel( + r, + r0, + total + )}` } // Bar layout is entirely fixed in pixels — width per bar, gap, and max @@ -311,12 +324,15 @@ export const buildBarSvg = (segments, maxValue) => { // A faint card behind the bars anchors them to a baseline instead of // leaving them floating on top of the basemap, kept tight and mostly - // transparent so it reads as a chart, not a UI placeholder + // transparent so it reads as a chart, not a UI placeholder. The thin + // outline (not a shadow — see MARKER_OUTLINE) is what gives the card + // its edge definition against the basemap, the same as the donut's + // outer ring. return `${label}${axis}${bars}${label}${axis}${bars}` } @@ -326,8 +342,8 @@ export const buildBarSvg = (segments, maxValue) => { export const TOOLTIP_OFFSET_X = 40 // Returns a positioned, clickable DOM element for a maplibre-gl Marker. -// The chart itself lives in an inner wrapper so hover styling (transform, -// filter) never touches the outer element's transform, which maplibre-gl +// The chart itself lives in an inner wrapper so hover styling (transform) +// never touches the outer element's transform, which maplibre-gl // overwrites directly to position the marker on the map. size only applies // to donuts (bar layout is fixed, see buildBarSvg); maxValue only to bars. export const createChartMarkerElement = ( @@ -344,8 +360,7 @@ export const createChartMarkerElement = ( : getDonutHoverScale(size) const inner = document.createElement('div') - inner.style.filter = MARKER_SHADOW - inner.style.transition = 'transform 120ms ease-out, filter 120ms ease-out' + inner.style.transition = 'transform 120ms ease-out' inner.style.transformOrigin = 'center' inner.innerHTML = chartType === CHART_TYPE_BAR @@ -357,12 +372,10 @@ export const createChartMarkerElement = ( // relying on the tooltip) is what makes fine detail like donut // percentages actually readable without zooming the whole map inner.style.transform = `scale(${hoverScale})` - inner.style.filter = MARKER_SHADOW_HOVER el.style.zIndex = 1 }) el.addEventListener('mouseleave', () => { inner.style.transform = '' - inner.style.filter = MARKER_SHADOW el.style.zIndex = '' })