diff --git a/.storybook/preview-head.html b/.storybook/preview-head.html
new file mode 100644
index 000000000..965f8201c
--- /dev/null
+++ b/.storybook/preview-head.html
@@ -0,0 +1,6 @@
+
+
+
diff --git a/src/__demo__/SingleValue.stories.js b/src/__demo__/SingleValue.stories.js
new file mode 100644
index 000000000..d71914f04
--- /dev/null
+++ b/src/__demo__/SingleValue.stories.js
@@ -0,0 +1,840 @@
+import { storiesOf } from '@storybook/react'
+import React, { useState, useMemo, useRef, useEffect, useCallback } from 'react'
+import { createVisualization } from '../index.js'
+const constainerStyleBase = {
+ width: 800,
+ height: 800,
+ border: '1px solid magenta',
+ marginBottom: 14,
+}
+const innerContainerStyle = {
+ overflow: 'hidden',
+ display: 'flex',
+ justifyContent: 'center',
+ height: '100%',
+}
+
+const baseDataObj = {
+ response: {
+ headers: [
+ {
+ name: 'dx',
+ column: 'Data',
+ valueType: 'TEXT',
+ type: 'java.lang.String',
+ hidden: false,
+ meta: true,
+ },
+ {
+ name: 'value',
+ column: 'Value',
+ valueType: 'NUMBER',
+ type: 'java.lang.Double',
+ hidden: false,
+ meta: false,
+ },
+ ],
+ metaData: {
+ items: {
+ 202308: {
+ uid: '202308',
+ code: '202308',
+ name: 'August 2023',
+ dimensionItemType: 'PERIOD',
+ valueType: 'TEXT',
+ totalAggregationType: 'SUM',
+ startDate: '2023-08-01T00:00:00.000',
+ endDate: '2023-08-31T00:00:00.000',
+ },
+ 202309: {
+ uid: '202309',
+ code: '202309',
+ name: 'September 2023',
+ dimensionItemType: 'PERIOD',
+ valueType: 'TEXT',
+ totalAggregationType: 'SUM',
+ startDate: '2023-09-01T00:00:00.000',
+ endDate: '2023-09-30T00:00:00.000',
+ },
+ 202310: {
+ uid: '202310',
+ code: '202310',
+ name: 'October 2023',
+ dimensionItemType: 'PERIOD',
+ valueType: 'TEXT',
+ totalAggregationType: 'SUM',
+ startDate: '2023-10-01T00:00:00.000',
+ endDate: '2023-10-31T00:00:00.000',
+ },
+ 202311: {
+ uid: '202311',
+ code: '202311',
+ name: 'November 2023',
+ dimensionItemType: 'PERIOD',
+ valueType: 'TEXT',
+ totalAggregationType: 'SUM',
+ startDate: '2023-11-01T00:00:00.000',
+ endDate: '2023-11-30T00:00:00.000',
+ },
+ 202312: {
+ uid: '202312',
+ code: '202312',
+ name: 'December 2023',
+ dimensionItemType: 'PERIOD',
+ valueType: 'TEXT',
+ totalAggregationType: 'SUM',
+ startDate: '2023-12-01T00:00:00.000',
+ endDate: '2023-12-31T00:00:00.000',
+ },
+ 202401: {
+ uid: '202401',
+ code: '202401',
+ name: 'January 2024',
+ dimensionItemType: 'PERIOD',
+ valueType: 'TEXT',
+ totalAggregationType: 'SUM',
+ startDate: '2024-01-01T00:00:00.000',
+ endDate: '2024-01-31T00:00:00.000',
+ },
+ 202402: {
+ uid: '202402',
+ code: '202402',
+ name: 'February 2024',
+ dimensionItemType: 'PERIOD',
+ valueType: 'TEXT',
+ totalAggregationType: 'SUM',
+ startDate: '2024-02-01T00:00:00.000',
+ endDate: '2024-02-29T00:00:00.000',
+ },
+ 202403: {
+ uid: '202403',
+ code: '202403',
+ name: 'March 2024',
+ dimensionItemType: 'PERIOD',
+ valueType: 'TEXT',
+ totalAggregationType: 'SUM',
+ startDate: '2024-03-01T00:00:00.000',
+ endDate: '2024-03-31T00:00:00.000',
+ },
+ 202404: {
+ uid: '202404',
+ code: '202404',
+ name: 'April 2024',
+ dimensionItemType: 'PERIOD',
+ valueType: 'TEXT',
+ totalAggregationType: 'SUM',
+ startDate: '2024-04-01T00:00:00.000',
+ endDate: '2024-04-30T00:00:00.000',
+ },
+ 202405: {
+ uid: '202405',
+ code: '202405',
+ name: 'May 2024',
+ dimensionItemType: 'PERIOD',
+ valueType: 'TEXT',
+ totalAggregationType: 'SUM',
+ startDate: '2024-05-01T00:00:00.000',
+ endDate: '2024-05-31T00:00:00.000',
+ },
+ 202406: {
+ uid: '202406',
+ code: '202406',
+ name: 'June 2024',
+ dimensionItemType: 'PERIOD',
+ valueType: 'TEXT',
+ totalAggregationType: 'SUM',
+ startDate: '2024-06-01T00:00:00.000',
+ endDate: '2024-06-30T00:00:00.000',
+ },
+ 202407: {
+ uid: '202407',
+ code: '202407',
+ name: 'July 2024',
+ dimensionItemType: 'PERIOD',
+ valueType: 'TEXT',
+ totalAggregationType: 'SUM',
+ startDate: '2024-07-01T00:00:00.000',
+ endDate: '2024-07-31T00:00:00.000',
+ },
+ ou: {
+ uid: 'ou',
+ name: 'Organisation unit',
+ dimensionType: 'ORGANISATION_UNIT',
+ },
+ O6uvpzGd5pu: {
+ uid: 'O6uvpzGd5pu',
+ code: 'OU_264',
+ name: 'Bo',
+ dimensionItemType: 'ORGANISATION_UNIT',
+ valueType: 'TEXT',
+ totalAggregationType: 'SUM',
+ },
+ LAST_12_MONTHS: {
+ name: 'Last 12 months',
+ },
+ dx: {
+ uid: 'dx',
+ name: 'Data',
+ dimensionType: 'DATA_X',
+ },
+ pe: {
+ uid: 'pe',
+ name: 'Period',
+ dimensionType: 'PERIOD',
+ },
+ FnYCr2EAzWS: {
+ uid: 'FnYCr2EAzWS',
+ code: 'IN_52493',
+ name: 'BCG Coverage <1y',
+ legendSet: 'BtxOoQuLyg1',
+ dimensionItemType: 'INDICATOR',
+ valueType: 'NUMBER',
+ totalAggregationType: 'AVERAGE',
+ indicatorType: {
+ name: 'Per cent',
+ displayName: 'Per cent',
+ factor: 100,
+ number: false,
+ },
+ },
+ },
+ dimensions: {
+ dx: ['FnYCr2EAzWS'],
+ pe: [
+ '202308',
+ '202309',
+ '202310',
+ '202311',
+ '202312',
+ '202401',
+ '202402',
+ '202403',
+ '202404',
+ '202405',
+ '202406',
+ '202407',
+ ],
+ ou: ['O6uvpzGd5pu'],
+ co: [],
+ },
+ },
+ rowContext: {},
+ rows: [['FnYCr2EAzWS', '34.19']],
+ width: 2,
+ height: 1,
+ headerWidth: 2,
+ },
+ headers: [
+ {
+ name: 'dx',
+ column: 'Data',
+ valueType: 'TEXT',
+ type: 'java.lang.String',
+ hidden: false,
+ meta: true,
+ isPrefix: false,
+ isCollect: false,
+ index: 0,
+ },
+ {
+ name: 'value',
+ column: 'Value',
+ valueType: 'NUMBER',
+ type: 'java.lang.Double',
+ hidden: false,
+ meta: false,
+ isPrefix: false,
+ isCollect: false,
+ index: 1,
+ },
+ ],
+ rows: [['FnYCr2EAzWS', '34.19']],
+ metaData: {
+ items: {
+ 202308: {
+ uid: '202308',
+ code: '202308',
+ name: 'August 2023',
+ dimensionItemType: 'PERIOD',
+ valueType: 'TEXT',
+ totalAggregationType: 'SUM',
+ startDate: '2023-08-01T00:00:00.000',
+ endDate: '2023-08-31T00:00:00.000',
+ },
+ 202309: {
+ uid: '202309',
+ code: '202309',
+ name: 'September 2023',
+ dimensionItemType: 'PERIOD',
+ valueType: 'TEXT',
+ totalAggregationType: 'SUM',
+ startDate: '2023-09-01T00:00:00.000',
+ endDate: '2023-09-30T00:00:00.000',
+ },
+ 202310: {
+ uid: '202310',
+ code: '202310',
+ name: 'October 2023',
+ dimensionItemType: 'PERIOD',
+ valueType: 'TEXT',
+ totalAggregationType: 'SUM',
+ startDate: '2023-10-01T00:00:00.000',
+ endDate: '2023-10-31T00:00:00.000',
+ },
+ 202311: {
+ uid: '202311',
+ code: '202311',
+ name: 'November 2023',
+ dimensionItemType: 'PERIOD',
+ valueType: 'TEXT',
+ totalAggregationType: 'SUM',
+ startDate: '2023-11-01T00:00:00.000',
+ endDate: '2023-11-30T00:00:00.000',
+ },
+ 202312: {
+ uid: '202312',
+ code: '202312',
+ name: 'December 2023',
+ dimensionItemType: 'PERIOD',
+ valueType: 'TEXT',
+ totalAggregationType: 'SUM',
+ startDate: '2023-12-01T00:00:00.000',
+ endDate: '2023-12-31T00:00:00.000',
+ },
+ 202401: {
+ uid: '202401',
+ code: '202401',
+ name: 'January 2024',
+ dimensionItemType: 'PERIOD',
+ valueType: 'TEXT',
+ totalAggregationType: 'SUM',
+ startDate: '2024-01-01T00:00:00.000',
+ endDate: '2024-01-31T00:00:00.000',
+ },
+ 202402: {
+ uid: '202402',
+ code: '202402',
+ name: 'February 2024',
+ dimensionItemType: 'PERIOD',
+ valueType: 'TEXT',
+ totalAggregationType: 'SUM',
+ startDate: '2024-02-01T00:00:00.000',
+ endDate: '2024-02-29T00:00:00.000',
+ },
+ 202403: {
+ uid: '202403',
+ code: '202403',
+ name: 'March 2024',
+ dimensionItemType: 'PERIOD',
+ valueType: 'TEXT',
+ totalAggregationType: 'SUM',
+ startDate: '2024-03-01T00:00:00.000',
+ endDate: '2024-03-31T00:00:00.000',
+ },
+ 202404: {
+ uid: '202404',
+ code: '202404',
+ name: 'April 2024',
+ dimensionItemType: 'PERIOD',
+ valueType: 'TEXT',
+ totalAggregationType: 'SUM',
+ startDate: '2024-04-01T00:00:00.000',
+ endDate: '2024-04-30T00:00:00.000',
+ },
+ 202405: {
+ uid: '202405',
+ code: '202405',
+ name: 'May 2024',
+ dimensionItemType: 'PERIOD',
+ valueType: 'TEXT',
+ totalAggregationType: 'SUM',
+ startDate: '2024-05-01T00:00:00.000',
+ endDate: '2024-05-31T00:00:00.000',
+ },
+ 202406: {
+ uid: '202406',
+ code: '202406',
+ name: 'June 2024',
+ dimensionItemType: 'PERIOD',
+ valueType: 'TEXT',
+ totalAggregationType: 'SUM',
+ startDate: '2024-06-01T00:00:00.000',
+ endDate: '2024-06-30T00:00:00.000',
+ },
+ 202407: {
+ uid: '202407',
+ code: '202407',
+ name: 'July 2024',
+ dimensionItemType: 'PERIOD',
+ valueType: 'TEXT',
+ totalAggregationType: 'SUM',
+ startDate: '2024-07-01T00:00:00.000',
+ endDate: '2024-07-31T00:00:00.000',
+ },
+ ou: {
+ uid: 'ou',
+ name: 'Organisation unit',
+ dimensionType: 'ORGANISATION_UNIT',
+ },
+ O6uvpzGd5pu: {
+ uid: 'O6uvpzGd5pu',
+ code: 'OU_264',
+ name: 'Bo',
+ dimensionItemType: 'ORGANISATION_UNIT',
+ valueType: 'TEXT',
+ totalAggregationType: 'SUM',
+ },
+ LAST_12_MONTHS: {
+ name: 'Last 12 months',
+ },
+ dx: {
+ uid: 'dx',
+ name: 'Data',
+ dimensionType: 'DATA_X',
+ },
+ pe: {
+ uid: 'pe',
+ name: 'Period',
+ dimensionType: 'PERIOD',
+ },
+ FnYCr2EAzWS: {
+ uid: 'FnYCr2EAzWS',
+ code: 'IN_52493',
+ name: 'BCG Coverage <1y',
+ legendSet: 'BtxOoQuLyg1',
+ dimensionItemType: 'INDICATOR',
+ valueType: 'NUMBER',
+ totalAggregationType: 'AVERAGE',
+ },
+ },
+ dimensions: {
+ dx: ['FnYCr2EAzWS'],
+ pe: [
+ '202308',
+ '202309',
+ '202310',
+ '202311',
+ '202312',
+ '202401',
+ '202402',
+ '202403',
+ '202404',
+ '202405',
+ '202406',
+ '202407',
+ ],
+ ou: ['O6uvpzGd5pu'],
+ co: [],
+ },
+ },
+}
+const numberIndicatorType = {
+ name: 'Plain',
+ number: true,
+}
+const subtextIndicatorType = {
+ name: 'Custom',
+ displayName: 'Custom subtext',
+ number: true,
+}
+const percentIndicatorType = {
+ name: 'Per cent',
+ displayName: 'Per cent',
+ factor: 100,
+ number: false,
+}
+const layout = {
+ name: 'BCG coverage last 12 months - Bo',
+ created: '2013-10-16T19:50:52.464',
+ lastUpdated: '2021-07-06T12:53:57.296',
+ translations: [],
+ favorites: [],
+ lastUpdatedBy: {
+ id: 'xE7jOejl9FI',
+ code: null,
+ name: 'John Traore',
+ displayName: 'John Traore',
+ username: 'admin',
+ },
+ regressionType: 'NONE',
+ displayDensity: 'NORMAL',
+ fontSize: 'NORMAL',
+ sortOrder: 0,
+ topLimit: 0,
+ hideEmptyRows: false,
+ showHierarchy: false,
+ completedOnly: false,
+ skipRounding: false,
+ dataDimensionItems: [
+ {
+ indicator: {
+ name: 'BCG Coverage <1y',
+ dimensionItemType: 'INDICATOR',
+ displayName: 'BCG Coverage <1y',
+ access: {
+ manage: true,
+ externalize: true,
+ write: true,
+ read: true,
+ update: true,
+ delete: true,
+ },
+ displayShortName: 'BCG Coverage <1y',
+ id: 'FnYCr2EAzWS',
+ },
+ dataDimensionItemType: 'INDICATOR',
+ },
+ ],
+ subscribers: [],
+ aggregationType: 'DEFAULT',
+ digitGroupSeparator: 'SPACE',
+ hideEmptyRowItems: 'NONE',
+ noSpaceBetweenColumns: false,
+ cumulativeValues: false,
+ percentStackedValues: false,
+ showData: true,
+ colTotals: false,
+ rowTotals: false,
+ rowSubTotals: false,
+ colSubTotals: false,
+ hideTitle: false,
+ hideSubtitle: false,
+ showDimensionLabels: false,
+ interpretations: [],
+ type: 'SINGLE_VALUE',
+ reportingParams: {
+ grandParentOrganisationUnit: false,
+ parentOrganisationUnit: false,
+ organisationUnit: false,
+ reportingPeriod: false,
+ },
+ numberType: 'VALUE',
+ fontStyle: {},
+ colorSet: 'DEFAULT',
+ yearlySeries: [],
+ regression: false,
+ hideEmptyColumns: false,
+ fixColumnHeaders: false,
+ fixRowHeaders: false,
+ filters: [
+ {
+ items: [
+ {
+ name: 'Bo',
+ dimensionItemType: 'ORGANISATION_UNIT',
+ displayShortName: 'Bo',
+ displayName: 'Bo',
+ access: {
+ manage: true,
+ externalize: true,
+ write: true,
+ read: true,
+ update: true,
+ delete: true,
+ },
+ id: 'O6uvpzGd5pu',
+ },
+ ],
+ dimension: 'ou',
+ },
+ {
+ items: [
+ {
+ name: 'LAST_12_MONTHS',
+ dimensionItemType: 'PERIOD',
+ displayShortName: 'LAST_12_MONTHS',
+ displayName: 'LAST_12_MONTHS',
+ access: {
+ manage: true,
+ externalize: true,
+ write: true,
+ read: true,
+ update: true,
+ delete: true,
+ },
+ id: 'LAST_12_MONTHS',
+ },
+ ],
+ dimension: 'pe',
+ },
+ ],
+ parentGraphMap: {
+ O6uvpzGd5pu: 'ImspTQPwCqd',
+ },
+ columns: [
+ {
+ items: [
+ {
+ name: 'BCG Coverage <1y',
+ dimensionItemType: 'INDICATOR',
+ displayName: 'BCG Coverage <1y',
+ access: {
+ manage: true,
+ externalize: true,
+ write: true,
+ read: true,
+ update: true,
+ delete: true,
+ },
+ displayShortName: 'BCG Coverage <1y',
+ id: 'FnYCr2EAzWS',
+ },
+ ],
+ dimension: 'dx',
+ },
+ ],
+ rows: [],
+ subscribed: false,
+ displayName: 'BCG coverage last 12 months - Bo',
+ access: {
+ manage: true,
+ externalize: true,
+ write: true,
+ read: true,
+ update: true,
+ delete: true,
+ },
+ favorite: false,
+ user: {
+ id: 'xE7jOejl9FI',
+ code: null,
+ name: 'John Traore',
+ displayName: 'John Traore',
+ username: 'admin',
+ },
+ href: 'http://localhost:8080/api/41/visualizations/mYMnDl5Z9oD',
+ id: 'mYMnDl5Z9oD',
+ legend: {
+ showKey: false,
+ },
+ sorting: [],
+ series: [],
+ icons: [],
+ seriesKey: {
+ hidden: false,
+ },
+ axes: [],
+}
+const icon =
+ ''
+
+const baseExtraOptions = {
+ dashboard: true,
+ animation: 200,
+ legendSets: [],
+ icon,
+}
+
+const indicatorTypes = ['plain', 'percent', 'subtext']
+
+storiesOf('SingleValue', module).add('default', () => {
+ const newChartRef = useRef(null)
+ const oldContainerRef = useRef(null)
+ const newContainerRef = useRef(null)
+ const [transpose, setTranspose] = useState(false)
+ const [dashboard, setDashboard] = useState(false)
+ const [showIcon, setShowIcon] = useState(true)
+ const [indicatorType, setIndicatorType] = useState('subtext')
+ const [exportAsPdf, setExportAsPdf] = useState(true)
+ const [width, setWidth] = useState(constainerStyleBase.width)
+ const [height, setHeight] = useState(constainerStyleBase.height)
+ const containerStyle = useMemo(
+ () => ({
+ ...constainerStyleBase,
+ width,
+ height,
+ }),
+ [width, height]
+ )
+ useEffect(() => {
+ if (oldContainerRef.current && newContainerRef.current) {
+ requestAnimationFrame(() => {
+ const extraOptions = {
+ ...baseExtraOptions,
+ dashboard,
+ icon: showIcon ? icon : undefined,
+ }
+ const dataObj = { ...baseDataObj }
+
+ if (indicatorType === 'plain') {
+ dataObj.metaData.items.FnYCr2EAzWS.indicatorType =
+ numberIndicatorType
+ }
+ if (indicatorType === 'percent') {
+ dataObj.metaData.items.FnYCr2EAzWS.indicatorType =
+ percentIndicatorType
+ }
+ if (indicatorType === 'subtext') {
+ dataObj.metaData.items.FnYCr2EAzWS.indicatorType =
+ subtextIndicatorType
+ }
+ createVisualization(
+ [dataObj],
+ layout,
+ oldContainerRef.current,
+ extraOptions,
+ undefined,
+ undefined,
+ 'dhis'
+ )
+ const newVisualization = createVisualization(
+ [dataObj],
+ layout,
+ newContainerRef.current,
+ extraOptions,
+ undefined,
+ undefined,
+ 'highcharts'
+ )
+ newChartRef.current = newVisualization.visualization
+ })
+ }
+ }, [containerStyle, dashboard, showIcon, indicatorType])
+ const downloadOffline = useCallback(() => {
+ if (newChartRef.current) {
+ const currentBackgroundColor =
+ newChartRef.current.userOptions.chart.backgroundColor
+
+ newChartRef.current.update({
+ exporting: {
+ chartOptions: {
+ isPdfExport: exportAsPdf,
+ },
+ },
+ })
+ newChartRef.current.exportChartLocal(
+ {
+ sourceHeight: 768,
+ sourceWidth: 1024,
+ scale: 1,
+ fallbackToExportServer: false,
+ filename: 'testOfflineDownload',
+ showExportInProgress: true,
+ type: exportAsPdf ? 'application/pdf' : 'image/png',
+ },
+ {
+ chart: {
+ backgroundColor:
+ currentBackgroundColor === 'transparent'
+ ? '#ffffff'
+ : currentBackgroundColor,
+ },
+ }
+ )
+ }
+ }, [exportAsPdf])
+
+ return (
+ <>
+
+
+ >
+ )
+})
diff --git a/src/visualizations/config/adapters/dhis_highcharts/chart/default.js b/src/visualizations/config/adapters/dhis_highcharts/chart/default.js
new file mode 100644
index 000000000..9d4af9829
--- /dev/null
+++ b/src/visualizations/config/adapters/dhis_highcharts/chart/default.js
@@ -0,0 +1,27 @@
+import { getEvents } from '../events/index.js'
+import getType from '../type.js'
+
+const DEFAULT_CHART = {
+ spacingTop: 20,
+ style: {
+ fontFamily: 'Roboto,Helvetica Neue,Helvetica,Arial,sans-serif',
+ },
+}
+
+const DASHBOARD_CHART = {
+ spacingTop: 0,
+ spacingRight: 5,
+ spacingBottom: 2,
+ spacingLeft: 5,
+}
+
+export default function getDefaultChart(layout, el, extraOptions) {
+ return Object.assign(
+ {},
+ getType(layout.type),
+ { renderTo: el || layout.el },
+ DEFAULT_CHART,
+ extraOptions.dashboard ? DASHBOARD_CHART : undefined,
+ getEvents(layout.type)
+ )
+}
diff --git a/src/visualizations/config/adapters/dhis_highcharts/chart/index.js b/src/visualizations/config/adapters/dhis_highcharts/chart/index.js
new file mode 100644
index 000000000..c6010e016
--- /dev/null
+++ b/src/visualizations/config/adapters/dhis_highcharts/chart/index.js
@@ -0,0 +1,12 @@
+import { VIS_TYPE_SINGLE_VALUE } from '../../../../../modules/visTypes.js'
+import getDefaultChart from './default.js'
+import getSingleValueChart from './singleValue.js'
+
+export default function getChart(layout, el, extraOptions, series) {
+ switch (layout.type) {
+ case VIS_TYPE_SINGLE_VALUE:
+ return getSingleValueChart(layout, el, extraOptions, series)
+ default:
+ return getDefaultChart(layout, el, extraOptions)
+ }
+}
diff --git a/src/visualizations/config/adapters/dhis_highcharts/chart/singleValue.js b/src/visualizations/config/adapters/dhis_highcharts/chart/singleValue.js
new file mode 100644
index 000000000..43a6f66a2
--- /dev/null
+++ b/src/visualizations/config/adapters/dhis_highcharts/chart/singleValue.js
@@ -0,0 +1,19 @@
+import { getSingleValueBackgroundColor } from '../customSVGOptions/singleValue/getSingleValueBackgroundColor.js'
+import getDefaultChart from './default.js'
+
+export default function getSingleValueChart(layout, el, extraOptions, series) {
+ const chart = {
+ ...getDefaultChart(layout, el, extraOptions),
+ backgroundColor: getSingleValueBackgroundColor(
+ layout.legend,
+ extraOptions.legendSets,
+ series[0]
+ ),
+ }
+
+ if (extraOptions.dashboard) {
+ chart.spacingTop = 7
+ }
+
+ return chart
+}
diff --git a/src/visualizations/config/adapters/dhis_highcharts/customSVGOptions/index.js b/src/visualizations/config/adapters/dhis_highcharts/customSVGOptions/index.js
new file mode 100644
index 000000000..ef5b18509
--- /dev/null
+++ b/src/visualizations/config/adapters/dhis_highcharts/customSVGOptions/index.js
@@ -0,0 +1,29 @@
+import { VIS_TYPE_SINGLE_VALUE } from '../../../../../modules/visTypes.js'
+import getSingleValueCustomSVGOptions from './singleValue/index.js'
+
+export default function getCustomSVGOptions({
+ extraConfig,
+ layout,
+ extraOptions,
+ metaData,
+ series,
+}) {
+ const baseOptions = {
+ visualizationType: layout.type,
+ }
+ switch (layout.type) {
+ case VIS_TYPE_SINGLE_VALUE:
+ return {
+ ...baseOptions,
+ ...getSingleValueCustomSVGOptions({
+ extraConfig,
+ layout,
+ extraOptions,
+ metaData,
+ series,
+ }),
+ }
+ default:
+ break
+ }
+}
diff --git a/src/visualizations/config/adapters/dhis_highcharts/customSVGOptions/singleValue/getSingleValueBackgroundColor.js b/src/visualizations/config/adapters/dhis_highcharts/customSVGOptions/singleValue/getSingleValueBackgroundColor.js
new file mode 100644
index 000000000..650c895a5
--- /dev/null
+++ b/src/visualizations/config/adapters/dhis_highcharts/customSVGOptions/singleValue/getSingleValueBackgroundColor.js
@@ -0,0 +1,17 @@
+import { LEGEND_DISPLAY_STYLE_FILL } from '../../../../../../modules/legends.js'
+import { getSingleValueLegendColor } from './getSingleValueLegendColor.js'
+
+export function getSingleValueBackgroundColor(
+ legendOptions,
+ legendSets,
+ value
+) {
+ const legendColor = getSingleValueLegendColor(
+ legendOptions,
+ legendSets,
+ value
+ )
+ return legendColor && legendOptions.style === LEGEND_DISPLAY_STYLE_FILL
+ ? legendColor
+ : 'transparent'
+}
diff --git a/src/visualizations/config/adapters/dhis_highcharts/customSVGOptions/singleValue/getSingleValueFormattedValue.js b/src/visualizations/config/adapters/dhis_highcharts/customSVGOptions/singleValue/getSingleValueFormattedValue.js
new file mode 100644
index 000000000..f0b91dee3
--- /dev/null
+++ b/src/visualizations/config/adapters/dhis_highcharts/customSVGOptions/singleValue/getSingleValueFormattedValue.js
@@ -0,0 +1,23 @@
+import { renderValue } from '../../../../../../modules/renderValue.js'
+import { VALUE_TYPE_TEXT } from '../../../../../../modules/valueTypes.js'
+
+export const INDICATOR_FACTOR_100 = 100
+
+export function getSingleValueFormattedValue(value, layout, metaData) {
+ const valueType = metaData.items[metaData.dimensions.dx[0]].valueType
+ const indicatorType =
+ metaData.items[metaData.dimensions.dx[0]].indicatorType
+
+ let formattedValue = renderValue(value, valueType || VALUE_TYPE_TEXT, {
+ digitGroupSeparator: layout.digitGroupSeparator,
+ skipRounding: layout.skipRounding,
+ })
+
+ // only show the percentage symbol for per cent
+ // for other factors, show the full text under the value
+ if (indicatorType?.factor === INDICATOR_FACTOR_100) {
+ formattedValue += '%'
+ }
+
+ return formattedValue
+}
diff --git a/src/visualizations/config/adapters/dhis_highcharts/customSVGOptions/singleValue/getSingleValueLegendColor.js b/src/visualizations/config/adapters/dhis_highcharts/customSVGOptions/singleValue/getSingleValueLegendColor.js
new file mode 100644
index 000000000..9f042fc4d
--- /dev/null
+++ b/src/visualizations/config/adapters/dhis_highcharts/customSVGOptions/singleValue/getSingleValueLegendColor.js
@@ -0,0 +1,8 @@
+import { getColorByValueFromLegendSet } from '../../../../../../modules/legends.js'
+
+export function getSingleValueLegendColor(legendOptions, legendSets, value) {
+ const legendSet = legendOptions && legendSets[0]
+ return legendSet
+ ? getColorByValueFromLegendSet(legendSet, value)
+ : undefined
+}
diff --git a/src/visualizations/config/adapters/dhis_highcharts/customSVGOptions/singleValue/getSingleValueSubtext.js b/src/visualizations/config/adapters/dhis_highcharts/customSVGOptions/singleValue/getSingleValueSubtext.js
new file mode 100644
index 000000000..b14a3f263
--- /dev/null
+++ b/src/visualizations/config/adapters/dhis_highcharts/customSVGOptions/singleValue/getSingleValueSubtext.js
@@ -0,0 +1,11 @@
+import { INDICATOR_FACTOR_100 } from './getSingleValueFormattedValue.js'
+
+export function getSingleValueSubtext(metaData) {
+ const indicatorType =
+ metaData.items[metaData.dimensions.dx[0]].indicatorType
+
+ return indicatorType?.displayName &&
+ indicatorType?.factor !== INDICATOR_FACTOR_100
+ ? indicatorType?.displayName
+ : undefined
+}
diff --git a/src/visualizations/config/adapters/dhis_highcharts/customSVGOptions/singleValue/getSingleValueTextColor.js b/src/visualizations/config/adapters/dhis_highcharts/customSVGOptions/singleValue/getSingleValueTextColor.js
new file mode 100644
index 000000000..2f3eb0da0
--- /dev/null
+++ b/src/visualizations/config/adapters/dhis_highcharts/customSVGOptions/singleValue/getSingleValueTextColor.js
@@ -0,0 +1,27 @@
+import { colors } from '@dhis2/ui'
+import { LEGEND_DISPLAY_STYLE_TEXT } from '../../../../../../modules/legends.js'
+import { shouldUseContrastColor } from '../../../../../util/shouldUseContrastColor.js'
+import { getSingleValueLegendColor } from './getSingleValueLegendColor.js'
+
+export function getSingleValueTextColor(
+ baseColor,
+ value,
+ legendOptions,
+ legendSets
+) {
+ const legendColor = getSingleValueLegendColor(
+ legendOptions,
+ legendSets,
+ value
+ )
+
+ if (!legendColor) {
+ return baseColor
+ }
+
+ if (legendOptions.style === LEGEND_DISPLAY_STYLE_TEXT) {
+ return legendColor
+ }
+
+ return shouldUseContrastColor(legendColor) ? colors.white : baseColor
+}
diff --git a/src/visualizations/config/adapters/dhis_highcharts/customSVGOptions/singleValue/getSingleValueTitleColor.js b/src/visualizations/config/adapters/dhis_highcharts/customSVGOptions/singleValue/getSingleValueTitleColor.js
new file mode 100644
index 000000000..bf4f0672b
--- /dev/null
+++ b/src/visualizations/config/adapters/dhis_highcharts/customSVGOptions/singleValue/getSingleValueTitleColor.js
@@ -0,0 +1,34 @@
+import { colors } from '@dhis2/ui'
+import { LEGEND_DISPLAY_STYLE_FILL } from '../../../../../../modules/legends.js'
+import { shouldUseContrastColor } from '../../../../../util/shouldUseContrastColor.js'
+import { getSingleValueLegendColor } from './getSingleValueLegendColor.js'
+
+export function getSingleValueTitleColor(
+ customColor,
+ defaultColor,
+ value,
+ legendOptions,
+ legendSets
+) {
+ // Never override custom color
+ if (customColor) {
+ return customColor
+ }
+
+ const isUsingLegendBackground =
+ legendOptions?.style === LEGEND_DISPLAY_STYLE_FILL
+
+ // If not using legend background, always return default color
+ if (!isUsingLegendBackground) {
+ return defaultColor
+ }
+
+ const legendColor = getSingleValueLegendColor(
+ legendOptions,
+ legendSets,
+ value
+ )
+
+ // Return default color or contrasting color when using legend background and default color
+ return shouldUseContrastColor(legendColor) ? colors.white : defaultColor
+}
diff --git a/src/visualizations/config/adapters/dhis_highcharts/customSVGOptions/singleValue/index.js b/src/visualizations/config/adapters/dhis_highcharts/customSVGOptions/singleValue/index.js
new file mode 100644
index 000000000..bb0ff56f1
--- /dev/null
+++ b/src/visualizations/config/adapters/dhis_highcharts/customSVGOptions/singleValue/index.js
@@ -0,0 +1,27 @@
+import { colors } from '@dhis2/ui'
+import { getSingleValueFormattedValue } from './getSingleValueFormattedValue.js'
+import { getSingleValueSubtext } from './getSingleValueSubtext.js'
+import { getSingleValueTextColor } from './getSingleValueTextColor.js'
+
+export default function getSingleValueCustomSVGOptions({
+ layout,
+ extraOptions,
+ metaData,
+ series,
+}) {
+ const { dashboard, icon } = extraOptions
+ const value = series[0]
+ return {
+ value,
+ fontColor: getSingleValueTextColor(
+ colors.grey900,
+ value,
+ layout.legend,
+ extraOptions.legendSets
+ ),
+ formattedValue: getSingleValueFormattedValue(value, layout, metaData),
+ icon,
+ dashboard,
+ subText: getSingleValueSubtext(metaData),
+ }
+}
diff --git a/src/visualizations/config/adapters/dhis_highcharts/chart.js b/src/visualizations/config/adapters/dhis_highcharts/events/index.js
similarity index 51%
rename from src/visualizations/config/adapters/dhis_highcharts/chart.js
rename to src/visualizations/config/adapters/dhis_highcharts/events/index.js
index e50a52ca9..4f8bf0904 100644
--- a/src/visualizations/config/adapters/dhis_highcharts/chart.js
+++ b/src/visualizations/config/adapters/dhis_highcharts/events/index.js
@@ -1,20 +1,6 @@
-import getType from './type.js'
+import loadCustomSVG from './loadCustomSVG/index.js'
-const DEFAULT_CHART = {
- spacingTop: 20,
- style: {
- fontFamily: 'Roboto,Helvetica Neue,Helvetica,Arial,sans-serif',
- },
-}
-
-const DASHBOARD_CHART = {
- spacingTop: 0,
- spacingRight: 5,
- spacingBottom: 2,
- spacingLeft: 5,
-}
-
-const getEvents = () => ({
+export const getEvents = (visType) => ({
events: {
load: function () {
// Align legend icon with legend text
@@ -31,17 +17,7 @@ const getEvents = () => ({
})
}
})
+ loadCustomSVG.call(this, visType)
},
},
})
-
-export default function (layout, el, dashboard) {
- return Object.assign(
- {},
- getType(layout.type),
- { renderTo: el || layout.el },
- DEFAULT_CHART,
- dashboard ? DASHBOARD_CHART : undefined,
- getEvents()
- )
-}
diff --git a/src/visualizations/config/adapters/dhis_highcharts/events/loadCustomSVG/index.js b/src/visualizations/config/adapters/dhis_highcharts/events/loadCustomSVG/index.js
new file mode 100644
index 000000000..6e01df566
--- /dev/null
+++ b/src/visualizations/config/adapters/dhis_highcharts/events/loadCustomSVG/index.js
@@ -0,0 +1,12 @@
+import { VIS_TYPE_SINGLE_VALUE } from '../../../../../../modules/visTypes.js'
+import loadSingleValueSVG from './singleValue/index.js'
+
+export default function loadCustomSVG(visType) {
+ switch (visType) {
+ case VIS_TYPE_SINGLE_VALUE:
+ loadSingleValueSVG.call(this)
+ break
+ default:
+ break
+ }
+}
diff --git a/src/visualizations/config/adapters/dhis_highcharts/events/loadCustomSVG/singleValue/addIconElement.js b/src/visualizations/config/adapters/dhis_highcharts/events/loadCustomSVG/singleValue/addIconElement.js
new file mode 100644
index 000000000..dfa2c0c57
--- /dev/null
+++ b/src/visualizations/config/adapters/dhis_highcharts/events/loadCustomSVG/singleValue/addIconElement.js
@@ -0,0 +1,32 @@
+const parser = new DOMParser()
+
+export function addIconElement(svgString, color) {
+ const svgIconDocument = parser.parseFromString(svgString, 'image/svg+xml')
+ const iconElHeight = svgIconDocument.documentElement.getAttribute('height')
+ const iconElWidth = svgIconDocument.documentElement.getAttribute('width')
+ const iconGroup = this.renderer
+ .g('icon')
+ .attr({ color, 'data-test': 'visualization-icon' })
+ .css({
+ visibility: 'hidden',
+ })
+
+ /* Force the group element to have the same dimensions as the original
+ * SVG image by adding this rect. This ensures the icon has the intended
+ * whitespace around it and makes scaling and translating easier. */
+ this.renderer.rect(0, 0, iconElWidth, iconElHeight).add(iconGroup)
+
+ Array.from(svgIconDocument.documentElement.children).forEach((pathNode) => {
+ /* It is also possible to use the SVGRenderer to draw the icon but that
+ * approach is more error prone, so during review it was decided to just
+ * append the SVG children to the iconGroup using native the native DOM
+ * API. For reference see this commit, for an implementation using the
+ * SVVGRenderer:
+ * https://github.com/dhis2/analytics/pull/1698/commits/f95bee838e07f4cdfc3cab6e92f28f49a386a0ad */
+ iconGroup.element.appendChild(pathNode)
+ })
+
+ iconGroup.add()
+
+ return iconGroup
+}
diff --git a/src/visualizations/config/adapters/dhis_highcharts/events/loadCustomSVG/singleValue/checkIfFitsWithinContainer.js b/src/visualizations/config/adapters/dhis_highcharts/events/loadCustomSVG/singleValue/checkIfFitsWithinContainer.js
new file mode 100644
index 000000000..182611977
--- /dev/null
+++ b/src/visualizations/config/adapters/dhis_highcharts/events/loadCustomSVG/singleValue/checkIfFitsWithinContainer.js
@@ -0,0 +1,29 @@
+import { ACTUAL_NUMBER_HEIGHT_FACTOR } from './constants.js'
+
+export function checkIfFitsWithinContainer(
+ availableSpace,
+ valueElement,
+ subTextElement,
+ icon,
+ subText,
+ spacing
+) {
+ const valueRect = valueElement.getBBox(true)
+ const subTextRect = subText
+ ? subTextElement.getBBox(true)
+ : { width: 0, height: 0 }
+ const requiredValueWidth = icon
+ ? valueRect.width + spacing.iconGap + spacing.iconSize
+ : valueRect.width
+ const requiredHeight = subText
+ ? valueRect.height * ACTUAL_NUMBER_HEIGHT_FACTOR +
+ spacing.subTextTop +
+ subTextRect.height
+ : valueRect.height * ACTUAL_NUMBER_HEIGHT_FACTOR
+ const fitsHorizontally =
+ availableSpace.width > requiredValueWidth &&
+ availableSpace.width > subTextRect.width
+ const fitsVertically = availableSpace.height > requiredHeight
+
+ return fitsHorizontally && fitsVertically
+}
diff --git a/src/visualizations/config/adapters/dhis_highcharts/events/loadCustomSVG/singleValue/computeLayoutRect.js b/src/visualizations/config/adapters/dhis_highcharts/events/loadCustomSVG/singleValue/computeLayoutRect.js
new file mode 100644
index 000000000..a5d2705c9
--- /dev/null
+++ b/src/visualizations/config/adapters/dhis_highcharts/events/loadCustomSVG/singleValue/computeLayoutRect.js
@@ -0,0 +1,43 @@
+import { computeSpacingTop } from './computeSpacingTop.js'
+import { ACTUAL_NUMBER_HEIGHT_FACTOR } from './constants.js'
+
+export function computeLayoutRect(
+ valueElement,
+ subTextElement,
+ iconElement,
+ spacing
+) {
+ const valueRect = valueElement.getBBox()
+ const containerCenterY = this.chartHeight / 2
+ const containerCenterX = this.chartWidth / 2
+ const minY = computeSpacingTop.call(this, spacing.valueTop)
+
+ let width = valueRect.width
+ let height = valueRect.height * ACTUAL_NUMBER_HEIGHT_FACTOR
+ let sideMarginTop = 0
+ let sideMarginBottom = 0
+
+ if (iconElement) {
+ width += spacing.iconGap + spacing.iconSize
+ }
+
+ if (subTextElement) {
+ const subTextRect = subTextElement.getBBox()
+ if (subTextRect.width > width) {
+ sideMarginTop = (subTextRect.width - width) / 2
+ width = subTextRect.width
+ } else {
+ sideMarginBottom = (width - subTextRect.width) / 2
+ }
+ height += spacing.subTextTop + subTextRect.height
+ }
+
+ return {
+ x: containerCenterX - width / 2,
+ y: Math.max(containerCenterY - height / 2, minY),
+ width,
+ height,
+ sideMarginTop,
+ sideMarginBottom,
+ }
+}
diff --git a/src/visualizations/config/adapters/dhis_highcharts/events/loadCustomSVG/singleValue/computeSpacingTop.js b/src/visualizations/config/adapters/dhis_highcharts/events/loadCustomSVG/singleValue/computeSpacingTop.js
new file mode 100644
index 000000000..1de00c836
--- /dev/null
+++ b/src/visualizations/config/adapters/dhis_highcharts/events/loadCustomSVG/singleValue/computeSpacingTop.js
@@ -0,0 +1,15 @@
+export function computeSpacingTop(valueSpacingTop) {
+ if (this.subtitle.textStr) {
+ /* If a subtitle is present this will be below the title so base
+ * the value X position on this */
+ const subTitleRect = this.subtitle.element.getBBox()
+ return subTitleRect.y + subTitleRect.height + valueSpacingTop
+ } else if (this.title.textStr) {
+ // Otherwise base on title
+ const titleRect = this.title.element.getBBox()
+ return titleRect.y + titleRect.height + valueSpacingTop
+ } else {
+ // If neither are present only adjust for valueSpacingTop
+ return valueSpacingTop
+ }
+}
diff --git a/src/visualizations/config/adapters/dhis_highcharts/events/loadCustomSVG/singleValue/constants.js b/src/visualizations/config/adapters/dhis_highcharts/events/loadCustomSVG/singleValue/constants.js
new file mode 100644
index 000000000..b76e26a44
--- /dev/null
+++ b/src/visualizations/config/adapters/dhis_highcharts/events/loadCustomSVG/singleValue/constants.js
@@ -0,0 +1,4 @@
+// multiply value text size with this factor
+// to get very close to the actual number height
+// as numbers don't go below the baseline like e.g. "j" and "g"
+export const ACTUAL_NUMBER_HEIGHT_FACTOR = 2 / 3
diff --git a/src/visualizations/config/adapters/dhis_highcharts/events/loadCustomSVG/singleValue/getAvailableSpace.js b/src/visualizations/config/adapters/dhis_highcharts/events/loadCustomSVG/singleValue/getAvailableSpace.js
new file mode 100644
index 000000000..c9f567f4c
--- /dev/null
+++ b/src/visualizations/config/adapters/dhis_highcharts/events/loadCustomSVG/singleValue/getAvailableSpace.js
@@ -0,0 +1,10 @@
+import { computeSpacingTop } from './computeSpacingTop.js'
+import { MIN_SIDE_WHITESPACE } from './styles.js'
+
+export function getAvailableSpace(valueSpacingTop) {
+ return {
+ height:
+ this.chartHeight - computeSpacingTop.call(this, valueSpacingTop),
+ width: this.chartWidth - MIN_SIDE_WHITESPACE * 2,
+ }
+}
diff --git a/src/visualizations/config/adapters/dhis_highcharts/events/loadCustomSVG/singleValue/index.js b/src/visualizations/config/adapters/dhis_highcharts/events/loadCustomSVG/singleValue/index.js
new file mode 100644
index 000000000..84cc83e7d
--- /dev/null
+++ b/src/visualizations/config/adapters/dhis_highcharts/events/loadCustomSVG/singleValue/index.js
@@ -0,0 +1,55 @@
+import { addIconElement } from './addIconElement.js'
+import { checkIfFitsWithinContainer } from './checkIfFitsWithinContainer.js'
+import { getAvailableSpace } from './getAvailableSpace.js'
+import { positionElements } from './positionElements.js'
+import { DynamicStyles } from './styles.js'
+
+export default function loadSingleValueSVG() {
+ const { formattedValue, icon, subText, fontColor } =
+ this.userOptions.customSVGOptions
+ const dynamicStyles = new DynamicStyles(this.userOptions?.isPdfExport)
+ const valueElement = this.renderer
+ .text(formattedValue)
+ .attr('data-test', 'visualization-primary-value')
+ .css({ color: fontColor, visibility: 'hidden' })
+ .add()
+ const subTextElement = subText
+ ? this.renderer
+ .text(subText)
+ .attr('data-test', 'visualization-subtext')
+ .css({ color: fontColor, visibility: 'hidden' })
+ .add()
+ : null
+ const iconElement = icon ? addIconElement.call(this, icon, fontColor) : null
+
+ let fitsWithinContainer = false
+ let styles = {}
+
+ while (!fitsWithinContainer && dynamicStyles.hasNext()) {
+ styles = dynamicStyles.next()
+
+ valueElement.css(styles.value)
+ subTextElement?.css(styles.subText)
+
+ fitsWithinContainer = checkIfFitsWithinContainer(
+ getAvailableSpace.call(this, styles.spacing.valueTop),
+ valueElement,
+ subTextElement,
+ icon,
+ subText,
+ styles.spacing
+ )
+ }
+
+ positionElements.call(
+ this,
+ valueElement,
+ subTextElement,
+ iconElement,
+ styles.spacing
+ )
+
+ valueElement.css({ visibility: 'visible' })
+ iconElement?.css({ visibility: 'visible' })
+ subTextElement?.css({ visibility: 'visible' })
+}
diff --git a/src/visualizations/config/adapters/dhis_highcharts/events/loadCustomSVG/singleValue/positionElements.js b/src/visualizations/config/adapters/dhis_highcharts/events/loadCustomSVG/singleValue/positionElements.js
new file mode 100644
index 000000000..052c86b5b
--- /dev/null
+++ b/src/visualizations/config/adapters/dhis_highcharts/events/loadCustomSVG/singleValue/positionElements.js
@@ -0,0 +1,62 @@
+import { computeLayoutRect } from './computeLayoutRect.js'
+import { ACTUAL_NUMBER_HEIGHT_FACTOR } from './constants.js'
+
+export function positionElements(
+ valueElement,
+ subTextElement,
+ iconElement,
+ spacing
+) {
+ const valueElementBox = valueElement.getBBox()
+ /* Layout here refers to a virtual rect that wraps
+ * all indiviual parts of the single value visualization
+ * (value, subtext and icon) */
+ const layoutRect = computeLayoutRect.call(
+ this,
+ valueElement,
+ subTextElement,
+ iconElement,
+ spacing
+ )
+
+ valueElement.align(
+ {
+ align: 'right',
+ verticalAlign: 'top',
+ alignByTranslate: false,
+ x: (valueElementBox.width + layoutRect.sideMarginTop) * -1,
+ y: valueElementBox.height * ACTUAL_NUMBER_HEIGHT_FACTOR,
+ },
+ false,
+ layoutRect
+ )
+
+ if (iconElement) {
+ const { height } = iconElement.getBBox()
+ const scale = spacing.iconSize / height
+ const translateX = layoutRect.x + layoutRect.sideMarginTop
+ const iconHeight = height * scale
+ const valueElementHeight =
+ valueElementBox.height * ACTUAL_NUMBER_HEIGHT_FACTOR
+ const translateY = layoutRect.y + (valueElementHeight - iconHeight) / 2
+
+ /* The icon is a with elements that contain coordinates.
+ * These path-coordinates only scale correctly when using CSS translate */
+ iconElement.css({
+ transform: `translate(${translateX}px, ${translateY}px) scale(${scale})`,
+ })
+ }
+
+ if (subTextElement) {
+ subTextElement.align(
+ {
+ align: 'left',
+ verticalAlign: 'bottom',
+ alignByTranslate: false,
+ x: layoutRect.sideMarginBottom,
+ },
+ false,
+ layoutRect
+ )
+ }
+}
diff --git a/src/visualizations/config/adapters/dhis_highcharts/events/loadCustomSVG/singleValue/styles.js b/src/visualizations/config/adapters/dhis_highcharts/events/loadCustomSVG/singleValue/styles.js
new file mode 100644
index 000000000..f1b944ee2
--- /dev/null
+++ b/src/visualizations/config/adapters/dhis_highcharts/events/loadCustomSVG/singleValue/styles.js
@@ -0,0 +1,62 @@
+const valueStyles = [
+ { 'font-size': '164px', 'letter-spacing': '-5px' },
+ { 'font-size': '128px', 'letter-spacing': '-4px' },
+ { 'font-size': '96px', 'letter-spacing': '-3px' },
+ { 'font-size': '64px', 'letter-spacing': '-2.5px' },
+ { 'font-size': '40px', 'letter-spacing': '-1.5px' },
+ { 'font-size': '20px', 'letter-spacing': '-1px' },
+]
+
+const subTextStyles = [
+ { 'font-size': '36px', 'letter-spacing': '-1.4px' },
+ { 'font-size': '32px', 'letter-spacing': '-1.2px' },
+ { 'font-size': '26px', 'letter-spacing': '-0.8px' },
+ { 'font-size': '20px', 'letter-spacing': '-0.6px' },
+ { 'font-size': '14px', 'letter-spacing': '0.2px' },
+ { 'font-size': '9px', 'letter-spacing': '0px' },
+]
+
+const spacings = [
+ { valueTop: 8, subTextTop: 12, iconGap: 8, iconSize: 164 },
+ { valueTop: 8, subTextTop: 12, iconGap: 6, iconSize: 128 },
+ { valueTop: 8, subTextTop: 8, iconGap: 4, iconSize: 96 },
+ { valueTop: 8, subTextTop: 8, iconGap: 4, iconSize: 64 },
+ { valueTop: 8, subTextTop: 8, iconGap: 4, iconSize: 40 },
+ { valueTop: 8, subTextTop: 4, iconGap: 2, iconSize: 20 },
+]
+
+export const MIN_SIDE_WHITESPACE = 4
+
+export class DynamicStyles {
+ constructor(isPdfExport) {
+ this.currentIndex = 0
+ this.isPdfExport = isPdfExport
+ }
+ getStyle() {
+ return {
+ value: {
+ ...valueStyles[this.currentIndex],
+ 'font-weight': this.isPdfExport ? 'normal' : '300',
+ },
+ subText: subTextStyles[this.currentIndex],
+ spacing: spacings[this.currentIndex],
+ }
+ }
+ next() {
+ if (this.currentIndex === valueStyles.length - 1) {
+ throw new Error('No next available, already on the smallest style')
+ } else {
+ ++this.currentIndex
+ }
+
+ return this.getStyle()
+ }
+ first() {
+ this.currentIndex = 0
+
+ return this.getStyle()
+ }
+ hasNext() {
+ return this.currentIndex < valueStyles.length - 1
+ }
+}
diff --git a/src/visualizations/config/adapters/dhis_highcharts/exporting.js b/src/visualizations/config/adapters/dhis_highcharts/exporting.js
new file mode 100644
index 000000000..032a9c689
--- /dev/null
+++ b/src/visualizations/config/adapters/dhis_highcharts/exporting.js
@@ -0,0 +1,25 @@
+import { VIS_TYPE_SINGLE_VALUE } from '../../../../modules/visTypes.js'
+import loadSingleValueSVG from './events/loadCustomSVG/singleValue/index.js'
+
+export default function getExporting(visType) {
+ const exporting = {
+ // disable exporting context menu
+ enabled: false,
+ }
+ switch (visType) {
+ case VIS_TYPE_SINGLE_VALUE:
+ return {
+ ...exporting,
+ chartOptions: {
+ chart: {
+ events: {
+ load: loadSingleValueSVG,
+ },
+ },
+ },
+ }
+
+ default:
+ return exporting
+ }
+}
diff --git a/src/visualizations/config/adapters/dhis_highcharts/index.js b/src/visualizations/config/adapters/dhis_highcharts/index.js
index 29ecf41c0..1c6b428e9 100644
--- a/src/visualizations/config/adapters/dhis_highcharts/index.js
+++ b/src/visualizations/config/adapters/dhis_highcharts/index.js
@@ -14,7 +14,9 @@ import {
} from '../../../../modules/visTypes.js'
import { defaultMultiAxisTheme1 } from '../../../util/colors/themes.js'
import addTrendLines, { isRegressionIneligible } from './addTrendLines.js'
-import getChart from './chart.js'
+import getChart from './chart/index.js'
+import getCustomSVGOptions from './customSVGOptions/index.js'
+import getExporting from './exporting.js'
import getScatterData from './getScatterData.js'
import getSortedConfig from './getSortedConfig.js'
import getTrimmedConfig from './getTrimmedConfig.js'
@@ -77,21 +79,17 @@ export default function ({ store, layout, el, extraConfig, extraOptions }) {
let config = {
// type etc
- chart: getChart(_layout, el, _extraOptions.dashboard),
+ chart: getChart(_layout, el, _extraOptions, series),
// title
- title: getTitle(
- _layout,
- store.data[0].metaData,
- _extraOptions.dashboard
- ),
+ title: getTitle(_layout, store.data[0].metaData, _extraOptions, series),
// subtitle
subtitle: getSubtitle(
series,
_layout,
store.data[0].metaData,
- _extraOptions.dashboard
+ _extraOptions
),
// x-axis
@@ -127,7 +125,7 @@ export default function ({ store, layout, el, extraConfig, extraOptions }) {
noData: _extraOptions.noData.text,
resetZoom: _extraOptions.resetZoom.text,
},
- noData: getNoData(),
+ noData: getNoData(_layout.type),
// credits
credits: {
@@ -135,10 +133,20 @@ export default function ({ store, layout, el, extraConfig, extraOptions }) {
},
// exporting
- exporting: {
- // disable exporting context menu
- enabled: false,
- },
+ exporting: getExporting(_layout.type),
+
+ /* The config object passed to the Highcharts Chart constructor
+ * can contain arbitrary properties, which are made accessible
+ * under the Chart instance's `userOptions` member. This means
+ * that in event callback functions the custom SVG options are
+ * accessible as `this.userOptions.customSVGOptions` */
+ customSVGOptions: getCustomSVGOptions({
+ extraConfig,
+ layout: _layout,
+ extraOptions: _extraOptions,
+ metaData: store.data[0].metaData,
+ series,
+ }),
}
// get plot options for scatter
diff --git a/src/visualizations/config/adapters/dhis_highcharts/noData.js b/src/visualizations/config/adapters/dhis_highcharts/noData.js
index 8597b5ccd..b2f40d7ff 100644
--- a/src/visualizations/config/adapters/dhis_highcharts/noData.js
+++ b/src/visualizations/config/adapters/dhis_highcharts/noData.js
@@ -1,8 +1,13 @@
-export default function () {
+import { VIS_TYPE_SINGLE_VALUE } from '../../../../modules/visTypes.js'
+
+export default function (visualizationType) {
return {
style: {
fontSize: '13px',
fontWeight: 'normal',
+ /* Hide no data label for single value visualizations because
+ * the data is always missing. */
+ opacity: visualizationType === VIS_TYPE_SINGLE_VALUE ? 0 : 1,
},
}
}
diff --git a/src/visualizations/config/adapters/dhis_highcharts/plotOptions.js b/src/visualizations/config/adapters/dhis_highcharts/plotOptions.js
index 928019506..e9e775096 100644
--- a/src/visualizations/config/adapters/dhis_highcharts/plotOptions.js
+++ b/src/visualizations/config/adapters/dhis_highcharts/plotOptions.js
@@ -79,6 +79,6 @@ export default ({
}
: {}
default:
- return {}
+ return null
}
}
diff --git a/src/visualizations/config/adapters/dhis_highcharts/series/index.js b/src/visualizations/config/adapters/dhis_highcharts/series/index.js
index e4d4eae67..e4ec840f0 100644
--- a/src/visualizations/config/adapters/dhis_highcharts/series/index.js
+++ b/src/visualizations/config/adapters/dhis_highcharts/series/index.js
@@ -9,6 +9,7 @@ import {
isYearOverYear,
VIS_TYPE_LINE,
VIS_TYPE_SCATTER,
+ VIS_TYPE_SINGLE_VALUE,
} from '../../../../../modules/visTypes.js'
import { getAxisStringFromId } from '../../../../util/axisId.js'
import {
@@ -225,6 +226,9 @@ export default function ({
displayStrategy,
}) {
switch (layout.type) {
+ case VIS_TYPE_SINGLE_VALUE:
+ series = []
+ break
case VIS_TYPE_PIE:
series = getPie(
series,
@@ -249,7 +253,7 @@ export default function ({
})
}
- series.forEach((seriesObj) => {
+ series?.forEach((seriesObj) => {
// animation
seriesObj.animation = {
duration: getAnimation(
diff --git a/src/visualizations/config/adapters/dhis_highcharts/subtitle/__tests__/singleValue.spec.js b/src/visualizations/config/adapters/dhis_highcharts/subtitle/__tests__/singleValue.spec.js
new file mode 100644
index 000000000..c7baa2ad6
--- /dev/null
+++ b/src/visualizations/config/adapters/dhis_highcharts/subtitle/__tests__/singleValue.spec.js
@@ -0,0 +1,64 @@
+import getSingleValueSubtitle from '../singleValue.js'
+
+jest.mock(
+ '../../../../../util/getFilterText',
+ () => () => 'The default filter text'
+)
+
+describe('getSingleValueSubtitle', () => {
+ it('returns empty subtitle when flag hideSubtitle exists', () => {
+ expect(getSingleValueSubtitle({ hideSubtitle: true })).toEqual('')
+ })
+
+ it('returns the subtitle provided in the layout', () => {
+ const subtitle = 'The subtitle was already set'
+ expect(getSingleValueSubtitle({ subtitle })).toEqual(subtitle)
+ })
+
+ it('returns an empty string when layout does not have filters', () => {
+ expect(getSingleValueSubtitle({})).toEqual('')
+ })
+
+ it('returns the filter text', () => {
+ expect(getSingleValueSubtitle({ filters: [] })).toEqual(
+ 'The default filter text'
+ )
+ })
+
+ describe('not dashboard', () => {
+ describe('layout does not include title', () => {
+ it('returns empty subtitle', () => {
+ expect(
+ getSingleValueSubtitle({ filters: undefined }, {}, false)
+ ).toEqual('')
+ })
+ })
+
+ /* All these tests have been moved and adjusted from here:
+ * src/visualizations/config/adapters/dhis_dhis/title/__tests__`
+ * The test below asserted the default subtitle behaviour, for
+ * visualization types other than SingleValue. It expected that
+ * the title was being used as subtitle. It fails now, and I
+ * believe that this behaviour does not make sense. So instead
+ * of fixing it, I disabled it. */
+ // describe('layout includes title', () => {
+ // it('returns filter title as subtitle', () => {
+ // expect(
+ // getSingleValueSubtitle(
+ // { filters: undefined, title: 'Chart title' },
+ // {},
+ // false
+ // )
+ // ).toEqual('The default filter text')
+ // })
+ // })
+ })
+
+ describe('dashboard', () => {
+ it('returns filter title as subtitle', () => {
+ expect(getSingleValueSubtitle({ filters: {} }, {}, true)).toEqual(
+ 'The default filter text'
+ )
+ })
+ })
+})
diff --git a/src/visualizations/config/adapters/dhis_highcharts/subtitle/index.js b/src/visualizations/config/adapters/dhis_highcharts/subtitle/index.js
index 9d2dc1bc7..6509c3e5a 100644
--- a/src/visualizations/config/adapters/dhis_highcharts/subtitle/index.js
+++ b/src/visualizations/config/adapters/dhis_highcharts/subtitle/index.js
@@ -7,16 +7,21 @@ import {
FONT_STYLE_OPTION_TEXT_ALIGN,
FONT_STYLE_VISUALIZATION_SUBTITLE,
mergeFontStyleWithDefault,
+ defaultFontStyle,
} from '../../../../../modules/fontStyle.js'
import {
VIS_TYPE_YEAR_OVER_YEAR_LINE,
VIS_TYPE_YEAR_OVER_YEAR_COLUMN,
isVerticalType,
VIS_TYPE_SCATTER,
+ VIS_TYPE_SINGLE_VALUE,
} from '../../../../../modules/visTypes.js'
import getFilterText from '../../../../util/getFilterText.js'
import { getTextAlignOption } from '../getTextAlignOption.js'
import getYearOverYearTitle from '../title/yearOverYear.js'
+import getSingleValueSubtitle, {
+ getSingleValueSubtitleColor,
+} from './singleValue.js'
const DASHBOARD_SUBTITLE = {
style: {
@@ -31,23 +36,48 @@ const DASHBOARD_SUBTITLE = {
}
function getDefault(layout, dashboard, filterTitle) {
- return {
- text: dashboard || isString(layout.title) ? filterTitle : undefined,
- }
+ return dashboard || isString(layout.title) ? filterTitle : undefined
}
-export default function (series, layout, metaData, dashboard) {
+export default function (series, layout, metaData, extraOptions) {
+ if (layout.hideSubtitle) {
+ return null
+ }
+
+ const { dashboard, legendSets } = extraOptions
+ const legendOptions = layout.legend
const fontStyle = mergeFontStyleWithDefault(
layout.fontStyle && layout.fontStyle[FONT_STYLE_VISUALIZATION_SUBTITLE],
FONT_STYLE_VISUALIZATION_SUBTITLE
)
- let subtitle = {
- text: undefined,
- }
-
- if (layout.hideSubtitle) {
- return null
- }
+ const subtitle = Object.assign(
+ {
+ text: undefined,
+ },
+ dashboard
+ ? DASHBOARD_SUBTITLE
+ : {
+ align: getTextAlignOption(
+ fontStyle[FONT_STYLE_OPTION_TEXT_ALIGN],
+ FONT_STYLE_VISUALIZATION_SUBTITLE,
+ isVerticalType(layout.type)
+ ),
+ style: {
+ // DHIS2-578: dynamically truncate subtitle when it's taking more than 1 line
+ color: undefined,
+ fontSize: `${fontStyle[FONT_STYLE_OPTION_FONT_SIZE]}px`,
+ fontWeight: fontStyle[FONT_STYLE_OPTION_BOLD]
+ ? FONT_STYLE_OPTION_BOLD
+ : 'normal',
+ fontStyle: fontStyle[FONT_STYLE_OPTION_ITALIC]
+ ? FONT_STYLE_OPTION_ITALIC
+ : 'normal',
+ whiteSpace: 'nowrap',
+ overflow: 'hidden',
+ textOverflow: 'ellipsis',
+ },
+ }
+ )
// DHIS2-578: allow for optional custom subtitle
const customSubtitle =
@@ -59,6 +89,9 @@ export default function (series, layout, metaData, dashboard) {
const filterTitle = getFilterText(layout.filters, metaData)
switch (layout.type) {
+ case VIS_TYPE_SINGLE_VALUE:
+ subtitle.text = getSingleValueSubtitle(layout, metaData)
+ break
case VIS_TYPE_YEAR_OVER_YEAR_LINE:
case VIS_TYPE_YEAR_OVER_YEAR_COLUMN:
subtitle.text = getYearOverYearTitle(
@@ -71,37 +104,46 @@ export default function (series, layout, metaData, dashboard) {
subtitle.text = filterTitle
break
default:
- subtitle = getDefault(layout, dashboard, filterTitle)
+ subtitle.text = getDefault(layout, dashboard, filterTitle)
}
}
+ switch (layout.type) {
+ case VIS_TYPE_SINGLE_VALUE:
+ {
+ const defaultColor =
+ defaultFontStyle?.[FONT_STYLE_VISUALIZATION_SUBTITLE]?.[
+ FONT_STYLE_OPTION_TEXT_COLOR
+ ]
+ const customColor =
+ layout?.fontStyle?.[FONT_STYLE_VISUALIZATION_SUBTITLE]?.[
+ FONT_STYLE_OPTION_TEXT_COLOR
+ ]
+ subtitle.style.color = getSingleValueSubtitleColor(
+ customColor,
+ defaultColor,
+ series[0],
+ legendOptions,
+ legendSets
+ )
+ if (dashboard) {
+ // Single value subtitle text should be multiline
+ /* TODO: The default color of the subtitle now is #4a5768 but the
+ * original implementation used #666, which is a lighter grey.
+ * If we want to keep this color, changes are needed here. */
+ Object.assign(subtitle.style, {
+ wordWrap: 'normal',
+ whiteSpace: 'normal',
+ overflow: 'visible',
+ textOverflow: 'initial',
+ })
+ }
+ }
+ break
+ default:
+ subtitle.style.color = fontStyle[FONT_STYLE_OPTION_TEXT_COLOR]
+ break
+ }
+
return subtitle
- ? Object.assign(
- {},
- dashboard
- ? DASHBOARD_SUBTITLE
- : {
- align: getTextAlignOption(
- fontStyle[FONT_STYLE_OPTION_TEXT_ALIGN],
- FONT_STYLE_VISUALIZATION_SUBTITLE,
- isVerticalType(layout.type)
- ),
- style: {
- // DHIS2-578: dynamically truncate subtitle when it's taking more than 1 line
- color: fontStyle[FONT_STYLE_OPTION_TEXT_COLOR],
- fontSize: `${fontStyle[FONT_STYLE_OPTION_FONT_SIZE]}px`,
- fontWeight: fontStyle[FONT_STYLE_OPTION_BOLD]
- ? FONT_STYLE_OPTION_BOLD
- : 'normal',
- fontStyle: fontStyle[FONT_STYLE_OPTION_ITALIC]
- ? FONT_STYLE_OPTION_ITALIC
- : 'normal',
- whiteSpace: 'nowrap',
- overflow: 'hidden',
- textOverflow: 'ellipsis',
- },
- },
- subtitle
- )
- : subtitle
}
diff --git a/src/visualizations/config/adapters/dhis_highcharts/subtitle/singleValue.js b/src/visualizations/config/adapters/dhis_highcharts/subtitle/singleValue.js
new file mode 100644
index 000000000..922f142cf
--- /dev/null
+++ b/src/visualizations/config/adapters/dhis_highcharts/subtitle/singleValue.js
@@ -0,0 +1,18 @@
+import getFilterText from '../../../../util/getFilterText.js'
+export { getSingleValueTitleColor as getSingleValueSubtitleColor } from '../customSVGOptions/singleValue/getSingleValueTitleColor.js'
+
+export default function getSingleValueSubtitle(layout, metaData) {
+ if (layout.hideSubtitle || 1 === 0) {
+ return ''
+ }
+
+ if (typeof layout.subtitle === 'string' && layout.subtitle.length) {
+ return layout.subtitle
+ }
+
+ if (layout.filters) {
+ return getFilterText(layout.filters, metaData)
+ }
+
+ return ''
+}
diff --git a/src/visualizations/config/adapters/dhis_highcharts/title/__tests__/singleValue.spec.js b/src/visualizations/config/adapters/dhis_highcharts/title/__tests__/singleValue.spec.js
new file mode 100644
index 000000000..bc8022f81
--- /dev/null
+++ b/src/visualizations/config/adapters/dhis_highcharts/title/__tests__/singleValue.spec.js
@@ -0,0 +1,57 @@
+import { getSingleValueTitleText } from '../singleValue.js'
+
+jest.mock('../../../../../util/getFilterText', () => () => 'The filter text')
+
+describe('getSingleValueTitle', () => {
+ it('returns empty title when flag hideTitle exists', () => {
+ expect(getSingleValueTitleText({ hideTitle: true })).toEqual('')
+ })
+
+ it('returns the title provided in the layout', () => {
+ const title = 'The title was already set'
+ expect(getSingleValueTitleText({ title })).toEqual(title)
+ })
+
+ it('returns null when layout does not have columns', () => {
+ expect(getSingleValueTitleText({})).toEqual('')
+ })
+
+ it('returns the filter text based on column items', () => {
+ expect(
+ getSingleValueTitleText({
+ columns: [
+ {
+ items: [{}],
+ },
+ ],
+ })
+ ).toEqual('The filter text')
+ })
+
+ describe('not dashboard', () => {
+ it('returns filter text as title', () => {
+ expect(
+ getSingleValueTitleText(
+ {
+ columns: [
+ {
+ items: [{}],
+ },
+ ],
+ filters: [],
+ },
+ {},
+ false
+ )
+ ).toEqual('The filter text')
+ })
+ })
+
+ describe('dashboard', () => {
+ it('returns empty string', () => {
+ expect(getSingleValueTitleText({ filters: {} }, {}, true)).toEqual(
+ ''
+ )
+ })
+ })
+})
diff --git a/src/visualizations/config/adapters/dhis_highcharts/title/index.js b/src/visualizations/config/adapters/dhis_highcharts/title/index.js
index e4e4f1a4a..7a86ec47f 100644
--- a/src/visualizations/config/adapters/dhis_highcharts/title/index.js
+++ b/src/visualizations/config/adapters/dhis_highcharts/title/index.js
@@ -7,6 +7,7 @@ import {
FONT_STYLE_OPTION_TEXT_ALIGN,
FONT_STYLE_VISUALIZATION_TITLE,
mergeFontStyleWithDefault,
+ defaultFontStyle,
} from '../../../../../modules/fontStyle.js'
import {
VIS_TYPE_YEAR_OVER_YEAR_LINE,
@@ -14,10 +15,15 @@ import {
VIS_TYPE_GAUGE,
isVerticalType,
VIS_TYPE_SCATTER,
+ VIS_TYPE_SINGLE_VALUE,
} from '../../../../../modules/visTypes.js'
import getFilterText from '../../../../util/getFilterText.js'
import { getTextAlignOption } from '../getTextAlignOption.js'
import getScatterTitle from './scatter.js'
+import {
+ getSingleValueTitleColor,
+ getSingleValueTitleText,
+} from './singleValue.js'
import getYearOverYearTitle from './yearOverYear.js'
const DASHBOARD_TITLE_STYLE = {
@@ -41,42 +47,22 @@ function getDefault(layout, metaData, dashboard) {
return null
}
-export default function (layout, metaData, dashboard) {
+export default function (layout, metaData, extraOptions, series) {
+ if (layout.hideTitle) {
+ return {
+ text: undefined,
+ }
+ }
+ const { dashboard, legendSets } = extraOptions
+ const legendOptions = layout.legend
const fontStyle = mergeFontStyleWithDefault(
layout.fontStyle && layout.fontStyle[FONT_STYLE_VISUALIZATION_TITLE],
FONT_STYLE_VISUALIZATION_TITLE
)
-
- const title = {
- text: undefined,
- }
-
- if (layout.hideTitle) {
- return title
- }
-
- const customTitle = (layout.title && layout.displayTitle) || layout.title
-
- if (isString(customTitle) && customTitle.length) {
- title.text = customTitle
- } else {
- switch (layout.type) {
- case VIS_TYPE_GAUGE:
- case VIS_TYPE_YEAR_OVER_YEAR_LINE:
- case VIS_TYPE_YEAR_OVER_YEAR_COLUMN:
- title.text = getYearOverYearTitle(layout, metaData, dashboard)
- break
- case VIS_TYPE_SCATTER:
- title.text = getScatterTitle(layout, metaData, dashboard)
- break
- default:
- title.text = getDefault(layout, metaData, dashboard)
- break
- }
- }
-
- return Object.assign(
- {},
+ const title = Object.assign(
+ {
+ text: undefined,
+ },
dashboard
? DASHBOARD_TITLE_STYLE
: {
@@ -87,7 +73,7 @@ export default function (layout, metaData, dashboard) {
isVerticalType(layout.type)
),
style: {
- color: fontStyle[FONT_STYLE_OPTION_TEXT_COLOR],
+ color: undefined,
fontSize: `${fontStyle[FONT_STYLE_OPTION_FONT_SIZE]}px`,
fontWeight: fontStyle[FONT_STYLE_OPTION_BOLD]
? FONT_STYLE_OPTION_BOLD
@@ -99,7 +85,65 @@ export default function (layout, metaData, dashboard) {
overflow: 'hidden',
textOverflow: 'ellipsis',
},
- },
- title
+ }
)
+
+ const customTitleText =
+ (layout.title && layout.displayTitle) || layout.title
+
+ if (isString(customTitleText) && customTitleText.length) {
+ title.text = customTitleText
+ } else {
+ switch (layout.type) {
+ case VIS_TYPE_SINGLE_VALUE:
+ title.text = getSingleValueTitleText(
+ layout,
+ metaData,
+ dashboard
+ )
+ break
+ case VIS_TYPE_GAUGE:
+ case VIS_TYPE_YEAR_OVER_YEAR_LINE:
+ case VIS_TYPE_YEAR_OVER_YEAR_COLUMN:
+ title.text = getYearOverYearTitle(layout, metaData, dashboard)
+ break
+ case VIS_TYPE_SCATTER:
+ title.text = getScatterTitle(layout, metaData, dashboard)
+ break
+ default:
+ title.text = getDefault(layout, metaData, dashboard)
+ break
+ }
+ }
+
+ switch (layout.type) {
+ case VIS_TYPE_SINGLE_VALUE:
+ {
+ const defaultColor =
+ defaultFontStyle?.[FONT_STYLE_VISUALIZATION_TITLE]?.[
+ FONT_STYLE_OPTION_TEXT_COLOR
+ ]
+ const customColor =
+ layout?.fontStyle?.[FONT_STYLE_VISUALIZATION_TITLE]?.[
+ FONT_STYLE_OPTION_TEXT_COLOR
+ ]
+ title.style.color = getSingleValueTitleColor(
+ customColor,
+ defaultColor,
+ series[0],
+ legendOptions,
+ legendSets
+ )
+ if (dashboard) {
+ // TODO: is this always what we want?
+ title.style.fontWeight = 'normal'
+ }
+ }
+ break
+ default:
+ title.style.color = fontStyle[FONT_STYLE_OPTION_TEXT_COLOR]
+ break
+ }
+
+ return title
}
diff --git a/src/visualizations/config/adapters/dhis_highcharts/title/singleValue.js b/src/visualizations/config/adapters/dhis_highcharts/title/singleValue.js
new file mode 100644
index 000000000..fdf5d891a
--- /dev/null
+++ b/src/visualizations/config/adapters/dhis_highcharts/title/singleValue.js
@@ -0,0 +1,23 @@
+import getFilterText from '../../../../util/getFilterText.js'
+export { getSingleValueTitleColor } from '../customSVGOptions/singleValue/getSingleValueTitleColor.js'
+
+export function getSingleValueTitleText(layout, metaData) {
+ if (layout.hideTitle) {
+ return ''
+ }
+
+ if (typeof layout.title === 'string' && layout.title.length) {
+ return layout.title
+ }
+
+ if (layout.columns) {
+ const firstItem = layout.columns[0].items[0]
+
+ const column = Object.assign({}, layout.columns[0], {
+ items: [firstItem],
+ })
+
+ return getFilterText([column], metaData)
+ }
+ return ''
+}
diff --git a/src/visualizations/config/adapters/dhis_highcharts/type.js b/src/visualizations/config/adapters/dhis_highcharts/type.js
index bc56c6d98..08cb62a49 100644
--- a/src/visualizations/config/adapters/dhis_highcharts/type.js
+++ b/src/visualizations/config/adapters/dhis_highcharts/type.js
@@ -12,6 +12,7 @@ import {
VIS_TYPE_STACKED_COLUMN,
VIS_TYPE_YEAR_OVER_YEAR_COLUMN,
VIS_TYPE_SCATTER,
+ VIS_TYPE_SINGLE_VALUE,
} from '../../../../modules/visTypes.js'
export default function (type) {
@@ -33,6 +34,8 @@ export default function (type) {
return { type: 'solidgauge' }
case VIS_TYPE_SCATTER:
return { type: 'scatter', zoomType: 'xy' }
+ case VIS_TYPE_SINGLE_VALUE:
+ return {}
case VIS_TYPE_COLUMN:
case VIS_TYPE_STACKED_COLUMN:
case VIS_TYPE_YEAR_OVER_YEAR_COLUMN:
diff --git a/src/visualizations/config/adapters/dhis_highcharts/xAxis/index.js b/src/visualizations/config/adapters/dhis_highcharts/xAxis/index.js
index c3af4b20b..1439fc201 100644
--- a/src/visualizations/config/adapters/dhis_highcharts/xAxis/index.js
+++ b/src/visualizations/config/adapters/dhis_highcharts/xAxis/index.js
@@ -16,6 +16,7 @@ import {
VIS_TYPE_RADAR,
VIS_TYPE_SCATTER,
isTwoCategoryChartType,
+ VIS_TYPE_SINGLE_VALUE,
} from '../../../../../modules/visTypes.js'
import { getAxis } from '../../../../util/axes.js'
import getAxisTitle from '../getAxisTitle.js'
@@ -82,6 +83,7 @@ export default function (store, layout, extraOptions, series) {
switch (layout.type) {
case VIS_TYPE_PIE:
case VIS_TYPE_GAUGE:
+ case VIS_TYPE_SINGLE_VALUE:
xAxis = noAxis()
break
case VIS_TYPE_YEAR_OVER_YEAR_LINE:
diff --git a/src/visualizations/config/adapters/dhis_highcharts/yAxis/index.js b/src/visualizations/config/adapters/dhis_highcharts/yAxis/index.js
index 1e9aab2a9..d253acdff 100644
--- a/src/visualizations/config/adapters/dhis_highcharts/yAxis/index.js
+++ b/src/visualizations/config/adapters/dhis_highcharts/yAxis/index.js
@@ -11,6 +11,7 @@ import {
isStacked,
VIS_TYPE_GAUGE,
VIS_TYPE_SCATTER,
+ VIS_TYPE_SINGLE_VALUE,
} from '../../../../../modules/visTypes.js'
import { getAxis } from '../../../../util/axes.js'
import { getAxisStringFromId } from '../../../../util/axisId.js'
@@ -148,14 +149,12 @@ function getDefault(layout, series, extraOptions) {
}
export default function (layout, series, extraOptions) {
- let yAxis
switch (layout.type) {
+ case VIS_TYPE_SINGLE_VALUE:
+ return null
case VIS_TYPE_GAUGE:
- yAxis = getGauge(layout, series, extraOptions.legendSets[0])
- break
+ return getGauge(layout, series, extraOptions.legendSets[0])
default:
- yAxis = getDefault(layout, series, extraOptions)
+ return getDefault(layout, series, extraOptions)
}
-
- return yAxis
}
diff --git a/src/visualizations/config/generators/highcharts/index.js b/src/visualizations/config/generators/highcharts/index.js
index 92a775910..3620e81f5 100644
--- a/src/visualizations/config/generators/highcharts/index.js
+++ b/src/visualizations/config/generators/highcharts/index.js
@@ -3,16 +3,24 @@ import HM from 'highcharts/highcharts-more'
import HB from 'highcharts/modules/boost'
import HE from 'highcharts/modules/exporting'
import HNDTD from 'highcharts/modules/no-data-to-display'
+import HOE from 'highcharts/modules/offline-exporting'
import HPF from 'highcharts/modules/pattern-fill'
import HSG from 'highcharts/modules/solid-gauge'
+import PEBFP from './pdfExportBugFixPlugin/index.js'
// apply
HM(H)
HSG(H)
HNDTD(H)
HE(H)
+HOE(H)
HPF(H)
HB(H)
+PEBFP(H)
+
+/* Whitelist some additional SVG attributes here. Without this,
+ * the PDF export for the SingleValue visualization breaks. */
+H.AST.allowedAttributes.push('fill-rule', 'clip-rule')
function drawLegendSymbolWrap() {
const pick = H.pick
@@ -75,7 +83,6 @@ export default function (config, el) {
// silence warning about accessibility
config.accessibility = { enabled: false }
-
if (config.lang) {
H.setOptions({
lang: config.lang,
diff --git a/src/visualizations/config/generators/highcharts/pdfExportBugFixPlugin/index.js b/src/visualizations/config/generators/highcharts/pdfExportBugFixPlugin/index.js
new file mode 100644
index 000000000..7b4899cde
--- /dev/null
+++ b/src/visualizations/config/generators/highcharts/pdfExportBugFixPlugin/index.js
@@ -0,0 +1,7 @@
+import nonASCIIFontBugfix from './nonASCIIFont.js'
+import textShadowBugFix from './textShadow.js'
+
+export default function (H) {
+ textShadowBugFix(H)
+ nonASCIIFontBugfix(H)
+}
diff --git a/src/visualizations/config/generators/highcharts/pdfExportBugFixPlugin/nonASCIIFont.js b/src/visualizations/config/generators/highcharts/pdfExportBugFixPlugin/nonASCIIFont.js
new file mode 100644
index 000000000..d2c8d9835
--- /dev/null
+++ b/src/visualizations/config/generators/highcharts/pdfExportBugFixPlugin/nonASCIIFont.js
@@ -0,0 +1,9 @@
+/* This is a workaround for https://github.com/highcharts/highcharts/issues/22008
+ * We add some transparent text in a non-ASCII script to the chart to prevent
+ * the chart from being exported in a serif font */
+
+export default function (H) {
+ H.addEvent(H.Chart, 'load', function () {
+ this.renderer.text('ыки', 20, 20).attr({ opacity: 0 }).add()
+ })
+}
diff --git a/src/visualizations/config/generators/highcharts/pdfExportBugFixPlugin/textShadow.js b/src/visualizations/config/generators/highcharts/pdfExportBugFixPlugin/textShadow.js
new file mode 100644
index 000000000..21a96e1a5
--- /dev/null
+++ b/src/visualizations/config/generators/highcharts/pdfExportBugFixPlugin/textShadow.js
@@ -0,0 +1,308 @@
+/* This plugin was provided by HighCharts support and resolves an issue with label
+ * text that has a white outline, such as the one we use for stacked bar charts.
+ * For example: "ANC: 1-4 visits by districts this year (stacked)"
+ * This issue has actually been resolved in HighCharts v11, so once we have upgraded
+ * to that version, this plugin can be removed. */
+
+export default function (H) {
+ const { AST, defaultOptions, downloadURL } = H,
+ { ajax } = H.HttpUtilities,
+ doc = document,
+ win = window,
+ OfflineExporting =
+ H._modules['Extensions/OfflineExporting/OfflineExporting.js'],
+ { getScript, svgToPdf, imageToDataUrl, svgToDataUrl } = OfflineExporting
+
+ H.wrap(
+ OfflineExporting,
+ 'downloadSVGLocal',
+ function (proceed, svg, options, failCallback, successCallback) {
+ var dummySVGContainer = doc.createElement('div'),
+ imageType = options.type || 'image/png',
+ filename =
+ (options.filename || 'chart') +
+ '.' +
+ (imageType === 'image/svg+xml'
+ ? 'svg'
+ : imageType.split('/')[1]),
+ scale = options.scale || 1
+ var svgurl,
+ blob,
+ finallyHandler,
+ libURL = options.libURL || defaultOptions.exporting.libURL,
+ objectURLRevoke = true,
+ pdfFont = options.pdfFont
+ // Allow libURL to end with or without fordward slash
+ libURL = libURL.slice(-1) !== '/' ? libURL + '/' : libURL
+ /*
+ * Detect if we need to load TTF fonts for the PDF, then load them and
+ * proceed.
+ *
+ * @private
+ */
+ var loadPdfFonts = function (svgElement, callback) {
+ var hasNonASCII = function (s) {
+ return (
+ // eslint-disable-next-line no-control-regex
+ /[^\u0000-\u007F\u200B]+/.test(s)
+ )
+ }
+ // Register an event in order to add the font once jsPDF is
+ // initialized
+ var addFont = function (variant, base64) {
+ win.jspdf.jsPDF.API.events.push([
+ 'initialized',
+ function () {
+ this.addFileToVFS(variant, base64)
+ this.addFont(variant, 'HighchartsFont', variant)
+ if (!this.getFontList().HighchartsFont) {
+ this.setFont('HighchartsFont')
+ }
+ },
+ ])
+ }
+ // If there are no non-ASCII characters in the SVG, do not use
+ // bother downloading the font files
+ if (pdfFont && !hasNonASCII(svgElement.textContent || '')) {
+ pdfFont = void 0
+ }
+ // Add new font if the URL is declared, #6417.
+ var variants = ['normal', 'italic', 'bold', 'bolditalic']
+ // Shift the first element off the variants and add as a font.
+ // Then asynchronously trigger the next variant until calling the
+ // callback when the variants are empty.
+ var normalBase64
+ var shiftAndLoadVariant = function () {
+ var variant = variants.shift()
+ // All variants shifted and possibly loaded, proceed
+ if (!variant) {
+ return callback()
+ }
+ var url = pdfFont && pdfFont[variant]
+ if (url) {
+ ajax({
+ url: url,
+ responseType: 'blob',
+ success: function (data, xhr) {
+ var reader = new FileReader()
+ reader.onloadend = function () {
+ if (typeof this.result === 'string') {
+ var base64 = this.result.split(',')[1]
+ addFont(variant, base64)
+ if (variant === 'normal') {
+ normalBase64 = base64
+ }
+ }
+ shiftAndLoadVariant()
+ }
+ reader.readAsDataURL(xhr.response)
+ },
+ error: shiftAndLoadVariant,
+ })
+ } else {
+ // For other variants, fall back to normal text weight/style
+ if (normalBase64) {
+ addFont(variant, normalBase64)
+ }
+ shiftAndLoadVariant()
+ }
+ }
+ shiftAndLoadVariant()
+ }
+ /*
+ * @private
+ */
+ var downloadPDF = function () {
+ AST.setElementHTML(dummySVGContainer, svg)
+ var textElements =
+ dummySVGContainer.getElementsByTagName('text'),
+ // Copy style property to element from parents if it's not
+ // there. Searches up hierarchy until it finds prop, or hits the
+ // chart container.
+ setStylePropertyFromParents = function (el, propName) {
+ var curParent = el
+ while (curParent && curParent !== dummySVGContainer) {
+ if (curParent.style[propName]) {
+ el.style[propName] = curParent.style[propName]
+ break
+ }
+ curParent = curParent.parentNode
+ }
+ }
+ var titleElements,
+ outlineElements
+ // Workaround for the text styling. Making sure it does pick up
+ // settings for parent elements.
+ ;[].forEach.call(textElements, function (el) {
+ // Workaround for the text styling. making sure it does pick up
+ // the root element
+ ;['font-family', 'font-size'].forEach(function (property) {
+ setStylePropertyFromParents(el, property)
+ })
+ el.style.fontFamily =
+ pdfFont && pdfFont.normal
+ ? // Custom PDF font
+ 'HighchartsFont'
+ : // Generic font (serif, sans-serif etc)
+ String(
+ el.style.fontFamily &&
+ el.style.fontFamily.split(' ').splice(-1)
+ )
+ // Workaround for plotband with width, removing title from text
+ // nodes
+ titleElements = el.getElementsByTagName('title')
+ ;[].forEach.call(titleElements, function (titleElement) {
+ el.removeChild(titleElement)
+ })
+
+ // Remove all .highcharts-text-outline elements, #17170
+ outlineElements = el.getElementsByClassName(
+ 'highcharts-text-outline'
+ )
+ while (outlineElements.length > 0) {
+ const outline = outlineElements[0]
+ if (outline.parentNode) {
+ outline.parentNode.removeChild(outline)
+ }
+ }
+ })
+ var svgNode = dummySVGContainer.querySelector('svg')
+ if (svgNode) {
+ loadPdfFonts(svgNode, function () {
+ svgToPdf(svgNode, 0, function (pdfData) {
+ try {
+ downloadURL(pdfData, filename)
+ if (successCallback) {
+ successCallback()
+ }
+ } catch (e) {
+ failCallback(e)
+ }
+ })
+ })
+ }
+ }
+ // Initiate download depending on file type
+ if (imageType === 'image/svg+xml') {
+ // SVG download. In this case, we want to use Microsoft specific
+ // Blob if available
+ try {
+ if (typeof win.navigator.msSaveOrOpenBlob !== 'undefined') {
+ // eslint-disable-next-line no-undef
+ blob = new MSBlobBuilder()
+ blob.append(svg)
+ svgurl = blob.getBlob('image/svg+xml')
+ } else {
+ svgurl = svgToDataUrl(svg)
+ }
+ downloadURL(svgurl, filename)
+ if (successCallback) {
+ successCallback()
+ }
+ } catch (e) {
+ failCallback(e)
+ }
+ } else if (imageType === 'application/pdf') {
+ if (win.jspdf && win.jspdf.jsPDF) {
+ downloadPDF()
+ } else {
+ // Must load pdf libraries first. // Don't destroy the object
+ // URL yet since we are doing things asynchronously. A cleaner
+ // solution would be nice, but this will do for now.
+ objectURLRevoke = true
+ getScript(libURL + 'jspdf.js', function () {
+ getScript(libURL + 'svg2pdf.js', downloadPDF)
+ })
+ }
+ } else {
+ // PNG/JPEG download - create bitmap from SVG
+ svgurl = svgToDataUrl(svg)
+ finallyHandler = function () {
+ try {
+ OfflineExporting.domurl.revokeObjectURL(svgurl)
+ } catch (e) {
+ // Ignore
+ }
+ }
+ // First, try to get PNG by rendering on canvas
+ imageToDataUrl(
+ svgurl,
+ imageType,
+ {},
+ scale,
+ function (imageURL) {
+ // Success
+ try {
+ downloadURL(imageURL, filename)
+ if (successCallback) {
+ successCallback()
+ }
+ } catch (e) {
+ failCallback(e)
+ }
+ },
+ function () {
+ // Failed due to tainted canvas
+ // Create new and untainted canvas
+ var canvas = doc.createElement('canvas'),
+ ctx = canvas.getContext('2d'),
+ imageWidth =
+ svg.match(
+ // eslint-disable-next-line no-useless-escape
+ /^