diff --git a/core/src/client/vis/statistics.test.ts b/core/src/client/vis/statistics.test.ts
new file mode 100644
index 00000000000..e618c9d658c
--- /dev/null
+++ b/core/src/client/vis/statistics.test.ts
@@ -0,0 +1,32 @@
+LABKEY.vis = {};
+require('../../../webapp/vis/src/statistics.js');
+
+describe('LABKEY.vis.Stats', () => {
+ test('getMean', () => {
+ expect(LABKEY.vis.Stat.getMean([1, 2, 3, 4, 5])).toBe(3);
+ expect(LABKEY.vis.Stat.getMean([-1, 0, 1])).toBe(0);
+ expect(LABKEY.vis.Stat.getMean([1.123])).toBe(1.123);
+ });
+
+ test('getStdDev', () => {
+ expect(LABKEY.vis.Stat.getStdDev([1, 2, 3, 4, 5])).toBeCloseTo(1.4142, 4);
+ expect(LABKEY.vis.Stat.getStdDev([1, 2, 3, 4, 5], true)).toBeCloseTo(1.5811, 4);
+ expect(LABKEY.vis.Stat.getStdDev([-1, 0, 1])).toBeCloseTo(0.8165, 4);
+ expect(LABKEY.vis.Stat.getStdDev([-1, 0, 1], true)).toBeCloseTo(1, 4);
+ expect(LABKEY.vis.Stat.getStdDev([1.123])).toBe(0);
+
+ expect(LABKEY.vis.Stat.getStdDev([])).toBe(undefined);
+ expect(LABKEY.vis.Stat.getStdDev([1.123], true)).toBe(undefined);
+ });
+
+ test('getStdErr', () => {
+ expect(LABKEY.vis.Stat.getStdErr([1, 2, 3, 4, 5])).toBeCloseTo(0.6325, 4);
+ expect(LABKEY.vis.Stat.getStdErr([1, 2, 3, 4, 5], true)).toBeCloseTo(0.7071, 4);
+ expect(LABKEY.vis.Stat.getStdErr([-1, 0, 1])).toBeCloseTo(0.4714, 4);
+ expect(LABKEY.vis.Stat.getStdErr([-1, 0, 1], true)).toBeCloseTo(0.5774, 4);
+ expect(LABKEY.vis.Stat.getStdErr([1.123])).toBe(0);
+
+ expect(LABKEY.vis.Stat.getStdErr([])).toBe(undefined);
+ expect(LABKEY.vis.Stat.getStdErr([1.123], true)).toBe(undefined);
+ });
+});
\ No newline at end of file
diff --git a/core/src/client/vis/utils.test.ts b/core/src/client/vis/utils.test.ts
new file mode 100644
index 00000000000..6047d0c645d
--- /dev/null
+++ b/core/src/client/vis/utils.test.ts
@@ -0,0 +1,279 @@
+LABKEY.vis = {};
+require('../../../webapp/vis/src/statistics.js');
+require('../../../webapp/vis/src/utils.js');
+
+describe('LABKEY.vis.getValue', () => {
+ test('value not object', () => {
+ expect(LABKEY.vis.getValue()).toBeUndefined();
+ expect(LABKEY.vis.getValue(undefined)).toBeUndefined();
+ expect(LABKEY.vis.getValue(null)).toBeNull();
+ expect(LABKEY.vis.getValue(5)).toBe(5);
+ expect(LABKEY.vis.getValue('test')).toBe('test');
+ });
+
+ test('value is object', () => {
+ expect(LABKEY.vis.getValue({})).toBeUndefined();
+ expect(LABKEY.vis.getValue({ value: undefined })).toBeUndefined();
+ expect(LABKEY.vis.getValue({ value: null })).toBeNull();
+ expect(LABKEY.vis.getValue({ value: 5 })).toBe(5);
+ expect(LABKEY.vis.getValue({ value: 'test' })).toBe('test');
+ expect(LABKEY.vis.getValue({ value: 'test', other: 1 })).toBe('test');
+ });
+
+ test('formattedValue, displayValue, preferredProp', () => {
+ expect(LABKEY.vis.getValue({ formattedValue: 'formatted', displayValue: 'display', value: 'value' })).toBe('formatted');
+ expect(LABKEY.vis.getValue({ formattedValue: null, displayValue: 'display', value: 'value' })).toBe(null);
+ expect(LABKEY.vis.getValue({ formattedValue: undefined, displayValue: 'display', value: 'value' })).toBe(undefined);
+ expect(LABKEY.vis.getValue({ displayValue: 'display', value: 'value' })).toBe('display');
+ expect(LABKEY.vis.getValue({ displayValue: null, value: 'value' })).toBe(null);
+ expect(LABKEY.vis.getValue({ displayValue: undefined, value: 'value' })).toBe(undefined);
+ expect(LABKEY.vis.getValue({ value: 'value' })).toBe('value');
+ expect(LABKEY.vis.getValue({ value: null })).toBeNull();
+ expect(LABKEY.vis.getValue({ value: undefined })).toBeUndefined();
+
+ expect(LABKEY.vis.getValue({ formattedValue: 'formatted', displayValue: 'display', value: 'value' }, 'bogus')).toBe('formatted');
+ expect(LABKEY.vis.getValue({ formattedValue: 'formatted', displayValue: 'display', value: 'value' }, 'formattedValue')).toBe('formatted');
+ expect(LABKEY.vis.getValue({ formattedValue: 'formatted', displayValue: 'display', value: 'value' }, 'displayValue')).toBe('display');
+ expect(LABKEY.vis.getValue({ formattedValue: 'formatted', displayValue: 'display', value: 'value' }, 'value')).toBe('value');
+ });
+});
+
+describe('LABKEY.vis.getAggregateData', () => {
+ const data = [
+ { main: 'A', sub: 'a', value: 10 },
+ { main: 'A', sub: 'b', value: 20 },
+ { main: 'A', sub: 'b', value: 15 },
+ { main: 'B', sub: 'a', value: 30 },
+ ];
+
+ test('without subgroup', () => {
+ let results = LABKEY.vis.getAggregateData(data, 'main', undefined, 'value');
+ expect(results).toStrictEqual([
+ { label: 'A', value: 3 },
+ { label: 'B', value: 1 },
+ ]);
+ results = LABKEY.vis.getAggregateData(data, 'main', undefined, 'value', 'COUNT');
+ expect(results).toStrictEqual([
+ { label: 'A', value: 3 },
+ { label: 'B', value: 1 },
+ ]);
+ results = LABKEY.vis.getAggregateData(data, 'main', undefined, 'value', 'MEAN');
+ expect(results).toStrictEqual([
+ { aggType: 'MEAN', label: 'A', value: 15 },
+ { aggType: 'MEAN', label: 'B', value: 30 },
+ ]);
+ });
+
+ test('with subgroup', () => {
+ let results = LABKEY.vis.getAggregateData(data, 'main', 'sub', 'value');
+ expect(results).toStrictEqual([
+ { label: 'A', subLabel: 'a', value: 1 },
+ { label: 'A', subLabel: 'b', value: 2 },
+ { label: 'B', subLabel: 'a', value: 1 },
+ ]);
+ results = LABKEY.vis.getAggregateData(data, 'main', 'sub', 'value', 'MEAN');
+ expect(results).toStrictEqual([
+ { aggType: 'MEAN', label: 'A', subLabel: 'a', value: 10 },
+ { aggType: 'MEAN', label: 'A', subLabel: 'b', value: 17.5 },
+ { aggType: 'MEAN', label: 'B', subLabel: 'a', value: 30 },
+ ]);
+ });
+
+ test('errorBarType', () => {
+ const data2 = [
+ { main: 'A', sub: 'a', value: 10 },
+ { main: 'A', sub: 'b', value: 1 },
+ { main: 'A', sub: 'b', value: 2 },
+ { main: 'A', sub: 'b', value: 3 },
+ { main: 'B', sub: 'a', value: 30 },
+ ];
+ let results = LABKEY.vis.getAggregateData(data2, 'main', 'sub', 'value', 'COUNT', undefined, false, 'SD');
+ expect(results).toStrictEqual([
+ { label: 'A', subLabel: 'a', value: 1 },
+ { label: 'A', subLabel: 'b', value: 3 },
+ { label: 'B', subLabel: 'a', value: 1 },
+ ]);
+ results = LABKEY.vis.getAggregateData(data2, 'main', 'sub', 'value', 'MEAN', undefined, false, 'SD');
+ expect(results).toStrictEqual([
+ { aggType: 'MEAN', label: 'A', subLabel: 'a', value: 10, errorType: 'SD', error: undefined },
+ { aggType: 'MEAN', label: 'A', subLabel: 'b', value: 2, errorType: 'SD', error: 1 },
+ { aggType: 'MEAN', label: 'B', subLabel: 'a', value: 30, errorType: 'SD', error: undefined },
+ ]);
+ results = LABKEY.vis.getAggregateData(data2, 'main', 'sub', 'value', 'MEAN', undefined, false, 'SEM');
+ expect(results[1].errorType).toBe('SEM');
+ expect(results[1].error).toBeCloseTo(0.5774, 4);
+ });
+
+ test('includeTotal', () => {
+ let results = LABKEY.vis.getAggregateData(data, 'main', 'sub', 'value', 'COUNT', undefined, true);
+ expect(results).toStrictEqual([
+ { label: 'A', subLabel: 'a', value: 1, total: 1 },
+ { label: 'A', subLabel: 'b', value: 2, total: 3 },
+ { label: 'B', subLabel: 'a', value: 1, total: 4 },
+ ]);
+ results = LABKEY.vis.getAggregateData(data, 'main', 'sub', 'value', 'MEAN', undefined, true);
+ expect(results).toStrictEqual([
+ { aggType: 'MEAN', label: 'A', subLabel: 'a', value: 10, total: 1 },
+ { aggType: 'MEAN', label: 'A', subLabel: 'b', value: 17.5, total: 3 },
+ { aggType: 'MEAN', label: 'B', subLabel: 'a', value: 30, total: 4 },
+ ]);
+ });
+
+ test('keepNames', () => {
+ let results = LABKEY.vis.getAggregateData(data, 'main', 'sub', 'value', 'COUNT', undefined, false, undefined, true);
+ expect(results).toStrictEqual([
+ { label: 'A', main: { value: 'A' }, subLabel: 'a', sub: { value: 'a' }, value: { aggType: 'COUNT', value: 1 } },
+ { label: 'A', main: { value: 'A' }, subLabel: 'b', sub: { value: 'b' }, value: { aggType: 'COUNT', value: 2 } },
+ { label: 'B', main: { value: 'B' }, subLabel: 'a', sub: { value: 'a' }, value: { aggType: 'COUNT', value: 1 } },
+ ]);
+ results = LABKEY.vis.getAggregateData(data, 'main', 'sub', 'value', 'MEAN', undefined, false, undefined, true);
+ expect(results).toStrictEqual([
+ { label: 'A', main: { value: 'A' }, subLabel: 'a', sub: { value: 'a' }, value: { aggType: 'MEAN', value: 10 }, aggType: 'MEAN' },
+ { label: 'A', main: { value: 'A' }, subLabel: 'b', sub: { value: 'b' }, value: { aggType: 'MEAN', value: 17.5 }, aggType: 'MEAN' },
+ { label: 'B', main: { value: 'B' }, subLabel: 'a', sub: { value: 'a' }, value: { aggType: 'MEAN', value: 30 }, aggType: 'MEAN' },
+ ]);
+ });
+
+ test('nullDisplayValue', () => {
+ const dataWithNulls = [
+ { main: 'A', sub: 'a', value: 10 },
+ { main: null, sub: 'b', value: 20 },
+ { main: 'A', sub: null, value: 15 },
+ { main: 'B', sub: 'a', value: 30 },
+ { main: null, sub: null, value: 5 },
+ ];
+ let results = LABKEY.vis.getAggregateData(dataWithNulls, 'main', 'sub', 'value', 'COUNT', '(empty)');
+ expect(results).toStrictEqual([
+ { label: 'A', subLabel: 'a', value: 1 },
+ { label: 'A', subLabel: '(empty)', value: 1 },
+ { label: '(empty)', subLabel: 'b', value: 1 },
+ { label: '(empty)', subLabel: '(empty)', value: 1 },
+ { label: 'B', subLabel: 'a', value: 1 },
+ ]);
+ });
+
+ test('no values', () => {
+ const dataWithNulls = [
+ { main: 'A', sub: 'a', value: undefined },
+ ];
+ expect(LABKEY.vis.getAggregateData(dataWithNulls, 'main', 'sub', 'value', 'COUNT')).toStrictEqual([{ label: 'A', subLabel: 'a', value: 0 }]);
+ expect(LABKEY.vis.getAggregateData(dataWithNulls, 'main', 'sub', 'value', 'SUM')).toStrictEqual([{ aggType: 'SUM', label: 'A', subLabel: 'a', value: null }]);
+ expect(LABKEY.vis.getAggregateData(dataWithNulls, 'main', 'sub', 'value', 'MIN')).toStrictEqual([{ aggType: 'MIN', label: 'A', subLabel: 'a', value: null }]);
+ expect(LABKEY.vis.getAggregateData(dataWithNulls, 'main', 'sub', 'value', 'MAX')).toStrictEqual([{ aggType: 'MAX', label: 'A', subLabel: 'a', value: null }]);
+ expect(LABKEY.vis.getAggregateData(dataWithNulls, 'main', 'sub', 'value', 'MEAN')).toStrictEqual([{ aggType: 'MEAN', label: 'A', subLabel: 'a', value: null }]);
+ expect(LABKEY.vis.getAggregateData(dataWithNulls, 'main', 'sub', 'value', 'MEDIAN')).toStrictEqual([{ aggType: 'MEDIAN', label: 'A', subLabel: 'a', value: null }]);
+ });
+});
+
+describe('LABKEY.vis.formatDate', () => {
+ // see supported date and time formats https://www.labkey.org/Documentation/wiki-page.view?name=dateformats#date
+ 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"];
+ const timeFormats = ["", "HH:mm:ss", "HH:mm", "HH:mm:ss.SSS", "hh:mm a"];
+
+ test('dateFormat only', () => {
+ const testDate = new Date(Date.UTC(2024, 0, 15, 13, 45, 30, 123)); // Jan 15, 2024
+ const expectedResults = [
+ "2024-01-15",
+ "2024-Jan-15",
+ "2024-01",
+ "15-01-2024",
+ "15-Jan-2024",
+ "15-Jan-24",
+ "15Jan2024",
+ "15Jan24",
+ "01/15/2024",
+ "01-15-2024",
+ "January 15 2024"
+ ];
+
+ dateFormats.forEach((format, index) => {
+ const formattedDate = LABKEY.vis.formatDate(testDate, format);
+ expect(formattedDate).toBe(expectedResults[index]);
+ });
+ });
+
+ test('dateFormat and timeFormat', () => {
+ const testDate = new Date("2024-01-15 13:45:30.123");
+ const expectedResults = [
+ "2024-01-15",
+ "2024-01-15 13:45:30",
+ "2024-01-15 13:45",
+ "2024-01-15 13:45:30.123",
+ "2024-01-15 01:45 PM",
+ "2024-Jan-15",
+ "2024-Jan-15 13:45:30",
+ "2024-Jan-15 13:45",
+ "2024-Jan-15 13:45:30.123",
+ "2024-Jan-15 01:45 PM",
+ "2024-01",
+ "2024-01 13:45:30",
+ "2024-01 13:45",
+ "2024-01 13:45:30.123",
+ "2024-01 01:45 PM",
+ "15-01-2024",
+ "15-01-2024 13:45:30",
+ "15-01-2024 13:45",
+ "15-01-2024 13:45:30.123",
+ "15-01-2024 01:45 PM",
+ "15-Jan-2024",
+ "15-Jan-2024 13:45:30",
+ "15-Jan-2024 13:45",
+ "15-Jan-2024 13:45:30.123",
+ "15-Jan-2024 01:45 PM",
+ "15-Jan-24",
+ "15-Jan-24 13:45:30",
+ "15-Jan-24 13:45",
+ "15-Jan-24 13:45:30.123",
+ "15-Jan-24 01:45 PM",
+ "15Jan2024",
+ "15Jan2024 13:45:30",
+ "15Jan2024 13:45",
+ "15Jan2024 13:45:30.123",
+ "15Jan2024 01:45 PM",
+ "15Jan24",
+ "15Jan24 13:45:30",
+ "15Jan24 13:45",
+ "15Jan24 13:45:30.123",
+ "15Jan24 01:45 PM",
+ "01/15/2024",
+ "01/15/2024 13:45:30",
+ "01/15/2024 13:45",
+ "01/15/2024 13:45:30.123",
+ "01/15/2024 01:45 PM",
+ "01-15-2024",
+ "01-15-2024 13:45:30",
+ "01-15-2024 13:45",
+ "01-15-2024 13:45:30.123",
+ "01-15-2024 01:45 PM",
+ "January 15 2024",
+ "January 15 2024 13:45:30",
+ "January 15 2024 13:45",
+ "January 15 2024 13:45:30.123",
+ "January 15 2024 01:45 PM"
+ ];
+
+ dateFormats.forEach((dateFormat, di) => {
+ timeFormats.forEach((timeFormat, ti) => {
+ const formattedDate = LABKEY.vis.formatDate(testDate, dateFormat + (timeFormat !== '' ? ' ' + timeFormat : ''));
+ expect(formattedDate).toBe(expectedResults[di * timeFormats.length + ti]);
+ });
+ });
+ });
+});
+
+describe('LABKEY.vis.isValidDate', () => {
+ test('valid dates', () => {
+ expect(LABKEY.vis.isValidDate(new Date())).toBe(true);
+ expect(LABKEY.vis.isValidDate(new Date('2024-01-15'))).toBe(true);
+ expect(LABKEY.vis.isValidDate(new Date('January 15, 2024'))).toBe(true);
+ expect(LABKEY.vis.isValidDate(new Date('2024-01-15T13:45:30Z'))).toBe(true);
+ });
+
+ test('invalid dates', () => {
+ expect(LABKEY.vis.isValidDate(new Date('invalid date string'))).toBe(false);
+ expect(LABKEY.vis.isValidDate(NaN)).toBe(false);
+ expect(LABKEY.vis.isValidDate(undefined)).toBe(false);
+ expect(LABKEY.vis.isValidDate(null)).toBe(false);
+ expect(LABKEY.vis.isValidDate('2024-01-15')).toBe(false); // string,
+ });
+});
diff --git a/core/webapp/vis/demo/index.html b/core/webapp/vis/demo/index.html
index 918d79116e4..44cef32d028 100644
--- a/core/webapp/vis/demo/index.html
+++ b/core/webapp/vis/demo/index.html
@@ -105,6 +105,11 @@
+
diff --git a/core/webapp/vis/src/geom.js b/core/webapp/vis/src/geom.js
index 29f49c6c7b1..39ab71d86e8 100644
--- a/core/webapp/vis/src/geom.js
+++ b/core/webapp/vis/src/geom.js
@@ -328,6 +328,7 @@ LABKEY.vis.Geom.ControlRange.prototype.render = function(renderer, grid, scales,
* @param {Number} [config.size] (Optional) Number used to determine the size of all paths. Defaults to 2.
* @param {Boolean} [config.dashed] (Optional) Whether or not to use dashed lines for top and bottom bars. Defaults to false.
* @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.
+ * @param {Boolean} [config.errorShowVertical] (Optional) Whether or not to render the vertical line of the error bar. Defaults to false.
*/
LABKEY.vis.Geom.ErrorBar = function(config){
this.type = "ErrorBar";
@@ -339,7 +340,8 @@ LABKEY.vis.Geom.ErrorBar = function(config){
this.size = ('size' in config && config.size != null && config.size != undefined) ? config.size : 2;
this.dashed = ('dashed' in config && config.dashed != null && config.dashed != undefined) ? config.dashed : false;
this.width = ('width' in config && config.width != null && config.width != undefined) ? config.width : 6;
- this.topOnly = ('topOnly' in config && config.topOnly != null && config.topOnly != undefined) ? config.topOnly : false;
+ this.topOnly = config.topOnly ?? false;
+ this.errorShowVertical = config.showVertical ?? false;
return this;
};
diff --git a/core/webapp/vis/src/internal/D3Renderer.js b/core/webapp/vis/src/internal/D3Renderer.js
index 2a408e2200e..b3df4053cd3 100644
--- a/core/webapp/vis/src/internal/D3Renderer.js
+++ b/core/webapp/vis/src/internal/D3Renderer.js
@@ -15,7 +15,7 @@ LABKEY.vis.internal.Axis = function() {
tickRectCls, tickRectHeightOffset = 12, tickRectWidthOffset = 8, tickClick, axisSel, tickSel, textSel, gridLineSel,
borderSel, grid, scalesList = [], gridLinesVisible = 'both', tickDigits, tickValues, tickMax, tickLabelMax,
tickColor = '#000000', tickTextColor = '#000000', gridLineColor = '#DDDDDD', borderColor = '#000000',
- tickPadding = 0, tickLength = 8, tickWidth = 1, tickOverlapRotation = 25, gridLineWidth = 1, borderWidth = 1,
+ tickPadding = 0, tickLength = 8, tickWidth = 1, tickOverlapRotation, gridLineWidth = 1, borderWidth = 1,
fontFamily = 'Roboto, arial, helvetica, sans-serif', fontSize = 11, adjustedStarts, adjustedEnds, xLogGutterBorder = 0, yLogGutterBorder = 0,
yGutterXOffset = 0, xGutterYOffset = 0, addLogGutterLabel = false, xGridExtension = 0, yGridExtension = 0, logGutterSel;
@@ -453,37 +453,73 @@ LABKEY.vis.internal.Axis = function() {
}
}
}
- if (tickHover || tickClick || tickMouseOver || tickMouseOut) {
+
+ const hasTickAction = tickHover || tickClick || tickMouseOver || tickMouseOut;
+ if (hasTickAction) {
addTickAreaRects(textAnchors, !hasOverlap);
addHighlightRects(textAnchors);
}
- if (orientation == 'bottom') {
- if (hasOverlap) {
- textEls.attr('transform', function(v) {return 'rotate(' + tickOverlapRotation + ',' + textXFn(v) + ',' + textYFn(v) + ')';})
- .attr('text-anchor', 'start');
+ if (orientation === 'bottom' && hasOverlap) {
+ // if we have a large number of ticks, rotate the text by the specified amount, else wrap text
+ if (hasTickAction || tickOverlapRotation !== undefined || textEls[0].length > 10) {
+ if (!tickOverlapRotation) tickOverlapRotation = 35;
+ const rotate = (v) => `rotate(${tickOverlapRotation}, ${textXFn(v)}, ${textYFn(v)})`;
- if (tickHover || tickClick || tickMouseOver || tickMouseOut)
- {
+ textEls.attr('transform', rotate).attr('text-anchor', 'start');
+
+ if (hasTickAction) {
addTickAreaRects(textAnchors);
textAnchors.selectAll("rect." + (tickRectCls ? tickRectCls : "tick-rect"))
- .attr('transform', function (v)
- {
- return 'rotate(' + tickOverlapRotation + ',' + textXFn(v) + ',' + textYFn(v) + ')';
- });
+ .attr('transform', rotate);
addHighlightRects(textAnchors);
- textAnchors.selectAll('rect.highlight')
- .attr('transform', function (v)
- {
- return 'rotate(' + tickOverlapRotation + ',' + textXFn(v) + ',' + textYFn(v) + ')';
- });
+ textAnchors.selectAll('rect.highlight').attr('transform', rotate);
}
-
} else {
- textEls.attr('transform', '');
+ // wrap text to multiple lines if we have overlapping tick labels and are not rotating
+ function wrapAxisTickLabel(text) {
+ let width;
+ text.each(function(v) {
+ if (!width) width = scale(v) - grid.leftEdge;
+
+ const textEl = d3.select(this);
+ const words = textEl.text().split(/[\s]+/).reverse();
+ const lineHeight = 1.1; // ems
+ const x = this.getAttribute("x");
+ const y = this.getAttribute("y");
+ let word;
+ let line = [];
+ let lineNumber = 0;
+ let tspan = textEl.text(null)
+ .append("tspan")
+ .attr("x", x)
+ .attr("y", y)
+ .attr("dy", "0em");
+
+ while (word = words.pop()) {
+ line.push(word);
+ tspan.text(line.join(" "));
+ if (tspan.node().getComputedTextLength() > width) {
+ line.pop();
+ tspan.text(line.join(" "));
+ line = [word];
+ tspan = textEl.append("tspan")
+ .attr("x", x)
+ .attr("y", y)
+ .attr("dy", ++lineNumber * lineHeight + "em")
+ .text(word);
+ }
+ }
+ });
+ }
+
+ textEls.attr('transform', '').call(wrapAxisTickLabel);
textAnchors.selectAll('rect.highlight').attr('transform', '');
}
+ } else if (orientation === 'bottom') {
+ textEls.attr('transform', '');
+ textAnchors.selectAll('rect.highlight').attr('transform', '');
}
if (!borderSel) {
@@ -2080,6 +2116,12 @@ LABKEY.vis.internal.D3Renderer = function(plot) {
anchorSel.exit().remove();
anchorSel.enter().append('a').attr('class', 'point').append('path');
+ if (geom.errorAes !== undefined) {
+ geom.errorShowVertical = true;
+ geom.errorWidth = Math.max(2, Math.min(10, geom.size)); // match size of points, if provided, default to 2 with max of 10
+ renderErrorBar(layer, plot, geom, data);
+ }
+
// two different ways to add the hover title (so that it works in IE as well)
anchorSel.attr('xlink:title', hoverTextAcc);
anchorSel.append('title').text(hoverTextAcc);
@@ -2165,22 +2207,22 @@ LABKEY.vis.internal.D3Renderer = function(plot) {
}
};
- var renderErrorBar = function(layer, plot, geom, data) {
- var colorAcc, sizeAcc, topFn, bottomFn, verticalFn, selection, newBars;
+ var renderErrorBar = function(layer, plot, geom, data, xAcc) {
+ const errorLineWidth = geom.errorWidth != null ? geom.errorWidth : geom.width;
+ const xAcc_ = xAcc || function(row) {return geom.getX(row);};
- colorAcc = geom.colorAes && geom.colorScale ? function(row) {return geom.colorScale.scale(geom.colorAes.getValue(row) + geom.layerName);} : geom.color;
- sizeAcc = geom.sizeAes && geom.sizeScale ? function(row) {return geom.sizeScale.scale(geom.sizeAes.getValue(row));} : geom.size;
- topFn = function(d) {
+ const colorAcc = geom.colorAes && geom.colorScale ? function(row) {return geom.colorScale.scale(geom.colorAes.getValue(row) + geom.layerName);} : geom.color;
+ const topFn = function(d) {
var x, y, value, error;
- x = geom.getX(d);
+ x = xAcc_(d);
value = geom.yAes.getValue(d);
error = geom.errorAes.getValue(d);
y = geom.yScale.scale(value + error);
- return value == null || isNaN(x) || isNaN(y) ? null : LABKEY.vis.makeLine(x - geom.width, y, x + geom.width, y);
+ return value == null || isNaN(x) || isNaN(y) ? null : LABKEY.vis.makeLine(x - errorLineWidth, y, x + errorLineWidth, y);
};
- bottomFn = function(d) {
+ const bottomFn = function(d) {
var x, y, value, error;
- x = geom.getX(d);
+ x = xAcc_(d);
value = geom.yAes.getValue(d);
error = geom.errorAes.getValue(d);
y = geom.yScale.scale(value - error);
@@ -2188,15 +2230,16 @@ LABKEY.vis.internal.D3Renderer = function(plot) {
if (y == null && geom.yScale.trans == "log") {
return null;
}
- return value == null || isNaN(x) || isNaN(y) ? null : LABKEY.vis.makeLine(x - geom.width, y, x + geom.width, y);
+ return value == null || isNaN(x) || isNaN(y) ? null : LABKEY.vis.makeLine(x - errorLineWidth, y, x + errorLineWidth, y);
};
- verticalFn = function(d) {
- var x, y1, y2, value, error;
- x = geom.getX(d);
+ const verticalFn = function(d) {
+ var x, y, y1, y2, value, error;
+ x = xAcc_(d);
value = geom.yAes.getValue(d);
error = geom.errorAes.getValue(d);
+ y = geom.yScale.scale(value);
y1 = geom.yScale.scale(value + error);
- y2 = geom.yScale.scale(value - error);
+ y2 = geom.topOnly ? y : geom.yScale.scale(value - error);
// if we have a log scale, y2 will be null for negative values so set to scale min
if (y2 == null && geom.yScale.trans == "log") {
y2 = geom.yScale.range[0];
@@ -2207,23 +2250,29 @@ LABKEY.vis.internal.D3Renderer = function(plot) {
data.filter(function(d) {
// Note: while we don't actually use the color here, we need to calculate it so they show up in the legend,
// even if the points are null.
- var x = geom.getX(d), y = geom.yAes.getValue(d), error = geom.errorAes.getValue(d);
+ var x = xAcc_(d), y = geom.yAes.getValue(d), error = geom.errorAes.getValue(d);
if (typeof colorAcc == 'function') { colorAcc(d); }
return (isNaN(x) || x == null || isNaN(y) || y == null || isNaN(error) || error == null);
});
- selection = layer.selectAll('.error-bar').data(data);
+ const selection = layer.selectAll('.error-bar').data(data);
selection.exit().remove();
- newBars = selection.enter().append('g').attr('class', 'error-bar');
+ const newBars = selection.enter().append('g').attr('class', 'error-bar');
newBars.append('path').attr('class','error-bar-top');
if (!geom.topOnly) {
newBars.append('path').attr('class', 'error-bar-bottom');
}
+ if (geom.errorShowVertical) {
+ newBars.append('path').attr('class','error-bar-vert');
+ }
- selection.selectAll('.error-bar-top').attr('d', topFn).attr('stroke', colorAcc).attr('stroke-width', sizeAcc);
+ selection.selectAll('.error-bar-top').attr('d', topFn).attr('stroke', colorAcc).attr('stroke-width', 1);
if (!geom.topOnly) {
- selection.selectAll('.error-bar-bottom').attr('d', bottomFn).attr('stroke', colorAcc).attr('stroke-width', sizeAcc);
+ selection.selectAll('.error-bar-bottom').attr('d', bottomFn).attr('stroke', colorAcc).attr('stroke-width', 1);
+ }
+ if (geom.errorShowVertical) {
+ selection.selectAll('.error-bar-vert').attr('d', verticalFn).attr('stroke', colorAcc).attr('stroke-width', 1);
}
if (geom.dashed) {
@@ -2231,6 +2280,9 @@ LABKEY.vis.internal.D3Renderer = function(plot) {
if (!geom.topOnly) {
selection.selectAll('.error-bar-bottom').style("stroke-dasharray", ("2, 1"));
}
+ if (geom.errorShowVertical) {
+ selection.selectAll('.error-bar-vert').style("stroke-dasharray", ("2, 1"));
+ }
}
};
@@ -3137,6 +3189,7 @@ LABKEY.vis.internal.D3Renderer = function(plot) {
hoverFn = geom.hoverFn ? geom.hoverFn : function(d) {
return (d.label !== undefined ? d.label + '\n' : '')
+ (d.subLabel !== undefined ? 'Subcategory: ' + d.subLabel + '\n' : '')
+ + (d.errorType !== undefined && d.error !== undefined ? d.errorType + ': ' + d.error + '\n' : '')
+ 'Value: ' + geom.yAes.getValue(d);
};
@@ -3169,6 +3222,15 @@ LABKEY.vis.internal.D3Renderer = function(plot) {
yZero = {};
yZero[geom.yAes.value] = 0;
+ if (geom.errorAes !== undefined) {
+ geom.topOnly = true;
+ geom.errorShowVertical = true;
+ geom.errorWidth = Math.max(2, Math.min(25, barWidth / 8)); // min 2 and max of 25 but default to 1/8 of bar width
+ renderErrorBar(layer, plot, geom, data, function(d) {
+ return xAcc(d) + (barWidth / 2);
+ });
+ }
+
// group each bar with an a tag for hover
barWrappers = layer.selectAll('a.bar-individual').data(data);
barWrappers.exit().remove();
diff --git a/core/webapp/vis/src/plot.js b/core/webapp/vis/src/plot.js
index 7ed90d8b03b..988a84de3e4 100644
--- a/core/webapp/vis/src/plot.js
+++ b/core/webapp/vis/src/plot.js
@@ -168,7 +168,7 @@
* @param {Integer} [config.tickLength] (Optional) The length, in pixels for the x and y axis tick marks. Defaults to 8.
* @param {Integer} [config.tickWidth] (Optional) The x and y axis tick line width. Defaults to 1.
* @param {Integer} [config.tickOverlapRotation] (Optional) The degree of rotation for overlapping x axis tick labels.
- * Defaults to 15 degrees.
+ * Defaults to 35 degrees.
* @param {Integer} [config.gridLineWidth] (Optional) The line width for the grid lines of the plot. Defaults to 1.
* @param {Integer} [config.borderWidth] (Optional) The border line width with the x and y axis. Defaults to 1.
*
diff --git a/core/webapp/vis/src/statistics.js b/core/webapp/vis/src/statistics.js
index 0e9d57985ca..e00fed4607f 100644
--- a/core/webapp/vis/src/statistics.js
+++ b/core/webapp/vis/src/statistics.js
@@ -199,20 +199,48 @@ LABKEY.vis.Stat.MEAN = LABKEY.vis.Stat.getMean;
/**
* Returns the standard deviation.
* @param values An array of numbers.
+ * @param sample If true, use (n-1) for the denominator of the final step. Defaults to false.
* @returns {Number}
*/
-LABKEY.vis.Stat.getStdDev = function(values)
+LABKEY.vis.Stat.getStdDev = function(values, sample = false)
{
if (values == null)
throw "invalid input";
+ if (values.length === 0)
+ return undefined;
+ if (sample && values.length === 1)
+ return undefined;
+
var mean = LABKEY.vis.Stat.getMean(values);
var squareDiffs = values.map(function(value){
var diff = value - mean;
return diff * diff;
});
- var avgSquareDiff = LABKEY.vis.Stat.getMean(squareDiffs);
- return Math.sqrt(avgSquareDiff);
+ if (sample) {
+ var sumSquareDiffs = squareDiffs.reduce(function(a,b){return a + b});
+ return Math.sqrt(sumSquareDiffs / (squareDiffs.length - 1));
+ }
+ return Math.sqrt(LABKEY.vis.Stat.getMean(squareDiffs));
};
+LABKEY.vis.Stat.SD = LABKEY.vis.Stat.getStdDev;
+
+/**
+ * Returns the SEM (standard error of the mean).
+ * @param values An array of numbers.
+ * @param sample If true, use (n-1) for the denominator of the final step. Defaults to false.
+ * @returns {Number}
+ */
+LABKEY.vis.Stat.getStdErr = function(values, sample = false) {
+ if (values == null)
+ throw "invalid input";
+ if (values.length === 0)
+ return undefined;
+ if (sample && values.length === 1)
+ return undefined;
+
+ return LABKEY.vis.Stat.getStdDev(values, sample) / Math.sqrt(values.length);
+}
+LABKEY.vis.Stat.SEM = LABKEY.vis.Stat.getStdErr;
// CUSUM_WEIGHT_FACTOR of 0.5 and CUSUM_CONTROL_LIMIT of 5 to achieve a 3*stdDev boundary
LABKEY.vis.Stat.CUSUM_WEIGHT_FACTOR = 0.5;
diff --git a/core/webapp/vis/src/utils.js b/core/webapp/vis/src/utils.js
index 7f2091e7895..9dfbf0ab2c1 100644
--- a/core/webapp/vis/src/utils.js
+++ b/core/webapp/vis/src/utils.js
@@ -6,10 +6,6 @@
// Contains helpers that aren't specific to plot, layer, geom, etc. and are used throughout the API.
-if(!LABKEY){
- var LABKEY = {};
-}
-
if(!LABKEY.vis){
/**
* @namespace The namespace for the internal LabKey visualization library. Contains classes within
@@ -185,7 +181,7 @@ LABKEY.vis.groupCountData = function(data, groupAccessor, subgroupAccessor, prop
if (groupedData[groupName].hasOwnProperty(subgroupName))
{
var row = {rawData: groupedData[groupName][subgroupName]},
- count = row['rawData'].length;
+ count = row.rawData.length;
total += count;
row[nameProp] = groupName;
@@ -199,7 +195,7 @@ LABKEY.vis.groupCountData = function(data, groupAccessor, subgroupAccessor, prop
else
{
var row = {rawData: groupedData[groupName]},
- count = row['rawData'].length;
+ count = row.rawData.length;
total += count;
row[nameProp] = groupName;
@@ -222,15 +218,18 @@ LABKEY.vis.groupCountData = function(data, groupAccessor, subgroupAccessor, prop
* @param {String} aggregate MIN/MAX/SUM/COUNT/etc. Defaults to COUNT.
* @param {String} nullDisplayValue The display value to use for null dimension values. Defaults to 'null'.
* @param {Boolean} includeTotal Whether or not to include the cumulative totals. Defaults to false.
+ * @param {String} errorBarType SD/STDERR. Defaults to null/undefined.
+ * @param {Boolean} keepNames True to use the dimension names in the results data. Defaults to false.
+ * @param {String} rowPropName (Optional) The property name to use when accessing the dimension value from the row object.
* @returns {Array} An array of results for each group/subgroup/aggregate
*/
-LABKEY.vis.getAggregateData = function(data, dimensionName, subDimensionName, measureName, aggregate, nullDisplayValue, includeTotal)
+LABKEY.vis.getAggregateData = function(data, dimensionName, subDimensionName, measureName, aggregate, nullDisplayValue, includeTotal, errorBarType, keepNames = false, rowPropName)
{
var results = [], subgroupAccessor,
- groupAccessor = typeof dimensionName === 'function' ? dimensionName : function(row){ return LABKEY.vis.getValue(row[dimensionName]);},
+ groupAccessor = typeof dimensionName === 'function' ? dimensionName : function(row){ return LABKEY.vis.getValue(row[dimensionName], rowPropName);},
hasSubgroup = subDimensionName != undefined && subDimensionName != null,
hasMeasure = measureName != undefined && measureName != null,
- measureAccessor = hasMeasure ? function(row){ return LABKEY.vis.getValue(row[measureName]); } : null;
+ measureAccessor = hasMeasure ? function(row){ return LABKEY.vis.getValue(row[measureName], 'value'); } : null;
if (hasSubgroup) {
if (typeof subDimensionName === 'function') {
@@ -244,41 +243,69 @@ LABKEY.vis.getAggregateData = function(data, dimensionName, subDimensionName, me
for (var i = 0; i < groupData.length; i++)
{
- var row = {label: groupData[i]['name']};
- if (row['label'] == null || row['label'] == 'null')
- row['label'] = nullDisplayValue || 'null';
+ var row = {label: groupData[i].name};
+ if (row.label == null || row.label == 'null')
+ row.label = nullDisplayValue || 'null';
if (hasSubgroup)
{
- row['subLabel'] = groupData[i]['subname'];
- if (row['subLabel'] == null || row['subLabel'] == 'null')
- row['subLabel'] = nullDisplayValue || 'null';
+ row.subLabel = groupData[i].subname;
+ if (row.subLabel == null || row.subLabel == 'null')
+ row.subLabel = nullDisplayValue || 'null';
}
if (includeTotal) {
- row['total'] = groupData[i]['total'];
+ row.total = groupData[i].total;
}
- var values = measureAccessor != undefined && measureAccessor != null
+ var values = measureAccessor !== undefined && measureAccessor !== null
? LABKEY.vis.Stat.sortNumericAscending(groupData[i].rawData, measureAccessor)
: null;
- if (aggregate == undefined || aggregate == null || aggregate == 'COUNT')
+ if (aggregate === undefined || aggregate === null || aggregate === 'COUNT')
{
- row['value'] = values != null ? values.length : groupData[i]['count'];
+ row.value = values != null ? values.length : groupData[i].count;
}
else if (typeof LABKEY.vis.Stat[aggregate] == 'function')
{
try {
- row.value = LABKEY.vis.Stat[aggregate](values);
+ if (values?.length > 0) {
+ row.value = LABKEY.vis.Stat[aggregate](values);
+ } else {
+ row.value = null;
+ }
+ row.aggType = aggregate;
} catch (e) {
row.value = null;
}
+
+ if (errorBarType === 'SD' || errorBarType === 'SEM') {
+ row.error = LABKEY.vis.Stat[errorBarType](values, true);
+ row.errorType = errorBarType;
+ }
}
else
{
throw 'Aggregate ' + aggregate + ' is not yet supported.';
}
+ if (keepNames) {
+ // if the value was/is a number, convert it back so that the axis domain min/max calculate correctly
+ var dimValue = row.label;
+ row[dimensionName] = { value: !isNaN(Number(dimValue)) ? Number(dimValue) : dimValue };
+ row[measureName] = { value: row.value };
+ row[measureName].aggType = aggregate;
+
+ if (row.hasOwnProperty('subLabel')) {
+ row[subDimensionName] = { value: row.subLabel };
+ }
+ if (row.hasOwnProperty('error')) {
+ row[measureName].error = row.error;
+ }
+ if (row.hasOwnProperty('errorType')) {
+ row[measureName].errorType = row.errorType;
+ }
+ }
+
results.push(row);
}
@@ -367,9 +394,11 @@ LABKEY.vis.naturalSortFn = function(aso, bso) {
return b[i] ? -1 : 0;
};
-LABKEY.vis.getValue = function(obj) {
+LABKEY.vis.getValue = function(obj, preferredProp) {
if (obj && typeof obj == 'object') {
- if (obj.hasOwnProperty('formattedValue')) {
+ if (preferredProp && obj.hasOwnProperty(preferredProp)) {
+ return obj[preferredProp];
+ } else if (obj.hasOwnProperty('formattedValue')) {
return obj.formattedValue;
} else if (obj.hasOwnProperty('displayValue')) {
return obj.displayValue;
@@ -379,3 +408,63 @@ LABKEY.vis.getValue = function(obj) {
return obj;
};
+
+LABKEY.vis.isValidDate = function(date) {
+ return date instanceof Date && !isNaN(date);
+}
+
+LABKEY.vis.formatDate = function(date, format) {
+ if (!LABKEY.vis.isValidDate(date)) return date;
+
+ // Helper function to pad numbers with a leading zero
+ const pad = (num) => num.toString().padStart(2, '0');
+
+ // Month name arrays
+ const monthAbbrs = [
+ "Jan", "Feb", "Mar", "Apr", "May", "Jun",
+ "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"
+ ];
+ const monthFullNames = [
+ "January", "February", "March", "April", "May", "June",
+ "July", "August", "September", "October", "November", "December"
+ ];
+
+ // Get date and time components
+ const day = date.getDate();
+ const monthIndex = date.getMonth(); // 0-11
+ const year = date.getFullYear();
+ const hours = date.getHours(); // 0-23
+ const minutes = date.getMinutes();
+ const seconds = date.getSeconds();
+ const milliseconds = date.getMilliseconds();
+
+ // --- 12-hour clock and AM/PM ---
+ const ampm = hours >= 12 ? 'PM' : 'AM';
+ // 0 (midnight) or 12 (noon) should be 12
+ const hours12 = hours % 12 || 12;
+
+ // --- Token Replacement ---
+ let formatted = format;
+
+ // Year
+ formatted = formatted.replace(/yyyy/g, year.toString());
+ formatted = formatted.replace(/yy/g, year.toString().slice(-2));
+
+ // Month
+ formatted = formatted.replace(/MMMM/g, monthFullNames[monthIndex]);
+ formatted = formatted.replace(/MMM/g, monthAbbrs[monthIndex]);
+ formatted = formatted.replace(/MM/g, pad(monthIndex + 1)); // 1-12
+
+ // Day
+ formatted = formatted.replace(/dd/g, pad(day));
+
+ // Time
+ formatted = formatted.replace(/HH/g, pad(hours)); // 24-hour
+ formatted = formatted.replace(/hh/g, pad(hours12)); // 12-hour
+ formatted = formatted.replace(/mm/g, pad(minutes));
+ formatted = formatted.replace(/ss/g, pad(seconds));
+ formatted = formatted.replace(/SSS/g, milliseconds.toString().padStart(3, '0'));
+ formatted = formatted.replace(/ a/g, ' ' + ampm);
+
+ return formatted;
+}
diff --git a/query/src/org/labkey/query/reports/ReportServiceImpl.java b/query/src/org/labkey/query/reports/ReportServiceImpl.java
index 34e3df3e282..f40adb2e388 100644
--- a/query/src/org/labkey/query/reports/ReportServiceImpl.java
+++ b/query/src/org/labkey/query/reports/ReportServiceImpl.java
@@ -1071,6 +1071,7 @@ public static void registerUsageMetrics(String moduleName)
// Iterate all the database reports once and produce two occurrence maps: all reports by type and just the charts by render type
MultiSet chartCountsByRenderType = new HashMultiSet<>();
AtomicInteger genericChartWithTrendlineTypeCount = new AtomicInteger();
+ AtomicInteger genericChartWithErrorBarsCount = new AtomicInteger();
Map countsByType = ContainerManager.getAllChildren(ContainerManager.getRoot()).stream()
.flatMap(c -> ReportService.get().getReports(null, c).stream())
.peek(report -> {
@@ -1082,6 +1083,8 @@ public static void registerUsageMetrics(String moduleName)
String configJson = descriptor.getJSON();
if (configJson.contains("\"trendlineType\":") && !configJson.contains("\"trendlineType\":\"\""))
genericChartWithTrendlineTypeCount.getAndIncrement();
+ if (configJson.contains("\"errorBars\":\"SD\"") || configJson.contains("\"errorBars\":\"SEM\""))
+ genericChartWithErrorBarsCount.getAndIncrement();
}
}
})
@@ -1090,7 +1093,8 @@ public static void registerUsageMetrics(String moduleName)
return Map.of(
"reportCountsByType", countsByType,
"genericChartCountsByRenderType", MultiSetUtils.getOccurrenceMap(chartCountsByRenderType),
- "genericChartWithTrendlineTypeCount", genericChartWithTrendlineTypeCount
+ "genericChartWithTrendlineTypeCount", genericChartWithTrendlineTypeCount,
+ "genericChartWithErrorBarsCount", genericChartWithErrorBarsCount
);
});
}
diff --git a/visualization/resources/web/vis/chartWizard/chartLayoutDialog/genericChartAxisPanel.js b/visualization/resources/web/vis/chartWizard/chartLayoutDialog/genericChartAxisPanel.js
index e4de6d72547..7001d188ec7 100644
--- a/visualization/resources/web/vis/chartWizard/chartLayoutDialog/genericChartAxisPanel.js
+++ b/visualization/resources/web/vis/chartWizard/chartLayoutDialog/genericChartAxisPanel.js
@@ -137,6 +137,61 @@ Ext4.define('LABKEY.vis.GenericChartAxisPanel', {
}
});
+ this.aggregateMethodCombobox = Ext4.create('Ext.form.field.ComboBox', {
+ fieldLabel: 'Aggregate Method',
+ name: 'aggregate',
+ getInputValue: this.getAggregateMethod,
+ triggerAction: 'all',
+ mode: 'local',
+ width: 300,
+ store: Ext4.create('Ext.data.ArrayStore', {
+ fields: ['value', 'display'],
+ data: [
+ ['', 'None'],
+ ['SUM', 'Sum'],
+ ['MIN', 'Min'],
+ ['MAX', 'Max'],
+ ['MEAN', 'Mean'],
+ ['MEDIAN', 'Median']
+ ]
+ }),
+ forceSelection: 'true',
+ editable: false,
+ valueField: 'value',
+ displayField: 'display',
+ value: '',
+ listeners: {
+ scope: this,
+ change: function(rg, newValue) {
+ this.onAggregateMethodChange(newValue);
+ }
+ }
+ });
+
+ this.errorBarsRadioGroup = Ext4.create('Ext.form.RadioGroup', {
+ fieldLabel: 'Error Bars',
+ columns: 1,
+ vertical: true,
+ items: [
+ Ext4.create('Ext.form.field.Radio', {
+ boxLabel: 'None',
+ inputValue: '',
+ name: 'errorBars',
+ checked: 'true'
+ }),
+ Ext4.create('Ext.form.field.Radio', {
+ boxLabel: 'Standard Deviation',
+ inputValue: 'SD',
+ name: 'errorBars'
+ }),
+ Ext4.create('Ext.form.field.Radio', {
+ boxLabel: 'Standard Error of the Mean',
+ inputValue: 'SEM',
+ name: 'errorBars'
+ })
+ ]
+ });
+
this.items = this.getInputFields();
this.callParent();
@@ -147,18 +202,27 @@ Ext4.define('LABKEY.vis.GenericChartAxisPanel', {
return [
this.axisLabelFieldContainer,
this.scaleTypeRadioGroup,
- this.scaleRangeRadioGroup
+ this.scaleRangeRadioGroup,
+ this.aggregateMethodCombobox,
+ this.errorBarsRadioGroup
];
},
getPanelOptionValues: function()
{
- return {
+ var values = {
label: this.getAxisLabel(),
scaleTrans: this.getScaleType(),
scaleRangeType: this.getScaleRangeType(),
scaleRange: this.getScaleRange()
};
+
+ if (!this.aggregateMethodCombobox.hideForDatatype)
+ values.aggregate = this.getAggregateMethod();
+ if (!this.errorBarsRadioGroup.hideForDatatype)
+ values.errorBars = this.getErrorBarsType();
+
+ return values;
},
setPanelOptionValues: function(config)
@@ -181,6 +245,11 @@ Ext4.define('LABKEY.vis.GenericChartAxisPanel', {
this.setScaleRange(config);
this.adjustScaleAndRangeOptions();
+
+ if (config.aggregate)
+ this.setAggregateMethod(config.aggregate);
+ if (config.errorBars)
+ this.setErrorBars(config.errorBars);
},
getAxisLabel: function(){
@@ -212,6 +281,14 @@ Ext4.define('LABKEY.vis.GenericChartAxisPanel', {
return Ext4.isString(this.defaultAxisLabel) ? this.defaultAxisLabel : null;
},
+ getAggregateMethod : function(){
+ return this.aggregateMethodCombobox.getValue();
+ },
+
+ getErrorBarsType: function(){
+ return this.errorBarsRadioGroup.getValue().errorBars;
+ },
+
getScaleType: function(){
return this.scaleTypeRadioGroup.getValue().scaleType;
},
@@ -269,6 +346,24 @@ Ext4.define('LABKEY.vis.GenericChartAxisPanel', {
radioComp.setValue(true);
},
+ onAggregateMethodChange: function(value) {
+ // reset error bars to None on aggregate change
+ this.setErrorBars('');
+ },
+
+ setAggregateMethod: function(value){
+ this.aggregateMethodCombobox.setValue(value);
+ this.onAggregateMethodChange(value);
+ },
+
+ setErrorBars: function(value){
+ this.errorBarsRadioGroup.setValue(value);
+ const selector = value === '' ? 'radio': 'radio[inputValue="' + value + '"]';
+ var radioComp = this.errorBarsRadioGroup.down(selector);
+ if (radioComp)
+ radioComp.setValue(true);
+ },
+
validateManualScaleRange: function() {
var range = this.getScaleRange();
@@ -292,14 +387,27 @@ Ext4.define('LABKEY.vis.GenericChartAxisPanel', {
if (Ext4.isBoolean(isNumeric)) {
this.setRangeOptionVisible(isNumeric);
this.setScaleTypeOptionVisible(isNumeric);
+ this.setAggregateOptionVisible(isNumeric);
+ this.setErrorBarsOptionVisible(isNumeric);
}
//some render type axis options should always be hidden
- var fixXChartTypes = this.renderType === 'bar_chart' || this.renderType === 'box_plot';
+ var isBar = this.renderType === 'bar_chart';
+ var isBox = this.renderType === 'box_plot';
+ var isLine = this.renderType === 'line_plot';
+ var fixXChartTypes = isBar || isBox;
if ((this.axisName === 'x' && fixXChartTypes) || overrideAsHidden) {
this.setRangeOptionVisible(false);
this.setScaleTypeOptionVisible(false);
}
+
+ // only show aggregate method for line chart and error bars option for both bar and line
+ if (!(this.axisName === 'y' && isLine)) {
+ this.setAggregateOptionVisible(false);
+ }
+ if (!(this.axisName === 'y' && (isBar || isLine))) {
+ this.setErrorBarsOptionVisible(false);
+ }
},
setRangeOptionVisible : function(visible)
@@ -312,6 +420,16 @@ Ext4.define('LABKEY.vis.GenericChartAxisPanel', {
this.scaleTypeRadioGroup.hideForDatatype = !visible;
},
+ setAggregateOptionVisible : function(visible)
+ {
+ this.aggregateMethodCombobox.hideForDatatype = !visible;
+ },
+
+ setErrorBarsOptionVisible : function(visible)
+ {
+ this.errorBarsRadioGroup.hideForDatatype = !visible;
+ },
+
validateChanges : function ()
{
if (this.getScaleRangeType() == 'manual') {
diff --git a/visualization/resources/web/vis/chartWizard/chartLayoutPanel.js b/visualization/resources/web/vis/chartWizard/chartLayoutPanel.js
index ac8cd9f5728..2f543859381 100644
--- a/visualization/resources/web/vis/chartWizard/chartLayoutPanel.js
+++ b/visualization/resources/web/vis/chartWizard/chartLayoutPanel.js
@@ -345,6 +345,13 @@ Ext4.define('LABKEY.vis.ChartLayoutPanel', {
{
var includeLayoutField = inputField.hideForDatatype ? false : this.hasMatchingLayoutOption(chartTypeLayoutOptions, inputField.layoutOptions);
inputField.setVisible(includeLayoutField);
+
+ // hide aggregate method options and error bars options if there are multiple y-axis measures or sides in use
+ if (inputField.isVisible() && (inputField.fieldLabel === 'Aggregate Method' || inputField.fieldLabel === 'Error Bars')) {
+ const sides = LABKEY.vis.GenericChartHelper.getDistinctYAxisSides(measures);
+ const yMeasureCount = LABKEY.Utils.isArray(measures.y) ? measures.y.length : (measures.y ? 1 : 0);
+ inputField.setVisible(yMeasureCount < 2 && sides.length < 2);
+ }
}, this);
}
}, this);
diff --git a/visualization/resources/web/vis/chartWizard/genericChartPanel.js b/visualization/resources/web/vis/chartWizard/genericChartPanel.js
index 577ecc4d732..799812bdae0 100644
--- a/visualization/resources/web/vis/chartWizard/genericChartPanel.js
+++ b/visualization/resources/web/vis/chartWizard/genericChartPanel.js
@@ -1046,6 +1046,20 @@ Ext4.define('LABKEY.ext4.GenericChartPanel', {
config.scales[axisName].min = options[axisName].scaleRange.min;
config.scales[axisName].max = options[axisName].scaleRange.max;
}
+
+ if (config.measures[axisName]) {
+ // LKS y-axis supports multiple measures, so account for that when applying options
+ // note that we only apply to the first measure in the array
+ var measure = LABKEY.Utils.isArray(config.measures[axisName]) ? config.measures[axisName][0] : config.measures[axisName];
+
+ if (options[axisName].aggregate !== undefined) {
+ measure.aggregate = options[axisName].aggregate;
+ measure.errorBars = undefined; // reset error bars if aggregate changes
+ }
+ if (options[axisName].errorBars !== undefined) {
+ measure.errorBars = options[axisName].errorBars;
+ }
+ }
}
},
@@ -1324,6 +1338,16 @@ Ext4.define('LABKEY.ext4.GenericChartPanel', {
if (chartConfig.scales && chartConfig.scales[axisName]) {
Ext4.apply(this.options[axisName], chartConfig.scales[axisName]);
}
+ if (chartConfig.measures && chartConfig.measures[axisName]) {
+ // LKS y-axis supports multiple measures, so account for that when applying options
+ // note that we only apply to the first measure in the array
+ var measure = LABKEY.Utils.isArray(chartConfig.measures[axisName]) ? chartConfig.measures[axisName][0] : chartConfig.measures[axisName];
+
+ if (measure.aggregate)
+ this.options[axisName].aggregate = measure.aggregate.value ?? measure.aggregate;
+ if (measure.errorBars)
+ this.options[axisName].errorBars = measure.errorBars;
+ }
},
loadInitialSelection : function()
@@ -1596,29 +1620,8 @@ Ext4.define('LABKEY.ext4.GenericChartPanel', {
labels = LABKEY.vis.GenericChartHelper.generateLabels(chartConfig.labels);
- if (chartType === 'bar_chart' || chartType === 'pie_chart') {
- var dimName = null, measureName = null, subDimName = null,
- aggType = 'COUNT';
-
- if (chartConfig.measures.x) {
- dimName = chartConfig.measures.x.converted ? chartConfig.measures.x.convertedName : chartConfig.measures.x.name;
- }
- if (chartConfig.measures.xSub) {
- subDimName = chartConfig.measures.xSub.converted ? chartConfig.measures.xSub.convertedName : chartConfig.measures.xSub.name;
- }
- if (chartConfig.measures.y) {
- measureName = chartConfig.measures.y.converted ? chartConfig.measures.y.convertedName : chartConfig.measures.y.name;
-
- if (Ext4.isDefined(chartConfig.measures.y.aggregate)) {
- aggType = chartConfig.measures.y.aggregate.value || chartConfig.measures.y.aggregate;
- }
- // backwards compatibility for bar charts saved prior to aggregate method selection UI
- else if (measureName != null) {
- aggType = 'SUM';
- }
- }
-
- data = LABKEY.vis.getAggregateData(data, dimName, subDimName, measureName, aggType, '[Blank]', false);
+ if (chartType === 'bar_chart' || chartType === 'pie_chart' || chartType === 'line_plot') {
+ data = LABKEY.vis.GenericChartHelper.generateDataForChartType(chartConfig, chartType, geom, data);
// convert any undefined values to zero for display purposes in Bar and Pie chart case
Ext4.each(data, function(d) {
diff --git a/visualization/resources/web/vis/genericChart/genericChartHelper.js b/visualization/resources/web/vis/genericChart/genericChartHelper.js
index 0f2105cc784..df59985f05c 100644
--- a/visualization/resources/web/vis/genericChart/genericChartHelper.js
+++ b/visualization/resources/web/vis/genericChart/genericChartHelper.js
@@ -14,6 +14,7 @@ if(!LABKEY.vis) {
LABKEY.vis.GenericChartHelper = new function(){
var DEFAULT_TICK_LABEL_MAX = 25;
+ var NULL_VALUE_LABEL ='[Blank]';
var $ = jQuery;
var getRenderTypes = function() {
@@ -130,7 +131,9 @@ LABKEY.vis.GenericChartHelper = new function(){
? properties[0].aggregate : properties.aggregate;
if (LABKEY.Utils.isDefined(aggregateProps)) {
- var aggLabel = LABKEY.Utils.isObject(aggregateProps) ? aggregateProps.name : LABKEY.Utils.capitalize(aggregateProps.toLowerCase());
+ var aggLabel = LABKEY.Utils.isObject(aggregateProps)
+ ? (aggregateProps.name ?? aggregateProps.label)
+ : LABKEY.Utils.capitalize(aggregateProps.toLowerCase());
label = aggLabel + ' of ' + label;
}
else {
@@ -288,21 +291,26 @@ LABKEY.vis.GenericChartHelper = new function(){
var getChartTypeBasedWidth = function(chartType, measures, measureStore, defaultWidth) {
var width = defaultWidth;
- if (chartType == 'bar_chart' && LABKEY.Utils.isObject(measures.x)) {
- // 15px per bar + 15px between bars + 300 for default margins
- var xBarCount = measureStore.members(measures.x.name).length;
- width = Math.max((xBarCount * 15 * 2) + 300, defaultWidth);
+ try {
+ if (chartType == 'bar_chart' && LABKEY.Utils.isObject(measures.x)) {
+ // 15px per bar + 15px between bars + 300 for default margins
+ var xBarCount = measureStore.members(measures.x.name).length;
+ width = Math.max((xBarCount * 15 * 2) + 300, defaultWidth);
- if (LABKEY.Utils.isObject(measures.xSub)) {
- // 15px per bar per group + 200px between groups + 300 for default margins
- var xSubCount = measureStore.members(measures.xSub.name).length;
- width = (xBarCount * xSubCount * 15) + (xSubCount * 200) + 300;
+ if (LABKEY.Utils.isObject(measures.xSub)) {
+ // 15px per bar per group + 200px between groups + 300 for default margins
+ var xSubCount = measureStore.members(measures.xSub.name).length;
+ width = (xBarCount * xSubCount * 15) + (xSubCount * 200) + 300;
+ }
}
- }
- else if (chartType == 'box_plot' && LABKEY.Utils.isObject(measures.x)) {
- // 20px per box + 20px between boxes + 300 for default margins
- var xBoxCount = measureStore.members(measures.x.name).length;
- width = Math.max((xBoxCount * 20 * 2) + 300, defaultWidth);
+ else if (chartType == 'box_plot' && LABKEY.Utils.isObject(measures.x)) {
+ // 20px per box + 20px between boxes + 300 for default margins
+ var xBoxCount = measureStore.members(measures.x.name).length;
+ width = Math.max((xBoxCount * 20 * 2) + 300, defaultWidth);
+ }
+ } catch (e) {
+ // measureStore.members can throw if the measure name is not found
+ // we don't care about this here when trying to figure out the width, just use the defaultWidth
}
return width;
@@ -481,6 +489,16 @@ LABKEY.vis.GenericChartHelper = new function(){
else if (isMeasureXMatch && isNumericType(type)) {
scales.x.tickFormat = _getNumberFormatFn(fields[i], defaultFormatFn);
}
+ else if (isMeasureXMatch && isDateType(type)) {
+ // Issue 47898 and 54125: use the date format defined by the field for tick labels
+ if (fields[i].format) {
+ const dateFormat = fields[i].format;
+ scales.x.tickFormat = function(v){
+ const d = new Date(v);
+ return LABKEY.vis.isValidDate(d) ? LABKEY.vis.formatDate(d, dateFormat) : v;
+ };
+ }
+ }
var yMeasures = ensureMeasuresAsArray(measures.y);
$.each(yMeasures, function(idx, yMeasure) {
@@ -567,19 +585,23 @@ LABKEY.vis.GenericChartHelper = new function(){
var generateAes = function(chartType, measures, schemaName, queryName) {
var aes = {}, xMeasureType = getMeasureType(measures.x);
var xMeasureName = !measures.x ? undefined : (measures.x.converted ? measures.x.convertedName : measures.x.name);
+ const isBox = chartType === "box_plot";
+ const isScatter = chartType === "scatter_plot";
+ const rowPropName = isDateType(xMeasureType) ? 'value' : undefined; // Issue 54125
+
+ // Issue 50074: box plots with numeric x-axis to support null values
+ var nullValueLabel = isNumericType(xMeasureType) || isDateType(xMeasureType) ? NULL_VALUE_LABEL : undefined;
- if (chartType === "box_plot") {
+ if (isBox) {
if (!measures.x) {
aes.x = generateMeasurelessAcc(queryName);
} else {
- // Issue 50074: box plots with numeric x-axis to support null values
- var nullValueLabel = isNumericType(xMeasureType) ? "[Blank]" : undefined;
- aes.x = generateDiscreteAcc(xMeasureName, measures.x.label, nullValueLabel);
+ aes.x = generateDiscreteAcc(xMeasureName, measures.x.label, nullValueLabel, rowPropName);
}
- } else if (isNumericType(xMeasureType) || (chartType === 'scatter_plot' && measures.x.measure)) {
+ } else if (isNumericType(xMeasureType) || (isScatter && measures.x.measure)) {
aes.x = generateContinuousAcc(xMeasureName);
} else {
- aes.x = generateDiscreteAcc(xMeasureName, measures.x.label);
+ aes.x = generateDiscreteAcc(xMeasureName, measures.x.label, nullValueLabel, rowPropName);
}
// charts that have multiple y-measures selected will need to put the aes.y function on their specific layer
@@ -670,6 +692,11 @@ LABKEY.vis.GenericChartHelper = new function(){
hover += sep + measure.label + ': ' + _getRowValue(row, measure.name);
sep = ', \n';
+ // include the std dev / SEM value in the hover display for a value if available
+ if (row[measure.name] && row[measure.name].error !== undefined && row[measure.name].errorType !== undefined) {
+ hover += sep + row[measure.name].errorType + ': ' + row[measure.name].error;
+ }
+
distinctNames.push(measure.name);
}
}, this);
@@ -727,13 +754,14 @@ LABKEY.vis.GenericChartHelper = new function(){
* @param {String} measureName The name of the measure.
* @param {String} measureLabel The label of the measure.
* @param {String} nullValueLabel The label value to use for null values
+ * @param {String} propName (Optional) The property name to get from the row, defaults to undefined.
* @returns {Function}
*/
- var generateDiscreteAcc = function(measureName, measureLabel, nullValueLabel)
+ var generateDiscreteAcc = function(measureName, measureLabel, nullValueLabel, propName)
{
return function(row)
{
- var value = _getRowValue(row, measureName);
+ var value = _getRowValue(row, measureName, propName);
if (value === null)
value = nullValueLabel !== undefined ? nullValueLabel : "Not in " + measureLabel;
@@ -1059,12 +1087,15 @@ LABKEY.vis.GenericChartHelper = new function(){
scales.y = {};
}
- if (!scales.y.domain) {
- var values = $.map(data, function(d) {return d.value;}),
- min = Math.min(0, Math.min.apply(Math, values)),
- max = Math.max(0, Math.max.apply(Math, values));
+ var values = $.map(data, d => d.value + (d.error ?? 0));
+ var min = Math.min(0, Math.min.apply(Math, values));
+ var max = Math.max(0, Math.max.apply(Math, values));
+ if (!scales.y.domain) {
scales.y.domain = [min, max];
+ } else if (scales.y.domain[0] === null) {
+ // if user has set a max but not a min, default to 0 for bar chart
+ scales.y.domain[0] = min;
}
}
else if (renderType === 'box_plot' && chartConfig.pointType === 'all')
@@ -1083,13 +1114,20 @@ LABKEY.vis.GenericChartHelper = new function(){
$.each(yMeasures, function(idx, yMeasure) {
var pathAes = {
sortFn: function(a, b) {
- const aVal = _getRowValue(a, xName);
- const bVal = _getRowValue(b, xName);
-
- // No need to handle the case for a or b or a.getValue() or b.getValue() null as they are
- // not currently included in this plot.
- if (isDate){
- return new Date(aVal) - new Date(bVal);
+ // Issue 54125: use row value instead of formatted value for sorting
+ const aVal = _getRowValue(a, xName, 'value');
+ const bVal = _getRowValue(b, xName, 'value');
+
+ if (aVal === null || aVal === NULL_VALUE_LABEL) {
+ return 1;
+ } else if (bVal === null || bVal === NULL_VALUE_LABEL) {
+ return -1;
+ } else if (isDate){
+ const aDate = new Date(aVal);
+ const bDate = new Date(bVal);
+ if (!LABKEY.vis.isValidDate(aDate)) return 1;
+ else if (!LABKEY.vis.isValidDate(bDate)) return -1;
+ return aDate - bDate;
}
return aVal - bVal;
},
@@ -1182,6 +1220,11 @@ LABKEY.vis.GenericChartHelper = new function(){
layerAes.shape = emptyTextFn;
}
+ // allow for bar chart and line chart to provide an errorAes for showing error bars
+ if (geom && geom.errorAes) {
+ layerAes.error = geom.errorAes.getValue;
+ }
+
layers.push(
new LABKEY.vis.Layer({
name: yMeasures.length > 1 ? yMeasure.label || yMeasure.name : undefined,
@@ -1342,13 +1385,16 @@ LABKEY.vis.GenericChartHelper = new function(){
});
};
- var _willRotateXAxisTickText = function(scales, plotConfig, maxTickLength, data) {
+ var _wrapXAxisTickTextLines = function(scales, plotConfig, maxTickLength, data) {
if (scales.x && scales.x.scaleType === 'discrete') {
var tickCount = scales.x && scales.x.tickLabelMax ? Math.min(scales.x.tickLabelMax, data.length) : data.length;
- return (tickCount * maxTickLength * 4) > (plotConfig.width - 150);
+ // after 10 tick labels, we switch to rotating the label, so use that as the max here
+ tickCount = Math.min(tickCount, 10);
+ var approxTickLabelWidth = plotConfig.width / tickCount;
+ return Math.max(1, Math.floor((maxTickLength * 8) / approxTickLabelWidth));
}
- return false;
+ return 1;
};
var _getPlotMargins = function(renderType, scales, aes, data, plotConfig, chartConfig) {
@@ -1367,10 +1413,9 @@ LABKEY.vis.GenericChartHelper = new function(){
}
});
- if (_willRotateXAxisTickText(scales, plotConfig, maxLen, data)) {
- // min bottom margin: 50, max bottom margin: 150
- margins.bottom = Math.min(Math.max(50, maxLen*5), 175);
- }
+ var wrapLines = _wrapXAxisTickTextLines(scales, plotConfig, maxLen, data);
+ // min bottom margin: 50, max bottom margin: 150
+ margins.bottom = Math.min(150, 60 + ((wrapLines - 1) * 25));
}
// issue 31857: allow custom margins to be set in Chart Layout dialog
@@ -1828,6 +1873,50 @@ LABKEY.vis.GenericChartHelper = new function(){
LABKEY.Query.MeasureStore.selectRows(queryConfig);
};
+ var generateDataForChartType = function(chartConfig, chartType, geom, data) {
+ let dimName = null;
+ let dimIsDate = false;
+ let subDimName = null;
+ let measureName = null;
+ let aggType = chartType === 'bar_chart' || chartType === 'pie_chart' ? 'COUNT' : null;
+ let aggErrorType = null;
+
+ if (chartConfig.measures.x) {
+ dimName = chartConfig.measures.x.converted ? chartConfig.measures.x.convertedName : chartConfig.measures.x.name;
+ dimIsDate = isDateType(getMeasureType(chartConfig.measures.x));
+ }
+ if (chartConfig.measures.xSub) {
+ subDimName = chartConfig.measures.xSub.converted ? chartConfig.measures.xSub.convertedName : chartConfig.measures.xSub.name;
+ } else if (chartConfig.measures.series) {
+ subDimName = chartConfig.measures.series.name;
+ }
+
+ // LKS y-axis supports multiple measures, but for aggregation we only support one
+ if (chartConfig.measures.y && (!LABKEY.Utils.isArray(chartConfig.measures.y) || chartConfig.measures.y.length === 1)) {
+ const yMeasure = LABKEY.Utils.isArray(chartConfig.measures.y) ? chartConfig.measures.y[0] : chartConfig.measures.y;
+ measureName = yMeasure.converted ? yMeasure.convertedName : yMeasure.name;
+
+ if (LABKEY.Utils.isDefined(yMeasure.aggregate)) {
+ aggType = yMeasure.aggregate.value || yMeasure.aggregate;
+ aggType = LABKEY.Utils.isObject(aggType) ? aggType.value : aggType;
+ aggErrorType = aggType === 'MEAN' ? yMeasure.errorBars : null;
+ }
+ else if (measureName != null && (chartType === 'bar_chart' || chartType === 'pie_chart')) {
+ // default to SUM for bar and pie charts
+ aggType = 'SUM';
+ }
+ }
+
+ if (aggType) {
+ data = LABKEY.vis.getAggregateData(data, dimName, subDimName, measureName, aggType, NULL_VALUE_LABEL, false, aggErrorType, chartType === 'line_plot', dimIsDate ? 'value' : undefined);
+ if (aggErrorType) {
+ geom.errorAes = { getValue: d => d.error };
+ }
+ }
+
+ return data;
+ }
+
var generateChartSVG = function(renderTo, chartConfig, measureStore, trendlineData) {
var responseMetaData = measureStore.getResponseMetadata();
@@ -1850,28 +1939,8 @@ LABKEY.vis.GenericChartHelper = new function(){
var geom = generateGeom(chartType, chartConfig.geomOptions);
var labels = generateLabels(chartConfig.labels);
- if (chartType === 'bar_chart' || chartType === 'pie_chart') {
- var dimName = null, subDimName = null; measureName = null, aggType = 'COUNT';
-
- if (chartConfig.measures.x) {
- dimName = chartConfig.measures.x.converted ? chartConfig.measures.x.convertedName : chartConfig.measures.x.name;
- }
- if (chartConfig.measures.xSub) {
- subDimName = chartConfig.measures.xSub.converted ? chartConfig.measures.xSub.convertedName : chartConfig.measures.xSub.name;
- }
- if (chartConfig.measures.y) {
- measureName = chartConfig.measures.y.converted ? chartConfig.measures.y.convertedName : chartConfig.measures.y.name;
-
- if (LABKEY.Utils.isDefined(chartConfig.measures.y.aggregate)) {
- aggType = chartConfig.measures.y.aggregate;
- aggType = LABKEY.Utils.isObject(aggType) ? aggType.value : aggType;
- }
- else if (measureName != null) {
- aggType = 'SUM';
- }
- }
-
- data = LABKEY.vis.getAggregateData(data, dimName, subDimName, measureName, aggType, '[Blank]', false);
+ if (chartType === 'bar_chart' || chartType === 'pie_chart' || chartType === 'line_plot') {
+ data = generateDataForChartType(chartConfig, chartType, geom, data);
}
var validation = _validateChartConfig(chartConfig, aes, scales, measureStore);
@@ -1965,6 +2034,7 @@ LABKEY.vis.GenericChartHelper = new function(){
generateAggregateData: generateAggregateData,
generatePointHover: generatePointHover,
generateBoxplotHover: generateBoxplotHover,
+ generateDataForChartType: generateDataForChartType,
generateDiscreteAcc: generateDiscreteAcc,
generateContinuousAcc: generateContinuousAcc,
generateGroupingAcc: generateGroupingAcc,
diff --git a/visualization/resources/web/vis/timeChart/timeChartHelper.js b/visualization/resources/web/vis/timeChart/timeChartHelper.js
index b47220fa759..1ee62f8932e 100644
--- a/visualization/resources/web/vis/timeChart/timeChartHelper.js
+++ b/visualization/resources/web/vis/timeChart/timeChartHelper.js
@@ -451,7 +451,7 @@ LABKEY.vis.TimeChartHelper = new function() {
{
var aggregateErrorLayerConfig = {
data: aggregateData,
- geom: new LABKEY.vis.Geom.ErrorBar(),
+ geom: new LABKEY.vis.Geom.ErrorBar({ showVertical: true }),
aes: generateAggregateLayerAes(chartSeriesName, chartSeries.yAxisSide, columnName, intervalKey, aggregateSubjectColumn, errorColumnName)
};