)}
@@ -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 `
`
+}
+
+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 ``
+ }
+
+ // 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 ``
+}
+
+// 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 ``
+}
+
+// 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
}