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 @@
+<link rel="preconnect" href="https://fonts.googleapis.com" />
+<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
+<link
+    href="https://fonts.googleapis.com/css2?family=Roboto:ital,wght@0,100;0,300;0,400;0,500;0,700;0,900;1,100;1,300;1,400;1,500;1,700;1,900&display=swap"
+    rel="stylesheet"
+/>
diff --git a/CHANGELOG.md b/CHANGELOG.md
index e8890cb2b..9b7982142 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,31 @@
+# [26.9.0](https://github.com/dhis2/analytics/compare/v26.8.8...v26.9.0) (2024-10-22)
+
+
+### Features
+
+* implement Single Value as a Highcharts.Chart instance and add offline exporting module ([#1698](https://github.com/dhis2/analytics/issues/1698)) ([40fdfba](https://github.com/dhis2/analytics/commit/40fdfba1c3041cb7cf57845aa101c8a64f0cd919))
+
+## [26.8.8](https://github.com/dhis2/analytics/compare/v26.8.7...v26.8.8) (2024-10-20)
+
+
+### Bug Fixes
+
+* **translations:** sync translations from transifex (master) ([f187092](https://github.com/dhis2/analytics/commit/f1870928b37733395d7f911f48ea7268fed97be1))
+
+## [26.8.7](https://github.com/dhis2/analytics/compare/v26.8.6...v26.8.7) (2024-10-18)
+
+
+### Bug Fixes
+
+* compute totals and cumulative values for numeric/boolean types respecting totalAggregationType (DHIS2-9155) ([#1700](https://github.com/dhis2/analytics/issues/1700)) ([a2bfd20](https://github.com/dhis2/analytics/commit/a2bfd203cb53f174106d8b570cea52cbfc6136f7))
+
+## [26.8.6](https://github.com/dhis2/analytics/compare/v26.8.5...v26.8.6) (2024-10-06)
+
+
+### Bug Fixes
+
+* **translations:** sync translations from transifex (master) ([60f505e](https://github.com/dhis2/analytics/commit/60f505e792cceafba9ba8275031fad82641d9411))
+
 ## [26.8.5](https://github.com/dhis2/analytics/compare/v26.8.4...v26.8.5) (2024-09-22)
 
 
diff --git a/i18n/en.pot b/i18n/en.pot
index 2e98715e2..8f0bb1884 100644
--- a/i18n/en.pot
+++ b/i18n/en.pot
@@ -5,8 +5,8 @@ msgstr ""
 "Content-Type: text/plain; charset=utf-8\n"
 "Content-Transfer-Encoding: 8bit\n"
 "Plural-Forms: nplurals=2; plural=(n != 1)\n"
-"POT-Creation-Date: 2024-08-27T11:29:09.031Z\n"
-"PO-Revision-Date: 2024-08-27T11:29:09.033Z\n"
+"POT-Creation-Date: 2024-10-11T12:49:26.846Z\n"
+"PO-Revision-Date: 2024-10-11T12:49:26.847Z\n"
 
 msgid "view only"
 msgstr "view only"
@@ -855,6 +855,9 @@ msgstr "Financial Years"
 msgid "Years"
 msgstr "Years"
 
+msgid "Value: {{value}}"
+msgstr "Value: {{value}}"
+
 msgid "Bold text"
 msgstr "Bold text"
 
@@ -1125,6 +1128,9 @@ msgstr "{{thresholdFactor}} × Z-score low"
 msgid "{{thresholdFactor}} × Z-score high"
 msgstr "{{thresholdFactor}} × Z-score high"
 
+msgid "Not applicable"
+msgstr "Not applicable"
+
 msgid "Data"
 msgstr "Data"
 
diff --git a/i18n/lo.po b/i18n/lo.po
index 670a341a4..d77d79e5a 100644
--- a/i18n/lo.po
+++ b/i18n/lo.po
@@ -4,15 +4,15 @@
 # Somkhit Bouavong <bouavongk@gmail.com>, 2022
 # Philip Larsen Donnelly, 2023
 # Phouthasinh PHEUAYSITHIPHONE, 2023
-# Saysamone Sibounma, 2023
 # Namwan Chanthavisouk, 2024
+# Saysamone Sibounma, 2024
 # 
 msgid ""
 msgstr ""
 "Project-Id-Version: i18next-conv\n"
-"POT-Creation-Date: 2024-01-25T12:05:03.360Z\n"
+"POT-Creation-Date: 2024-10-11T12:49:26.846Z\n"
 "PO-Revision-Date: 2020-04-28 22:05+0000\n"
-"Last-Translator: Namwan Chanthavisouk, 2024\n"
+"Last-Translator: Saysamone Sibounma, 2024\n"
 "Language-Team: Lao (https://app.transifex.com/hisp-uio/teams/100509/lo/)\n"
 "MIME-Version: 1.0\n"
 "Content-Type: text/plain; charset=UTF-8\n"
@@ -78,6 +78,12 @@ msgstr "ກ່ຽວກັບບັນຊີລາຍຊື່"
 msgid "About this visualization"
 msgstr "ກ່ຽວກັບການສ້າງພາບຂໍ້ມູນ"
 
+msgid "About this event chart"
+msgstr "ກ່ຽວກັບເຫດການແຜນຜັງ"
+
+msgid "About this event report"
+msgstr "ກ່ຽວກັບບົດລາຍງານເຫດການຕ່າງໆ"
+
 msgid "This app could not retrieve required data."
 msgstr "ແອັບນີ້ບໍ່ສາມາດດຶງຂໍ້ມູນທີ່ຕ້ອງການໄດ້"
 
@@ -91,7 +97,7 @@ msgid "Data / New calculation"
 msgstr "ຂໍ້ມູນ / ຄິດໄລ່ໃໝ່"
 
 msgid "Remove item"
-msgstr "ລົບລາຍການ"
+msgstr "ເອົາລາຍການອອກ"
 
 msgid "Check formula"
 msgstr "ກວດເບິ່ງສູດ"
@@ -435,39 +441,6 @@ msgstr "ບໍ່ສາມາດອັບເດດຂໍ້ຄວາມ"
 msgid "Enter interpretation text"
 msgstr "ປ້ອນຂໍ້ຄວາມ"
 
-msgid "Bold text"
-msgstr "ຕົວອັກສອນເຂັ້ມ"
-
-msgid "Italic text"
-msgstr "ຕົວອັກສອນສະຫຼ່ຽງ"
-
-msgid "Link to a URL"
-msgstr "ເຊື່ອມຕໍ່ກັບ URL"
-
-msgid "Mention a user"
-msgstr "ກ່າວເຖິງຜູ້ໃຊ້"
-
-msgid "Add emoji"
-msgstr "ເພີ່ມ emoji"
-
-msgid "Preview"
-msgstr "ເບິ່ງຕົວຢ່າງ"
-
-msgid "Back to write mode"
-msgstr "ກັບໄປທີ່ໂໝດຂຽນ"
-
-msgid "Too many results. Try refining the search."
-msgstr "ຜົນໄດ້ຮັບຫຼາຍເກີນໄປ. ປັບປຸງການຄົ້ນຫາ."
-
-msgid "Search for a user"
-msgstr "ຄົ້ນຫາຜູ້ໃຊ້"
-
-msgid "Searching for \"{{- searchText}}\""
-msgstr "ຄົ້ນຫາ \"{{- searchText}}\""
-
-msgid "No results found"
-msgstr "ບໍ່ພົບຜົນການຊອກຫາ"
-
 msgid "Not available offline"
 msgstr "ບໍ່ສາມາດໃຊ້ໄດ້ອອບລາຍ"
 
@@ -880,6 +853,30 @@ msgstr "ສົກປີງົບປະມານ"
 msgid "Years"
 msgstr "ປີ"
 
+msgid "Value: {{value}}"
+msgstr ""
+
+msgid "Bold text"
+msgstr "ຕົວອັກສອນເຂັ້ມ"
+
+msgid "Italic text"
+msgstr "ຕົວອັກສອນສະຫຼ່ຽງ"
+
+msgid "Link to a URL"
+msgstr "ເຊື່ອມຕໍ່ກັບ URL"
+
+msgid "Mention a user"
+msgstr "ກ່າວເຖິງຜູ້ໃຊ້"
+
+msgid "Add emoji"
+msgstr "ເພີ່ມ emoji"
+
+msgid "Preview"
+msgstr "ເບິ່ງຕົວຢ່າງ"
+
+msgid "Back to write mode"
+msgstr "ກັບໄປທີ່ໂໝດຂຽນ"
+
 msgid "Interpretations and details"
 msgstr "ຂໍ້ມູນ ແລະ ລາຍລະອຽດ"
 
@@ -910,6 +907,18 @@ msgstr "ບໍ່ສາມາດໂຫຼດການແປ"
 msgid "Retry"
 msgstr "ລອງໃໝ່"
 
+msgid "Too many results. Try refining the search."
+msgstr "ຜົນໄດ້ຮັບຫຼາຍເກີນໄປ. ປັບປຸງການຄົ້ນຫາ."
+
+msgid "Search for a user"
+msgstr "ຄົ້ນຫາຜູ້ໃຊ້"
+
+msgid "Searching for \"{{- searchText}}\""
+msgstr "ຄົ້ນຫາ \"{{- searchText}}\""
+
+msgid "No results found"
+msgstr "ບໍ່ພົບຜົນການຊອກຫາ"
+
 msgid "Series"
 msgstr "ແທ່ງ"
 
@@ -1117,6 +1126,9 @@ msgstr "{{thresholdFactor}} x ຄະແນນ z ຕ່ຳ"
 msgid "{{thresholdFactor}} × Z-score high"
 msgstr "{{thresholdFactor}} x ຄະແນນ z ສູງ"
 
+msgid "Not applicable"
+msgstr ""
+
 msgid "Data"
 msgstr "ຂໍ້ມູນ"
 
diff --git a/i18n/zh.po b/i18n/zh.po
index 529b2383d..cefd5a3e8 100644
--- a/i18n/zh.po
+++ b/i18n/zh.po
@@ -8,7 +8,7 @@
 msgid ""
 msgstr ""
 "Project-Id-Version: i18next-conv\n"
-"POT-Creation-Date: 2024-01-25T12:05:03.360Z\n"
+"POT-Creation-Date: 2024-08-27T11:29:09.031Z\n"
 "PO-Revision-Date: 2020-04-28 22:05+0000\n"
 "Last-Translator: easylin <lin_xd@126.com>, 2024\n"
 "Language-Team: Chinese (https://app.transifex.com/hisp-uio/teams/100509/zh/)\n"
@@ -50,7 +50,7 @@ msgstr "已创建 {{time}}"
 
 msgid "Viewed {{count}} times"
 msgid_plural "Viewed {{count}} times"
-msgstr[0] "查看了 {{count}} 次"
+msgstr[0] "查看了 {{count}} 条"
 
 msgid "Notifications"
 msgstr "通知"
@@ -76,6 +76,12 @@ msgstr "关于此行列表"
 msgid "About this visualization"
 msgstr "关于此可视化"
 
+msgid "About this event chart"
+msgstr "关于该事件图表"
+
+msgid "About this event report"
+msgstr "关于本事件报表"
+
 msgid "This app could not retrieve required data."
 msgstr "此应用无法检索所需数据。"
 
@@ -124,7 +130,7 @@ msgid "Yes, delete"
 msgstr "是的,删除"
 
 msgid "Totals only"
-msgstr "总计"
+msgstr "仅总数"
 
 msgid "Details only"
 msgstr "仅详细信息"
@@ -429,39 +435,6 @@ msgstr "无法更新解释"
 msgid "Enter interpretation text"
 msgstr "输入解释文本"
 
-msgid "Bold text"
-msgstr "粗体文字"
-
-msgid "Italic text"
-msgstr "斜体文字"
-
-msgid "Link to a URL"
-msgstr "链接到 URL"
-
-msgid "Mention a user"
-msgstr "提及用户"
-
-msgid "Add emoji"
-msgstr "添加表情符号"
-
-msgid "Preview"
-msgstr "预览"
-
-msgid "Back to write mode"
-msgstr "返回写入模式"
-
-msgid "Too many results. Try refining the search."
-msgstr "结果太多。尝试优化搜索。"
-
-msgid "Search for a user"
-msgstr "搜索用户"
-
-msgid "Searching for \"{{- searchText}}\""
-msgstr "搜索“{{- searchText}}”"
-
-msgid "No results found"
-msgstr "没有结果"
-
 msgid "Not available offline"
 msgstr "离线不可用"
 
@@ -704,13 +677,13 @@ msgid "Financial year (Start November)"
 msgstr "财政年(11月始)"
 
 msgid "Financial year (Start October)"
-msgstr "财务十月"
+msgstr "财政年度(10 月开始)"
 
 msgid "Financial year (Start July)"
-msgstr "财务七月"
+msgstr "财政年度(7 月开始)"
 
 msgid "Financial year (Start April)"
-msgstr "财务四月"
+msgstr "财政年度(4 月开始)"
 
 msgid "Today"
 msgstr "今天"
@@ -746,16 +719,16 @@ msgid "Last week"
 msgstr "上周"
 
 msgid "Last 4 weeks"
-msgstr "最近四周"
+msgstr "最近 4 周"
 
 msgid "Last 12 weeks"
-msgstr "最近12周"
+msgstr "最近 12 周"
 
 msgid "Last 52 weeks"
-msgstr "Last 52 weeks"
+msgstr "最近52 周"
 
 msgid "Weeks this year"
-msgstr "Weeks this year"
+msgstr "今年的周"
 
 msgid "This bi-week"
 msgstr "本双周"
@@ -776,7 +749,7 @@ msgid "Last 3 months"
 msgstr "最近3个月"
 
 msgid "Last 6 months"
-msgstr "Last 6 months"
+msgstr "最近 6 个月"
 
 msgid "Last 12 months"
 msgstr "最近12月"
@@ -788,13 +761,13 @@ msgid "This bi-month"
 msgstr "本双月"
 
 msgid "Last bi-month"
-msgstr "Last bi-month"
+msgstr "上两个月"
 
 msgid "Last 6 bi-months"
-msgstr "Last 6 bi-months"
+msgstr "最近 6 个双月"
 
 msgid "Bi-months this year"
-msgstr "Bi-months this year"
+msgstr "今年的双月"
 
 msgid "This quarter"
 msgstr "本季度"
@@ -803,7 +776,7 @@ msgid "Last quarter"
 msgstr "最近一季"
 
 msgid "Last 4 quarters"
-msgstr "最近四个季度"
+msgstr "最近 4 个季度"
 
 msgid "Quarters this year"
 msgstr "今年的季度"
@@ -824,7 +797,7 @@ msgid "Last financial year"
 msgstr "上一财政年"
 
 msgid "Last 5 financial years"
-msgstr "最近五个财政年"
+msgstr "最近 5 个财政年度"
 
 msgid "This year"
 msgstr "今年"
@@ -851,20 +824,41 @@ msgid "Months"
 msgstr "月"
 
 msgid "Bi-months"
-msgstr "Bi-months"
+msgstr "双月"
 
 msgid "Quarters"
 msgstr "四分之一"
 
 msgid "Six-months"
-msgstr "Six-months"
+msgstr "六个月"
 
 msgid "Financial Years"
-msgstr "Financial Years"
+msgstr "财政年度"
 
 msgid "Years"
 msgstr "年"
 
+msgid "Bold text"
+msgstr "粗体文字"
+
+msgid "Italic text"
+msgstr "斜体文字"
+
+msgid "Link to a URL"
+msgstr "链接到 URL"
+
+msgid "Mention a user"
+msgstr "提及用户"
+
+msgid "Add emoji"
+msgstr "添加表情符号"
+
+msgid "Preview"
+msgstr "预览"
+
+msgid "Back to write mode"
+msgstr "返回写入模式"
+
 msgid "Interpretations and details"
 msgstr "解释和细节"
 
@@ -895,6 +889,18 @@ msgstr "无法加载翻译"
 msgid "Retry"
 msgstr "重试"
 
+msgid "Too many results. Try refining the search."
+msgstr "结果太多。尝试优化搜索。"
+
+msgid "Search for a user"
+msgstr "搜索用户"
+
+msgid "Searching for \"{{- searchText}}\""
+msgstr "搜索“{{- searchText}}”"
+
+msgid "No results found"
+msgstr "没有结果"
+
 msgid "Series"
 msgstr "系列"
 
@@ -1154,7 +1160,7 @@ msgid "Radar"
 msgstr "雷达图"
 
 msgid "Scatter"
-msgstr "分散"
+msgstr "散点图"
 
 msgid "Single value"
 msgstr "单个值"
diff --git a/package.json b/package.json
index 18ea8cc83..0e3c04c3e 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
 {
     "name": "@dhis2/analytics",
-    "version": "26.8.5",
+    "version": "26.9.0",
     "main": "./build/cjs/index.js",
     "module": "./build/es/index.js",
     "exports": {
@@ -20,7 +20,6 @@
     },
     "scripts": {
         "build": "d2-app-scripts build",
-        "postbuild": "yarn build-storybook",
         "build-storybook": "build-storybook",
         "start-storybook": "start-storybook --port 5000",
         "start": "yarn start-storybook",
diff --git a/src/__demo__/SingleValue.stories.js b/src/__demo__/SingleValue.stories.js
new file mode 100644
index 000000000..c47b82cbd
--- /dev/null
+++ b/src/__demo__/SingleValue.stories.js
@@ -0,0 +1,802 @@
+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 =
+    '<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M32 12.5C32 13.0523 32.4477 13.5 33 13.5C33.5523 13.5 34 13.0523 34 12.5V11C34 10.4477 33.5523 10 33 10C32.4477 10 32 10.4477 32 11V12.5Z" fill="currentColor"/><path d="M16 24V27H18V24H21V22H18V19H16V22H13V24H16Z" fill="currentColor"/><path fill-rule="evenodd" clip-rule="evenodd" d="M4 17C4 15.3431 5.34315 14 7 14H27C28.6569 14 30 15.3431 30 17V19H31V18H32V17C32 16.4477 32.4477 16 33 16C33.5523 16 34 16.4477 34 17V18H35V19H35.718C36.4722 19 37.1987 19.284 37.7529 19.7956L43.0348 24.6713C43.6501 25.2392 44 26.0384 44 26.8757V35H38.874C38.4299 36.7252 36.8638 38 35 38C33.1362 38 31.5701 36.7252 31.126 35H15.874C15.4299 36.7252 13.8638 38 12 38C10.1362 38 8.57006 36.7252 8.12602 35H4V17ZM31.126 33C31.5701 31.2748 33.1362 30 35 30C36.8638 30 38.4299 31.2748 38.874 33H42V28L30 28V33H31.126ZM30 26L41.5257 26L36.3963 21.2652C36.2116 21.0947 35.9694 21 35.718 21H30V26ZM27 16C27.5523 16 28 16.4477 28 17V33H15.874C15.4299 31.2748 13.8638 30 12 30C10.1362 30 8.57006 31.2748 8.12602 33H6V17C6 16.4477 6.44772 16 7 16H27ZM12 36C13.1046 36 14 35.1046 14 34C14 32.8954 13.1046 32 12 32C10.8954 32 10 32.8954 10 34C10 35.1046 10.8954 36 12 36ZM37 34C37 35.1046 36.1046 36 35 36C33.8954 36 33 35.1046 33 34C33 32.8954 33.8954 32 35 32C36.1046 32 37 32.8954 37 34Z" fill="currentColor"/><path d="M36.5 17C36.5 16.4477 36.9477 16 37.5 16H39C39.5523 16 40 16.4477 40 17C40 17.5523 39.5523 18 39 18H37.5C36.9477 18 36.5 17.5523 36.5 17Z" fill="currentColor"/><path d="M35.8285 12.759C35.4193 13.1298 35.3881 13.7622 35.759 14.1715C36.1298 14.5807 36.7622 14.6119 37.1715 14.241L38.0857 13.4126C38.4949 13.0417 38.5261 12.4093 38.1552 12.0001C37.7844 11.5908 37.152 11.5597 36.7427 11.9306L35.8285 12.759Z" fill="currentColor"/></svg>'
+
+const baseExtraOptions = {
+    dashboard: true,
+    animation: 200,
+    legendSets: [],
+    icon,
+}
+
+const indicatorTypes = ['plain', 'percent', 'subtext']
+
+storiesOf('SingleValue', module).add('default', () => {
+    const newChartRef = useRef(null)
+    const newContainerRef = useRef(null)
+    const [dashboard, setDashboard] = useState(false)
+    const [showIcon, setShowIcon] = useState(true)
+    const [indicatorType, setIndicatorType] = useState('plain')
+    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 (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
+                }
+                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 (
+        <>
+            <div
+                style={{
+                    display: 'flex',
+                    gap: 12,
+                    marginBottom: 20,
+                    alignItems: 'center',
+                }}
+            >
+                <div>
+                    <label htmlFor="width">Width</label>
+                    <input
+                        type="number"
+                        name="width"
+                        id="width"
+                        min="1"
+                        step="5"
+                        onChange={(event) =>
+                            setWidth(parseInt(event.target.value))
+                        }
+                        value={width.toString()}
+                    />
+                </div>
+                <div>
+                    <label htmlFor="height">Height</label>
+                    <input
+                        type="number"
+                        name="height"
+                        id="height"
+                        min="1"
+                        step="5"
+                        onChange={(event) =>
+                            setHeight(parseInt(event.target.value))
+                        }
+                        value={height.toString()}
+                    />
+                </div>
+                <label>
+                    <input
+                        checked={dashboard}
+                        onChange={() => setDashboard(!dashboard)}
+                        type="checkbox"
+                    />
+                    &nbsp;Dashboard view
+                </label>
+                <label>
+                    <input
+                        checked={showIcon}
+                        onChange={() => setShowIcon(!showIcon)}
+                        type="checkbox"
+                    />
+                    &nbsp;Show icon
+                </label>
+                <label>
+                    Indicator type&nbsp;
+                    <select
+                        onChange={(event) =>
+                            setIndicatorType(event.target.value)
+                        }
+                    >
+                        {indicatorTypes.map((type, index) => {
+                            return <option key={index}>{type}</option>
+                        })}
+                    </select>
+                </label>
+                <label>
+                    <input
+                        checked={exportAsPdf}
+                        onChange={() => setExportAsPdf(!exportAsPdf)}
+                        type="checkbox"
+                    />
+                    &nbsp;Export as PDF
+                </label>
+                <button onClick={downloadOffline}>Download offline</button>
+            </div>
+            <div style={{ display: 'flex', gap: 12 }}>
+                <div style={containerStyle}>
+                    <div
+                        ref={newContainerRef}
+                        style={innerContainerStyle}
+                    ></div>
+                </div>
+            </div>
+        </>
+    )
+})
diff --git a/src/components/PivotTable/PivotTableValueCell.js b/src/components/PivotTable/PivotTableValueCell.js
index 78d204f2c..f20fe554d 100644
--- a/src/components/PivotTable/PivotTableValueCell.js
+++ b/src/components/PivotTable/PivotTableValueCell.js
@@ -1,3 +1,4 @@
+import i18n from '@dhis2/d2-i18n'
 import PropTypes from 'prop-types'
 import React, { useRef } from 'react'
 import { applyLegendSet } from '../../modules/pivotTable/applyLegendSet.js'
@@ -74,7 +75,13 @@ export const PivotTableValueCell = ({
         <PivotTableCell
             key={column}
             classes={classes}
-            title={cellContent.renderedValue}
+            title={
+                cellContent.titleValue ??
+                i18n.t('Value: {{value}}', {
+                    value: cellContent.renderedValue,
+                    nsSeparator: '^^',
+                })
+            }
             style={style}
             onClick={isClickable ? onClick : undefined}
             ref={cellRef}
diff --git a/src/components/PivotTable/styles/PivotTable.style.js b/src/components/PivotTable/styles/PivotTable.style.js
index 60466810a..769454e98 100644
--- a/src/components/PivotTable/styles/PivotTable.style.js
+++ b/src/components/PivotTable/styles/PivotTable.style.js
@@ -158,6 +158,11 @@ export const cell = css`
     .TRUE_ONLY {
         text-align: right;
     }
+    .N_A {
+        text-align: center;
+        color: ${colors.grey600};
+    }
+
     .clickable {
         cursor: pointer;
     }
diff --git a/src/modules/pivotTable/PivotTableEngine.js b/src/modules/pivotTable/PivotTableEngine.js
index 6d16a8985..ad798e686 100644
--- a/src/modules/pivotTable/PivotTableEngine.js
+++ b/src/modules/pivotTable/PivotTableEngine.js
@@ -12,6 +12,7 @@ import {
     VALUE_TYPE_NUMBER,
     VALUE_TYPE_TEXT,
     isBooleanValueType,
+    isCumulativeValueType,
     isNumericValueType,
 } from '../valueTypes.js'
 import { AdaptiveClippingController } from './AdaptiveClippingController.js'
@@ -41,6 +42,8 @@ import {
     NUMBER_TYPE_COLUMN_PERCENTAGE,
     NUMBER_TYPE_ROW_PERCENTAGE,
     NUMBER_TYPE_VALUE,
+    VALUE_TYPE_NA,
+    VALUE_NA,
 } from './pivotTableConstants.js'
 
 const dataFields = [
@@ -245,7 +248,7 @@ const applyTotalAggregationType = (
 ) => {
     switch (overrideTotalAggregationType || totalAggregationType) {
         case AGGREGATE_TYPE_NA:
-            return 'N/A'
+            return VALUE_NA
         case AGGREGATE_TYPE_AVERAGE:
             return (
                 ((numerator || value) * multiplier) /
@@ -401,19 +404,46 @@ export class PivotTableEngine {
             rawCell.renderedValue = renderedValue
         }
 
+        if (
+            [CELL_TYPE_TOTAL, CELL_TYPE_SUBTOTAL].includes(rawCell.cellType) &&
+            rawCell.rawValue === AGGREGATE_TYPE_NA
+        ) {
+            rawCell.titleValue = i18n.t('Not applicable')
+        }
+
         if (this.options.cumulativeValues) {
+            let titleValue
+
+            if (this.data[row] && this.data[row][column]) {
+                const dataRow = this.data[row][column]
+
+                const rawValue =
+                    cellType === CELL_TYPE_VALUE
+                        ? dataRow[this.dimensionLookup.dataHeaders.value]
+                        : dataRow.value
+
+                titleValue = i18n.t('Value: {{value}}', {
+                    value: renderValue(rawValue, valueType, this.visualization),
+                    nsSeparator: '^^',
+                })
+            }
+
             const cumulativeValue = this.getCumulative({
                 row,
                 column,
             })
 
             if (cumulativeValue !== undefined && cumulativeValue !== null) {
-                // force to NUMBER for accumulated values
+                // force to TEXT for N/A (accumulated) values
+                // force to NUMBER for accumulated values if no valueType present
                 rawCell.valueType =
-                    valueType === undefined || valueType === null
+                    cumulativeValue === VALUE_NA
+                        ? VALUE_TYPE_NA
+                        : valueType === undefined || valueType === null
                         ? VALUE_TYPE_NUMBER
                         : valueType
                 rawCell.empty = false
+                rawCell.titleValue = titleValue
                 rawCell.rawValue = cumulativeValue
                 rawCell.renderedValue = renderValue(
                     cumulativeValue,
@@ -523,16 +553,12 @@ export class PivotTableEngine {
 
         const cellValue = this.data[row][column]
 
+        // empty cell
         if (!cellValue) {
-            // Empty cell
-            // The cell still needs to get the valueType to render correctly 0 and cumulative values
-            return {
-                valueType: VALUE_TYPE_NUMBER,
-                totalAggregationType: AGGREGATE_TYPE_SUM,
-            }
+            return undefined
         }
 
-        if (!Array.isArray(cellValue)) {
+        if (cellValue && !Array.isArray(cellValue)) {
             // This is a total cell
             return {
                 valueType: cellValue.valueType,
@@ -741,23 +767,30 @@ export class PivotTableEngine {
                 totalCell.totalAggregationType = currentAggType
             }
 
-            const currentValueType = dxDimension?.valueType
+            // Force value type of total cells to NUMBER for value cells with numeric or boolean types.
+            // This is to simplify the code below where we compare the previous value type.
+            // All numeric/boolean value types use the same style for rendering the total cell (right aligned content)
+            // and using NUMBER for the total cell is enough for that.
+            // (see DHIS2-9155)
+            const currentValueType =
+                isNumericValueType(dxDimension?.valueType) ||
+                isBooleanValueType(dxDimension?.valueType)
+                    ? VALUE_TYPE_NUMBER
+                    : dxDimension?.valueType
+
             const previousValueType = totalCell.valueType
             if (previousValueType && currentValueType !== previousValueType) {
-                totalCell.valueType = AGGREGATE_TYPE_NA
+                totalCell.valueType = VALUE_TYPE_NA
             } else {
                 totalCell.valueType = currentValueType
             }
 
-            // compute subtotals and totals for all numeric and boolean value types
-            // in that case, force value type of subtotal and total cells to NUMBER to format them correctly
+            // Compute totals for all numeric and boolean value types only.
+            // In practice valueType here is NUMBER (see the comment above).
+            // When is not, it means there is some value cell with a valueType other than numeric/boolean,
+            // the total should not be computed then.
             // (see DHIS2-9155)
-            if (
-                isNumericValueType(dxDimension?.valueType) ||
-                isBooleanValueType(dxDimension?.valueType)
-            ) {
-                totalCell.valueType = VALUE_TYPE_NUMBER
-
+            if (isNumericValueType(totalCell.valueType)) {
                 dataFields.forEach((field) => {
                     const headerIndex = this.dimensionLookup.dataHeaders[field]
                     const value = parseValue(dataRow[headerIndex])
@@ -882,6 +915,28 @@ export class PivotTableEngine {
             }
         }
     }
+
+    computeOverrideTotalAggregationType(totalCell, visualization) {
+        // Avoid undefined on total cells with valueTypes that cannot be totalized.
+        // This happens for example when a column/row has all value cells of type TEXT.
+        if (
+            !(
+                isNumericValueType(totalCell.valueType) ||
+                isBooleanValueType(totalCell.valueType)
+            )
+        ) {
+            return AGGREGATE_TYPE_NA
+        }
+
+        // DHIS2-15698: do not override total aggregation type when numberType option is not present
+        // (numberType option default is VALUE)
+        return (
+            visualization.numberType &&
+            visualization.numberType !== NUMBER_TYPE_VALUE &&
+            AGGREGATE_TYPE_SUM
+        )
+    }
+
     finalizeTotal({ row, column }) {
         if (!this.data[row]) {
             return
@@ -890,12 +945,17 @@ export class PivotTableEngine {
         if (totalCell && totalCell.count) {
             totalCell.value = applyTotalAggregationType(
                 totalCell,
-                // DHIS2-15698: do not override total aggregation type when numberType option is not present
-                // (numberType option default is VALUE)
-                this.visualization.numberType &&
-                    this.visualization.numberType !== NUMBER_TYPE_VALUE &&
-                    AGGREGATE_TYPE_SUM
+                this.computeOverrideTotalAggregationType(
+                    totalCell,
+                    this.visualization
+                )
             )
+
+            // override valueType for styling cells with N/A value
+            if (totalCell.value === AGGREGATE_TYPE_NA) {
+                totalCell.valueType = VALUE_TYPE_NA
+            }
+
             this.adaptiveClippingController.add(
                 { row, column },
                 renderValue(
@@ -1028,10 +1088,19 @@ export class PivotTableEngine {
                         column,
                     })
                     const valueType = dxDimension?.valueType || VALUE_TYPE_TEXT
+                    const totalAggregationType =
+                        dxDimension?.totalAggregationType
+
+                    // only accumulate numeric (except for PERCENTAGE and UNIT_INTERVAL) and boolean values
+                    // accumulating other value types like text values does not make sense
+                    if (
+                        isCumulativeValueType(valueType) &&
+                        totalAggregationType === AGGREGATE_TYPE_SUM
+                    ) {
+                        // initialise to 0 for cumulative types
+                        // (||= is not transformed correctly in Babel with the current setup)
+                        acc || (acc = 0)
 
-                    // only accumulate numeric values
-                    // accumulating text values does not make sense
-                    if (valueType === VALUE_TYPE_NUMBER) {
                         if (this.data[row] && this.data[row][column]) {
                             const dataRow = this.data[row][column]
 
@@ -1049,7 +1118,7 @@ export class PivotTableEngine {
                     }
 
                     return acc
-                }, 0)
+                }, '')
             })
         } else {
             this.accumulators = { rows: {} }
diff --git a/src/modules/pivotTable/pivotTableConstants.js b/src/modules/pivotTable/pivotTableConstants.js
index 1221972c9..1ab1b290d 100644
--- a/src/modules/pivotTable/pivotTableConstants.js
+++ b/src/modules/pivotTable/pivotTableConstants.js
@@ -9,6 +9,8 @@ export const AGGREGATE_TYPE_SUM = 'SUM'
 export const AGGREGATE_TYPE_AVERAGE = 'AVERAGE'
 export const AGGREGATE_TYPE_NA = 'N/A'
 
+export const VALUE_TYPE_NA = 'N_A' // this ends up as CSS class and / is problematic
+
 export const NUMBER_TYPE_VALUE = 'VALUE'
 export const NUMBER_TYPE_ROW_PERCENTAGE = 'ROW_PERCENTAGE'
 export const NUMBER_TYPE_COLUMN_PERCENTAGE = 'COLUMN_PERCENTAGE'
@@ -35,3 +37,5 @@ export const WRAPPED_TEXT_JUSTIFY_BUFFER = 25
 export const WRAPPED_TEXT_LINE_HEIGHT = 1.0
 
 export const CLIPPED_AXIS_PARTITION_SIZE_PX = 1000
+
+export const VALUE_NA = 'N/A'
diff --git a/src/modules/valueTypes.js b/src/modules/valueTypes.js
index 89462b5c6..1097ac84f 100644
--- a/src/modules/valueTypes.js
+++ b/src/modules/valueTypes.js
@@ -36,5 +36,16 @@ const NUMERIC_VALUE_TYPES = [
 
 const BOOLEAN_VALUE_TYPES = [VALUE_TYPE_BOOLEAN, VALUE_TYPE_TRUE_ONLY]
 
+const CUMULATIVE_VALUE_TYPES = [
+    VALUE_TYPE_NUMBER,
+    VALUE_TYPE_INTEGER,
+    VALUE_TYPE_INTEGER_POSITIVE,
+    VALUE_TYPE_INTEGER_NEGATIVE,
+    VALUE_TYPE_INTEGER_ZERO_OR_POSITIVE,
+    ...BOOLEAN_VALUE_TYPES,
+]
+
+export const isCumulativeValueType = (type) =>
+    CUMULATIVE_VALUE_TYPES.includes(type)
 export const isNumericValueType = (type) => NUMERIC_VALUE_TYPES.includes(type)
 export const isBooleanValueType = (type) => BOOLEAN_VALUE_TYPES.includes(type)
diff --git a/src/visualizations/config/adapters/dhis_dhis/index.js b/src/visualizations/config/adapters/dhis_dhis/index.js
deleted file mode 100644
index 06a5256bf..000000000
--- a/src/visualizations/config/adapters/dhis_dhis/index.js
+++ /dev/null
@@ -1,38 +0,0 @@
-import getSubtitle from './subtitle/index.js'
-import getTitle from './title/index.js'
-import getValue from './value/index.js'
-
-export const INDICATOR_FACTOR_100 = 100
-
-export default function ({ store, layout, extraOptions }) {
-    const data = store.generateData({
-        type: layout.type,
-        seriesId:
-            layout.columns && layout.columns.length
-                ? layout.columns[0].dimension
-                : null,
-        categoryId:
-            layout.rows && layout.rows.length ? layout.rows[0].dimension : null,
-    })
-    const metaData = store.data[0].metaData
-
-    const config = {
-        value: data[0],
-        formattedValue:
-            data[0] === undefined
-                ? extraOptions.noData.text
-                : getValue(data[0], layout, metaData),
-        title: getTitle(layout, metaData, extraOptions.dashboard),
-        subtitle: getSubtitle(layout, metaData, extraOptions.dashboard),
-    }
-
-    const indicatorType =
-        metaData.items[metaData.dimensions.dx[0]].indicatorType
-
-    // Use % symbol for factor 100 and the full string for others
-    if (indicatorType?.factor !== INDICATOR_FACTOR_100) {
-        config.subText = indicatorType?.displayName
-    }
-
-    return config
-}
diff --git a/src/visualizations/config/adapters/dhis_dhis/subtitle/__tests__/index.spec.js b/src/visualizations/config/adapters/dhis_dhis/subtitle/__tests__/index.spec.js
deleted file mode 100644
index 486333c8c..000000000
--- a/src/visualizations/config/adapters/dhis_dhis/subtitle/__tests__/index.spec.js
+++ /dev/null
@@ -1,53 +0,0 @@
-import { VIS_TYPE_SINGLE_VALUE } from '../../../../../../modules/visTypes.js'
-import getSubtitle from '../index.js'
-
-jest.mock('../singleValue', () => () => 'The sv filter title')
-jest.mock(
-    '../../../../../util/getFilterText',
-    () => () => 'The default filter text'
-)
-
-describe('getSubtitle', () => {
-    it('returns empty subtitle when flag hideSubtitle exists', () => {
-        expect(getSubtitle({ hideSubtitle: true })).toEqual('')
-    })
-
-    it('returns the subtitle provided in the layout', () => {
-        const subtitle = 'The subtitle was already set'
-        expect(getSubtitle({ subtitle })).toEqual(subtitle)
-    })
-
-    it('returns subtitle for single value vis', () => {
-        expect(getSubtitle({ type: VIS_TYPE_SINGLE_VALUE })).toEqual(
-            'The sv filter title'
-        )
-    })
-
-    describe('not dashboard', () => {
-        describe('layout does not include title', () => {
-            it('returns empty subtitle', () => {
-                expect(getSubtitle({ filters: {} }, {}, false)).toEqual('')
-            })
-        })
-
-        describe('layout includes title', () => {
-            it('returns filter title as subtitle', () => {
-                expect(
-                    getSubtitle(
-                        { filters: {}, title: 'Chart title' },
-                        {},
-                        false
-                    )
-                ).toEqual('The default filter text')
-            })
-        })
-    })
-
-    describe('dashboard', () => {
-        it('returns filter title as subtitle', () => {
-            expect(getSubtitle({ filters: {} }, {}, true)).toEqual(
-                'The default filter text'
-            )
-        })
-    })
-})
diff --git a/src/visualizations/config/adapters/dhis_dhis/subtitle/__tests__/singleValue.spec.js b/src/visualizations/config/adapters/dhis_dhis/subtitle/__tests__/singleValue.spec.js
deleted file mode 100644
index 39b497f64..000000000
--- a/src/visualizations/config/adapters/dhis_dhis/subtitle/__tests__/singleValue.spec.js
+++ /dev/null
@@ -1,15 +0,0 @@
-import getSingleValueSubtitle from '../singleValue.js'
-
-jest.mock('../../../../../util/getFilterText', () => () => 'The filter text')
-
-describe('getSingleValueSubtitle', () => {
-    it('returns null when layout does not have filters', () => {
-        expect(getSingleValueSubtitle({})).toEqual('')
-    })
-
-    it('returns the filter text', () => {
-        expect(getSingleValueSubtitle({ filters: [] })).toEqual(
-            'The filter text'
-        )
-    })
-})
diff --git a/src/visualizations/config/adapters/dhis_dhis/subtitle/index.js b/src/visualizations/config/adapters/dhis_dhis/subtitle/index.js
deleted file mode 100644
index 1be507be4..000000000
--- a/src/visualizations/config/adapters/dhis_dhis/subtitle/index.js
+++ /dev/null
@@ -1,33 +0,0 @@
-import { VIS_TYPE_SINGLE_VALUE } from '../../../../../modules/visTypes.js'
-import getFilterText from '../../../../util/getFilterText.js'
-import getSingleValueTitle from './singleValue.js'
-
-function getDefault(layout, dashboard, metaData) {
-    if (dashboard || typeof layout.title === 'string') {
-        return getFilterText(layout.filters, metaData)
-    }
-
-    return ''
-}
-
-export default function (layout, metaData, dashboard) {
-    if (layout.hideSubtitle) {
-        return ''
-    }
-
-    if (typeof layout.subtitle === 'string' && layout.subtitle.length) {
-        return layout.subtitle
-    } else {
-        let subtitle
-        switch (layout.type) {
-            case VIS_TYPE_SINGLE_VALUE:
-                subtitle = getSingleValueTitle(layout, metaData)
-
-                break
-            default:
-                subtitle = getDefault(layout, dashboard, metaData)
-        }
-
-        return subtitle
-    }
-}
diff --git a/src/visualizations/config/adapters/dhis_dhis/subtitle/singleValue.js b/src/visualizations/config/adapters/dhis_dhis/subtitle/singleValue.js
deleted file mode 100644
index de246ba2f..000000000
--- a/src/visualizations/config/adapters/dhis_dhis/subtitle/singleValue.js
+++ /dev/null
@@ -1,5 +0,0 @@
-import getFilterText from '../../../../util/getFilterText.js'
-
-export default function (layout, metaData) {
-    return layout.filters ? getFilterText(layout.filters, metaData) : ''
-}
diff --git a/src/visualizations/config/adapters/dhis_dhis/title/__tests__/index.spec.js b/src/visualizations/config/adapters/dhis_dhis/title/__tests__/index.spec.js
deleted file mode 100644
index 15a4b8a56..000000000
--- a/src/visualizations/config/adapters/dhis_dhis/title/__tests__/index.spec.js
+++ /dev/null
@@ -1,36 +0,0 @@
-import { VIS_TYPE_SINGLE_VALUE } from '../../../../../../modules/visTypes.js'
-import getTitle from '../index.js'
-
-jest.mock('../singleValue', () => () => 'The sv filter title')
-jest.mock('../../../../../util/getFilterText', () => () => 'The filter text')
-
-describe('getTitle', () => {
-    it('returns empty title when flag hideTitle exists', () => {
-        expect(getTitle({ hideTitle: true })).toEqual('')
-    })
-
-    it('returns the title provided in the layout', () => {
-        const title = 'The title was already set'
-        expect(getTitle({ title })).toEqual(title)
-    })
-
-    it('returns title for single value vis', () => {
-        expect(getTitle({ type: VIS_TYPE_SINGLE_VALUE })).toEqual(
-            'The sv filter title'
-        )
-    })
-
-    describe('not dashboard', () => {
-        it('returns filter text as title', () => {
-            expect(getTitle({ filters: {} }, {}, false)).toEqual(
-                'The filter text'
-            )
-        })
-    })
-
-    describe('dashboard', () => {
-        it('returns empty string', () => {
-            expect(getTitle({ filters: {} }, {}, true)).toEqual('')
-        })
-    })
-})
diff --git a/src/visualizations/config/adapters/dhis_dhis/title/__tests__/singleValue.spec.js b/src/visualizations/config/adapters/dhis_dhis/title/__tests__/singleValue.spec.js
deleted file mode 100644
index 304be7bdb..000000000
--- a/src/visualizations/config/adapters/dhis_dhis/title/__tests__/singleValue.spec.js
+++ /dev/null
@@ -1,21 +0,0 @@
-import getSingleValueTitle from '../singleValue.js'
-
-jest.mock('../../../../../util/getFilterText', () => () => 'The filter text')
-
-describe('getSingleValueTitle', () => {
-    it('returns null when layout does not have columns', () => {
-        expect(getSingleValueTitle({})).toEqual('')
-    })
-
-    it('returns the filter text based on column items', () => {
-        expect(
-            getSingleValueTitle({
-                columns: [
-                    {
-                        items: [{}],
-                    },
-                ],
-            })
-        ).toEqual('The filter text')
-    })
-})
diff --git a/src/visualizations/config/adapters/dhis_dhis/title/index.js b/src/visualizations/config/adapters/dhis_dhis/title/index.js
deleted file mode 100644
index fb4c6b040..000000000
--- a/src/visualizations/config/adapters/dhis_dhis/title/index.js
+++ /dev/null
@@ -1,30 +0,0 @@
-import { VIS_TYPE_SINGLE_VALUE } from '../../../../../modules/visTypes.js'
-import getFilterText from '../../../../util/getFilterText.js'
-import getSingleValueTitle from './singleValue.js'
-
-function getDefault(layout, metaData, dashboard) {
-    return layout.filters && !dashboard
-        ? getFilterText(layout.filters, metaData)
-        : ''
-}
-
-export default function (layout, metaData, dashboard) {
-    if (layout.hideTitle) {
-        return ''
-    }
-
-    if (typeof layout.title === 'string' && layout.title.length) {
-        return layout.title
-    } else {
-        let title
-        switch (layout.type) {
-            case VIS_TYPE_SINGLE_VALUE:
-                title = getSingleValueTitle(layout, metaData)
-
-                break
-            default:
-                title = getDefault(layout, metaData, dashboard)
-        }
-        return title
-    }
-}
diff --git a/src/visualizations/config/adapters/dhis_dhis/type.js b/src/visualizations/config/adapters/dhis_dhis/type.js
deleted file mode 100644
index 412124e58..000000000
--- a/src/visualizations/config/adapters/dhis_dhis/type.js
+++ /dev/null
@@ -1,10 +0,0 @@
-import { VIS_TYPE_SINGLE_VALUE } from '../../../../modules/visTypes.js'
-
-export default function (type) {
-    switch (type) {
-        case VIS_TYPE_SINGLE_VALUE:
-            return { type: VIS_TYPE_SINGLE_VALUE }
-        default:
-            return { type: VIS_TYPE_SINGLE_VALUE }
-    }
-}
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_dhis/value/index.js b/src/visualizations/config/adapters/dhis_highcharts/customSVGOptions/singleValue/getSingleValueFormattedValue.js
similarity index 69%
rename from src/visualizations/config/adapters/dhis_dhis/value/index.js
rename to src/visualizations/config/adapters/dhis_highcharts/customSVGOptions/singleValue/getSingleValueFormattedValue.js
index 508f1c9a4..f0b91dee3 100644
--- a/src/visualizations/config/adapters/dhis_dhis/value/index.js
+++ b/src/visualizations/config/adapters/dhis_highcharts/customSVGOptions/singleValue/getSingleValueFormattedValue.js
@@ -1,8 +1,9 @@
-import { renderValue } from '../../../../../modules/renderValue.js'
-import { VALUE_TYPE_TEXT } from '../../../../../modules/valueTypes.js'
-import { INDICATOR_FACTOR_100 } from '../index.js'
+import { renderValue } from '../../../../../../modules/renderValue.js'
+import { VALUE_TYPE_TEXT } from '../../../../../../modules/valueTypes.js'
 
-export default function (value, layout, metaData) {
+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
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 <g> with <path> 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..0f3ddb271 100644
--- a/src/visualizations/config/adapters/dhis_highcharts/index.js
+++ b/src/visualizations/config/adapters/dhis_highcharts/index.js
@@ -14,10 +14,13 @@ 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'
+import getLang from './lang.js'
 import getLegend from './legend.js'
 import { applyLegendSet, getLegendSetTooltip } from './legendSet.js'
 import getNoData from './noData.js'
@@ -77,21 +80,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
@@ -123,11 +122,8 @@ export default function ({ store, layout, el, extraConfig, extraOptions }) {
         pane: getPane(_layout.type),
 
         // no data + zoom
-        lang: {
-            noData: _extraOptions.noData.text,
-            resetZoom: _extraOptions.resetZoom.text,
-        },
-        noData: getNoData(),
+        lang: getLang(_layout.type, _extraOptions),
+        noData: getNoData(_layout.type),
 
         // credits
         credits: {
@@ -135,10 +131,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
@@ -234,5 +240,7 @@ export default function ({ store, layout, el, extraConfig, extraOptions }) {
     // force apply extra config
     Object.assign(config, extraConfig)
 
+    console.log(objectClean(config))
+
     return objectClean(config)
 }
diff --git a/src/visualizations/config/adapters/dhis_highcharts/lang.js b/src/visualizations/config/adapters/dhis_highcharts/lang.js
new file mode 100644
index 000000000..80299fe41
--- /dev/null
+++ b/src/visualizations/config/adapters/dhis_highcharts/lang.js
@@ -0,0 +1,15 @@
+import { VIS_TYPE_SINGLE_VALUE } from '../../../../modules/visTypes.js'
+
+export default function getLang(visType, extraOptions) {
+    return {
+        /* The SingleValue visualization consists of some custom SVG elements
+         * rendered on an empty chart. Since the chart is empty, there is never
+         * any data and Highcharts will show the noData text. To avoid this we
+         * clear the text here. */
+        noData:
+            visType === VIS_TYPE_SINGLE_VALUE
+                ? undefined
+                : extraOptions.noData.text,
+        resetZoom: extraOptions.resetZoom.text,
+    }
+}
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_dhis/title/singleValue.js b/src/visualizations/config/adapters/dhis_highcharts/title/singleValue.js
similarity index 50%
rename from src/visualizations/config/adapters/dhis_dhis/title/singleValue.js
rename to src/visualizations/config/adapters/dhis_highcharts/title/singleValue.js
index 802c866c0..fdf5d891a 100644
--- a/src/visualizations/config/adapters/dhis_dhis/title/singleValue.js
+++ b/src/visualizations/config/adapters/dhis_highcharts/title/singleValue.js
@@ -1,6 +1,15 @@
 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
+    }
 
-export default function (layout, metaData) {
     if (layout.columns) {
         const firstItem = layout.columns[0].items[0]
 
@@ -10,6 +19,5 @@ export default function (layout, metaData) {
 
         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/adapters/index.js b/src/visualizations/config/adapters/index.js
index 7b49438ee..4db1838e0 100644
--- a/src/visualizations/config/adapters/index.js
+++ b/src/visualizations/config/adapters/index.js
@@ -1,7 +1,5 @@
-import dhis_dhis from './dhis_dhis/index.js'
 import dhis_highcharts from './dhis_highcharts/index.js'
 
 export default {
     dhis_highcharts,
-    dhis_dhis,
 }
diff --git a/src/visualizations/config/generators/dhis/index.js b/src/visualizations/config/generators/dhis/index.js
deleted file mode 100644
index b5a6c3958..000000000
--- a/src/visualizations/config/generators/dhis/index.js
+++ /dev/null
@@ -1,36 +0,0 @@
-import { VIS_TYPE_SINGLE_VALUE } from '../../../../modules/visTypes.js'
-import getSingleValueGenerator from './singleValue.js'
-
-export default function (config, parentEl, extraOptions) {
-    if (config) {
-        const node =
-            typeof parentEl === 'object'
-                ? parentEl
-                : typeof parentEl === 'string'
-                ? document.querySelector(parentEl)
-                : null
-
-        if (node) {
-            if (node.lastChild) {
-                node.removeChild(node.lastChild)
-            }
-
-            let content
-
-            switch (config.type) {
-                case VIS_TYPE_SINGLE_VALUE:
-                default:
-                    content = getSingleValueGenerator(
-                        config,
-                        node,
-                        extraOptions
-                    )
-                    break
-            }
-
-            node.appendChild(content)
-
-            return node.innerHTML
-        }
-    }
-}
diff --git a/src/visualizations/config/generators/dhis/singleValue.js b/src/visualizations/config/generators/dhis/singleValue.js
deleted file mode 100644
index 25ec5bab9..000000000
--- a/src/visualizations/config/generators/dhis/singleValue.js
+++ /dev/null
@@ -1,531 +0,0 @@
-import { colors } from '@dhis2/ui'
-import {
-    FONT_STYLE_VISUALIZATION_TITLE,
-    FONT_STYLE_VISUALIZATION_SUBTITLE,
-    FONT_STYLE_OPTION_FONT_SIZE,
-    FONT_STYLE_OPTION_TEXT_COLOR,
-    FONT_STYLE_OPTION_TEXT_ALIGN,
-    FONT_STYLE_OPTION_ITALIC,
-    FONT_STYLE_OPTION_BOLD,
-    TEXT_ALIGN_LEFT,
-    TEXT_ALIGN_RIGHT,
-    TEXT_ALIGN_CENTER,
-    mergeFontStyleWithDefault,
-    defaultFontStyle,
-} from '../../../../modules/fontStyle.js'
-import {
-    getColorByValueFromLegendSet,
-    LEGEND_DISPLAY_STYLE_FILL,
-} from '../../../../modules/legends.js'
-
-const svgNS = 'http://www.w3.org/2000/svg'
-
-// multiply text width with this factor
-// to get very close to actual text width
-// nb: dependent on viewbox etc
-const ACTUAL_TEXT_WIDTH_FACTOR = 0.9
-
-// 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"
-const ACTUAL_NUMBER_HEIGHT_FACTOR = 0.67
-
-// do not allow text width to exceed this threshold
-// a threshold >1 does not really make sense but text width vs viewbox is complicated
-const TEXT_WIDTH_CONTAINER_WIDTH_FACTOR = 1.3
-
-// do not allow text size to exceed this
-const TEXT_SIZE_CONTAINER_HEIGHT_FACTOR = 0.6
-const TEXT_SIZE_MAX_THRESHOLD = 400
-
-// multiply text size with this factor
-// to get an appropriate letter spacing
-const LETTER_SPACING_TEXT_SIZE_FACTOR = (1 / 35) * -1
-const LETTER_SPACING_MIN_THRESHOLD = -6
-const LETTER_SPACING_MAX_THRESHOLD = -1
-
-// fixed top margin above title/subtitle
-const TOP_MARGIN_FIXED = 16
-
-// multiply text size with this factor
-// to get an appropriate sub text size
-const SUB_TEXT_SIZE_FACTOR = 0.5
-const SUB_TEXT_SIZE_MIN_THRESHOLD = 26
-const SUB_TEXT_SIZE_MAX_THRESHOLD = 40
-
-// multiply text size with this factor
-// to get an appropriate icon padding
-const ICON_PADDING_FACTOR = 0.3
-
-// Compute text width before rendering
-// Not exactly precise but close enough
-const getTextWidth = (text, font) => {
-    const canvas = document.createElement('canvas')
-    const context = canvas.getContext('2d')
-    context.font = font
-    return Math.round(
-        context.measureText(text).width * ACTUAL_TEXT_WIDTH_FACTOR
-    )
-}
-
-const getTextHeightForNumbers = (textSize) =>
-    textSize * ACTUAL_NUMBER_HEIGHT_FACTOR
-
-const getIconPadding = (textSize) => Math.round(textSize * ICON_PADDING_FACTOR)
-
-const getTextSize = (
-    formattedValue,
-    containerWidth,
-    containerHeight,
-    showIcon
-) => {
-    let size = Math.min(
-        Math.round(containerHeight * TEXT_SIZE_CONTAINER_HEIGHT_FACTOR),
-        TEXT_SIZE_MAX_THRESHOLD
-    )
-
-    const widthThreshold = Math.round(
-        containerWidth * TEXT_WIDTH_CONTAINER_WIDTH_FACTOR
-    )
-
-    const textWidth =
-        getTextWidth(formattedValue, `${size}px Roboto`) +
-        (showIcon ? getIconPadding(size) : 0)
-
-    if (textWidth > widthThreshold) {
-        size = Math.round(size * (widthThreshold / textWidth))
-    }
-
-    return size
-}
-
-const generateValueSVG = ({
-    formattedValue,
-    subText,
-    valueColor,
-    textColor,
-    icon,
-    noData,
-    containerWidth,
-    containerHeight,
-    topMargin = 0,
-}) => {
-    const showIcon = icon && formattedValue !== noData.text
-
-    const textSize = getTextSize(
-        formattedValue,
-        containerWidth,
-        containerHeight,
-        showIcon
-    )
-
-    const textWidth = getTextWidth(formattedValue, `${textSize}px Roboto`)
-
-    const iconSize = textSize
-
-    const subTextSize =
-        textSize * SUB_TEXT_SIZE_FACTOR > SUB_TEXT_SIZE_MAX_THRESHOLD
-            ? SUB_TEXT_SIZE_MAX_THRESHOLD
-            : textSize * SUB_TEXT_SIZE_FACTOR < SUB_TEXT_SIZE_MIN_THRESHOLD
-            ? SUB_TEXT_SIZE_MIN_THRESHOLD
-            : textSize * SUB_TEXT_SIZE_FACTOR
-
-    const svgValue = document.createElementNS(svgNS, 'svg')
-    svgValue.setAttribute('viewBox', `0 0 ${containerWidth} ${containerHeight}`)
-    svgValue.setAttribute('width', '50%')
-    svgValue.setAttribute('height', '50%')
-    svgValue.setAttribute('x', '50%')
-    svgValue.setAttribute('y', '50%')
-    svgValue.setAttribute('style', 'overflow: visible')
-
-    let fillColor = colors.grey900
-
-    if (valueColor) {
-        fillColor = valueColor
-    } else if (formattedValue === noData.text) {
-        fillColor = colors.grey600
-    }
-
-    // show icon if configured in maintenance app
-    if (showIcon) {
-        // embed icon to allow changing color
-        // (elements with fill need to use "currentColor" for this to work)
-        const iconSvgNode = document.createElementNS(svgNS, 'svg')
-        iconSvgNode.setAttribute('viewBox', '0 0 48 48')
-        iconSvgNode.setAttribute('width', iconSize)
-        iconSvgNode.setAttribute('height', iconSize)
-        iconSvgNode.setAttribute('y', (iconSize / 2 - topMargin / 2) * -1)
-        iconSvgNode.setAttribute(
-            'x',
-            `-${(iconSize + getIconPadding(textSize) + textWidth) / 2}`
-        )
-        iconSvgNode.setAttribute('style', `color: ${fillColor}`)
-        iconSvgNode.setAttribute('data-test', 'visualization-icon')
-
-        const parser = new DOMParser()
-        const svgIconDocument = parser.parseFromString(icon, 'image/svg+xml')
-
-        Array.from(svgIconDocument.documentElement.children).forEach((node) =>
-            iconSvgNode.appendChild(node)
-        )
-
-        svgValue.appendChild(iconSvgNode)
-    }
-
-    const letterSpacing = Math.round(textSize * LETTER_SPACING_TEXT_SIZE_FACTOR)
-
-    const textNode = document.createElementNS(svgNS, 'text')
-    textNode.setAttribute('font-size', textSize)
-    textNode.setAttribute('font-weight', '300')
-    textNode.setAttribute(
-        'letter-spacing',
-        letterSpacing < LETTER_SPACING_MIN_THRESHOLD
-            ? LETTER_SPACING_MIN_THRESHOLD
-            : letterSpacing > LETTER_SPACING_MAX_THRESHOLD
-            ? LETTER_SPACING_MAX_THRESHOLD
-            : letterSpacing
-    )
-    textNode.setAttribute('text-anchor', 'middle')
-    textNode.setAttribute(
-        'x',
-        showIcon ? `${(iconSize + getIconPadding(textSize)) / 2}` : 0
-    )
-    textNode.setAttribute(
-        'y',
-        topMargin / 2 + getTextHeightForNumbers(textSize) / 2
-    )
-    textNode.setAttribute('fill', fillColor)
-    textNode.setAttribute('data-test', 'visualization-primary-value')
-
-    textNode.appendChild(document.createTextNode(formattedValue))
-
-    svgValue.appendChild(textNode)
-
-    if (subText) {
-        const subTextNode = document.createElementNS(svgNS, 'text')
-        subTextNode.setAttribute('text-anchor', 'middle')
-        subTextNode.setAttribute('font-size', subTextSize)
-        subTextNode.setAttribute('y', iconSize / 2 + topMargin / 2)
-        subTextNode.setAttribute('dy', subTextSize * 1.7)
-        subTextNode.setAttribute('fill', textColor)
-        subTextNode.appendChild(document.createTextNode(subText))
-
-        svgValue.appendChild(subTextNode)
-    }
-
-    return svgValue
-}
-
-const generateDashboardItem = (
-    config,
-    {
-        svgContainer,
-        width,
-        height,
-        valueColor,
-        titleColor,
-        backgroundColor,
-        noData,
-        icon,
-    }
-) => {
-    svgContainer.appendChild(
-        generateValueSVG({
-            formattedValue: config.formattedValue,
-            subText: config.subText,
-            valueColor,
-            textColor: titleColor,
-            noData,
-            icon,
-            containerWidth: width,
-            containerHeight: height,
-        })
-    )
-
-    const container = document.createElement('div')
-    container.setAttribute(
-        'style',
-        `display: flex; flex-direction: column; align-items: center; justify-content: center; width: 100%; height: 100%; padding-top: 8px; ${
-            backgroundColor ? `background-color:${backgroundColor};` : ''
-        }`
-    )
-
-    const titleStyle = `padding: 0 8px; text-align: center; font-size: 12px; color: ${
-        titleColor || '#666'
-    };`
-
-    const title = document.createElement('span')
-    title.setAttribute('style', titleStyle)
-    if (config.title) {
-        title.appendChild(document.createTextNode(config.title))
-
-        container.appendChild(title)
-    }
-
-    if (config.subtitle) {
-        const subtitle = document.createElement('span')
-        subtitle.setAttribute('style', titleStyle + ' margin-top: 4px;')
-
-        subtitle.appendChild(document.createTextNode(config.subtitle))
-
-        container.appendChild(subtitle)
-    }
-
-    container.appendChild(svgContainer)
-
-    return container
-}
-
-const getTextAnchorFromTextAlign = (textAlign) => {
-    switch (textAlign) {
-        default:
-        case TEXT_ALIGN_LEFT:
-            return 'start'
-        case TEXT_ALIGN_CENTER:
-            return 'middle'
-        case TEXT_ALIGN_RIGHT:
-            return 'end'
-    }
-}
-
-const getXFromTextAlign = (textAlign) => {
-    switch (textAlign) {
-        default:
-        case TEXT_ALIGN_LEFT:
-            return '1%'
-        case TEXT_ALIGN_CENTER:
-            return '50%'
-        case TEXT_ALIGN_RIGHT:
-            return '99%'
-    }
-}
-
-const generateDVItem = (
-    config,
-    {
-        svgContainer,
-        width,
-        height,
-        valueColor,
-        noData,
-        backgroundColor,
-        titleColor,
-        fontStyle,
-        icon,
-    }
-) => {
-    if (backgroundColor) {
-        svgContainer.setAttribute(
-            'style',
-            `background-color: ${backgroundColor};`
-        )
-
-        const background = document.createElementNS(svgNS, 'rect')
-        background.setAttribute('width', '100%')
-        background.setAttribute('height', '100%')
-        background.setAttribute('fill', backgroundColor)
-        svgContainer.appendChild(background)
-    }
-
-    const svgWrapper = document.createElementNS(svgNS, 'svg')
-
-    // title
-    const title = document.createElementNS(svgNS, 'text')
-
-    const titleFontStyle = mergeFontStyleWithDefault(
-        fontStyle && fontStyle[FONT_STYLE_VISUALIZATION_TITLE],
-        FONT_STYLE_VISUALIZATION_TITLE
-    )
-
-    const titleYPosition =
-        TOP_MARGIN_FIXED +
-        parseInt(titleFontStyle[FONT_STYLE_OPTION_FONT_SIZE]) +
-        'px'
-
-    const titleAttributes = {
-        x: getXFromTextAlign(titleFontStyle[FONT_STYLE_OPTION_TEXT_ALIGN]),
-        y: titleYPosition,
-        'text-anchor': getTextAnchorFromTextAlign(
-            titleFontStyle[FONT_STYLE_OPTION_TEXT_ALIGN]
-        ),
-        'font-size': `${titleFontStyle[FONT_STYLE_OPTION_FONT_SIZE]}px`,
-        'font-weight': titleFontStyle[FONT_STYLE_OPTION_BOLD]
-            ? FONT_STYLE_OPTION_BOLD
-            : 'normal',
-        'font-style': titleFontStyle[FONT_STYLE_OPTION_ITALIC]
-            ? FONT_STYLE_OPTION_ITALIC
-            : 'normal',
-        'data-test': 'visualization-title',
-        fill:
-            titleColor &&
-            titleFontStyle[FONT_STYLE_OPTION_TEXT_COLOR] ===
-                defaultFontStyle[FONT_STYLE_VISUALIZATION_TITLE][
-                    FONT_STYLE_OPTION_TEXT_COLOR
-                ]
-                ? titleColor
-                : titleFontStyle[FONT_STYLE_OPTION_TEXT_COLOR],
-    }
-
-    Object.entries(titleAttributes).forEach(([key, value]) =>
-        title.setAttribute(key, value)
-    )
-
-    if (config.title) {
-        title.appendChild(document.createTextNode(config.title))
-        svgWrapper.appendChild(title)
-    }
-
-    // subtitle
-    const subtitle = document.createElementNS(svgNS, 'text')
-
-    const subtitleFontStyle = mergeFontStyleWithDefault(
-        fontStyle && fontStyle[FONT_STYLE_VISUALIZATION_SUBTITLE],
-        FONT_STYLE_VISUALIZATION_SUBTITLE
-    )
-
-    const subtitleAttributes = {
-        x: getXFromTextAlign(subtitleFontStyle[FONT_STYLE_OPTION_TEXT_ALIGN]),
-        y: titleYPosition,
-        dy: `${subtitleFontStyle[FONT_STYLE_OPTION_FONT_SIZE] + 10}`,
-        'text-anchor': getTextAnchorFromTextAlign(
-            subtitleFontStyle[FONT_STYLE_OPTION_TEXT_ALIGN]
-        ),
-        'font-size': `${subtitleFontStyle[FONT_STYLE_OPTION_FONT_SIZE]}px`,
-        'font-weight': subtitleFontStyle[FONT_STYLE_OPTION_BOLD]
-            ? FONT_STYLE_OPTION_BOLD
-            : 'normal',
-        'font-style': subtitleFontStyle[FONT_STYLE_OPTION_ITALIC]
-            ? FONT_STYLE_OPTION_ITALIC
-            : 'normal',
-        fill:
-            titleColor &&
-            subtitleFontStyle[FONT_STYLE_OPTION_TEXT_COLOR] ===
-                defaultFontStyle[FONT_STYLE_VISUALIZATION_SUBTITLE][
-                    FONT_STYLE_OPTION_TEXT_COLOR
-                ]
-                ? titleColor
-                : subtitleFontStyle[FONT_STYLE_OPTION_TEXT_COLOR],
-        'data-test': 'visualization-subtitle',
-    }
-
-    Object.entries(subtitleAttributes).forEach(([key, value]) =>
-        subtitle.setAttribute(key, value)
-    )
-
-    if (config.subtitle) {
-        subtitle.appendChild(document.createTextNode(config.subtitle))
-        svgWrapper.appendChild(subtitle)
-    }
-
-    svgContainer.appendChild(svgWrapper)
-
-    svgContainer.appendChild(
-        generateValueSVG({
-            formattedValue: config.formattedValue,
-            subText: config.subText,
-            valueColor,
-            textColor: titleColor,
-            noData,
-            icon,
-            containerWidth: width,
-            containerHeight: height,
-            topMargin:
-                TOP_MARGIN_FIXED +
-                ((config.title
-                    ? parseInt(title.getAttribute('font-size'))
-                    : 0) +
-                    (config.subtitle
-                        ? parseInt(subtitle.getAttribute('font-size'))
-                        : 0)) *
-                    2.5,
-        })
-    )
-
-    return svgContainer
-}
-
-const shouldUseContrastColor = (inputColor = '') => {
-    // based on https://stackoverflow.com/questions/3942878/how-to-decide-font-color-in-white-or-black-depending-on-background-color
-    var color =
-        inputColor.charAt(0) === '#' ? inputColor.substring(1, 7) : inputColor
-    var r = parseInt(color.substring(0, 2), 16) // hexToR
-    var g = parseInt(color.substring(2, 4), 16) // hexToG
-    var b = parseInt(color.substring(4, 6), 16) // hexToB
-    var uicolors = [r / 255, g / 255, b / 255]
-    var c = uicolors.map((col) => {
-        if (col <= 0.03928) {
-            return col / 12.92
-        }
-        return Math.pow((col + 0.055) / 1.055, 2.4)
-    })
-    var L = 0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2]
-    return L <= 0.179
-}
-
-export default function (
-    config,
-    parentEl,
-    { dashboard, legendSets, fontStyle, noData, legendOptions, icon }
-) {
-    const legendSet = legendOptions && legendSets[0]
-    const legendColor =
-        legendSet && getColorByValueFromLegendSet(legendSet, config.value)
-    let valueColor, titleColor, backgroundColor
-    if (legendColor) {
-        if (legendOptions.style === LEGEND_DISPLAY_STYLE_FILL) {
-            backgroundColor = legendColor
-            valueColor = titleColor =
-                shouldUseContrastColor(legendColor) && colors.white
-        } else {
-            valueColor = legendColor
-        }
-    }
-
-    parentEl.style.overflow = 'hidden'
-    parentEl.style.display = 'flex'
-    parentEl.style.justifyContent = 'center'
-
-    const parentElBBox = parentEl.getBoundingClientRect()
-    const width = parentElBBox.width
-    const height = parentElBBox.height
-
-    const svgContainer = document.createElementNS(svgNS, 'svg')
-    svgContainer.setAttribute('xmlns', svgNS)
-    svgContainer.setAttribute('viewBox', `0 0 ${width} ${height}`)
-    svgContainer.setAttribute('width', dashboard ? '100%' : width)
-    svgContainer.setAttribute('height', dashboard ? '100%' : height)
-    svgContainer.setAttribute('data-test', 'visualization-container')
-
-    if (dashboard) {
-        parentEl.style.borderRadius = '3px'
-
-        return generateDashboardItem(config, {
-            svgContainer,
-            width,
-            height,
-            valueColor,
-            backgroundColor,
-            noData,
-            icon,
-            ...(legendOptions.style === LEGEND_DISPLAY_STYLE_FILL &&
-            legendColor &&
-            shouldUseContrastColor(legendColor)
-                ? { titleColor: colors.white }
-                : {}),
-        })
-    } else {
-        parentEl.style.height = `100%`
-
-        return generateDVItem(config, {
-            svgContainer,
-            width,
-            height,
-            valueColor,
-            backgroundColor,
-            titleColor,
-            noData,
-            icon,
-            fontStyle,
-        })
-    }
-}
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
+                                    /^<svg[^>]*width\s*=\s*\"?(\d+)\"?[^>]*>/
+                                )[1] * scale,
+                            imageHeight =
+                                svg.match(
+                                    // eslint-disable-next-line no-useless-escape
+                                    /^<svg[^>]*height\s*=\s*\"?(\d+)\"?[^>]*>/
+                                )[1] * scale,
+                            downloadWithCanVG = function () {
+                                var v = win.canvg.Canvg.fromString(ctx, svg)
+                                v.start()
+                                try {
+                                    downloadURL(
+                                        win.navigator.msSaveOrOpenBlob
+                                            ? canvas.msToBlob()
+                                            : canvas.toDataURL(imageType),
+                                        filename
+                                    )
+                                    if (successCallback) {
+                                        successCallback()
+                                    }
+                                } catch (e) {
+                                    failCallback(e)
+                                } finally {
+                                    finallyHandler()
+                                }
+                            }
+                        canvas.width = imageWidth
+                        canvas.height = imageHeight
+                        if (win.canvg) {
+                            // Use preloaded canvg
+                            downloadWithCanVG()
+                        } else {
+                            // Must load canVG 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 + 'canvg.js', function () {
+                                downloadWithCanVG()
+                            })
+                        }
+                    },
+                    // No canvas support
+                    failCallback,
+                    // Failed to load image
+                    failCallback,
+                    // Finally
+                    function () {
+                        if (objectURLRevoke) {
+                            finallyHandler()
+                        }
+                    }
+                )
+            }
+        }
+    )
+}
diff --git a/src/visualizations/config/generators/index.js b/src/visualizations/config/generators/index.js
index bc7a75872..5c0f9cfc9 100644
--- a/src/visualizations/config/generators/index.js
+++ b/src/visualizations/config/generators/index.js
@@ -1,7 +1,5 @@
-import dhis from './dhis/index.js'
 import highcharts from './highcharts/index.js'
 
 export default {
     highcharts,
-    dhis,
 }
diff --git a/src/visualizations/store/adapters/dhis_dhis/index.js b/src/visualizations/store/adapters/dhis_dhis/index.js
deleted file mode 100644
index 62afa2342..000000000
--- a/src/visualizations/store/adapters/dhis_dhis/index.js
+++ /dev/null
@@ -1,102 +0,0 @@
-import { VIS_TYPE_SINGLE_VALUE } from '../../../../modules/visTypes.js'
-import getSingleValue from './singleValue.js'
-
-const VALUE_ID = 'value'
-
-function getHeaderIdIndexMap(headers) {
-    const map = new Map()
-
-    headers.forEach((header, index) => {
-        map.set(header.name, index)
-    })
-
-    return map
-}
-
-function getPrefixedId(row, header) {
-    return (header.isPrefix ? header.name + '_' : '') + row[header.index]
-}
-
-function getIdValueMap(rows, seriesHeader, categoryHeader, valueIndex) {
-    const map = new Map()
-
-    let key
-    let value
-
-    rows.forEach((row) => {
-        key = [
-            ...(seriesHeader ? [getPrefixedId(row, seriesHeader)] : []),
-            ...(categoryHeader ? [getPrefixedId(row, categoryHeader)] : []),
-        ].join('-')
-
-        value = row[valueIndex]
-
-        map.set(key, value)
-    })
-
-    return map
-}
-
-function getDefault(acc, seriesIds, categoryIds, idValueMap, metaData) {
-    seriesIds.forEach((seriesId) => {
-        const serieData = []
-
-        categoryIds.forEach((categoryId) => {
-            const value = idValueMap.get(`${seriesId}-${categoryId}`)
-
-            // DHIS2-1261: 0 is a valid value
-            // undefined value means the key was not found within the rows
-            // in that case null is returned as value in the serie
-            serieData.push(value === undefined ? null : parseFloat(value))
-        })
-
-        acc.push({
-            id: seriesId,
-            name: metaData.items[seriesId].name,
-            data: serieData,
-        })
-    })
-
-    return acc
-}
-
-function getValueFunction(type) {
-    switch (type) {
-        case VIS_TYPE_SINGLE_VALUE:
-            return getSingleValue
-        default:
-            return getDefault
-    }
-}
-
-export default function ({ type, data, seriesId, categoryId }) {
-    const valueFunction = getValueFunction(type)
-
-    return data.reduce((acc, res) => {
-        const headers = res.headers
-        const metaData = res.metaData
-        const rows = res.rows
-        const headerIdIndexMap = getHeaderIdIndexMap(headers)
-
-        const seriesIndex = headerIdIndexMap.get(seriesId)
-        const categoryIndex = headerIdIndexMap.get(categoryId)
-        const valueIndex = headerIdIndexMap.get(VALUE_ID)
-
-        const seriesHeader = headers[seriesIndex]
-        const categoryHeader = headers[categoryIndex]
-
-        const idValueMap = getIdValueMap(
-            rows,
-            seriesHeader,
-            categoryHeader,
-            valueIndex
-        )
-
-        const seriesIds = metaData.dimensions[seriesId]
-        const categoryIds = metaData.dimensions[categoryId]
-
-        valueFunction(acc, seriesIds, categoryIds, idValueMap, metaData)
-
-        return acc
-    }, [])
-}
diff --git a/src/visualizations/store/adapters/dhis_dhis/singleValue.js b/src/visualizations/store/adapters/dhis_dhis/singleValue.js
deleted file mode 100644
index 159838d82..000000000
--- a/src/visualizations/store/adapters/dhis_dhis/singleValue.js
+++ /dev/null
@@ -1,5 +0,0 @@
-export default function (acc, seriesIds, categoryIds, idValueMap) {
-    const seriesId = seriesIds[0]
-
-    acc.push(idValueMap.get(seriesId))
-}
diff --git a/src/visualizations/store/adapters/dhis_highcharts/index.js b/src/visualizations/store/adapters/dhis_highcharts/index.js
index 026a430c3..22f70cc1d 100644
--- a/src/visualizations/store/adapters/dhis_highcharts/index.js
+++ b/src/visualizations/store/adapters/dhis_highcharts/index.js
@@ -6,9 +6,11 @@ import {
     VIS_TYPE_PIE,
     VIS_TYPE_GAUGE,
     isTwoCategoryChartType,
+    VIS_TYPE_SINGLE_VALUE,
 } from '../../../../modules/visTypes.js'
 import getGauge from './gauge.js'
 import getPie from './pie.js'
+import getSingleValue from './singleValue.js'
 import getTwoCategory from './twoCategory.js'
 import getYearOnYear from './yearOnYear.js'
 
@@ -93,6 +95,8 @@ function getSeriesFunction(type, categoryIds) {
     }
 
     switch (type) {
+        case VIS_TYPE_SINGLE_VALUE:
+            return getSingleValue
         case VIS_TYPE_PIE:
             return getPie
         case VIS_TYPE_GAUGE:
diff --git a/src/visualizations/store/adapters/dhis_highcharts/singleValue.js b/src/visualizations/store/adapters/dhis_highcharts/singleValue.js
new file mode 100644
index 000000000..7eda97eb0
--- /dev/null
+++ b/src/visualizations/store/adapters/dhis_highcharts/singleValue.js
@@ -0,0 +1,9 @@
+export default function getSingleValue(
+    acc,
+    seriesIds,
+    categoryIds,
+    idValueMap
+) {
+    const seriesId = seriesIds[0][0]
+    acc.push(idValueMap.get(seriesId))
+}
diff --git a/src/visualizations/store/adapters/index.js b/src/visualizations/store/adapters/index.js
index 7b49438ee..4db1838e0 100644
--- a/src/visualizations/store/adapters/index.js
+++ b/src/visualizations/store/adapters/index.js
@@ -1,7 +1,5 @@
-import dhis_dhis from './dhis_dhis/index.js'
 import dhis_highcharts from './dhis_highcharts/index.js'
 
 export default {
     dhis_highcharts,
-    dhis_dhis,
 }
diff --git a/src/visualizations/util/shouldUseContrastColor.js b/src/visualizations/util/shouldUseContrastColor.js
new file mode 100644
index 000000000..d01616c9a
--- /dev/null
+++ b/src/visualizations/util/shouldUseContrastColor.js
@@ -0,0 +1,17 @@
+export const shouldUseContrastColor = (inputColor = '') => {
+    // based on https://stackoverflow.com/questions/3942878/how-to-decide-font-color-in-white-or-black-depending-on-background-color
+    var color =
+        inputColor.charAt(0) === '#' ? inputColor.substring(1, 7) : inputColor
+    var r = parseInt(color.substring(0, 2), 16) // hexToR
+    var g = parseInt(color.substring(2, 4), 16) // hexToG
+    var b = parseInt(color.substring(4, 6), 16) // hexToB
+    var uicolors = [r / 255, g / 255, b / 255]
+    var c = uicolors.map((col) => {
+        if (col <= 0.03928) {
+            return col / 12.92
+        }
+        return Math.pow((col + 0.055) / 1.055, 2.4)
+    })
+    var L = 0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2]
+    return L <= 0.179
+}