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) };