Skip to content

Commit b7c4268

Browse files
authored
Charting tool enhancements - Errors bars for Bar and Line chart (#7117)
- Addition of error bar support for bar and line charts with vertical error bars - New UI controls for aggregate method selection and error bar type configuration - Enhanced statistical functions for calculating standard deviation and standard error of the mean
1 parent f3f4b63 commit b7c4268

14 files changed

Lines changed: 854 additions & 155 deletions

File tree

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
LABKEY.vis = {};
2+
require('../../../webapp/vis/src/statistics.js');
3+
4+
describe('LABKEY.vis.Stats', () => {
5+
test('getMean', () => {
6+
expect(LABKEY.vis.Stat.getMean([1, 2, 3, 4, 5])).toBe(3);
7+
expect(LABKEY.vis.Stat.getMean([-1, 0, 1])).toBe(0);
8+
expect(LABKEY.vis.Stat.getMean([1.123])).toBe(1.123);
9+
});
10+
11+
test('getStdDev', () => {
12+
expect(LABKEY.vis.Stat.getStdDev([1, 2, 3, 4, 5])).toBeCloseTo(1.4142, 4);
13+
expect(LABKEY.vis.Stat.getStdDev([1, 2, 3, 4, 5], true)).toBeCloseTo(1.5811, 4);
14+
expect(LABKEY.vis.Stat.getStdDev([-1, 0, 1])).toBeCloseTo(0.8165, 4);
15+
expect(LABKEY.vis.Stat.getStdDev([-1, 0, 1], true)).toBeCloseTo(1, 4);
16+
expect(LABKEY.vis.Stat.getStdDev([1.123])).toBe(0);
17+
18+
expect(LABKEY.vis.Stat.getStdDev([])).toBe(undefined);
19+
expect(LABKEY.vis.Stat.getStdDev([1.123], true)).toBe(undefined);
20+
});
21+
22+
test('getStdErr', () => {
23+
expect(LABKEY.vis.Stat.getStdErr([1, 2, 3, 4, 5])).toBeCloseTo(0.6325, 4);
24+
expect(LABKEY.vis.Stat.getStdErr([1, 2, 3, 4, 5], true)).toBeCloseTo(0.7071, 4);
25+
expect(LABKEY.vis.Stat.getStdErr([-1, 0, 1])).toBeCloseTo(0.4714, 4);
26+
expect(LABKEY.vis.Stat.getStdErr([-1, 0, 1], true)).toBeCloseTo(0.5774, 4);
27+
expect(LABKEY.vis.Stat.getStdErr([1.123])).toBe(0);
28+
29+
expect(LABKEY.vis.Stat.getStdErr([])).toBe(undefined);
30+
expect(LABKEY.vis.Stat.getStdErr([1.123], true)).toBe(undefined);
31+
});
32+
});

core/src/client/vis/utils.test.ts

Lines changed: 279 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,279 @@
1+
LABKEY.vis = {};
2+
require('../../../webapp/vis/src/statistics.js');
3+
require('../../../webapp/vis/src/utils.js');
4+
5+
describe('LABKEY.vis.getValue', () => {
6+
test('value not object', () => {
7+
expect(LABKEY.vis.getValue()).toBeUndefined();
8+
expect(LABKEY.vis.getValue(undefined)).toBeUndefined();
9+
expect(LABKEY.vis.getValue(null)).toBeNull();
10+
expect(LABKEY.vis.getValue(5)).toBe(5);
11+
expect(LABKEY.vis.getValue('test')).toBe('test');
12+
});
13+
14+
test('value is object', () => {
15+
expect(LABKEY.vis.getValue({})).toBeUndefined();
16+
expect(LABKEY.vis.getValue({ value: undefined })).toBeUndefined();
17+
expect(LABKEY.vis.getValue({ value: null })).toBeNull();
18+
expect(LABKEY.vis.getValue({ value: 5 })).toBe(5);
19+
expect(LABKEY.vis.getValue({ value: 'test' })).toBe('test');
20+
expect(LABKEY.vis.getValue({ value: 'test', other: 1 })).toBe('test');
21+
});
22+
23+
test('formattedValue, displayValue, preferredProp', () => {
24+
expect(LABKEY.vis.getValue({ formattedValue: 'formatted', displayValue: 'display', value: 'value' })).toBe('formatted');
25+
expect(LABKEY.vis.getValue({ formattedValue: null, displayValue: 'display', value: 'value' })).toBe(null);
26+
expect(LABKEY.vis.getValue({ formattedValue: undefined, displayValue: 'display', value: 'value' })).toBe(undefined);
27+
expect(LABKEY.vis.getValue({ displayValue: 'display', value: 'value' })).toBe('display');
28+
expect(LABKEY.vis.getValue({ displayValue: null, value: 'value' })).toBe(null);
29+
expect(LABKEY.vis.getValue({ displayValue: undefined, value: 'value' })).toBe(undefined);
30+
expect(LABKEY.vis.getValue({ value: 'value' })).toBe('value');
31+
expect(LABKEY.vis.getValue({ value: null })).toBeNull();
32+
expect(LABKEY.vis.getValue({ value: undefined })).toBeUndefined();
33+
34+
expect(LABKEY.vis.getValue({ formattedValue: 'formatted', displayValue: 'display', value: 'value' }, 'bogus')).toBe('formatted');
35+
expect(LABKEY.vis.getValue({ formattedValue: 'formatted', displayValue: 'display', value: 'value' }, 'formattedValue')).toBe('formatted');
36+
expect(LABKEY.vis.getValue({ formattedValue: 'formatted', displayValue: 'display', value: 'value' }, 'displayValue')).toBe('display');
37+
expect(LABKEY.vis.getValue({ formattedValue: 'formatted', displayValue: 'display', value: 'value' }, 'value')).toBe('value');
38+
});
39+
});
40+
41+
describe('LABKEY.vis.getAggregateData', () => {
42+
const data = [
43+
{ main: 'A', sub: 'a', value: 10 },
44+
{ main: 'A', sub: 'b', value: 20 },
45+
{ main: 'A', sub: 'b', value: 15 },
46+
{ main: 'B', sub: 'a', value: 30 },
47+
];
48+
49+
test('without subgroup', () => {
50+
let results = LABKEY.vis.getAggregateData(data, 'main', undefined, 'value');
51+
expect(results).toStrictEqual([
52+
{ label: 'A', value: 3 },
53+
{ label: 'B', value: 1 },
54+
]);
55+
results = LABKEY.vis.getAggregateData(data, 'main', undefined, 'value', 'COUNT');
56+
expect(results).toStrictEqual([
57+
{ label: 'A', value: 3 },
58+
{ label: 'B', value: 1 },
59+
]);
60+
results = LABKEY.vis.getAggregateData(data, 'main', undefined, 'value', 'MEAN');
61+
expect(results).toStrictEqual([
62+
{ aggType: 'MEAN', label: 'A', value: 15 },
63+
{ aggType: 'MEAN', label: 'B', value: 30 },
64+
]);
65+
});
66+
67+
test('with subgroup', () => {
68+
let results = LABKEY.vis.getAggregateData(data, 'main', 'sub', 'value');
69+
expect(results).toStrictEqual([
70+
{ label: 'A', subLabel: 'a', value: 1 },
71+
{ label: 'A', subLabel: 'b', value: 2 },
72+
{ label: 'B', subLabel: 'a', value: 1 },
73+
]);
74+
results = LABKEY.vis.getAggregateData(data, 'main', 'sub', 'value', 'MEAN');
75+
expect(results).toStrictEqual([
76+
{ aggType: 'MEAN', label: 'A', subLabel: 'a', value: 10 },
77+
{ aggType: 'MEAN', label: 'A', subLabel: 'b', value: 17.5 },
78+
{ aggType: 'MEAN', label: 'B', subLabel: 'a', value: 30 },
79+
]);
80+
});
81+
82+
test('errorBarType', () => {
83+
const data2 = [
84+
{ main: 'A', sub: 'a', value: 10 },
85+
{ main: 'A', sub: 'b', value: 1 },
86+
{ main: 'A', sub: 'b', value: 2 },
87+
{ main: 'A', sub: 'b', value: 3 },
88+
{ main: 'B', sub: 'a', value: 30 },
89+
];
90+
let results = LABKEY.vis.getAggregateData(data2, 'main', 'sub', 'value', 'COUNT', undefined, false, 'SD');
91+
expect(results).toStrictEqual([
92+
{ label: 'A', subLabel: 'a', value: 1 },
93+
{ label: 'A', subLabel: 'b', value: 3 },
94+
{ label: 'B', subLabel: 'a', value: 1 },
95+
]);
96+
results = LABKEY.vis.getAggregateData(data2, 'main', 'sub', 'value', 'MEAN', undefined, false, 'SD');
97+
expect(results).toStrictEqual([
98+
{ aggType: 'MEAN', label: 'A', subLabel: 'a', value: 10, errorType: 'SD', error: undefined },
99+
{ aggType: 'MEAN', label: 'A', subLabel: 'b', value: 2, errorType: 'SD', error: 1 },
100+
{ aggType: 'MEAN', label: 'B', subLabel: 'a', value: 30, errorType: 'SD', error: undefined },
101+
]);
102+
results = LABKEY.vis.getAggregateData(data2, 'main', 'sub', 'value', 'MEAN', undefined, false, 'SEM');
103+
expect(results[1].errorType).toBe('SEM');
104+
expect(results[1].error).toBeCloseTo(0.5774, 4);
105+
});
106+
107+
test('includeTotal', () => {
108+
let results = LABKEY.vis.getAggregateData(data, 'main', 'sub', 'value', 'COUNT', undefined, true);
109+
expect(results).toStrictEqual([
110+
{ label: 'A', subLabel: 'a', value: 1, total: 1 },
111+
{ label: 'A', subLabel: 'b', value: 2, total: 3 },
112+
{ label: 'B', subLabel: 'a', value: 1, total: 4 },
113+
]);
114+
results = LABKEY.vis.getAggregateData(data, 'main', 'sub', 'value', 'MEAN', undefined, true);
115+
expect(results).toStrictEqual([
116+
{ aggType: 'MEAN', label: 'A', subLabel: 'a', value: 10, total: 1 },
117+
{ aggType: 'MEAN', label: 'A', subLabel: 'b', value: 17.5, total: 3 },
118+
{ aggType: 'MEAN', label: 'B', subLabel: 'a', value: 30, total: 4 },
119+
]);
120+
});
121+
122+
test('keepNames', () => {
123+
let results = LABKEY.vis.getAggregateData(data, 'main', 'sub', 'value', 'COUNT', undefined, false, undefined, true);
124+
expect(results).toStrictEqual([
125+
{ label: 'A', main: { value: 'A' }, subLabel: 'a', sub: { value: 'a' }, value: { aggType: 'COUNT', value: 1 } },
126+
{ label: 'A', main: { value: 'A' }, subLabel: 'b', sub: { value: 'b' }, value: { aggType: 'COUNT', value: 2 } },
127+
{ label: 'B', main: { value: 'B' }, subLabel: 'a', sub: { value: 'a' }, value: { aggType: 'COUNT', value: 1 } },
128+
]);
129+
results = LABKEY.vis.getAggregateData(data, 'main', 'sub', 'value', 'MEAN', undefined, false, undefined, true);
130+
expect(results).toStrictEqual([
131+
{ label: 'A', main: { value: 'A' }, subLabel: 'a', sub: { value: 'a' }, value: { aggType: 'MEAN', value: 10 }, aggType: 'MEAN' },
132+
{ label: 'A', main: { value: 'A' }, subLabel: 'b', sub: { value: 'b' }, value: { aggType: 'MEAN', value: 17.5 }, aggType: 'MEAN' },
133+
{ label: 'B', main: { value: 'B' }, subLabel: 'a', sub: { value: 'a' }, value: { aggType: 'MEAN', value: 30 }, aggType: 'MEAN' },
134+
]);
135+
});
136+
137+
test('nullDisplayValue', () => {
138+
const dataWithNulls = [
139+
{ main: 'A', sub: 'a', value: 10 },
140+
{ main: null, sub: 'b', value: 20 },
141+
{ main: 'A', sub: null, value: 15 },
142+
{ main: 'B', sub: 'a', value: 30 },
143+
{ main: null, sub: null, value: 5 },
144+
];
145+
let results = LABKEY.vis.getAggregateData(dataWithNulls, 'main', 'sub', 'value', 'COUNT', '(empty)');
146+
expect(results).toStrictEqual([
147+
{ label: 'A', subLabel: 'a', value: 1 },
148+
{ label: 'A', subLabel: '(empty)', value: 1 },
149+
{ label: '(empty)', subLabel: 'b', value: 1 },
150+
{ label: '(empty)', subLabel: '(empty)', value: 1 },
151+
{ label: 'B', subLabel: 'a', value: 1 },
152+
]);
153+
});
154+
155+
test('no values', () => {
156+
const dataWithNulls = [
157+
{ main: 'A', sub: 'a', value: undefined },
158+
];
159+
expect(LABKEY.vis.getAggregateData(dataWithNulls, 'main', 'sub', 'value', 'COUNT')).toStrictEqual([{ label: 'A', subLabel: 'a', value: 0 }]);
160+
expect(LABKEY.vis.getAggregateData(dataWithNulls, 'main', 'sub', 'value', 'SUM')).toStrictEqual([{ aggType: 'SUM', label: 'A', subLabel: 'a', value: null }]);
161+
expect(LABKEY.vis.getAggregateData(dataWithNulls, 'main', 'sub', 'value', 'MIN')).toStrictEqual([{ aggType: 'MIN', label: 'A', subLabel: 'a', value: null }]);
162+
expect(LABKEY.vis.getAggregateData(dataWithNulls, 'main', 'sub', 'value', 'MAX')).toStrictEqual([{ aggType: 'MAX', label: 'A', subLabel: 'a', value: null }]);
163+
expect(LABKEY.vis.getAggregateData(dataWithNulls, 'main', 'sub', 'value', 'MEAN')).toStrictEqual([{ aggType: 'MEAN', label: 'A', subLabel: 'a', value: null }]);
164+
expect(LABKEY.vis.getAggregateData(dataWithNulls, 'main', 'sub', 'value', 'MEDIAN')).toStrictEqual([{ aggType: 'MEDIAN', label: 'A', subLabel: 'a', value: null }]);
165+
});
166+
});
167+
168+
describe('LABKEY.vis.formatDate', () => {
169+
// see supported date and time formats https://www.labkey.org/Documentation/wiki-page.view?name=dateformats#date
170+
const dateFormats = ["yyyy-MM-dd", "yyyy-MMM-dd", "yyyy-MM", "dd-MM-yyyy", "dd-MMM-yyyy", "dd-MMM-yy", "ddMMMyyyy", "ddMMMyy", "MM/dd/yyyy", "MM-dd-yyyy", "MMMM dd yyyy"];
171+
const timeFormats = ["", "HH:mm:ss", "HH:mm", "HH:mm:ss.SSS", "hh:mm a"];
172+
173+
test('dateFormat only', () => {
174+
const testDate = new Date(Date.UTC(2024, 0, 15, 13, 45, 30, 123)); // Jan 15, 2024
175+
const expectedResults = [
176+
"2024-01-15",
177+
"2024-Jan-15",
178+
"2024-01",
179+
"15-01-2024",
180+
"15-Jan-2024",
181+
"15-Jan-24",
182+
"15Jan2024",
183+
"15Jan24",
184+
"01/15/2024",
185+
"01-15-2024",
186+
"January 15 2024"
187+
];
188+
189+
dateFormats.forEach((format, index) => {
190+
const formattedDate = LABKEY.vis.formatDate(testDate, format);
191+
expect(formattedDate).toBe(expectedResults[index]);
192+
});
193+
});
194+
195+
test('dateFormat and timeFormat', () => {
196+
const testDate = new Date("2024-01-15 13:45:30.123");
197+
const expectedResults = [
198+
"2024-01-15",
199+
"2024-01-15 13:45:30",
200+
"2024-01-15 13:45",
201+
"2024-01-15 13:45:30.123",
202+
"2024-01-15 01:45 PM",
203+
"2024-Jan-15",
204+
"2024-Jan-15 13:45:30",
205+
"2024-Jan-15 13:45",
206+
"2024-Jan-15 13:45:30.123",
207+
"2024-Jan-15 01:45 PM",
208+
"2024-01",
209+
"2024-01 13:45:30",
210+
"2024-01 13:45",
211+
"2024-01 13:45:30.123",
212+
"2024-01 01:45 PM",
213+
"15-01-2024",
214+
"15-01-2024 13:45:30",
215+
"15-01-2024 13:45",
216+
"15-01-2024 13:45:30.123",
217+
"15-01-2024 01:45 PM",
218+
"15-Jan-2024",
219+
"15-Jan-2024 13:45:30",
220+
"15-Jan-2024 13:45",
221+
"15-Jan-2024 13:45:30.123",
222+
"15-Jan-2024 01:45 PM",
223+
"15-Jan-24",
224+
"15-Jan-24 13:45:30",
225+
"15-Jan-24 13:45",
226+
"15-Jan-24 13:45:30.123",
227+
"15-Jan-24 01:45 PM",
228+
"15Jan2024",
229+
"15Jan2024 13:45:30",
230+
"15Jan2024 13:45",
231+
"15Jan2024 13:45:30.123",
232+
"15Jan2024 01:45 PM",
233+
"15Jan24",
234+
"15Jan24 13:45:30",
235+
"15Jan24 13:45",
236+
"15Jan24 13:45:30.123",
237+
"15Jan24 01:45 PM",
238+
"01/15/2024",
239+
"01/15/2024 13:45:30",
240+
"01/15/2024 13:45",
241+
"01/15/2024 13:45:30.123",
242+
"01/15/2024 01:45 PM",
243+
"01-15-2024",
244+
"01-15-2024 13:45:30",
245+
"01-15-2024 13:45",
246+
"01-15-2024 13:45:30.123",
247+
"01-15-2024 01:45 PM",
248+
"January 15 2024",
249+
"January 15 2024 13:45:30",
250+
"January 15 2024 13:45",
251+
"January 15 2024 13:45:30.123",
252+
"January 15 2024 01:45 PM"
253+
];
254+
255+
dateFormats.forEach((dateFormat, di) => {
256+
timeFormats.forEach((timeFormat, ti) => {
257+
const formattedDate = LABKEY.vis.formatDate(testDate, dateFormat + (timeFormat !== '' ? ' ' + timeFormat : ''));
258+
expect(formattedDate).toBe(expectedResults[di * timeFormats.length + ti]);
259+
});
260+
});
261+
});
262+
});
263+
264+
describe('LABKEY.vis.isValidDate', () => {
265+
test('valid dates', () => {
266+
expect(LABKEY.vis.isValidDate(new Date())).toBe(true);
267+
expect(LABKEY.vis.isValidDate(new Date('2024-01-15'))).toBe(true);
268+
expect(LABKEY.vis.isValidDate(new Date('January 15, 2024'))).toBe(true);
269+
expect(LABKEY.vis.isValidDate(new Date('2024-01-15T13:45:30Z'))).toBe(true);
270+
});
271+
272+
test('invalid dates', () => {
273+
expect(LABKEY.vis.isValidDate(new Date('invalid date string'))).toBe(false);
274+
expect(LABKEY.vis.isValidDate(NaN)).toBe(false);
275+
expect(LABKEY.vis.isValidDate(undefined)).toBe(false);
276+
expect(LABKEY.vis.isValidDate(null)).toBe(false);
277+
expect(LABKEY.vis.isValidDate('2024-01-15')).toBe(false); // string,
278+
});
279+
});

core/webapp/vis/demo/index.html

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,11 @@
105105
<div class="example" id="stats">
106106
</div>
107107

108+
<script type="text/javascript">
109+
if (!LABKEY) {
110+
var LABKEY = {};
111+
}
112+
</script>
108113
<script type="text/javascript" src="../src/utils.js"></script>
109114
<script type="text/javascript" src="../src/geom.js"></script>
110115
<script type="text/javascript" src="../src/statistics.js"></script>

core/webapp/vis/src/geom.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -328,6 +328,7 @@ LABKEY.vis.Geom.ControlRange.prototype.render = function(renderer, grid, scales,
328328
* @param {Number} [config.size] (Optional) Number used to determine the size of all paths. Defaults to 2.
329329
* @param {Boolean} [config.dashed] (Optional) Whether or not to use dashed lines for top and bottom bars. Defaults to false.
330330
* @param {Boolean} [config.topOnly] (Optional) Whether or not to only render the top line of the error bar. Defaults to false. Allows optimizing bars that will have an error of zero to not double-render.
331+
* @param {Boolean} [config.errorShowVertical] (Optional) Whether or not to render the vertical line of the error bar. Defaults to false.
331332
*/
332333
LABKEY.vis.Geom.ErrorBar = function(config){
333334
this.type = "ErrorBar";
@@ -339,7 +340,8 @@ LABKEY.vis.Geom.ErrorBar = function(config){
339340
this.size = ('size' in config && config.size != null && config.size != undefined) ? config.size : 2;
340341
this.dashed = ('dashed' in config && config.dashed != null && config.dashed != undefined) ? config.dashed : false;
341342
this.width = ('width' in config && config.width != null && config.width != undefined) ? config.width : 6;
342-
this.topOnly = ('topOnly' in config && config.topOnly != null && config.topOnly != undefined) ? config.topOnly : false;
343+
this.topOnly = config.topOnly ?? false;
344+
this.errorShowVertical = config.showVertical ?? false;
343345

344346
return this;
345347
};

0 commit comments

Comments
 (0)