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 d5f6a3577b..9fe6d4fc42 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.5.1", "react-redux": "^9.3.0", diff --git a/public/images/chart.png b/public/images/chart.png new file mode 100644 index 0000000000..12ff6333bd Binary files /dev/null and b/public/images/chart.png differ 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..3e1ab3c9e8 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, scaleLinear } 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,11 @@ import { PADDING_TIMELINE, DURATION_TIMELINE, } from '../../../constants/layers.js' +import { + createChartMarkerElement, + buildChartTooltipHtml, + TOOLTIP_OFFSET_X, +} from '../../../util/chartMarker.js' import { filterData } from '../../../util/filter.js' import { getLabelStyle } from '../../../util/labels.js' import { @@ -53,6 +64,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 +112,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 +162,163 @@ 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 { map } = this.context + const mapGL = 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) + ) + // 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([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( + (sum, s) => sum + s.value, + 0 + ) + const coordinates = poleOfInaccessibility(feature.geometry) + const el = createChartMarkerElement( + chartType, + feature.properties.chartValues, + { size: getSize(total), maxValue: maxSegmentValue } + ) + + el.addEventListener('click', (evt) => { + evt.stopPropagation() + 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() { + this.removeChartMarkers() + await super.removeLayer() + } + // Set initial period setPeriod(callback) { const { @@ -161,7 +358,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 +440,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..b6fae54bca 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,8 @@ const thematicLoader = async ({ legendIsolated, unclassifiedLegend: unclassifiedLegendFromConfig, noDataLegend: noDataLegendFromConfig, + chartType: chartTypeFromConfig, + isChartMap: isChartMapFromConfig, } = parseJsonConfig(config.config) if (countFeaturesWithoutCoordinates) { config.countFeaturesWithoutCoordinates = true @@ -95,6 +101,9 @@ const thematicLoader = async ({ if (legendIsolated) { config.legendIsolated = legendIsolated } + if (chartTypeFromConfig) { + config.chartType = chartTypeFromConfig + } if (unclassifiedLegendFromConfig) { config.unclassifiedLegend = unclassifiedLegendFromConfig } @@ -110,6 +119,24 @@ 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). 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), + engine, + keyAnalysisDisplayProperty, + userId, + analyticsEngine, + }) + } + const orgUnitIds = getOrgUnitsFromRows(config.rows).map((item) => item.id) let orgUnitsWithoutCoordsCount = null @@ -631,6 +658,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 +712,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 +722,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 +810,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..2ccc2a02b5 --- /dev/null +++ b/src/util/chartMarker.js @@ -0,0 +1,384 @@ +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 }. + +// 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))' +// 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 +// 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), +] + +// 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(' ') +} + +// 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 `` + } + + // 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 `${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, + end, + r: ring, + r0, + cx: r, + cy: r, + }) + offset += segment.value + + 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('') + + // 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. + // 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 + )}` +} + +// 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 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('') + + 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. 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}` +} + +// 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) +// 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' + + const hoverScale = + chartType === CHART_TYPE_BAR + ? BAR_HOVER_SCALE + : getDonutHoverScale(size) + + const inner = document.createElement('div') + inner.style.transition = 'transform 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})` + el.style.zIndex = 1 + }) + el.addEventListener('mouseleave', () => { + inner.style.transform = '' + el.style.zIndex = '' + }) + + el.appendChild(inner) + return el +} diff --git a/src/util/colors.js b/src/util/colors.js index 810f5a4799..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,6 +65,12 @@ export const getColorScale = (palette) => colorbrewer[name][palette.length].join(',') === palette.join(',') ) +// 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) => + colorSets[COLOR_SET_DEFAULT].colors.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..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' @@ -58,6 +60,7 @@ const validLayerProperties = [ 'labelTemplate', 'countFeaturesWithoutCoordinates', 'countEventsOutsideOrgUnits', + 'chartType', // mockup for DHIS2-21461, stored in layer config 'legendDecimalPlaces', 'legendIsolated', 'lastUpdated', @@ -180,6 +183,12 @@ const buildCommonLayerConfigData = (layer) => { if (layer.labelDataItem) { configData.labelDataItem = layer.labelDataItem } + if (layer.chartType) { + configData.chartType = layer.chartType + } + if (layer.thematicMapType === THEMATIC_CHART) { + configData.isChartMap = true + } return configData } @@ -187,11 +196,18 @@ 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 delete layer.unclassifiedLegend delete layer.countFeaturesWithoutCoordinates + delete layer.chartType delete layer.countEventsOutsideOrgUnits delete layer.labelDataItem }