From a2bfd203cb53f174106d8b570cea52cbfc6136f7 Mon Sep 17 00:00:00 2001 From: Edoardo Sabadelli Date: Fri, 18 Oct 2024 11:43:15 +0200 Subject: [PATCH 1/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 2/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 3/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 4/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 5/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 6/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": { From 9a507a2236567597f0cda20e4a0fff9fc1d082f3 Mon Sep 17 00:00:00 2001 From: "@dhis2-bot" Date: Sun, 27 Oct 2024 02:44:30 +0100 Subject: [PATCH 7/8] fix(translations): sync translations from transifex (master) Automatically merged. --- i18n/pt.po | 516 ++++++++++++++++++++++++++++++----------------------- i18n/zh.po | 8 +- 2 files changed, 295 insertions(+), 229 deletions(-) diff --git a/i18n/pt.po b/i18n/pt.po index 05e81fb21..974f1160e 100644 --- a/i18n/pt.po +++ b/i18n/pt.po @@ -1,19 +1,20 @@ # # Translators: # Sheila André , 2020 +# Viktor Varland , 2021 # Gabriela Rodriguez , 2022 -# Viktor Varland , 2022 +# Ge Joao , 2022 # Fernando Jorge Bade, 2022 # David Júnior , 2023 -# Ge Joao , 2023 # Philip Larsen Donnelly, 2024 +# Shelsea Chumaio, 2024 # msgid "" msgstr "" "Project-Id-Version: i18next-conv\n" -"POT-Creation-Date: 2023-09-27T14:15:13.876Z\n" +"POT-Creation-Date: 2024-10-11T12:49:26.846Z\n" "PO-Revision-Date: 2020-04-28 22:05+0000\n" -"Last-Translator: Philip Larsen Donnelly, 2024\n" +"Last-Translator: Shelsea Chumaio, 2024\n" "Language-Team: Portuguese (https://app.transifex.com/hisp-uio/teams/100509/pt/)\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" @@ -22,144 +23,157 @@ msgstr "" "Plural-Forms: nplurals=3; plural=(n == 0 || n == 1) ? 0 : n != 0 && n % 1000000 == 0 ? 1 : 2;\n" msgid "view only" -msgstr "" +msgstr " apenas para visualização" msgid "view and edit" -msgstr "" +msgstr "visualizar e editar" msgid "all users ({{accessLevel}})" -msgstr "" +msgstr "todos utilizadores ({{accessLevel}})" msgid "{{userOrGroup}} ({{accessLevel}})" -msgstr "" +msgstr "{{userOrGroup}}({{accessLevel}})" msgid "Shared with {{commaSeparatedListOfUsersAndGroups}}" -msgstr "" +msgstr " Partilhado com{{commaSeparatedListOfUsersAndGroups}}" msgid "Not shared with any users or groups" -msgstr "" +msgstr " Não partilhado com quaisquer utilizadores ou grupos" msgid "No description" msgstr "Sem descrição" msgid "Last updated {{time}}" -msgstr "" +msgstr " Última actualização{{time}}" msgid "Created {{time}} by {{author}}" -msgstr "" +msgstr "Criado{{time}} por {{author}}" msgid "Created {{time}}" -msgstr "" +msgstr "Criado {{time}}" msgid "Viewed {{count}} times" msgid_plural "Viewed {{count}} times" -msgstr[0] "" -msgstr[1] "" -msgstr[2] "" +msgstr[0] "Visualizado 1 vez" +msgstr[1] "Visualizado {{count}} vezes" +msgstr[2] "Visualizado {{count}} vezes" msgid "Notifications" msgstr "Notificação" msgid "You're subscribed and getting updates about new interpretations." -msgstr "" +msgstr " Está inscrito para receber actualizações sobre novas interpretações." msgid "Unsubscribe" -msgstr "" +msgstr " Cancelar a subscrição" msgid "Subscribe to get updates about new interpretations." -msgstr "" +msgstr " Subscreva para receber actualizações sobre novas interpretações." msgid "Subscribe" -msgstr "" +msgstr "Subcreva" msgid "About this map" -msgstr "" +msgstr " Sobre este mapa" msgid "About this line list" -msgstr "" +msgstr " Sobre esta lista de linhas" msgid "About this visualization" -msgstr "" +msgstr " Sobre esta visualização" + +msgid "About this event chart" +msgstr "Sobre esta tabela de eventos" + +msgid "About this event report" +msgstr "Sobre este relatório de evento" msgid "This app could not retrieve required data." -msgstr "" +msgstr " Esta aplicação não conseguiu obter os dados necessários." msgid "Network error" -msgstr "" +msgstr " Erro de rede" msgid "Data / Edit calculation" -msgstr "" +msgstr " Dados / Editar cálculo" msgid "Data / New calculation" -msgstr "" +msgstr " Dados / Novo cálculo" msgid "Remove item" -msgstr "" +msgstr " Remover item" msgid "Check formula" -msgstr "" +msgstr " Verificar a fórmula" msgid "Calculation name" -msgstr "" +msgstr "Designação do cálculo" msgid "Shown in table headers and chart axes/legends" -msgstr "" +msgstr " Apresentado em cabeçalhos de tabelas e eixos/legendas de gráficos" msgid "Delete calculation" -msgstr "" +msgstr "Eliminar cálculo" msgid "Cancel" msgstr "Cancelar" msgid "The calculation can only be saved with a valid formula" -msgstr "" +msgstr "O cálculo só pode ser guardado com uma fórmula válida" msgid "Add a name to save this calculation" -msgstr "" +msgstr " Adicionar um nome para guardar este cálculo" msgid "Save calculation" -msgstr "" +msgstr "Gravar cálculo" msgid "" "Are you sure you want to delete this calculation? It may be used by other " "visualizations." msgstr "" +"Tem a certeza de que pretende eliminar este cálculo? Ele pode ser usado por " +"outras visualizações." msgid "Yes, delete" -msgstr "" +msgstr "Sim, eliminar" msgid "Totals only" -msgstr "" +msgstr "Apenas os totais" msgid "Details only" -msgstr "" +msgstr "Apenas os detalhes" msgid "Loading" -msgstr "Carregando.." +msgstr "Carregando" msgid "Data elements" msgstr "Elementos de Dados" msgid "Search by data element name" -msgstr "" +msgstr " Pesquisa por nome de elemento de dados" msgid "No data elements found for \"{{- searchTerm}}\"" -msgstr "" +msgstr " Não foram encontrados elementos de dados para \"{{- searchTerm}}''" msgid "No data elements found" -msgstr "" +msgstr "Nenhum elemento de dados encontrado" msgid "" "Drag items here, or double click in the list, to start building a " "calculation formula" msgstr "" +"Arraste os itens para aqui, ou faça duplo clique na lista, para começar a " +"criar uma fórmula de cálculo" msgid "Math operators" -msgstr "" +msgstr " Operadores matemáticos" msgid "Data Type" msgstr "Tipo de dados" +msgid "Only {{dataType}} can be used in {{visType}}" +msgstr "Apenas {{dataType}} pode ser usado em{{visType}}" + msgid "All types" msgstr "Todos os tipos" @@ -167,49 +181,55 @@ msgid "Disaggregation" msgstr "Desagregação" msgid "No data" -msgstr "Não ha dados" +msgstr "Não há dados" msgid "Search by data item name" -msgstr "" +msgstr " Pesquisa por nome de item de dados" msgid "No items selected" -msgstr "" +msgstr " Nenhum item seleccionado" msgid "Selected Items" -msgstr "" +msgstr " Itens seleccionados" msgid "No indicators found" -msgstr "" +msgstr "Nenhum indicador encontrado" msgid "No data sets found" -msgstr "" +msgstr "Nenhum conjunto de dados encontrado" msgid "No event data items found" -msgstr "" +msgstr " Nenhum dado de evento encontrado" msgid "No program indicators found" -msgstr "" +msgstr " Nenhum indicador de programa encontrado" + +msgid "No calculations found" +msgstr "Nenhum cálculo encontrado" msgid "No indicators found for \"{{- searchTerm}}\"" -msgstr "" +msgstr " Nenhum indicador foi encontrado para ''{{- searchTerm}}''" msgid "No data sets found for \"{{- searchTerm}}\"" -msgstr "" +msgstr " Nenhum conjunto de dados encontrado para ''{{- searchTerm}}''" msgid "No event data items found for \"{{- searchTerm}}\"" -msgstr "" +msgstr " Nenhum dado de evento encontrado para ''{{- searchTerm}}''" msgid "No program indicators found for \"{{- searchTerm}}\"" -msgstr "" +msgstr "Nenhum indicador de programa encontrado para ''{{- searchTerm}}''" + +msgid "No calculations found for \"{{- searchTerm}}\"" +msgstr "Nenhum cálculo encontrado para ''{{- searchTerm}}''" msgid "Nothing found for \"{{- searchTerm}}\"" -msgstr "" +msgstr " Nada encontrado para ''{{- searchTerm}}''" msgid "Calculation" -msgstr "" +msgstr " Cálculo" msgid "Metric type" -msgstr "" +msgstr " Tipo de métrica" msgid "All metrics" msgstr "Todas métricas" @@ -248,34 +268,38 @@ msgid "All items" msgstr "Todos Itens" msgid "Automatically include all items" -msgstr "" +msgstr " Incluir automaticamente todos os itens" msgid "" "Select all {{- dimensionTitle}} items. With this option, new items added in " "the future will be automatically included." msgstr "" +" Seleccionar todos os itens {{- dimensionTitle}}. Com esta opção, os novos " +"itens adicionados no futuro serão automaticamente incluídos." msgid "Manually select items..." -msgstr "" +msgstr "Seleccionar manualmente os itens..." msgid "Nothing found in {{- dimensionTitle}}" -msgstr "" +msgstr " Nada encontrado em {{- dimensionTitle}}" msgid "Search" msgstr "Pesquisar" msgid "Nothing found for {{- searchTerm}}" -msgstr "" +msgstr " Nada encontrado para {{- searchTerm}}" msgid "Delete {{fileType}}" -msgstr "" +msgstr " Eliminar {{fileType}}" msgid "" "This {{fileType}} and related interpretations will be deleted. Continue?" msgstr "" +" Este {{fileType}} e as interpretações relacionadas serão eliminados. " +"Continuar?" msgid "Delete" -msgstr "Suprimir" +msgstr "Eliminar" msgid "File" msgstr "Arquivo" @@ -284,28 +308,28 @@ msgid "New" msgstr "Novo" msgid "Open…" -msgstr "" +msgstr " Abrir..." msgid "Save" msgstr "Salvar" msgid "Save…" -msgstr "" +msgstr "Gravar" msgid "Save as…" -msgstr "" +msgstr "Gravar como..." msgid "Rename…" -msgstr "" +msgstr "Renomear..." msgid "Translate…" -msgstr "" +msgstr "Traduzir..." msgid "Share…" -msgstr "" +msgstr "Partilhar..." msgid "Get link…" -msgstr "" +msgstr "Obter link..." msgid "Open in this app" msgstr "Abrir neste aplicativo" @@ -314,7 +338,7 @@ msgid "Close" msgstr "Fechar" msgid "Rename {{fileType}}" -msgstr "" +msgstr "Renomear {{fileType}}" msgid "Name" msgstr "Nome" @@ -326,22 +350,22 @@ msgid "Rename" msgstr "Renomear" msgid "{{- objectName}} (copy)" -msgstr "" +msgstr "{{- objectName}}(cópia)" msgid "Save {{fileType}} as" -msgstr "" +msgstr "Gravar {{fileType}} como" msgid "event report" msgstr "relatório de evento" msgid "line list" -msgstr "" +msgstr "lista de linha" msgid "map" msgstr "mapa" msgid "visualization" -msgstr "" +msgstr "visualização" msgid "Edit" msgstr "Editar" @@ -350,25 +374,25 @@ msgid "Write a reply" msgstr "Escreva uma resposta" msgid "Post reply" -msgstr "" +msgstr "Publicar resposta" msgid "Delete failed" -msgstr "Falha na exclusão" +msgstr "Falha na eliminação" msgid "Could not update comment" -msgstr "" +msgstr " Não foi possível actualizar o comentário" msgid "Enter comment text" -msgstr "" +msgstr "Introduzir o texto do comentário" msgid "Update" msgstr "Actualizar" msgid "Viewing interpretation: {{- visualisationName}}" -msgstr "" +msgstr "Ver interpretação: {{- visualisationName}}" msgid "Could not load interpretation" -msgstr "" +msgstr " Não foi possível carregar a interpretação" msgid "" "The interpretation couldn’t be displayed. Try again or contact your system " @@ -376,7 +400,7 @@ msgid "" msgstr "" msgid "Hide interpretation" -msgstr "" +msgstr " Ocultar interpretação" msgid "Write an interpretation" msgstr "Escreva uma interpretação" @@ -384,9 +408,11 @@ msgstr "Escreva uma interpretação" msgid "" "Other people viewing this interpretation in the future may see more data." msgstr "" +"Outras pessoas que vejam esta interpretação no futuro poderão ver mais " +"dados." msgid "Post interpretation" -msgstr "" +msgstr "Publicar interpretação" msgid "Interpretations" msgstr "Interpretações" @@ -396,66 +422,36 @@ msgstr "Responder" msgid "{{count}} replies" msgid_plural "{{count}} replies" -msgstr[0] "" -msgstr[1] "" -msgstr[2] "" +msgstr[0] "{{count}} resposta" +msgstr[1] "{{count}} respostas" +msgstr[2] "{{count}} respostas" msgid "View replies" -msgstr "" +msgstr "Ver respostas" msgid "Unlike" -msgstr "Ao contrário" +msgstr "Diferente" msgid "Like" msgstr "como" -msgid "Share" -msgstr "Partilha" - msgid "See interpretation" -msgstr "" +msgstr " Ver interpretação" + +msgid "Open in app" +msgstr " Abrir na aplicação" + +msgid "Share" +msgstr " Partilhar" msgid "Manage sharing" -msgstr "Gerenciar compartilhamento" +msgstr "Gerir a partilha" msgid "Could not update interpretation" -msgstr "" +msgstr "Não foi possível actualizar a interpretação" msgid "Enter interpretation text" -msgstr "" - -msgid "Bold text" -msgstr "" - -msgid "Italic text" -msgstr "" - -msgid "Link to a URL" -msgstr "" - -msgid "Mention a user" -msgstr "" - -msgid "Add emoji" -msgstr "" - -msgid "Preview" -msgstr "Pré-visualização" - -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 "" - -msgid "No results found" -msgstr "Nenhum resultado encontrado" +msgstr "Introduzir texto de interpretação" msgid "Not available offline" msgstr "Não disponível offline" @@ -467,13 +463,13 @@ msgid "Anyone" msgstr "Qualquer um" msgid "Only you" -msgstr "" +msgstr "Apenas tu" msgid "Others" msgstr "Outros" msgid "Not supported by this app yet" -msgstr "" +msgstr "Ainda não suportado por esta aplicação" msgid "Filter by name" msgstr "Filtrar por nome" @@ -491,149 +487,169 @@ msgid "Clear filters" msgstr "Limpar filtros" msgid "{{firstItemIndex}}-{{lastItemIndex}} of {{totalNumberOfItems}}" -msgstr "" +msgstr "{{firstItemIndex}}-{{lastItemIndex}} de {{totalNumberOfItems}}" msgid "Open" -msgstr "Abrir" +msgstr "Aberto" msgid "Couldn't load items" -msgstr "" +msgstr "Não foi possível carregar itens" msgid "" "There was a problem loading items. Try again or contact your system " "administrator." msgstr "" +"Houve um problema ao carregar itens. Tente novamente ou contacte o " +"administrador do sistema." msgid "No items found. Create a new to get started." -msgstr "" +msgstr "Nenhum item encontrado. Criar um novo para começar." msgid "" "No items found. Try adjusting your search or filter options to find what " "you're looking for." msgstr "" +"Nenhum item encontrado. Tente ajustar as suas opções de pesquisa ou de " +"filtro para encontrar o que procura." msgid "Create new" msgstr "Crie um novo" msgid "Open a visualization" -msgstr "" +msgstr "Abrir uma visualização" msgid "Loading visualizations" -msgstr "" +msgstr "Carregar visualizações" msgid "Couldn't load visualizations" -msgstr "" +msgstr " Não foi possível carregar visualizações" msgid "" "There was a problem loading visualizations. Try again or contact your system" " administrator." msgstr "" +"Houve um problema ao carregar as visualizações. Tente novamente ou contacte " +"o administrador do sistema." msgid "No visualizations found. Click New visualization to get started." msgstr "" +"Não foram encontradas visualizações. Clique em Nova visualização para " +"começar." msgid "" "No visualizations found. Try adjusting your search or filter options to find" " what you're looking for." msgstr "" +"Não foram encontradas visualizações. Tente ajustar as suas opções de " +"pesquisa ou de filtro para encontrar o que procura." msgid "New visualization" -msgstr "" +msgstr "Nova visualização" msgid "Open a map" -msgstr "" +msgstr "Abrir um mapa" msgid "Loading maps" -msgstr "" +msgstr "Carregar mapas" msgid "Couldn't load maps" -msgstr "" +msgstr "Não foi possível carregar mapas" msgid "" "There was a problem loading maps. Try again or contact your system " "administrator." msgstr "" +"Houve um problema ao carregar os mapas. Tente novamente ou contacte o " +"administrador do sistema." msgid "No maps found. Click New map to get started." -msgstr "" +msgstr "Não foram encontrados mapas. Clique em Novo mapa para começar." msgid "" "No maps found. Try adjusting your search or filter options to find what " "you're looking for." msgstr "" +"Não foram encontrados mapas. Tente ajustar as suas opções de pesquisa ou de " +"filtro para encontrar o que procura." msgid "New map" -msgstr "" +msgstr "Novo mapa" msgid "Open a line list" -msgstr "" +msgstr "Abrir uma lista de linhas" msgid "Loading line lists" -msgstr "" +msgstr "Carregar listas de linhas" msgid "Couldn't load line lists" -msgstr "" +msgstr "Não foi possível carregar as listas de linhas" msgid "" "There was a problem loading line lists. Try again or contact your system " "administrator." msgstr "" +"Houve um problema ao carregar as listas de linhas. Tente novamente ou " +"contacte o administrador do sistema." msgid "No line lists found. Click New line list to get started." msgstr "" +"Não foram encontradas listas de linhas. Clique em Nova lista de linhas para " +"começar." msgid "" "No line lists found. Try adjusting your search or filter options to find " "what you're looking for." msgstr "" +"Não foram encontradas listas de linhas. Tente ajustar as suas opções de " +"pesquisa ou de filtro para encontrar o que procura." msgid "New line list" -msgstr "" +msgstr "Nova lista de linhas" msgid "Options" msgstr "Opções" msgid "Hide" -msgstr "Esconder" +msgstr "Ocultar" msgid "{{count}} org units" msgid_plural "{{count}} org units" -msgstr[0] "" -msgstr[1] "" -msgstr[2] "" +msgstr[0] "{{count}} unidade orgânica" +msgstr[1] "{{count}} unidades orgânicas" +msgstr[2] "{{count}} unidades organizacionais" msgid "{{count}} levels" msgid_plural "{{count}} levels" -msgstr[0] "" -msgstr[1] "" -msgstr[2] "" +msgstr[0] "{{count}} nível" +msgstr[1] "{{count}} níveis" +msgstr[2] "{{count}} níveis" msgid "{{count}} groups" msgid_plural "{{count}} groups" -msgstr[0] "" -msgstr[1] "" -msgstr[2] "" +msgstr[0] "{{count}} grupo" +msgstr[1] "{{count}} grupos" +msgstr[2] "{{count}} grupos" msgid "Selected: {{commaSeparatedListOfOrganisationUnits}}" -msgstr "" +msgstr "Seleccionado: {{commaSeparatedListOfOrganisationUnits}}" msgid "Nothing selected" -msgstr "" +msgstr "Nada seleccionado" msgid "User organisation unit" msgstr "Unidade organizacional do utilizador" msgid "User sub-units" -msgstr "Dois níveis abaixo" +msgstr "Sub-unidades do utilizador" msgid "User sub-x2-units" -msgstr "Dois níveis abaixo" +msgstr "Sub-unidades nível 2 do utilizador" msgid "Select a level" -msgstr "Selecione um nível" +msgstr "Seleccione um nível" msgid "Select a group" -msgstr "Selecione um grupo" +msgstr "Seleccione um grupo" msgid "Deselect all" msgstr "Desmarcar todos" @@ -645,20 +661,20 @@ msgid "Year" msgstr "Ano" msgid "Select year" -msgstr "Selecionar ano" +msgstr "Seleccionar ano" msgid "Period" msgstr "Período" +msgid "Selected Periods" +msgstr "Períodos seleccionados" + msgid "Relative periods" msgstr "Períodos relativos" msgid "Fixed periods" msgstr "Períodos fixos" -msgid "Selected Periods" -msgstr "" - msgid "No periods selected" msgstr "Não há períodos relativos seleccionados" @@ -669,16 +685,16 @@ msgid "Weekly" msgstr "Semanal" msgid "Weekly (Start Wednesday)" -msgstr "" +msgstr "Semanalmente (início na Quarta-feira)" msgid "Weekly (Start Thursday)" -msgstr "" +msgstr " Semanal (início na Quinta-feira)" msgid "Weekly (Start Saturday)" -msgstr "" +msgstr " Semanal (início no Sábado)" msgid "Weekly (Start Sunday)" -msgstr "" +msgstr " Semanal (início no Domingo)" msgid "Bi-weekly" msgstr "Quinzenal" @@ -687,7 +703,7 @@ msgid "Monthly" msgstr "Mensal" msgid "Bi-monthly" -msgstr "Bimensal" +msgstr "Bimestre" msgid "Quarterly" msgstr "Trimestral" @@ -702,16 +718,16 @@ msgid "Yearly" msgstr "Anualmente" msgid "Financial year (Start November)" -msgstr "" +msgstr " Ano financeiro (início em Novembro)" msgid "Financial year (Start October)" -msgstr "Ano Financeiro - Outubro" +msgstr "Ano financeiro (início em Outubro)" msgid "Financial year (Start July)" -msgstr "Ano Financeiro - Julho" +msgstr "Ano financeiro (início em Julho)" msgid "Financial year (Start April)" -msgstr "Ano Financeiro - Abril" +msgstr "Ano financeiro (início em Abril)" msgid "Today" msgstr "Hoje" @@ -732,13 +748,13 @@ msgid "Last 30 days" msgstr "Últimos 30 dias" msgid "Last 60 days" -msgstr "" +msgstr "Últimos 60 dias" msgid "Last 90 days" -msgstr "" +msgstr "Últimos 90 dias" msgid "Last 180 days" -msgstr "" +msgstr "Últimos 180 dias" msgid "This week" msgstr "Esta semana" @@ -747,10 +763,10 @@ msgid "Last week" msgstr "Última semana" msgid "Last 4 weeks" -msgstr "Últimas quatro semanas" +msgstr "Últimas 4 semanas" msgid "Last 12 weeks" -msgstr "Últimas doze semanas" +msgstr "Últimas 12 semanas" msgid "Last 52 weeks" msgstr "Últimas 52 semanas" @@ -759,13 +775,13 @@ msgid "Weeks this year" msgstr "Semanas deste ano " msgid "This bi-week" -msgstr "Nesta bisemanal" +msgstr "Esta quinzena" msgid "Last bi-week" -msgstr "Ultimo bimensal " +msgstr "Última quinzena" msgid "Last 4 bi-weeks" -msgstr "Nas últimas 4 semanas" +msgstr "Últimas 4 semanas" msgid "This month" msgstr "Este mês" @@ -774,28 +790,28 @@ msgid "Last month" msgstr "Último mês" msgid "Last 3 months" -msgstr "Últimos três meses" +msgstr "Últimos 3 meses" msgid "Last 6 months" -msgstr "Últimos seis meses" +msgstr "Últimos 6 meses" msgid "Last 12 months" -msgstr "Últimos doze meses" +msgstr "Últimos 12 meses" msgid "Months this year" msgstr "Meses deste ano" msgid "This bi-month" -msgstr "Este bi-mês" +msgstr "Este bimestre" msgid "Last bi-month" msgstr "Último bimestre" msgid "Last 6 bi-months" -msgstr "Últimos seis bi-meses" +msgstr "Últimos 6 bimestres" msgid "Bi-months this year" -msgstr "Bimensais este ano" +msgstr "Bimestres deste ano" msgid "This quarter" msgstr "Este Trimestre" @@ -804,7 +820,7 @@ msgid "Last quarter" msgstr "Último trimestre" msgid "Last 4 quarters" -msgstr "Últimos quatro trimestres" +msgstr "Últimos 4 trimestres" msgid "Quarters this year" msgstr "Trimestres deste ano" @@ -816,7 +832,7 @@ msgid "Last six-month" msgstr "Último semestre" msgid "Last 2 six-month" -msgstr "" +msgstr " Últimos 2 semestres" msgid "This financial year" msgstr "Ano financeiro" @@ -825,7 +841,7 @@ msgid "Last financial year" msgstr "Último ano fiscal" msgid "Last 5 financial years" -msgstr "Últimos cinco anos fiscais" +msgstr "Últimos 5 anos fiscais" msgid "This year" msgstr "Este ano" @@ -834,7 +850,7 @@ msgid "Last year" msgstr "Último ano" msgid "Last 5 years" -msgstr "Últimos cinco anos" +msgstr "Últimos 5 anos" msgid "Last 10 years" msgstr "Últimos 10 anos" @@ -846,13 +862,13 @@ msgid "Weeks" msgstr "Semanas" msgid "Bi-weeks" -msgstr "Bisemanal" +msgstr "Quinzena" msgid "Months" msgstr "Meses" msgid "Bi-months" -msgstr "Bi-mensal" +msgstr "Bimestre" msgid "Quarters" msgstr "Trimestres" @@ -866,38 +882,74 @@ msgstr "Ano financeiro " msgid "Years" msgstr "Anos" +msgid "Value: {{value}}" +msgstr "Valor: {{value}}" + +msgid "Bold text" +msgstr "Texto em negrito" + +msgid "Italic text" +msgstr "Texto em itálico" + +msgid "Link to a URL" +msgstr "Ligação a um URL" + +msgid "Mention a user" +msgstr "Mencionar um utilizador" + +msgid "Add emoji" +msgstr "Adicionar emoji" + +msgid "Preview" +msgstr "Pré-visualização" + +msgid "Back to write mode" +msgstr "Voltar ao modo de escrita" + msgid "Interpretations and details" -msgstr "" +msgstr "Interpretações e pormenores" msgid "Translating to" -msgstr "" +msgstr "Traduzir para" msgid "Choose a locale" -msgstr "" +msgstr "Escolher um local" msgid "Base locale reference" -msgstr "" +msgstr "Referência à localidade de base" msgid "Choose a locale to translate from the menu above" -msgstr "" +msgstr "Escolha uma localidade para traduzir no menu acima" msgid "Translate: {{objectName}}" -msgstr "" +msgstr "Traduzir: {{objectName}}" msgid "Save translations" -msgstr "" +msgstr "Gravar traduções" msgid "Cannot save while offline" -msgstr "" +msgstr "Não é possível gravar quando se está offline" msgid "Could not load translations" -msgstr "" +msgstr "Não foi possível carregar traduções" msgid "Retry" -msgstr "" +msgstr "Repetir" + +msgid "Too many results. Try refining the search." +msgstr "Demasiados resultados. Tente refinar a pesquisa." + +msgid "Search for a user" +msgstr "Procurar um utilizador" + +msgid "Searching for \"{{- searchText}}\"" +msgstr "Procurar por \"{{- searchText}}\"" + +msgid "No results found" +msgstr "Nenhum resultado encontrado" msgid "Series" -msgstr "Série" +msgstr "Séries" msgid "Category" msgstr "Categoria" @@ -912,7 +964,7 @@ msgid "Rows" msgstr "Linhas" msgid "Points" -msgstr "" +msgstr "Pontos" msgid "Reporting rate" msgstr "Taxa de relatórios" @@ -921,10 +973,10 @@ msgid "Reporting rate on time" msgstr "Taxa de relatórios no prazo" msgid "Actual reports" -msgstr "Relatórios reais" +msgstr "Relatórios actuais" msgid "Actual reports on time" -msgstr "Relatórios reais dentro do prazo" +msgstr "Relatórios actuais dentro do prazo" msgid "Expected reports" msgstr "Relatórios esperados" @@ -933,7 +985,7 @@ msgid "Program" msgstr "Programa" msgid "Select a program" -msgstr "Selecione um programa" +msgstr "Seleccione um programa" msgid "Indicators" msgstr "Indicadores" @@ -942,16 +994,16 @@ msgid "Indicator group" msgstr "Grupo de Indicador" msgid "All groups" -msgstr "" +msgstr "Todos os grupos" msgid "Indicator" msgstr "Indicador" msgid "No indicator groups found" -msgstr "" +msgstr "Não foram encontrados grupos de indicadores" msgid "Loading indicator groups" -msgstr "" +msgstr "Carregando grupos de indicadores" msgid "Data element group" msgstr "Grupo de Elemento de Dados" @@ -960,19 +1012,19 @@ msgid "Data element" msgstr "Elemento de dado" msgid "No data element groups found" -msgstr "" +msgstr "Não foram encontrados grupos de elementos de dados" msgid "Loading data element groups" -msgstr "" +msgstr "Carregando de grupos de elementos de dados" msgid "Data sets" msgstr "Agregação de Dados" msgid "Data set" -msgstr "Ficha" +msgstr "Conjuntos de dados" msgid "All data sets" -msgstr "" +msgstr "Todos conjuntos de dados" msgid "Loading data sets" msgstr "Carregando conjuntos de dados" @@ -981,16 +1033,16 @@ msgid "Event data items" msgstr "Itens de dados do evento" msgid "All programs" -msgstr "" +msgstr "Todos os programas" msgid "Event data item" msgstr "Item de dados do evento" msgid "No programs found" -msgstr "" +msgstr "Nenhum programa encontrado" msgid "Loading programs" -msgstr "" +msgstr "Carregando programas" msgid "Program indicators" msgstr "Indicador do programa" @@ -999,28 +1051,30 @@ msgid "Program indicator" msgstr "Gestão do indicadores de programa" msgid "Calculations" -msgstr "" +msgstr "Cálculos" msgid "Number" msgstr "Número" msgid "Formula is empty. Add items to the formula from the lists on the left." msgstr "" +"A fórmula está vazia. Adicione itens à fórmula a partir das listas à " +"esquerda." msgid "Consecutive math operators" -msgstr "" +msgstr "Operadores matemáticos consecutivos" msgid "Consecutive data elements" -msgstr "" +msgstr "Elementos de dados consecutivos" msgid "Starts or ends with a math operator" -msgstr "" +msgstr "Começa ou termina com um operador matemático" msgid "Empty parentheses" -msgstr "" +msgstr "Parênteses vazios" msgid "Missing right parenthesis )" -msgstr "" +msgstr "Parênteses direito em falta )" msgid "Missing left parenthesis (" msgstr "" @@ -1103,6 +1157,9 @@ msgstr "" msgid "{{thresholdFactor}} × Z-score high" msgstr "" +msgid "Not applicable" +msgstr "" + msgid "Data" msgstr "Dados" @@ -1160,6 +1217,9 @@ msgstr "" msgid "Single value" msgstr "Valor unico" +msgid "Outlier table" +msgstr "" + msgid "All charts" msgstr "" diff --git a/i18n/zh.po b/i18n/zh.po index cefd5a3e8..684f61cec 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-08-27T11:29:09.031Z\n" +"POT-Creation-Date: 2024-10-11T12:49:26.846Z\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" @@ -838,6 +838,9 @@ msgstr "财政年度" msgid "Years" msgstr "年" +msgid "Value: {{value}}" +msgstr "值{{value}}" + msgid "Bold text" msgstr "粗体文字" @@ -1108,6 +1111,9 @@ msgstr "{{thresholdFactor}}×Z分数低" msgid "{{thresholdFactor}} × Z-score high" msgstr "{{thresholdFactor}}×Z分数高" +msgid "Not applicable" +msgstr "不适用" + msgid "Data" msgstr "数据" From 40c1446703a5b5cc0f594651743acdc4aedd80af Mon Sep 17 00:00:00 2001 From: "@dhis2-bot" Date: Sun, 27 Oct 2024 01:47:23 +0000 Subject: [PATCH 8/8] chore(release): cut 26.9.1 [skip ci] ## [26.9.1](https://github.com/dhis2/analytics/compare/v26.9.0...v26.9.1) (2024-10-27) ### Bug Fixes * **translations:** sync translations from transifex (master) ([9a507a2](https://github.com/dhis2/analytics/commit/9a507a2236567597f0cda20e4a0fff9fc1d082f3)) --- CHANGELOG.md | 7 +++++++ package.json | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9b7982142..12c72ef7a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +## [26.9.1](https://github.com/dhis2/analytics/compare/v26.9.0...v26.9.1) (2024-10-27) + + +### Bug Fixes + +* **translations:** sync translations from transifex (master) ([9a507a2](https://github.com/dhis2/analytics/commit/9a507a2236567597f0cda20e4a0fff9fc1d082f3)) + # [26.9.0](https://github.com/dhis2/analytics/compare/v26.8.8...v26.9.0) (2024-10-22) diff --git a/package.json b/package.json index 0e3c04c3e..592fb4e0e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@dhis2/analytics", - "version": "26.9.0", + "version": "26.9.1", "main": "./build/cjs/index.js", "module": "./build/es/index.js", "exports": {