@oliasoft-open-source/charts-library 2.5.26 → 2.6.0-beta-1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oliasoft-open-source/charts-library",
3
- "version": "2.5.26",
3
+ "version": "2.6.0-beta-1",
4
4
  "description": "React Chart Library (based on Chart.js and react-chart-js-2)",
5
5
  "main": "index.js",
6
6
  "scripts": {
package/release-notes.md CHANGED
@@ -1,5 +1,16 @@
1
1
  # Charts Library Release Notes
2
2
 
3
+ ## 2.6.0
4
+
5
+ - Add support for optional `autoAxisPadding` prop, which autoscales 5% padding around data ([OW-10398](https://oliasoft.atlassian.net/browse/OW-10398))
6
+ - Fix bug in default scales when all data points similar values ([OW-4327](https://oliasoft.atlassian.net/browse/OW-4327))
7
+
8
+ - Add guard to useEffect in line-chart
9
+
10
+ ## 2.5.27
11
+
12
+ - Add guard to useEffect in line-chart
13
+
3
14
  ## 2.5.26
4
15
 
5
16
  - Add guard to getLineChartAxis method
@@ -0,0 +1,165 @@
1
+ import getLineChartScales from '../get-line-chart-scales';
2
+ import { AxisType } from '../../../helpers/enums';
3
+ import { getAxisTypeFromKey } from '../line-chart-utils';
4
+ import { estimateDataSeriesHaveCloseValues } from '../../../helpers/range/estimate-data-series-have-close-values';
5
+ import { getSuggestedAxisRange } from '../../../helpers/range/range';
6
+
7
+ /**
8
+ Function that counts the number of occurences of "x" and "y" in an array and returns the one with the highest count.
9
+ @param {Array} scalesKeys - An array of strings that may contain "x" or "y".
10
+ @return {string} - Returns "x" if "x" occurs more times, "y" if "y" occurs more times, or null if they occur the same number of times.
11
+ */
12
+ const checkMultiAxis = (scalesKeys) => {
13
+ let counts = {
14
+ x: 0,
15
+ y: 0,
16
+ };
17
+
18
+ scalesKeys.forEach((axeKey) => {
19
+ const key = getAxisTypeFromKey(axeKey);
20
+ counts[key]++;
21
+ });
22
+ const res =
23
+ (counts.x > counts.y && AxisType.X) ||
24
+ (counts.y > counts.x && AxisType.Y) ||
25
+ (counts.x === counts.y && null);
26
+
27
+ return res;
28
+ };
29
+
30
+ /**
31
+ Function that returns an object containing all the values for a specific key in an array of objects,
32
+ grouped by the unique values of another key in the same objects.
33
+ @param {Array} data - An array of objects to search for the keys.
34
+ @return {Object} - Returns an object with keys representing the unique values for the annotationAxis key in the data array,
35
+ and values representing an array of all values for the value key in the data array.
36
+ */
37
+ const getAnnotationsData = (data) => {
38
+ return data.reduce((acc, obj) => {
39
+ return {
40
+ ...acc,
41
+ [obj.annotationAxis]: [...(acc[obj.annotationAxis] || []), obj.value],
42
+ };
43
+ }, {});
44
+ };
45
+
46
+ /**
47
+ * return data for each axes
48
+ * @function getAxesData
49
+ * @return {object}
50
+ */
51
+ const getAxesData = (scalesKeys, datasets, annotationsData) => {
52
+ const allData =
53
+ datasets?.reduce((acc, item) => [...acc, ...item.data], []) ?? [];
54
+
55
+ /**
56
+ * return data by key and depends on axes count
57
+ * @function getData
58
+ * @return {object}
59
+ */
60
+ const getData = () => {
61
+ return scalesKeys.reduce((acc, axeKey) => {
62
+ const key = getAxisTypeFromKey(axeKey);
63
+ const data = getAnnotationsData(annotationsData);
64
+ const formData = allData
65
+ ?.map((val) => val?.[key])
66
+ .concat(data[key] || []);
67
+
68
+ return {
69
+ ...acc,
70
+ [axeKey]: [...new Set(formData)],
71
+ };
72
+ }, {});
73
+ };
74
+
75
+ /**
76
+ Function that returns an object containing the unique values for a multi axis key in an array of objects.
77
+ @return {Object} - Returns an object with key representing the found key in the scalesKeys array,
78
+ and values representing an array of unique values for that key in the datasets array. If no key is found, an empty object is returned.
79
+ */
80
+ const getDataForMultiAxes = () => {
81
+ const multiAxesKey = checkMultiAxis(scalesKeys);
82
+ const axes = scalesKeys?.filter((key) => key?.includes(multiAxesKey)) ?? [];
83
+ const data = getAnnotationsData(annotationsData);
84
+ const [first, second] = axes;
85
+
86
+ /**
87
+ * Reduces an array of objects and returns an array of values from the specified key
88
+ * @param {Array} arr - The array of objects to be reduced
89
+ * @param {string} key - The key to extract the values from
90
+ * @returns {Array} - An array of values from the specified key
91
+ */
92
+ const reduceData = (arr, key) =>
93
+ arr.reduce((acc, { [key]: value }) => [...acc, value], []);
94
+
95
+ return datasets.reduce(
96
+ (acc, obj) => {
97
+ const include = Object.values(obj).some((val) => axes.includes(val));
98
+ const key = include ? second : first;
99
+
100
+ return {
101
+ ...acc,
102
+ [key]: [
103
+ ...new Set([
104
+ ...acc[key],
105
+ ...reduceData(obj?.data, multiAxesKey),
106
+ ...((!include && data?.[multiAxesKey]) || []),
107
+ ]),
108
+ ],
109
+ };
110
+ },
111
+ { [first]: [], [second]: [] },
112
+ );
113
+ };
114
+
115
+ return {
116
+ ...getData(),
117
+ ...getDataForMultiAxes(),
118
+ };
119
+ };
120
+
121
+ /**
122
+ * Auto scales axis ranges (mix, max) if no explicit range is set
123
+ * - overrides some edge cases not handled well by default chart.js
124
+ * - supports optional padding when `autoAxisPadding` is set
125
+ * - otherwise does not set min/max (falls back to chart.js default)
126
+ *
127
+ * @function autoScale
128
+ * @return {object} scales
129
+ */
130
+ export const autoScale = (options, state, generatedDatasets) => {
131
+ const { additionalAxesOptions = {}, annotations = {} } = options || {};
132
+ const { annotationsData = [] } = annotations || {};
133
+ const scales = getLineChartScales(options, state) || {};
134
+
135
+ if (
136
+ !additionalAxesOptions?.autoAxisPadding &&
137
+ !estimateDataSeriesHaveCloseValues(generatedDatasets)
138
+ ) {
139
+ return scales;
140
+ }
141
+ const scalesKeys = Object.keys(scales) ?? [];
142
+ const data =
143
+ getAxesData(scalesKeys, generatedDatasets, annotationsData) ?? {};
144
+
145
+ const adjustedScales = scalesKeys?.reduce((acc, key) => {
146
+ const { min: propMin = undefined, max: propMax = undefined } = scales[key];
147
+ const { min, max } = getSuggestedAxisRange({
148
+ data: data[key],
149
+ beginAtZero: additionalAxesOptions?.beginAtZero,
150
+ autoAxisPadding: additionalAxesOptions?.autoAxisPadding,
151
+ });
152
+
153
+ const res = {
154
+ [key]: {
155
+ ...scales[key],
156
+ min: propMin ?? min,
157
+ max: propMax ?? max,
158
+ },
159
+ };
160
+
161
+ return { ...acc, ...res };
162
+ }, {});
163
+
164
+ return adjustedScales ?? scales;
165
+ };
@@ -44,6 +44,7 @@ export const LineChartPropTypes = {
44
44
  max: PropTypes.number,
45
45
  }),
46
46
  }),
47
+ autoAxisPadding: PropTypes.bool,
47
48
  }),
