From 60f505e792cceafba9ba8275031fad82641d9411 Mon Sep 17 00:00:00 2001 From: "@dhis2-bot" Date: Sun, 6 Oct 2024 03:44:26 +0200 Subject: [PATCH 1/8] fix(translations): sync translations from transifex (master) Automatically merged. --- i18n/zh.po | 112 ++++++++++++++++++++++++++++------------------------- 1 file changed, 59 insertions(+), 53 deletions(-) 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 , 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 "单个值" From b478ff0849008fb4a4ab202f585716b1e789af5e Mon Sep 17 00:00:00 2001 From: "@dhis2-bot" Date: Sun, 6 Oct 2024 01:48:08 +0000 Subject: [PATCH 2/8] chore(release): cut 26.8.6 [skip ci] ## [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)) --- CHANGELOG.md | 7 +++++++ package.json | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e8890cb2b..1b7e5aa81 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +## [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/package.json b/package.json index 18ea8cc83..49ad4513a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@dhis2/analytics", - "version": "26.8.5", + "version": "26.8.6", "main": "./build/cjs/index.js", "module": "./build/es/index.js", "exports": { From a2bfd203cb53f174106d8b570cea52cbfc6136f7 Mon Sep 17 00:00:00 2001 From: Edoardo Sabadelli Date: Fri, 18 Oct 2024 11:43:15 +0200 Subject: [PATCH 3/8] fix: compute totals and cumulative values for numeric/boolean types respecting totalAggregationType (DHIS2-9155) (#1700) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: accumulate numeric (not PERCENTAGE, UNIT_INTERVAL) and boolean values This makes the cumulative values feature more in line with the way totals are computed. The difference is that there is no accumulation for PERCENTAGE and UNIT_INTERVAL types as these don't accumulate with a simple sum. * fix: allow totals for all numeric/boolean, respect totalAggregationType For row totals where 1 or more columns have a non-numeric/boolean data element, N/A is returned for the total cell. For column totals, the totalAggregationType of the data element is used to compute the total value. * fix: style N/A differently than a normal value * feat: allow custom title for cells Normally the title is the same as the cell's content. When cumulative values are used it help to see the original value in the title and the accumulated value in the cell. It's also useful to give some more info about a particular value (ie. N/A). * fix: avoid to show 0 for non cumulative types when cumulative is enabled * refactor: replace ||= operator, not transformed by Babel * fix: fix regression for DHIS2-17297 * fix: always use "Value:" prefix in cell tooltip * fix: handle better mixed values when accumulating * fix: do not fill the table with N/A with cumulative values Simply render the original value for non cumulative types. The tooltip can be used when in doubt to know if a cell value is accumulated. * fix: only accumulate when total agg type is SUM --------- Co-authored-by: Jan Henrik Øverland --- i18n/en.pot | 10 +- package.json | 1 - .../PivotTable/PivotTableValueCell.js | 9 +- .../PivotTable/styles/PivotTable.style.js | 5 + src/modules/pivotTable/PivotTableEngine.js | 127 ++++++++++++++---- src/modules/pivotTable/pivotTableConstants.js | 4 + src/modules/valueTypes.js | 11 ++ 7 files changed, 134 insertions(+), 33 deletions(-) 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/package.json b/package.json index 49ad4513a..2d024f2e7 100644 --- a/package.json +++ b/package.json @@ -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/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 = ({ { 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) From 786aeb4c5b65958ce05305db86b4205849e3b84f Mon Sep 17 00:00:00 2001 From: "@dhis2-bot" Date: Fri, 18 Oct 2024 09:46:05 +0000 Subject: [PATCH 4/8] chore(release): cut 26.8.7 [skip ci] ## [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)) --- CHANGELOG.md | 7 +++++++ package.json | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b7e5aa81..5119b6d8e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +## [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) diff --git a/package.json b/package.json index 2d024f2e7..3497753b2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@dhis2/analytics", - "version": "26.8.6", + "version": "26.8.7", "main": "./build/cjs/index.js", "module": "./build/es/index.js", "exports": { From f1870928b37733395d7f911f48ea7268fed97be1 Mon Sep 17 00:00:00 2001 From: "@dhis2-bot" Date: Sun, 20 Oct 2024 03:44:35 +0200 Subject: [PATCH 5/8] fix(translations): sync translations from transifex (master) Automatically merged. --- i18n/lo.po | 86 +++++++++++++++++++++++++++++++----------------------- 1 file changed, 49 insertions(+), 37 deletions(-) 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 , 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 "ຂໍ້ມູນ" From 24e9ffdea7336a7b96005c3b4a67d9c25ca64b56 Mon Sep 17 00:00:00 2001 From: "@dhis2-bot" Date: Sun, 20 Oct 2024 01:47:31 +0000 Subject: [PATCH 6/8] chore(release): cut 26.8.8 [skip ci] ## [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)) --- CHANGELOG.md | 7 +++++++ package.json | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5119b6d8e..e49acbdce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +## [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) diff --git a/package.json b/package.json index 3497753b2..40db741ba 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@dhis2/analytics", - "version": "26.8.7", + "version": "26.8.8", "main": "./build/cjs/index.js", "module": "./build/es/index.js", "exports": { From 40fdfba1c3041cb7cf57845aa101c8a64f0cd919 Mon Sep 17 00:00:00 2001 From: Hendrik de Graaf Date: Tue, 22 Oct 2024 16:44:51 +0200 Subject: [PATCH 7/8] feat: implement Single Value as a Highcharts.Chart instance and add offline exporting module (#1698) * feat: add offline exporting module * feat: add bugfix plugins to Highcharts that address PDF export issues * feat: implement Single Value as a Highcharts.Chart instance so it can be exported client-side --- .storybook/preview-head.html | 6 + src/__demo__/SingleValue.stories.js | 802 ++++++++++++++++++ .../config/adapters/dhis_dhis/index.js | 38 - .../subtitle/__tests__/index.spec.js | 53 -- .../subtitle/__tests__/singleValue.spec.js | 15 - .../adapters/dhis_dhis/subtitle/index.js | 33 - .../dhis_dhis/subtitle/singleValue.js | 5 - .../dhis_dhis/title/__tests__/index.spec.js | 36 - .../title/__tests__/singleValue.spec.js | 21 - .../config/adapters/dhis_dhis/title/index.js | 30 - .../config/adapters/dhis_dhis/type.js | 10 - .../adapters/dhis_highcharts/chart/default.js | 27 + .../adapters/dhis_highcharts/chart/index.js | 12 + .../dhis_highcharts/chart/singleValue.js | 19 + .../dhis_highcharts/customSVGOptions/index.js | 29 + .../getSingleValueBackgroundColor.js | 17 + .../getSingleValueFormattedValue.js} | 9 +- .../singleValue/getSingleValueLegendColor.js | 8 + .../singleValue/getSingleValueSubtext.js | 11 + .../singleValue/getSingleValueTextColor.js | 27 + .../singleValue/getSingleValueTitleColor.js | 34 + .../customSVGOptions/singleValue/index.js | 27 + .../{chart.js => events/index.js} | 30 +- .../events/loadCustomSVG/index.js | 12 + .../singleValue/addIconElement.js | 32 + .../singleValue/checkIfFitsWithinContainer.js | 29 + .../singleValue/computeLayoutRect.js | 43 + .../singleValue/computeSpacingTop.js | 15 + .../loadCustomSVG/singleValue/constants.js | 4 + .../singleValue/getAvailableSpace.js | 10 + .../events/loadCustomSVG/singleValue/index.js | 55 ++ .../singleValue/positionElements.js | 62 ++ .../loadCustomSVG/singleValue/styles.js | 62 ++ .../adapters/dhis_highcharts/exporting.js | 25 + .../config/adapters/dhis_highcharts/index.js | 42 +- .../config/adapters/dhis_highcharts/lang.js | 15 + .../adapters/dhis_highcharts/plotOptions.js | 2 +- .../adapters/dhis_highcharts/series/index.js | 6 +- .../subtitle/__tests__/singleValue.spec.js | 64 ++ .../dhis_highcharts/subtitle/index.js | 122 ++- .../dhis_highcharts/subtitle/singleValue.js | 18 + .../title/__tests__/singleValue.spec.js | 57 ++ .../adapters/dhis_highcharts/title/index.js | 114 ++- .../title/singleValue.js | 12 +- .../config/adapters/dhis_highcharts/type.js | 3 + .../adapters/dhis_highcharts/xAxis/index.js | 2 + .../adapters/dhis_highcharts/yAxis/index.js | 11 +- src/visualizations/config/adapters/index.js | 2 - .../config/generators/dhis/index.js | 36 - .../config/generators/dhis/singleValue.js | 531 ------------ .../config/generators/highcharts/index.js | 9 +- .../highcharts/pdfExportBugFixPlugin/index.js | 7 + .../pdfExportBugFixPlugin/nonASCIIFont.js | 9 + .../pdfExportBugFixPlugin/textShadow.js | 308 +++++++ src/visualizations/config/generators/index.js | 2 - .../store/adapters/dhis_dhis/index.js | 102 --- .../store/adapters/dhis_dhis/singleValue.js | 5 - .../store/adapters/dhis_highcharts/index.js | 4 + .../adapters/dhis_highcharts/singleValue.js | 9 + src/visualizations/store/adapters/index.js | 2 - .../util/shouldUseContrastColor.js | 17 + 61 files changed, 2104 insertions(+), 1055 deletions(-) create mode 100644 .storybook/preview-head.html create mode 100644 src/__demo__/SingleValue.stories.js delete mode 100644 src/visualizations/config/adapters/dhis_dhis/index.js delete mode 100644 src/visualizations/config/adapters/dhis_dhis/subtitle/__tests__/index.spec.js delete mode 100644 src/visualizations/config/adapters/dhis_dhis/subtitle/__tests__/singleValue.spec.js delete mode 100644 src/visualizations/config/adapters/dhis_dhis/subtitle/index.js delete mode 100644 src/visualizations/config/adapters/dhis_dhis/subtitle/singleValue.js delete mode 100644 src/visualizations/config/adapters/dhis_dhis/title/__tests__/index.spec.js delete mode 100644 src/visualizations/config/adapters/dhis_dhis/title/__tests__/singleValue.spec.js delete mode 100644 src/visualizations/config/adapters/dhis_dhis/title/index.js delete mode 100644 src/visualizations/config/adapters/dhis_dhis/type.js create mode 100644 src/visualizations/config/adapters/dhis_highcharts/chart/default.js create mode 100644 src/visualizations/config/adapters/dhis_highcharts/chart/index.js create mode 100644 src/visualizations/config/adapters/dhis_highcharts/chart/singleValue.js create mode 100644 src/visualizations/config/adapters/dhis_highcharts/customSVGOptions/index.js create mode 100644 src/visualizations/config/adapters/dhis_highcharts/customSVGOptions/singleValue/getSingleValueBackgroundColor.js rename src/visualizations/config/adapters/{dhis_dhis/value/index.js => dhis_highcharts/customSVGOptions/singleValue/getSingleValueFormattedValue.js} (69%) create mode 100644 src/visualizations/config/adapters/dhis_highcharts/customSVGOptions/singleValue/getSingleValueLegendColor.js create mode 100644 src/visualizations/config/adapters/dhis_highcharts/customSVGOptions/singleValue/getSingleValueSubtext.js create mode 100644 src/visualizations/config/adapters/dhis_highcharts/customSVGOptions/singleValue/getSingleValueTextColor.js create mode 100644 src/visualizations/config/adapters/dhis_highcharts/customSVGOptions/singleValue/getSingleValueTitleColor.js create mode 100644 src/visualizations/config/adapters/dhis_highcharts/customSVGOptions/singleValue/index.js rename src/visualizations/config/adapters/dhis_highcharts/{chart.js => events/index.js} (51%) create mode 100644 src/visualizations/config/adapters/dhis_highcharts/events/loadCustomSVG/index.js create mode 100644 src/visualizations/config/adapters/dhis_highcharts/events/loadCustomSVG/singleValue/addIconElement.js create mode 100644 src/visualizations/config/adapters/dhis_highcharts/events/loadCustomSVG/singleValue/checkIfFitsWithinContainer.js create mode 100644 src/visualizations/config/adapters/dhis_highcharts/events/loadCustomSVG/singleValue/computeLayoutRect.js create mode 100644 src/visualizations/config/adapters/dhis_highcharts/events/loadCustomSVG/singleValue/computeSpacingTop.js create mode 100644 src/visualizations/config/adapters/dhis_highcharts/events/loadCustomSVG/singleValue/constants.js create mode 100644 src/visualizations/config/adapters/dhis_highcharts/events/loadCustomSVG/singleValue/getAvailableSpace.js create mode 100644 src/visualizations/config/adapters/dhis_highcharts/events/loadCustomSVG/singleValue/index.js create mode 100644 src/visualizations/config/adapters/dhis_highcharts/events/loadCustomSVG/singleValue/positionElements.js create mode 100644 src/visualizations/config/adapters/dhis_highcharts/events/loadCustomSVG/singleValue/styles.js create mode 100644 src/visualizations/config/adapters/dhis_highcharts/exporting.js create mode 100644 src/visualizations/config/adapters/dhis_highcharts/lang.js create mode 100644 src/visualizations/config/adapters/dhis_highcharts/subtitle/__tests__/singleValue.spec.js create mode 100644 src/visualizations/config/adapters/dhis_highcharts/subtitle/singleValue.js create mode 100644 src/visualizations/config/adapters/dhis_highcharts/title/__tests__/singleValue.spec.js rename src/visualizations/config/adapters/{dhis_dhis => dhis_highcharts}/title/singleValue.js (50%) delete mode 100644 src/visualizations/config/generators/dhis/index.js delete mode 100644 src/visualizations/config/generators/dhis/singleValue.js create mode 100644 src/visualizations/config/generators/highcharts/pdfExportBugFixPlugin/index.js create mode 100644 src/visualizations/config/generators/highcharts/pdfExportBugFixPlugin/nonASCIIFont.js create mode 100644 src/visualizations/config/generators/highcharts/pdfExportBugFixPlugin/textShadow.js delete mode 100644 src/visualizations/store/adapters/dhis_dhis/index.js delete mode 100644 src/visualizations/store/adapters/dhis_dhis/singleValue.js create mode 100644 src/visualizations/store/adapters/dhis_highcharts/singleValue.js create mode 100644 src/visualizations/util/shouldUseContrastColor.js diff --git a/.storybook/preview-head.html b/.storybook/preview-head.html new file mode 100644 index 000000000..965f8201c --- /dev/null +++ b/.storybook/preview-head.html @@ -0,0 +1,6 @@ + + + diff --git a/src/__demo__/SingleValue.stories.js b/src/__demo__/SingleValue.stories.js new file mode 100644 index 000000000..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 = + '' + +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 ( + <> +
+
+ + + setWidth(parseInt(event.target.value)) + } + value={width.toString()} + /> +
+
+ + + setHeight(parseInt(event.target.value)) + } + value={height.toString()} + /> +
+ + + + + +
+
+
+
+
+
+ + ) +}) 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 with elements that contain coordinates. + * These path-coordinates only scale correctly when using CSS translate */ + iconElement.css({ + transform: `translate(${translateX}px, ${translateY}px) scale(${scale})`, + }) + } + + if (subTextElement) { + subTextElement.align( + { + align: 'left', + verticalAlign: 'bottom', + alignByTranslate: false, + x: layoutRect.sideMarginBottom, + }, + false, + layoutRect + ) + } +} diff --git a/src/visualizations/config/adapters/dhis_highcharts/events/loadCustomSVG/singleValue/styles.js b/src/visualizations/config/adapters/dhis_highcharts/events/loadCustomSVG/singleValue/styles.js new file mode 100644 index 000000000..f1b944ee2 --- /dev/null +++ b/src/visualizations/config/adapters/dhis_highcharts/events/loadCustomSVG/singleValue/styles.js @@ -0,0 +1,62 @@ +const valueStyles = [ + { 'font-size': '164px', 'letter-spacing': '-5px' }, + { 'font-size': '128px', 'letter-spacing': '-4px' }, + { 'font-size': '96px', 'letter-spacing': '-3px' }, + { 'font-size': '64px', 'letter-spacing': '-2.5px' }, + { 'font-size': '40px', 'letter-spacing': '-1.5px' }, + { 'font-size': '20px', 'letter-spacing': '-1px' }, +] + +const subTextStyles = [ + { 'font-size': '36px', 'letter-spacing': '-1.4px' }, + { 'font-size': '32px', 'letter-spacing': '-1.2px' }, + { 'font-size': '26px', 'letter-spacing': '-0.8px' }, + { 'font-size': '20px', 'letter-spacing': '-0.6px' }, + { 'font-size': '14px', 'letter-spacing': '0.2px' }, + { 'font-size': '9px', 'letter-spacing': '0px' }, +] + +const spacings = [ + { valueTop: 8, subTextTop: 12, iconGap: 8, iconSize: 164 }, + { valueTop: 8, subTextTop: 12, iconGap: 6, iconSize: 128 }, + { valueTop: 8, subTextTop: 8, iconGap: 4, iconSize: 96 }, + { valueTop: 8, subTextTop: 8, iconGap: 4, iconSize: 64 }, + { valueTop: 8, subTextTop: 8, iconGap: 4, iconSize: 40 }, + { valueTop: 8, subTextTop: 4, iconGap: 2, iconSize: 20 }, +] + +export const MIN_SIDE_WHITESPACE = 4 + +export class DynamicStyles { + constructor(isPdfExport) { + this.currentIndex = 0 + this.isPdfExport = isPdfExport + } + getStyle() { + return { + value: { + ...valueStyles[this.currentIndex], + 'font-weight': this.isPdfExport ? 'normal' : '300', + }, + subText: subTextStyles[this.currentIndex], + spacing: spacings[this.currentIndex], + } + } + next() { + if (this.currentIndex === valueStyles.length - 1) { + throw new Error('No next available, already on the smallest style') + } else { + ++this.currentIndex + } + + return this.getStyle() + } + first() { + this.currentIndex = 0 + + return this.getStyle() + } + hasNext() { + return this.currentIndex < valueStyles.length - 1 + } +} diff --git a/src/visualizations/config/adapters/dhis_highcharts/exporting.js b/src/visualizations/config/adapters/dhis_highcharts/exporting.js new file mode 100644 index 000000000..032a9c689 --- /dev/null +++ b/src/visualizations/config/adapters/dhis_highcharts/exporting.js @@ -0,0 +1,25 @@ +import { VIS_TYPE_SINGLE_VALUE } from '../../../../modules/visTypes.js' +import loadSingleValueSVG from './events/loadCustomSVG/singleValue/index.js' + +export default function getExporting(visType) { + const exporting = { + // disable exporting context menu + enabled: false, + } + switch (visType) { + case VIS_TYPE_SINGLE_VALUE: + return { + ...exporting, + chartOptions: { + chart: { + events: { + load: loadSingleValueSVG, + }, + }, + }, + } + + default: + return exporting + } +} diff --git a/src/visualizations/config/adapters/dhis_highcharts/index.js b/src/visualizations/config/adapters/dhis_highcharts/index.js index 29ecf41c0..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 + /^]*width\s*=\s*\"?(\d+)\"?[^>]*>/ + )[1] * scale, + imageHeight = + svg.match( + // eslint-disable-next-line no-useless-escape + /^]*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 +} From 5b5cea9e84e6c195a83994c450c416163a1f0bdc Mon Sep 17 00:00:00 2001 From: "@dhis2-bot" Date: Tue, 22 Oct 2024 14:47:48 +0000 Subject: [PATCH 8/8] chore(release): cut 26.9.0 [skip ci] # [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)) --- CHANGELOG.md | 7 +++++++ package.json | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e49acbdce..9b7982142 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +# [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) diff --git a/package.json b/package.json index 40db741ba..0e3c04c3e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@dhis2/analytics", - "version": "26.8.8", + "version": "26.9.0", "main": "./build/cjs/index.js", "module": "./build/es/index.js", "exports": {