48
49
  chartStyling: PropTypes.shape({
49
50
  width: PropTypes.oneOfType([PropTypes.number, PropTypes.string]),
@@ -141,6 +142,8 @@ export const getDefaultProps = (props) => {
141
142
  suggestedMin: props.chart.options.additionalAxesOptions.suggestedMin,
142
143
  suggestedMax: props.chart.options.additionalAxesOptions.suggestedMax,
143
144
  range: props.chart.options.additionalAxesOptions.range,
145
+ autoAxisPadding:
146
+ props.chart.options.additionalAxesOptions.autoAxisPadding ?? false,
144
147
  },
145
148
  chartStyling: {
146
149
  width: props.chart.options.chartStyling.width,
@@ -181,3 +181,12 @@ export const generateAxisId = (axisType, index = 0, hasMultiAxes = false) => {
181
181
  const i = hasMultiAxes ? index + 1 : '';
182
182
  return `${axisType}${i}`;
183
183
  };
184
+
185
+ /**
186
+ Get axis type from a key string.
187
+ @param {string} string - The key string to extract from.
188
+ @returns {string} e.g. x1 => x
189
+ */
190
+ export const getAxisTypeFromKey = (string) => {
191
+ return string.match(/[^0-9/]+/gi)[0];
192
+ };
@@ -43,6 +43,7 @@ export interface ILineChartAdditionalAxesOptions {
43
43
  x: IChartRange;
44
44
  y: IChartRange;
45
45
  };
46
+ autoAxisPadding: boolean;
46
47
  }
47
48
 
48
49
  export interface ILineChartAxis<PositionType> {
@@ -34,7 +34,6 @@ import {
34
34
  } from './state/action-types';
35
35
  import Controls from '../controls/controls';
36
36
  import { getDefaultProps, LineChartPropTypes } from './line-chart-prop-types';
37
- import getLineChartScales from './get-line-chart-scales';
38
37
  import getLineChartToolTips from './get-line-chart-tooltips';
39
38
  import getLineChartDataLabels from './get-line-chart-data-labels';
40
39
  import {
@@ -72,6 +71,7 @@ import {
72
71
  } from '../../helpers/enums';
73
72
  import { getAxesRangesFromChart } from './get-axes-ranges-from-chart';
74
73
  import { generateAxisId } from './line-chart-utils';
74
+ import { autoScale } from './axis-scales/axis-scales';
75
75
 
76
76
  ChartJS.register(
77
77
  LinearScale,
@@ -131,7 +131,7 @@ const LineChart = (props) => {
131
131
 
132
132
  useEffect(() => {
133
133
  const { range } = props.chart.options.additionalAxesOptions;
134
- if (range) {
134
+ if (range?.x && range?.y) {
135
135
  const axes = Object.entries(range).map(([key, { min, max }]) => {
136
136
  return {
137
137
  id: key,
@@ -479,7 +479,7 @@ const LineChart = (props) => {
479
479
  showLine: state.lineEnabled,
480
480
  },
481
481
  },
482
- scales: getLineChartScales(options, state),
482
+ scales: autoScale(options, state, generatedDatasets),
483
483
  plugins: {
484
484
  // title: getTitle(options),
485
485
  datalabels: getLineChartDataLabels(options),
@@ -58,3 +58,5 @@ export const ANIMATION_DURATION = {
58
58
  export const DEFAULT_CHART_NAME = 'new_chart';
59
59
 
60
60
  export const CUSTOM_LEGEND_PLUGIN_NAME = 'htmlLegend';
61
+
62
+ export const DECIMAL_POINT_TOLERANCE = 9; //ignore decimal points beyond this
@@ -0,0 +1,44 @@
1
+ import { DECIMAL_POINT_TOLERANCE } from '../chart-consts';
2
+
3
+ /**
4
+ * Rounds a number to N decimal places
5
+ *
6
+ * @param {number} v value
7
+ * @param {number} n decimal count
8
+ * @returns {number}
9
+ */
10
+ export const roundN = (v, n) => {
11
+ const factor = 10 ** n;
12
+ return Math.round(v * factor) / factor;
13
+ };
14
+
15
+ /**
16
+ * Rounds a number to DECIMAL_POINT_TOLERANCE decimal places
17
+ *
18
+ * @param {number} v value
19
+ * @returns {number}
20
+ */
21
+ export const round = (v) => roundN(v, DECIMAL_POINT_TOLERANCE);
22
+
23
+ /**
24
+ * Determines whether two numbers are close in value with a tolerance
25
+ * (mitigates excess JavaScript floating point precision quirks)
26
+ *
27
+ * Inspired by WellDesign implementations:
28
+ * - isCloseTo in rounding.js
29
+ * - isEqual in TDHYDutils.js (recommended by Truls, but quirks comparing values close to 0 need testing)
30
+ * - TODO: replace this with a universal Oliasoft implementation when ready
31
+ *
32
+ * @param {number} a
33
+ * @param {number} b
34
+ * @returns {boolean}
35
+ */
36
+ export const isEqualWithTolerance = (a, b) => {
37
+ if (typeof a == 'number' && typeof b === typeof a) {
38
+ const tolerance = 10 ** -DECIMAL_POINT_TOLERANCE;
39
+ const difference = Math.abs(b - a);
40
+ const roundedDifference = roundN(difference, DECIMAL_POINT_TOLERANCE);
41
+ return roundedDifference <= tolerance;
42
+ }
43
+ return false;
44
+ };
@@ -0,0 +1,54 @@
1
+ import { isEqualWithTolerance } from '../numbers/numbers';
2
+
3
+ /**
4
+ * Estimates whether any of the data series has values that are all close together
5
+ * - checks only the first and last values in each series (i.e. assumes they are ordered)
6
+ * - uses an equality check with tolerance for decimal precision noise
7
+ * - this is just an inexpensive "guesstimate" (full min/max detection can be used afterwards)
8
+ *
9
+ * @param {Array} generatedDatasets chart dataset series with x, y points
10
+ * @return {boolean} - at least one series has values that seem close together
11
+ */
12
+ export const estimateDataSeriesHaveCloseValues = (generatedDatasets) => {
13
+ if (!Array.isArray(generatedDatasets) || !generatedDatasets.length) {
14
+ return false;
15
+ }
16
+ const axesFirstLast = generatedDatasets.reduce((acc, dataset) => {
17
+ const xAxisId = dataset?.xAxisID ?? 'defaultX';
18
+ const yAxisId = dataset?.yAxisID ?? 'defaultY';
19
+ const data = dataset?.data;
20
+ if (data && data.length) {
21
+ const { x: xFirstCurrent, y: yFirstCurrent } = data?.[0] ?? {};
22
+ const { x: xLastCurrent, y: yLastCurrent } = data.at(-1) ?? {};
23
+ const xFirstAcc = acc?.[xAxisId]?.xFirst ?? xFirstCurrent;
24
+ const xLastAcc = acc?.[xAxisId]?.xLast ?? xLastCurrent;
25
+ const yFirstAcc = acc?.[yAxisId]?.yFirst ?? yFirstCurrent;
26
+ const yLastAcc = acc?.[yAxisId]?.yLast ?? yLastCurrent;
27
+ const xFirst = Math.min(xFirstCurrent, xFirstAcc);
28
+ const xLast = Math.max(xLastCurrent, xLastAcc);
29
+ const yFirst = Math.min(yFirstCurrent, yFirstAcc);
30
+ const yLast = Math.max(yLastCurrent, yLastAcc);
31
+ acc = {
32
+ ...acc,
33
+ [xAxisId]: {
34
+ ...acc[xAxisId],
35
+ xFirst,
36
+ xLast,
37
+ },
38
+ [yAxisId]: {
39
+ yFirst,
40
+ yLast,
41
+ },
42
+ };
43
+ }
44
+ return acc;
45
+ }, {});
46
+ return Object.values(axesFirstLast).some(
47
+ ({ xFirst, xLast, yFirst, yLast }) => {
48
+ return (
49
+ isEqualWithTolerance(xFirst, xLast) ||
50
+ isEqualWithTolerance(yFirst, yLast)
51
+ );
52
+ },
53
+ );
54
+ };
@@ -0,0 +1,95 @@
1
+ import { isEqualWithTolerance, round } from '../numbers/numbers';
2
+
3
+ const whiteSpacePercentage = 0.05; // relative amount of white space on each "side" of the data points
4
+
5
+ const defaultRange = { min: -1, max: 1 };
6
+
7
+ /**
8
+ * Overrides the default chart.js axis range for some edge-cases:
9
+ * - when no data -> default range
10
+ * - when all values are close to zero -> default range
11
+ * - when all values are close to each other -> custom 5% padding
12
+ * - when autoAxisPadding is set -> custom 5% padding
13
+ * - all other cases fall back to chart.js default behaviour
14
+ *
15
+ * `autoAxisPadding` feature requirements:
16
+ * - specified by Truls and ported by Mark+Oleg
17
+ * - numbers that are equal (within tolerance) shall be presented as a straight line
18
+ * - all other data series shall use 90% of width of axis (5% padding each side)
19
+ * - the padding on each side shall be symmetric
20
+ *
21
+ * @param {object} args
22
+ * @param {array<number|null>} args.data
23
+ * @param {boolean} [args.beginAtZero]
24
+ * @param {boolean>} [args.autoAxisPadding]
25
+ * @returns {object} returns {min, max} pair
26
+ */
27
+ export const getSuggestedAxisRange = ({
28
+ data,
29
+ beginAtZero = false,
30
+ autoAxisPadding = false,
31
+ }) => {
32
+ const dataMin = Math.min(
33
+ ...data.filter((v) => v !== null && v !== undefined && !isNaN(v)),
34
+ );
35
+ const dataMax = Math.max(
36
+ ...data.filter((v) => v !== null && v !== undefined && !isNaN(v)),
37
+ );
38
+ const isNegative = Math.sign(dataMin) === -1 || Math.sign(dataMax) === -1;
39
+ const isCloseToZeroWithTolerance =
40
+ isEqualWithTolerance(dataMin, 0) && isEqualWithTolerance(dataMax, 0);
41
+
42
+ /*
43
+ Use default range upon no data or when all values are close to 0
44
+ */
45
+ if (!data.length || isCloseToZeroWithTolerance) {
46
+ return defaultRange;
47
+ }
48
+
49
+ /*
50
+ When all values are close to the same, always add some padding OW-4327
51
+ */
52
+ if (isEqualWithTolerance(dataMin, dataMax)) {
53
+ const point = dataMax;
54
+ const padding = point * whiteSpacePercentage;
55
+ const minAxisValue = beginAtZero && !isNegative ? 0 : point - padding;
56
+ const maxAxisValue = beginAtZero && isNegative ? 0 : point + padding;
57
+ return {
58
+ min: round(minAxisValue),
59
+ max: round(maxAxisValue),
60
+ };
61
+ }
62
+
63
+ /*
64
+ Else fall back to native chart.js implementation when autoAxisPadding is off
65
+ */
66
+ if (!autoAxisPadding) {
67
+ return {
68
+ min: undefined,
69
+ max: undefined,
70
+ };
71
+ }
72
+
73
+ /*
74
+ Else use custom auto scaling with 5% padding (only when autoAxisPadding is set)
75
+ */
76
+ const rangeBeginAtZero = dataMin === 0 || dataMax === 0 || beginAtZero;
77
+ const positiveAndNegative =
78
+ Math.sign(dataMin) === -1 && Math.sign(dataMax) === 1;
79
+
80
+ const range = Math.abs(dataMax - dataMin);
81
+ const padding = autoAxisPadding ? range * whiteSpacePercentage : 0;
82
+ const minAxisValue =
83
+ !positiveAndNegative && rangeBeginAtZero && beginAtZero && !isNegative
84
+ ? 0
85
+ : dataMin - padding;
86
+ const maxAxisValue =
87
+ !positiveAndNegative && rangeBeginAtZero && beginAtZero && isNegative
88
+ ? 0
89
+ : dataMax + padding;
90
+
91
+ return {
92
+ min: round(minAxisValue),
93
+ max: round(maxAxisValue),
94
+ };
95
+ };