From c6bf9a13eacffb1f9b8029d1eb9410ec02ac81a0 Mon Sep 17 00:00:00 2001 From: cnathe Date: Tue, 7 Oct 2025 17:05:52 -0500 Subject: [PATCH 01/40] add LABKEY.vis.Stat.getStdErr and jest test coverage --- core/src/client/vis/statistics.test.ts | 32 ++++++++++++++++++++++++ core/webapp/vis/src/statistics.js | 34 +++++++++++++++++++++++--- 2 files changed, 63 insertions(+), 3 deletions(-) create mode 100644 core/src/client/vis/statistics.test.ts 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/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; From 69514a7b095e8ae37968d190ddcb88303ee55cc9 Mon Sep 17 00:00:00 2001 From: cnathe Date: Tue, 7 Oct 2025 17:10:15 -0500 Subject: [PATCH 02/40] Fix issue with showing vertical lines in error bars on study time chart --- core/webapp/vis/src/geom.js | 2 + core/webapp/vis/src/internal/D3Renderer.js | 18 +- .../web/vis/timeChart/timeChartHelper.js | 3462 ++++++++--------- 3 files changed, 1747 insertions(+), 1735 deletions(-) diff --git a/core/webapp/vis/src/geom.js b/core/webapp/vis/src/geom.js index 29f49c6c7b1..2719c12d07e 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"; @@ -340,6 +341,7 @@ LABKEY.vis.Geom.ErrorBar = function(config){ 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.errorShowVertical = ('showVertical' in config && config.showVertical != null && config.showVertical != undefined) ? 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..8818d720967 100644 --- a/core/webapp/vis/src/internal/D3Renderer.js +++ b/core/webapp/vis/src/internal/D3Renderer.js @@ -2167,6 +2167,7 @@ LABKEY.vis.internal.D3Renderer = function(plot) { var renderErrorBar = function(layer, plot, geom, data) { var colorAcc, sizeAcc, topFn, bottomFn, verticalFn, selection, newBars; + var errorLineWidth = geom.errorWidth ?? geom.width; 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; @@ -2176,7 +2177,7 @@ LABKEY.vis.internal.D3Renderer = function(plot) { 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) { var x, y, value, error; @@ -2188,7 +2189,7 @@ 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; @@ -2220,10 +2221,16 @@ LABKEY.vis.internal.D3Renderer = function(plot) { 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 +2238,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")); + } } }; diff --git a/visualization/resources/web/vis/timeChart/timeChartHelper.js b/visualization/resources/web/vis/timeChart/timeChartHelper.js index b47220fa759..254aaff4da0 100644 --- a/visualization/resources/web/vis/timeChart/timeChartHelper.js +++ b/visualization/resources/web/vis/timeChart/timeChartHelper.js @@ -1,1731 +1,1731 @@ -/* - * Copyright (c) 2013-2019 LabKey Corporation - * - * Licensed under the Apache License, Version 2.0: http://www.apache.org/licenses/LICENSE-2.0 - */ -if(!LABKEY.vis) { - LABKEY.vis = {}; -} - -/** - * @namespace Namespace used to encapsulate functions related to creating study Time Charts. - * Used in the Chart Wizard and when exporting Time Charts as scripts. - */ -LABKEY.vis.TimeChartHelper = new function() { - - var $ = jQuery; - var defaultVisitProperty = 'displayOrder'; - - /** - * Generate the main title and axis labels for the chart based on the specified x-axis and y-axis (left and right) labels. - * @param {String} mainTitle The label to be used as the main chart title. - * @param {String} subtitle The label to be used as the chart subtitle. - * @param {Array} axisArr An array of axis information including the x-axis and y-axis (left and right) labels. - * @returns {Object} - */ - var generateLabels = function(mainTitle, axisArr, subtitle) { - var xTitle = '', yLeftTitle = '', yRightTitle = ''; - for (var i = 0; i < axisArr.length; i++) - { - var axis = axisArr[i]; - if (axis.name == "y-axis") - { - if (axis.side == "left") - yLeftTitle = axis.label; - else - yRightTitle = axis.label; - } - else - { - xTitle = axis.label; - } - } - - return { - main : { value : mainTitle }, - subtitle : { value : subtitle, color: '#404040' }, - x : { value : xTitle }, - yLeft : { value : yLeftTitle }, - yRight : { value : yRightTitle } - }; - }; - - /** - * Generates an object containing {@link LABKEY.vis.Scale} objects used for the chart. - * @param {Object} config The chart configuration object that defines the selected measures, axis info, subjects/groups, etc. - * @param {Object} tickMap For visit based charts, the x-axis tick mark mapping, from generateTickMap. - * @param {Object} numberFormats The number format functions to use for the x-axis and y-axis (left and right) tick marks. - * @returns {Object} - */ - var generateScales = function(config, tickMap, numberFormats) { - if (config.measures.length == 0) - throw "There must be at least one specified measure in the chartInfo config!"; - - var xMin = null, xMax = null, xTrans = null, xTickFormat, xTickHoverText, - yLeftMin = null, yLeftMax = null, yLeftTrans = null, yLeftTickFormat, - yRightMin = null, yRightMax = null, yRightTrans = null, yRightTickFormat, - valExponentialDigits = 6; - - for (var i = 0; i < config.axis.length; i++) - { - var axis = config.axis[i]; - if (axis.name == "y-axis") - { - if (axis.side == "left") - { - yLeftMin = typeof axis.range.min == "number" ? axis.range.min : (config.hasNoData ? 0 : null); - yLeftMax = typeof axis.range.max == "number" ? axis.range.max : (config.hasNoData ? 10 : null); - yLeftTrans = axis.scale ? axis.scale : "linear"; - yLeftTickFormat = function(value) { - if (LABKEY.Utils.isNumber(value) && Math.abs(Math.round(value)).toString().length >= valExponentialDigits) { - return value.toExponential(); - } - else if (LABKEY.Utils.isFunction(numberFormats.left)) { - return numberFormats.left(value); - } - return value; - } - } - else - { - yRightMin = typeof axis.range.min == "number" ? axis.range.min : (config.hasNoData ? 0 : null); - yRightMax = typeof axis.range.max == "number" ? axis.range.max : (config.hasNoData ? 10 : null); - yRightTrans = axis.scale ? axis.scale : "linear"; - yRightTickFormat = function(value) { - if (LABKEY.Utils.isNumber(value) && Math.abs(Math.round(value)).toString().length >= valExponentialDigits) { - return value.toExponential(); - } - else if (LABKEY.Utils.isFunction(numberFormats.right)) { - return numberFormats.right(value); - } - return value; - } - } - } - else - { - xMin = typeof axis.range.min == "number" ? axis.range.min : null; - xMax = typeof axis.range.max == "number" ? axis.range.max : null; - xTrans = axis.scale ? axis.scale : "linear"; - } - } - - if (config.measures[0].time == "visit" && (config.measures[0].visitOptions === undefined || config.measures[0].visitOptions.visitDisplayProperty === defaultVisitProperty)) - { - xTickFormat = function(value) { - return tickMap[value] ? tickMap[value].label : ""; - }; - - xTickHoverText = function(value) { - return tickMap[value] ? tickMap[value].description : ""; - }; - } - // Issue 27309: Don't show decimal values on x-axis for date-based time charts with interval = "Days" - else if (config.measures[0].time == 'date' && config.measures[0].dateOptions.interval == 'Days') - { - xTickFormat = function(value) { - return LABKEY.Utils.isNumber(value) && value % 1 != 0 ? null : value; - }; - } - - return { - x: { - scaleType : 'continuous', - trans : xTrans, - domain : [xMin, xMax], - tickFormat : xTickFormat ? xTickFormat : null, - tickHoverText : xTickHoverText ? xTickHoverText : null - }, - yLeft: { - scaleType : 'continuous', - trans : yLeftTrans, - domain : [yLeftMin, yLeftMax], - tickFormat : yLeftTickFormat ? yLeftTickFormat : null - }, - yRight: { - scaleType : 'continuous', - trans : yRightTrans, - domain : [yRightMin, yRightMax], - tickFormat : yRightTickFormat ? yRightTickFormat : null - }, - shape: { - scaleType : 'discrete' - } - }; - }; - - /** - * Generate the x-axis interval column alias key. For date based charts, this will be a time interval (i.e. Days, Weeks, etc.) - * and for visit based charts, this will be the column alias for the visit field. - * @param {Object} config The chart configuration object that defines the selected measures, axis info, subjects/groups, etc. - * @param {Array} individualColumnAliases The array of column aliases for the individual subject data. - * @param {Array} aggregateColumnAliases The array of column aliases for the group/cohort aggregate data. - * @param {String} nounSingular The singular name of the study subject noun (i.e. Participant). - * @returns {String} - */ - var generateIntervalKey = function(config, individualColumnAliases, aggregateColumnAliases, nounSingular) - { - nounSingular = nounSingular || getStudySubjectInfo().nounSingular; - - if (config.measures.length == 0) - throw "There must be at least one specified measure in the chartInfo config!"; - if (!individualColumnAliases && !aggregateColumnAliases) - throw "We expect to either be displaying individual series lines or aggregate data!"; - - if (config.measures[0].time == "date") - { - return config.measures[0].dateOptions.interval; - } - else - { - return individualColumnAliases ? - LABKEY.vis.getColumnAlias(individualColumnAliases, nounSingular + "Visit/Visit") : - LABKEY.vis.getColumnAlias(aggregateColumnAliases, nounSingular + "Visit/Visit"); - } - }; - - /** - * Generate that x-axis tick mark mapping for a visit based chart. - * @param {Object} visitMap For visit based charts, the study visit information map. - * @returns {Object} - */ - var generateTickMap = function(visitMap) { - var tickMap = {}; - for (var rowId in visitMap) - { - if (visitMap.hasOwnProperty(rowId)) - { - tickMap[visitMap[rowId][defaultVisitProperty]] = { - label: visitMap[rowId].displayName, - description: visitMap[rowId].description || visitMap[rowId].displayName - }; - } - } - - return tickMap; - }; - - /** - * Generates the aesthetic map object needed by the visualization API to render the chart. See {@link LABKEY.vis.Plot} - * and {@link LABKEY.vis.Layer}. - * @param {Object} config The chart configuration object that defines the selected measures, axis info, subjects/groups, etc. - * @param {Object} visitMap For visit based charts, the study visit information map. - * @param {Array} individualColumnAliases The array of column aliases for the individual subject data. - * @param {String} intervalKey The x-axis interval column alias key (i.e. Days, Weeks, etc.), from generateIntervalKey. - * @param {String} nounColumnName The name of the study subject noun column (i.e. ParticipantId). - * @returns {Object} - */ - var generateAes = function(config, visitMap, individualColumnAliases, intervalKey, nounColumnName) - { - nounColumnName = nounColumnName || getStudySubjectInfo().columnName; - - if (config.measures.length == 0) - throw "There must be at least one specified measure in the chartInfo config!"; - - var xAes; - if (config.measures[0].time == "date") { - xAes = function(row) { - return _getRowValue(row, intervalKey); - }; - } - else { - xAes = function(row) { - var displayProp = config.measures[0].visitOptions ? config.measures[0].visitOptions.visitDisplayProperty : defaultVisitProperty; - return visitMap[_getRowValue(row, intervalKey, 'value')][displayProp]; - }; - } - - var individualSubjectColumn = individualColumnAliases ? LABKEY.vis.getColumnAlias(individualColumnAliases, nounColumnName) : null; - - return { - x: xAes, - color: function(row) { - return _getRowValue(row, individualSubjectColumn); - }, - group: function(row) { - return _getRowValue(row, individualSubjectColumn); - }, - shape: function(row) { - return _getRowValue(row, individualSubjectColumn); - }, - pathColor: function(rows) { - return LABKEY.Utils.isArray(rows) && rows.length > 0 ? _getRowValue(rows[0], individualSubjectColumn) : null; - } - }; - }; - - /** - * Generate an array of {@link LABKEY.vis.Layer} objects based on the selected chart series list. - * @param {Object} config The chart configuration object that defines the selected measures, axis info, subjects/groups, etc. - * @param {Object} visitMap For visit based charts, the study visit information map. - * @param {Array} individualColumnAliases The array of column aliases for the individual subject data. - * @param {Array} aggregateColumnAliases The array of column aliases for the group/cohort aggregate data. - * @param {Array} aggregateData The array of group/cohort aggregate data, from getChartData. - * @param {Array} seriesList The list of series that will be plotted for a given chart, from generateSeriesList. - * @param {String} intervalKey The x-axis interval column alias key (i.e. Days, Weeks, etc.), from generateIntervalKey. - * @param {String} nounColumnName The name of the study subject noun column (i.e. ParticipantId). - * @returns {Array} - */ - var generateLayers = function(config, visitMap, individualColumnAliases, aggregateColumnAliases, aggregateData, seriesList, intervalKey, nounColumnName) - { - nounColumnName = nounColumnName || getStudySubjectInfo().columnName; - - if (config.measures.length == 0) - throw "There must be at least one specified measure in the chartInfo config!"; - if (!individualColumnAliases && !aggregateColumnAliases) - throw "We expect to either be displaying individual series lines or aggregate data!"; - - var layers = []; - var isDateBased = config.measures[0].time == "date"; - var individualSubjectColumn = individualColumnAliases ? LABKEY.vis.getColumnAlias(individualColumnAliases, nounColumnName) : null; - var aggregateSubjectColumn = "UniqueId"; - - var generateLayerAes = function(name, yAxisSide, columnName){ - var yName = yAxisSide == "left" ? "yLeft" : "yRight"; - var aes = {}; - aes[yName] = function(row) { - // Have to parseFloat because for some reason ObsCon from Luminex was returning strings not floats/ints. - return row[columnName] ? parseFloat(_getRowValue(row, columnName)) : null; - }; - return aes; - }; - - var generateAggregateLayerAes = function(name, yAxisSide, columnName, intervalKey, subjectColumn, errorColumn){ - var yName = yAxisSide == "left" ? "yLeft" : "yRight"; - var aes = {}; - aes[yName] = function(row) { - // Have to parseFloat because for some reason ObsCon from Luminex was returning strings not floats/ints. - return row[columnName] ? parseFloat(_getRowValue(row, columnName)) : null; - }; - aes.group = aes.color = aes.shape = function(row) { - return _getRowValue(row, subjectColumn); - }; - aes.pathColor = function(rows) { - return LABKEY.Utils.isArray(rows) && rows.length > 0 ? _getRowValue(rows[0], subjectColumn) : null; - }; - aes.error = function(row) { - return row[errorColumn] ? _getRowValue(row, errorColumn) : null; - }; - return aes; - }; - - var hoverTextFn = function(subjectColumn, intervalKey, name, columnName, visitMap, errorColumn, errorType){ - if (visitMap) - { - if (errorColumn) - { - return function(row){ - var subject = _getRowValue(row, subjectColumn); - var errorVal = _getRowValue(row, errorColumn) || 'n/a'; - return ' ' + subject + ',\n '+ visitMap[_getRowValue(row, intervalKey, 'value')].displayName + - ',\n ' + name + ': ' + _getRowValue(row, columnName) + - ',\n ' + errorType + ': ' + errorVal; - } - } - else - { - return function(row){ - var subject = _getRowValue(row, subjectColumn); - return ' ' + subject + ',\n '+ visitMap[_getRowValue(row, intervalKey, 'value')].displayName + - ',\n ' + name + ': ' + _getRowValue(row, columnName); - }; - } - } - else - { - if (errorColumn) - { - return function(row){ - var subject = _getRowValue(row, subjectColumn); - var errorVal = _getRowValue(row, errorColumn) || 'n/a'; - return ' ' + subject + ',\n ' + intervalKey + ': ' + _getRowValue(row, intervalKey) + - ',\n ' + name + ': ' + _getRowValue(row, columnName) + - ',\n ' + errorType + ': ' + errorVal; - }; - } - else - { - return function(row){ - var subject = _getRowValue(row, subjectColumn); - return ' ' + subject + ',\n ' + intervalKey + ': ' + _getRowValue(row, intervalKey) + - ',\n ' + name + ': ' + _getRowValue(row, columnName); - }; - } - } - }; - - // Issue 15369: if two measures have the same name, use the alias for the subsequent series names (which will be unique) - // Issue 12369: if rendering two measures of the same pivoted value, use measure and pivot name for series names (which will be unique) - var useUniqueSeriesNames = false; - var uniqueChartSeriesNames = []; - for (var i = 0; i < seriesList.length; i++) - { - if (uniqueChartSeriesNames.indexOf(seriesList[i].name) > -1) - { - useUniqueSeriesNames = true; - break; - } - uniqueChartSeriesNames.push(seriesList[i].name); - } - - for (var i = seriesList.length -1; i >= 0; i--) - { - var chartSeries = seriesList[i]; - - var chartSeriesName = chartSeries.label; - if (useUniqueSeriesNames) - { - if (chartSeries.aliasLookupInfo.pivotValue) - chartSeriesName = chartSeries.aliasLookupInfo.measureName + " " + chartSeries.aliasLookupInfo.pivotValue; - else - chartSeriesName = chartSeries.aliasLookupInfo.alias; - } - - var columnName = individualColumnAliases ? LABKEY.vis.getColumnAlias(individualColumnAliases, chartSeries.aliasLookupInfo) : LABKEY.vis.getColumnAlias(aggregateColumnAliases, chartSeries.aliasLookupInfo); - if (individualColumnAliases) - { - if (!config.hideTrendLine) { - var pathLayerConfig = { - geom: new LABKEY.vis.Geom.Path({size: config.lineWidth}), - aes: generateLayerAes(chartSeriesName, chartSeries.yAxisSide, columnName) - }; - - if (seriesList.length > 1) - pathLayerConfig.name = chartSeriesName; - - layers.push(new LABKEY.vis.Layer(pathLayerConfig)); - } - - if (!config.hideDataPoints) - { - var pointLayerConfig = { - geom: new LABKEY.vis.Geom.Point(), - aes: generateLayerAes(chartSeriesName, chartSeries.yAxisSide, columnName) - }; - - if (seriesList.length > 1) - pointLayerConfig.name = chartSeriesName; - - if (isDateBased) - pointLayerConfig.aes.hoverText = hoverTextFn(individualSubjectColumn, intervalKey, chartSeriesName, columnName, null, null, null); - else - pointLayerConfig.aes.hoverText = hoverTextFn(individualSubjectColumn, intervalKey, chartSeriesName, columnName, visitMap, null, null); - - if (config.pointClickFn) - { - pointLayerConfig.aes.pointClickFn = generatePointClickFn( - config.pointClickFn, - {participant: individualSubjectColumn, interval: intervalKey, measure: columnName}, - {schemaName: chartSeries.schemaName, queryName: chartSeries.queryName, name: chartSeriesName} - ); - } - - layers.push(new LABKEY.vis.Layer(pointLayerConfig)); - } - } - - if (aggregateData && aggregateColumnAliases) - { - var errorBarType = null; - if (config.errorBars == 'SD') - errorBarType = '_STDDEV'; - else if (config.errorBars == 'SEM') - errorBarType = '_STDERR'; - - var errorColumnName = errorBarType ? LABKEY.vis.getColumnAlias(aggregateColumnAliases, chartSeries.aliasLookupInfo) + errorBarType : null; - - if (!config.hideTrendLine) { - var aggregatePathLayerConfig = { - data: aggregateData, - geom: new LABKEY.vis.Geom.Path({size: config.lineWidth}), - aes: generateAggregateLayerAes(chartSeriesName, chartSeries.yAxisSide, columnName, intervalKey, aggregateSubjectColumn, errorColumnName) - }; - - if (seriesList.length > 1) - aggregatePathLayerConfig.name = chartSeriesName; - - layers.push(new LABKEY.vis.Layer(aggregatePathLayerConfig)); - } - - if (errorColumnName) - { - var aggregateErrorLayerConfig = { - data: aggregateData, - geom: new LABKEY.vis.Geom.ErrorBar(), - aes: generateAggregateLayerAes(chartSeriesName, chartSeries.yAxisSide, columnName, intervalKey, aggregateSubjectColumn, errorColumnName) - }; - - if (seriesList.length > 1) - aggregateErrorLayerConfig.name = chartSeriesName; - - layers.push(new LABKEY.vis.Layer(aggregateErrorLayerConfig)); - } - - if (!config.hideDataPoints) - { - var aggregatePointLayerConfig = { - data: aggregateData, - geom: new LABKEY.vis.Geom.Point(), - aes: generateAggregateLayerAes(chartSeriesName, chartSeries.yAxisSide, columnName, intervalKey, aggregateSubjectColumn, errorColumnName) - }; - - if (seriesList.length > 1) - aggregatePointLayerConfig.name = chartSeriesName; - - if (isDateBased) - aggregatePointLayerConfig.aes.hoverText = hoverTextFn(aggregateSubjectColumn, intervalKey, chartSeriesName, columnName, null, errorColumnName, config.errorBars) - else - aggregatePointLayerConfig.aes.hoverText = hoverTextFn(aggregateSubjectColumn, intervalKey, chartSeriesName, columnName, visitMap, errorColumnName, config.errorBars); - - if (config.pointClickFn) - { - aggregatePointLayerConfig.aes.pointClickFn = generatePointClickFn( - config.pointClickFn, - {group: aggregateSubjectColumn, interval: intervalKey, measure: columnName}, - {schemaName: chartSeries.schemaName, queryName: chartSeries.queryName, name: chartSeriesName} - ); - } - - layers.push(new LABKEY.vis.Layer(aggregatePointLayerConfig)); - } - } - } - - return layers; - }; - - // private function - var generatePointClickFn = function(fnString, columnMap, measureInfo){ - // the developer is expected to return a function, so we encapalate it within the anonymous function - // (note: the function should have already be validated in a try/catch when applied via the developerOptionsPanel) - - // using new Function is quicker than eval(), even in IE. - var pointClickFn = new Function('return ' + fnString)(); - return function(clickEvent, data) { - pointClickFn(data, columnMap, measureInfo, clickEvent); - }; - }; - - /** - * Generate the list of series to be plotted in a given Time Chart. A series will be created for each measure and - * dimension that is selected in the chart. - * @param {Array} measures The array of selected measures from the chart config. - * @returns {Array} - */ - var generateSeriesList = function(measures) { - var arr = []; - for (var i = 0; i < measures.length; i++) - { - var md = measures[i]; - - if (md.dimension && md.dimension.values) - { - Ext4.each(md.dimension.values, function(val) { - arr.push({ - schemaName: md.dimension.schemaName, - queryName: md.dimension.queryName, - name: val, - label: val, - measureIndex: i, - yAxisSide: md.measure.yAxis, - aliasLookupInfo: {measureName: md.measure.name, pivotValue: val} - }); - }); - } - else - { - arr.push({ - schemaName: md.measure.schemaName, - queryName: md.measure.queryName, - name: md.measure.name, - label: md.measure.label, - measureIndex: i, - yAxisSide: md.measure.yAxis, - aliasLookupInfo: md.measure.alias ? {alias: md.measure.alias} : {measureName: md.measure.name} - }); - } - } - return arr; - }; - - // private function - var generateDataSortArray = function(subject, firstMeasure, isDateBased, nounSingular) - { - nounSingular = nounSingular || getStudySubjectInfo().nounSingular; - var hasDateCol = firstMeasure.dateOptions && firstMeasure.dateOptions.dateCol; - - return [ - subject, - { - schemaName : hasDateCol ? firstMeasure.dateOptions.dateCol.schemaName : firstMeasure.measure.schemaName, - queryName : hasDateCol ? firstMeasure.dateOptions.dateCol.queryName : firstMeasure.measure.queryName, - name : isDateBased && hasDateCol ? firstMeasure.dateOptions.dateCol.name : getSubjectVisitColName(nounSingular, 'DisplayOrder') - }, - { - schemaName : hasDateCol ? firstMeasure.dateOptions.dateCol.schemaName : firstMeasure.measure.schemaName, - queryName : hasDateCol ? firstMeasure.dateOptions.dateCol.queryName : firstMeasure.measure.queryName, - name : (isDateBased ? nounSingular + "Visit/Visit" : getSubjectVisitColName(nounSingular, 'SequenceNumMin')) - } - ]; - }; - - var getSubjectVisitColName = function(nounSingular, suffix) - { - var nounSingular = nounSingular || getStudySubjectInfo().nounSingular; - return nounSingular + 'Visit/Visit/' + suffix; - }; - - /** - * Determine whether or not the chart needs to clip the plotted lines and points based on manually set axis ranges. - * @param {Object} config The chart configuration object that defines the selected measures, axis info, subjects/groups, etc. - * @returns {boolean} - */ - var generateApplyClipRect = function(config) { - var xAxisIndex = getAxisIndex(config.axis, "x-axis"); - var leftAxisIndex = getAxisIndex(config.axis, "y-axis", "left"); - var rightAxisIndex = getAxisIndex(config.axis, "y-axis", "right"); - - return ( - xAxisIndex > -1 && (config.axis[xAxisIndex].range.min != null || config.axis[xAxisIndex].range.max != null) || - leftAxisIndex > -1 && (config.axis[leftAxisIndex].range.min != null || config.axis[leftAxisIndex].range.max != null) || - rightAxisIndex > -1 && (config.axis[rightAxisIndex].range.min != null || config.axis[rightAxisIndex].range.max != null) - ); - }; - - /** - * Generates axis range min and max values based on the full Time Chart data. This will be used when plotting multiple - * charts that are set to use the same axis ranges across all charts in the report. - * @param {Object} config The chart configuration object that defines the selected measures, axis info, subjects/groups, etc. - * @param {Object} data The data object, from getChartData. - * @param {Array} seriesList The list of series that will be plotted for a given chart, from generateSeriesList. - * @param {String} nounSingular The singular name of the study subject noun (i.e. Participant). - */ - var generateAcrossChartAxisRanges = function(config, data, seriesList, nounSingular) - { - nounSingular = nounSingular || getStudySubjectInfo().nounSingular; - - if (config.measures.length == 0) - throw "There must be at least one specified measure in the chartInfo config!"; - if (!data.individual && !data.aggregate) - throw "We expect to either be displaying individual series lines or aggregate data!"; - - var rows = []; - if (LABKEY.Utils.isDefined(data.individual)) { - rows = data.individual.measureStore.records(); - } - else if (LABKEY.Utils.isDefined(data.aggregate)) { - rows = data.aggregate.measureStore.records(); - } - - config.hasNoData = rows.length == 0; - - // In multi-chart case, we need to pre-compute the default axis ranges so that all charts share them - // (if 'automatic across charts' is selected for the given axis) - if (config.chartLayout != "single") - { - var leftMeasures = [], - rightMeasures = [], - xName, xFunc, - min, max, tempMin, tempMax, errorBarType, - leftAccessor, leftAccessorMax, leftAccessorMin, rightAccessorMax, rightAccessorMin, rightAccessor, - columnAliases = data.individual ? data.individual.columnAliases : (data.aggregate ? data.aggregate.columnAliases : null), - isDateBased = config.measures[0].time == "date", - xAxisIndex = getAxisIndex(config.axis, "x-axis"), - leftAxisIndex = getAxisIndex(config.axis, "y-axis", "left"), - rightAxisIndex = getAxisIndex(config.axis, "y-axis", "right"); - - for (var i = 0; i < seriesList.length; i++) - { - var columnName = LABKEY.vis.getColumnAlias(columnAliases, seriesList[i].aliasLookupInfo); - if (seriesList[i].yAxisSide == "left") - leftMeasures.push(columnName); - else if (seriesList[i].yAxisSide == "right") - rightMeasures.push(columnName); - } - - if (isDateBased) - { - xName = config.measures[0].dateOptions.interval; - xFunc = function(row){ - return _getRowValue(row, xName); - }; - } - else - { - var visitMap = data.individual ? data.individual.visitMap : data.aggregate.visitMap; - xName = LABKEY.vis.getColumnAlias(columnAliases, nounSingular + "Visit/Visit"); - xFunc = function(row){ - var displayProp = config.measures[0].visitOptions ? config.measures[0].visitOptions.visitDisplayProperty : defaultVisitProperty; - return visitMap[_getRowValue(row, xName, 'value')][displayProp]; - }; - } - - if (config.axis[xAxisIndex].range.type != 'automatic_per_chart') - { - if (config.axis[xAxisIndex].range.min == null) - config.axis[xAxisIndex].range.min = d3.min(rows, xFunc); - - if (config.axis[xAxisIndex].range.max == null) - config.axis[xAxisIndex].range.max = d3.max(rows, xFunc); - } - - if (config.errorBars !== 'None') - errorBarType = config.errorBars == 'SD' ? '_STDDEV' : '_STDERR'; - - if (leftAxisIndex > -1) - { - // If we have a left axis then we need to find the min/max - min = null; max = null; tempMin = null; tempMax = null; - leftAccessor = function(row) { - return _getRowValue(row, leftMeasures[i]); - }; - - if (errorBarType) - { - // If we have error bars we need to calculate min/max with the error values in mind. - leftAccessorMin = function(row) { - if (row.hasOwnProperty(leftMeasures[i] + errorBarType)) - { - var error = _getRowValue(row, leftMeasures[i] + errorBarType); - return _getRowValue(row, leftMeasures[i]) - error; - } - else - return null; - }; - - leftAccessorMax = function(row) { - if (row.hasOwnProperty(leftMeasures[i] + errorBarType)) - { - var error = _getRowValue(row, leftMeasures[i] + errorBarType); - return _getRowValue(row, leftMeasures[i]) + error; - } - else - return null; - }; - } - - if (config.axis[leftAxisIndex].range.type != 'automatic_per_chart') - { - if (config.axis[leftAxisIndex].range.min == null) - { - for (var i = 0; i < leftMeasures.length; i++) - { - tempMin = d3.min(rows, leftAccessorMin ? leftAccessorMin : leftAccessor); - min = min == null ? tempMin : tempMin < min ? tempMin : min; - } - config.axis[leftAxisIndex].range.min = min; - } - - if (config.axis[leftAxisIndex].range.max == null) - { - for (var i = 0; i < leftMeasures.length; i++) - { - tempMax = d3.max(rows, leftAccessorMax ? leftAccessorMax : leftAccessor); - max = max == null ? tempMax : tempMax > max ? tempMax : max; - } - config.axis[leftAxisIndex].range.max = max; - } - } - } - - if (rightAxisIndex > -1) - { - // If we have a right axis then we need to find the min/max - min = null; max = null; tempMin = null; tempMax = null; - rightAccessor = function(row){ - return _getRowValue(row, rightMeasures[i]); - }; - - if (errorBarType) - { - rightAccessorMin = function(row) { - if (row.hasOwnProperty(rightMeasures[i] + errorBarType)) - { - var error = _getRowValue(row, rightMeasures[i] + errorBarType); - return _getRowValue(row, rightMeasures[i]) - error; - } - else - return null; - }; - - rightAccessorMax = function(row) { - if (row.hasOwnProperty(rightMeasures[i] + errorBarType)) - { - var error = _getRowValue(row, rightMeasures[i] + errorBarType); - return _getRowValue(row, rightMeasures[i]) + error; - } - else - return null; - }; - } - - if (config.axis[rightAxisIndex].range.type != 'automatic_per_chart') - { - if (config.axis[rightAxisIndex].range.min == null) - { - for (var i = 0; i < rightMeasures.length; i++) - { - tempMin = d3.min(rows, rightAccessorMin ? rightAccessorMin : rightAccessor); - min = min == null ? tempMin : tempMin < min ? tempMin : min; - } - config.axis[rightAxisIndex].range.min = min; - } - - if (config.axis[rightAxisIndex].range.max == null) - { - for (var i = 0; i < rightMeasures.length; i++) - { - tempMax = d3.max(rows, rightAccessorMax ? rightAccessorMax : rightAccessor); - max = max == null ? tempMax : tempMax > max ? tempMax : max; - } - config.axis[rightAxisIndex].range.max = max; - } - } - } - } - }; - - /** - * Generates plot configs to be passed to the {@link LABKEY.vis.Plot} function for each chart in the report. - * @param {Object} config The chart configuration object that defines the selected measures, axis info, subjects/groups, etc. - * @param {Object} data The data object, from getChartData. - * @param {Array} seriesList The list of series that will be plotted for a given chart, from generateSeriesList. - * @param {boolean} applyClipRect A boolean indicating whether or not to clip the plotted data region, from generateApplyClipRect. - * @param {int} maxCharts The maximum number of charts to display in one report. - * @param {String} nounColumnName The name of the study subject noun column (i.e. ParticipantId). - * @returns {Array} - */ - var generatePlotConfigs = function(config, data, seriesList, applyClipRect, maxCharts, nounColumnName) { - var plotConfigInfoArr = [], - subjectColumnName = null; - - nounColumnName = nounColumnName || getStudySubjectInfo().columnName; - if (data.individual) - subjectColumnName = LABKEY.vis.getColumnAlias(data.individual.columnAliases, nounColumnName); - - var generateGroupSeries = function(rows, groups, subjectColumn) { - // subjectColumn is the aliasColumnName looked up from the getData response columnAliases array - // groups is config.subject.groups - var dataByGroup = {}; - - for (var i = 0; i < rows.length; i++) - { - var rowSubject = _getRowValue(rows[i], subjectColumn); - for (var j = 0; j < groups.length; j++) - { - if (groups[j].participantIds.indexOf(rowSubject) > -1) - { - if (!dataByGroup[groups[j].label]) - dataByGroup[groups[j].label] = []; - - dataByGroup[groups[j].label].push(rows[i]); - } - } - } - - return dataByGroup; - }; - - // four options: all series on one chart, one chart per subject, one chart per group, or one chart per measure/dimension - if (config.chartLayout == "per_subject") - { - var groupAccessor = function(row) { - return _getRowValue(row, subjectColumnName); - }; - - var dataPerParticipant = getDataWithSeriesCheck(data.individual.measureStore.records(), groupAccessor, seriesList, data.individual.columnAliases); - for (var participant in dataPerParticipant) - { - if (dataPerParticipant.hasOwnProperty(participant)) - { - // skip the group if there is no data for it - if (!dataPerParticipant[participant].hasSeriesData) - continue; - - plotConfigInfoArr.push({ - title: config.title ? config.title : participant, - subtitle: config.title ? participant : undefined, - series: seriesList, - individualData: dataPerParticipant[participant].data, - applyClipRect: applyClipRect - }); - - if (plotConfigInfoArr.length >= maxCharts) - break; - } - } - } - else if (config.chartLayout == "per_group") - { - var groupedIndividualData = null, groupedAggregateData = null; - - //Display individual lines - if (data.individual) { - groupedIndividualData = generateGroupSeries(data.individual.measureStore.records(), config.subject.groups, subjectColumnName); - } - - // Display aggregate lines - if (data.aggregate) { - var groupAccessor = function(row) { - return _getRowValue(row, 'UniqueId'); - }; - - groupedAggregateData = getDataWithSeriesCheck(data.aggregate.measureStore.records(), groupAccessor, seriesList, data.aggregate.columnAliases); - } - - for (var i = 0; i < (config.subject.groups.length > maxCharts ? maxCharts : config.subject.groups.length); i++) - { - var group = config.subject.groups[i]; - - // skip the group if there is no data for it - if ((groupedIndividualData != null && !groupedIndividualData[group.label]) - || (groupedAggregateData != null && (!groupedAggregateData[group.label] || !groupedAggregateData[group.label].hasSeriesData))) - { - continue; - } - - plotConfigInfoArr.push({ - title: config.title ? config.title : group.label, - subtitle: config.title ? group.label : undefined, - series: seriesList, - individualData: groupedIndividualData && groupedIndividualData[group.label] ? groupedIndividualData[group.label] : null, - aggregateData: groupedAggregateData && groupedAggregateData[group.label] ? groupedAggregateData[group.label].data : null, - applyClipRect: applyClipRect - }); - - if (plotConfigInfoArr.length > maxCharts) - break; - } - } - else if (config.chartLayout == "per_dimension") - { - for (var i = 0; i < (seriesList.length > maxCharts ? maxCharts : seriesList.length); i++) - { - // skip the measure/dimension if there is no data for it - if ((data.aggregate && !data.aggregate.hasData[seriesList[i].name]) - || (data.individual && !data.individual.hasData[seriesList[i].name])) - { - continue; - } - - plotConfigInfoArr.push({ - title: config.title ? config.title : seriesList[i].label, - subtitle: config.title ? seriesList[i].label : undefined, - series: [seriesList[i]], - individualData: data.individual ? data.individual.measureStore.records() : null, - aggregateData: data.aggregate ? data.aggregate.measureStore.records() : null, - applyClipRect: applyClipRect - }); - - if (plotConfigInfoArr.length > maxCharts) - break; - } - } - else if (config.chartLayout == "single") - { - //Single Line Chart, with all participants or groups. - plotConfigInfoArr.push({ - title: config.title, - series: seriesList, - individualData: data.individual ? data.individual.measureStore.records() : null, - aggregateData: data.aggregate ? data.aggregate.measureStore.records() : null, - height: 610, - style: null, - applyClipRect: applyClipRect - }); - } - - return plotConfigInfoArr; - }; - - // private function - var getDataWithSeriesCheck = function(data, groupAccessor, seriesList, columnAliases) { - /* - Groups data by the groupAccessor passed in. Also, checks for the existance of any series data for that groupAccessor. - Returns an object where each attribute will be a groupAccessor with an array of data rows and a boolean for hasSeriesData - */ - var groupedData = {}; - for (var i = 0; i < data.length; i++) - { - var value = groupAccessor(data[i]); - if (!groupedData[value]) - { - groupedData[value] = {data: [], hasSeriesData: false}; - } - groupedData[value].data.push(data[i]); - - for (var j = 0; j < seriesList.length; j++) - { - var seriesAlias = LABKEY.vis.getColumnAlias(columnAliases, seriesList[j].aliasLookupInfo); - if (seriesAlias && _getRowValue(data[i], seriesAlias) != null) - { - groupedData[value].hasSeriesData = true; - break; - } - } - } - return groupedData; - }; - - /** - * Get the index in the axes array for a given axis (ie left y-axis). - * @param {Array} axes The array of specified axis information for this chart. - * @param {String} axisName The chart axis (i.e. x-axis or y-axis). - * @param {String} [side] The y-axis side (i.e. left or right). - * @returns {number} - */ - var getAxisIndex = function(axes, axisName, side) { - var index = -1; - for (var i = 0; i < axes.length; i++) - { - if (!side && axes[i].name == axisName) - { - index = i; - break; - } - else if (axes[i].name == axisName && axes[i].side == side) - { - index = i; - break; - } - } - return index; - }; - - /** - * Get the data needed for the specified Time Chart based on the chart config. Makes calls to the - * {@link LABKEY.Query.Visualization.getData} to get the individual subject data and grouped aggregate data. - * Calls the success callback function in the config when it has received all of the requested data. - * @param {Object} config The chart configuration object that defines the selected measures, axis info, subjects/groups, etc. - */ - var getChartData = function(config) { - if (!config.success) - throw "You must specify a success callback function!"; - if (!config.failure) - throw "You must specify a failure callback function!"; - if (!config.chartInfo) - throw "You must specify a chartInfo config!"; - if (config.chartInfo.measures.length == 0) - throw "There must be at least one specified measure in the chartInfo config!"; - if (!config.chartInfo.displayIndividual && !config.chartInfo.displayAggregate) - throw "We expect to either be displaying individual series lines or aggregate data!"; - - // issue 22254: perf issues if we try to show individual lines for a group with a large number of subjects - var subjectLength = config.chartInfo.subject.values ? config.chartInfo.subject.values.length : 0; - if (config.chartInfo.displayIndividual && subjectLength > 10000) - { - config.chartInfo.displayIndividual = false; - config.chartInfo.subject.values = undefined; - } - - var chartData = {numberFormats: {}}; - var counter = config.chartInfo.displayIndividual && config.chartInfo.displayAggregate ? 2 : 1; - var isDateBased = config.chartInfo.measures[0].time == "date"; - var seriesList = generateSeriesList(config.chartInfo.measures); - - // get the visit map info for those visits in the response data - var trimVisitMapDomain = function(origVisitMap, visitsInDataArr) { - var trimmedVisits = []; - for (var v in origVisitMap) { - if (origVisitMap.hasOwnProperty(v)) { - if (visitsInDataArr.indexOf(parseInt(v)) != -1) { - trimmedVisits.push(Ext4.apply({id: v}, origVisitMap[v])); - } - } - } - // sort the trimmed visit list by displayOrder and then reset displayOrder starting at 1 - trimmedVisits.sort(function(a,b){return a.displayOrder - b.displayOrder}); - var newVisitMap = {}; - for (var i = 0; i < trimmedVisits.length; i++) - { - trimmedVisits[i].displayOrder = i + 1; - newVisitMap[trimmedVisits[i].id] = trimmedVisits[i]; - } - - return newVisitMap; - }; - - var successCallback = function(response, dataType) { - // check for success=false - if (LABKEY.Utils.isDefined(response.success) && LABKEY.Utils.isBoolean(response.success) && !response.success) - { - config.failure.call(config.scope, response); - return; - } - - // Issue 16156: for date based charts, give error message if there are no calculated interval values - if (isDateBased) { - var intervalAlias = config.chartInfo.measures[0].dateOptions.interval; - var uniqueNonNullValues = Ext4.Array.clean(response.measureStore.members(intervalAlias)); - chartData.hasIntervalData = uniqueNonNullValues.length > 0; - } - else { - chartData.hasIntervalData = true; - } - - // make sure each measure/dimension has at least some data, and get a list of which visits are in the data response - // also keep track of which measure/dimensions have negative values (for log scale) - response.hasData = {}; - response.hasNegativeValues = {}; - Ext4.each(seriesList, function(s) { - var alias = LABKEY.vis.getColumnAlias(response.columnAliases, s.aliasLookupInfo); - var uniqueNonNullValues = Ext4.Array.clean(response.measureStore.members(alias)); - - response.hasData[s.name] = uniqueNonNullValues.length > 0; - response.hasNegativeValues[s.name] = Ext4.Array.min(uniqueNonNullValues) < 0; - }); - - // trim the visit map domain to just those visits in the response data - if (!isDateBased) { - var nounSingular = config.nounSingular || getStudySubjectInfo().nounSingular; - var visitMappedName = LABKEY.vis.getColumnAlias(response.columnAliases, nounSingular + "Visit/Visit"); - var visitsInData = response.measureStore.members(visitMappedName); - response.visitMap = trimVisitMapDomain(response.visitMap, visitsInData); - } - else { - response.visitMap = {}; - } - - chartData[dataType] = response; - - generateNumberFormats(config.chartInfo, chartData, config.defaultNumberFormat); - - // if we have all request data back, return the result - counter--; - if (counter == 0) - config.success.call(config.scope, chartData); - }; - - var getSelectRowsSort = function(response, dataType) - { - var nounSingular = config.nounSingular || getStudySubjectInfo().nounSingular, - sort = dataType == 'aggregate' ? 'GroupingOrder,UniqueId' : response.measureToColumn[config.chartInfo.subject.name]; - - if (isDateBased) - { - sort += ',' + config.chartInfo.measures[0].dateOptions.interval; - } - else - { - // Issue 28529: if we have a SubjectVisit/sequencenum column, use that instead of SubjectVisit/Visit/SequenceNumMin - var sequenceNumCol = response.measureToColumn[nounSingular + 'Visit/sequencenum']; - if (!LABKEY.Utils.isDefined(sequenceNumCol)) - sequenceNumCol = response.measureToColumn[getSubjectVisitColName(nounSingular, 'SequenceNumMin')]; - - sort += ',' + response.measureToColumn[getSubjectVisitColName(nounSingular, 'DisplayOrder')] + ',' + sequenceNumCol; - } - - return sort; - }; - - var queryTempResultsForRows = function(response, dataType) - { - // Issue 28529: re-query for the actual data off of the temp query results - LABKEY.Query.MeasureStore.selectRows({ - containerPath: config.containerPath, - schemaName: response.schemaName, - queryName: response.queryName, - requiredVersion : 13.2, - maxRows: -1, - sort: getSelectRowsSort(response, dataType), - success: function(measureStore) { - response.measureStore = measureStore; - successCallback(response, dataType); - } - }); - }; - - if (config.chartInfo.displayIndividual) - { - //Get data for individual lines. - LABKEY.Query.Visualization.getData({ - metaDataOnly: true, - containerPath: config.containerPath, - success: function(response) { - queryTempResultsForRows(response, "individual"); - }, - failure : function(info, response, options) { - config.failure.call(config.scope, info, Ext4.JSON.decode(response.responseText)); - }, - measures: config.chartInfo.measures, - sorts: generateDataSortArray(config.chartInfo.subject, config.chartInfo.measures[0], isDateBased, config.nounSingular), - limit : config.dataLimit || 10000, - parameters : config.chartInfo.parameters, - filterUrl: config.chartInfo.filterUrl, - filterQuery: config.chartInfo.filterQuery - }); - } - - if (config.chartInfo.displayAggregate) - { - //Get data for Aggregates lines. - var groups = []; - for (var i = 0; i < config.chartInfo.subject.groups.length; i++) - { - var group = config.chartInfo.subject.groups[i]; - // encode the group id & type, so we can distinguish between cohort and participant group in the union table - groups.push(group.id + '-' + group.type); - } - - LABKEY.Query.Visualization.getData({ - metaDataOnly: true, - containerPath: config.containerPath, - success: function(response) { - queryTempResultsForRows(response, "aggregate"); - }, - failure : function(info) { - config.failure.call(config.scope, info); - }, - measures: config.chartInfo.measures, - groupBys: [ - // Issue 18747: if grouping by cohorts and ptid groups, order it so the cohorts are first - {schemaName: 'study', queryName: 'ParticipantGroupCohortUnion', name: 'GroupingOrder', values: [0,1]}, - {schemaName: 'study', queryName: 'ParticipantGroupCohortUnion', name: 'UniqueId', values: groups} - ], - sorts: generateDataSortArray(config.chartInfo.subject, config.chartInfo.measures[0], isDateBased, config.nounSingular), - limit : config.dataLimit || 10000, - parameters : config.chartInfo.parameters, - filterUrl: config.chartInfo.filterUrl, - filterQuery: config.chartInfo.filterQuery - }); - } - }; - - /** - * Get the set of measures from the tables/queries in the study schema. - * @param successCallback - * @param callbackScope - */ - var getStudyMeasures = function(successCallback, callbackScope) - { - if (getStudyTimepointType() != null) - { - LABKEY.Query.Visualization.getMeasures({ - filters: ['study|~'], - dateMeasures: false, - success: function (measures, response) - { - var o = Ext4.JSON.decode(response.responseText); - successCallback.call(callbackScope, o.measures); - }, - failure: this.onFailure, - scope: this - }); - } - else - { - successCallback.call(callbackScope, []); - } - }; - - /** - * If this is a container with a configured study, get the timepoint type from the study module context. - * @returns {String|null} - */ - var getStudyTimepointType = function() - { - var studyCtx = LABKEY.getModuleContext("study") || {}; - return LABKEY.Utils.isDefined(studyCtx.timepointType) ? studyCtx.timepointType : null; - }; - - /** - * Generate the number format functions for the left and right y-axis and attach them to the chart data object - * @param {Object} config The chart configuration object that defines the selected measures, axis info, subjects/groups, etc. - * @param {Object} data The data object, from getChartData. - * @param {Object} defaultNumberFormat - */ - var generateNumberFormats = function(config, data, defaultNumberFormat) { - var fields = data.individual ? data.individual.metaData.fields : data.aggregate.metaData.fields; - - for (var i = 0; i < config.axis.length; i++) - { - var axis = config.axis[i]; - if (axis.side) - { - // Find the first measure with the matching side that has a numberFormat. - for (var j = 0; j < config.measures.length; j++) - { - var measure = config.measures[j].measure; - - if (data.numberFormats[axis.side]) - break; - - if (measure.yAxis == axis.side) - { - var metaDataName = measure.alias; - for (var k = 0; k < fields.length; k++) - { - var field = fields[k]; - if (field.name == metaDataName) - { - if (field.extFormatFn) - { - data.numberFormats[axis.side] = eval(field.extFormatFn); - break; - } - } - } - } - } - - if (!data.numberFormats[axis.side]) - { - // If after all the searching we still don't have a numberformat use the default number format. - data.numberFormats[axis.side] = defaultNumberFormat; - } - } - } - }; - - /** - * Verifies the information in the chart config to make sure it has proper measures, axis info, subjects/groups, etc. - * Returns an object with a success parameter (boolean) and a message parameter (string). If the success pararameter - * is false there is a critical error and the chart cannot be rendered. If success is true the chart can be rendered. - * Message will contain an error or warning message if applicable. If message is not null and success is true, there is a warning. - * @param {Object} config The chart configuration object that defines the selected measures, axis info, subjects/groups, etc. - * @returns {Object} - */ - var validateChartConfig = function(config) { - var message = ""; - - if (!config.measures || config.measures.length == 0) - { - message = "No measure selected. Please select at lease one measure."; - return {success: false, message: message}; - } - - if (!config.axis || getAxisIndex(config.axis, "x-axis") == -1) - { - message = "Could not find x-axis in chart measure information."; - return {success: false, message: message}; - } - - if (config.chartSubjectSelection == "subjects" && config.subject.values.length == 0) - { - var nounSingular = getStudySubjectInfo().nounSingular; - message = "No " + nounSingular.toLowerCase() + " selected. " + - "Please select at least one " + nounSingular.toLowerCase() + "."; - return {success: false, message: message}; - } - - if (config.chartSubjectSelection == "groups" && config.subject.groups.length < 1) - { - message = "No group selected. Please select at least one group."; - return {success: false, message: message}; - } - - if (generateSeriesList(config.measures).length == 0) - { - message = "No series or dimension selected. Please select at least one series/dimension value."; - return {success: false, message: message}; - } - - if (!(config.displayIndividual || config.displayAggregate)) - { - message = "Please select either \"Show Individual Lines\" or \"Show Mean\"."; - return {success: false, message: message}; - } - - // issue 22254: perf issues if we try to show individual lines for a group with a large number of subjects - var subjectLength = config.subject.values ? config.subject.values.length : 0; - if (config.displayIndividual && subjectLength > 10000) - { - var nounPlural = getStudySubjectInfo().nounPlural; - message = "Unable to display individual series lines for greater than 10,000 total " + nounPlural.toLowerCase() + "."; - return {success: false, message: message}; - } - - return {success: true, message: message}; - }; - - /** - * Verifies that the chart data contains the expected interval values and measure/dimension data. Also checks to make - * sure that data can be used in a log scale (if applicable). Returns an object with a success parameter (boolean) - * and a message parameter (string). If the success pararameter is false there is a critical error and the chart - * cannot be rendered. If success is true the chart can be rendered. Message will contain an error or warning - * message if applicable. If message is not null and success is true, there is a warning. - * @param {Object} data The data object, from getChartData. - * @param {Object} config The chart configuration object that defines the selected measures, axis info, subjects/groups, etc. - * @param {Array} seriesList The list of series that will be plotted for a given chart, from generateSeriesList. - * @param {int} limit The data limit for a single report. - * @returns {Object} - */ - var validateChartData = function(data, config, seriesList, limit) { - var message = "", - sep = "", - msg = "", - commaSep = "", - noDataCounter = 0; - - // warn the user if the data limit has been reached - var individualDataCount = LABKEY.Utils.isDefined(data.individual) ? data.individual.measureStore.records().length : null; - var aggregateDataCount = LABKEY.Utils.isDefined(data.aggregate) ? data.aggregate.measureStore.records().length : null; - if (individualDataCount >= limit || aggregateDataCount >= limit) { - message += sep + "The data limit for plotting has been reached. Consider filtering your data."; - sep = "
"; - } - - // for date based charts, give error message if there are no calculated interval values - if (!data.hasIntervalData) - { - message += sep + "No calculated interval values (i.e. Days, Months, etc.) for the selected 'Measure Date' and 'Interval Start Date'."; - sep = "
"; - } - - // check to see if any of the measures don't have data - Ext4.iterate(data.aggregate ? data.aggregate.hasData : data.individual.hasData, function(key, value) { - if (!value) - { - noDataCounter++; - msg += commaSep + key; - commaSep = ", "; - } - }, this); - if (msg.length > 0) - { - msg = "No data found for the following measures/dimensions: " + msg; - - // if there is no data for any series, add to explanation - if (noDataCounter == seriesList.length) - { - var isDateBased = config && config.measures[0].time == "date"; - if (isDateBased) - msg += ". This may be the result of a missing start date value for the selected subject(s)."; - } - - message += sep + msg; - sep = "
"; - } - - // check to make sure that data can be used in a log scale (if applicable) - if (config) - { - var leftAxisIndex = getAxisIndex(config.axis, "y-axis", "left"); - var rightAxisIndex = getAxisIndex(config.axis, "y-axis", "right"); - - Ext4.each(config.measures, function(md){ - var m = md.measure; - - // check the left y-axis - if (m.yAxis == "left" && leftAxisIndex > -1 && config.axis[leftAxisIndex].scale == "log" - && ((data.individual && data.individual.hasNegativeValues && data.individual.hasNegativeValues[m.name]) - || (data.aggregate && data.aggregate.hasNegativeValues && data.aggregate.hasNegativeValues[m.name]))) - { - config.axis[leftAxisIndex].scale = "linear"; - message += sep + "Unable to use a log scale on the left y-axis. All y-axis values must be >= 0. Reverting to linear scale on left y-axis."; - sep = "
"; - } - - // check the right y-axis - if (m.yAxis == "right" && rightAxisIndex > -1 && config.axis[rightAxisIndex].scale == "log" - && ((data.individual && data.individual.hasNegativeValues[m.name]) - || (data.aggregate && data.aggregate.hasNegativeValues[m.name]))) - { - config.axis[rightAxisIndex].scale = "linear"; - message += sep + "Unable to use a log scale on the right y-axis. All y-axis values must be >= 0. Reverting to linear scale on right y-axis."; - sep = "
"; - } - - }); - } - - return {success: true, message: message}; - }; - - /** - * Support backwards compatibility for charts saved prior to chartInfo reconfiguration (2011-08-31). - * Support backwards compatibility for save thumbnail options (2012-06-19). - * @param chartInfo - * @param savedReportInfo - */ - var convertSavedReportConfig = function(chartInfo, savedReportInfo) - { - if (LABKEY.Utils.isDefined(chartInfo)) - { - Ext4.applyIf(chartInfo, { - axis: [], - //This is for charts saved prior to 2011-10-07 - chartSubjectSelection: chartInfo.chartLayout == 'per_group' ? 'groups' : 'subjects', - displayIndividual: true, - displayAggregate: false - }); - for (var i = 0; i < chartInfo.measures.length; i++) - { - var md = chartInfo.measures[i]; - - Ext4.applyIf(md.measure, {yAxis: "left"}); - - // if the axis info is in md, move it to the axis array - if (md.axis) - { - // default the y-axis to the left side if not specified - if (md.axis.name == "y-axis") - Ext4.applyIf(md.axis, {side: "left"}); - - // move the axis info to the axis array - if (getAxisIndex(chartInfo.axis, md.axis.name, md.axis.side) == -1) - chartInfo.axis.push(Ext4.apply({}, md.axis)); - - // if the chartInfo has an x-axis measure, move the date info it to the related y-axis measures - if (md.axis.name == "x-axis") - { - for (var j = 0; j < chartInfo.measures.length; j++) - { - var schema = md.measure.schemaName; - var query = md.measure.queryName; - if (chartInfo.measures[j].axis && chartInfo.measures[j].axis.name == "y-axis" - && chartInfo.measures[j].measure.schemaName == schema - && chartInfo.measures[j].measure.queryName == query) - { - chartInfo.measures[j].dateOptions = { - dateCol: Ext4.apply({}, md.measure), - zeroDateCol: Ext4.apply({}, md.dateOptions.zeroDateCol), - interval: md.dateOptions.interval - }; - } - } - - // remove the x-axis date measure from the measures array - chartInfo.measures.splice(i, 1); - i--; - } - else - { - // remove the axis property from the measure - delete md.axis; - } - } - } - } - - if (LABKEY.Utils.isObject(chartInfo) && LABKEY.Utils.isObject(savedReportInfo)) - { - if (chartInfo.saveThumbnail != undefined) - { - if (savedReportInfo.reportProps == null) - savedReportInfo.reportProps = {}; - - Ext4.applyIf(savedReportInfo.reportProps, { - thumbnailType: !chartInfo.saveThumbnail ? 'NONE' : 'AUTO' - }); - } - } - }; - - var getStudySubjectInfo = function() - { - var studyCtx = LABKEY.getModuleContext("study") || {}; - return LABKEY.Utils.isObject(studyCtx.subject) ? studyCtx.subject : { - tableName: 'Participant', - columnName: 'ParticipantId', - nounPlural: 'Participants', - nounSingular: 'Participant' - }; - }; - - var getMeasureAlias = function(measure) - { - if (LABKEY.Utils.isString(measure.alias)) - return measure.alias; - else - return measure.schemaName + '_' + measure.queryName + '_' + measure.name; - }; - - var getMeasuresLabelBySide = function(measures, side) - { - var labels = []; - Ext4.each(measures, function(measure) - { - if (measure.yAxis == side && labels.indexOf(measure.label) == -1) - labels.push(measure.label); - }); - - return labels.join(', '); - }; - - var getDistinctYAxisSides = function(measures) - { - return Ext4.Array.unique(Ext4.Array.pluck(measures, 'yAxis')); - }; - - var _getRowValue = function(row, propName, valueName) - { - if (row.hasOwnProperty(propName)) { - // backwards compatibility for response row that is not a LABKEY.Query.Row - if (!(row instanceof LABKEY.Query.Row)) { - return row[propName].displayValue || row[propName].value; - } - - var propValue = row.get(propName); - if (valueName != undefined && propValue.hasOwnProperty(valueName)) { - return propValue[valueName]; - } - else if (propValue.hasOwnProperty('displayValue')) { - return propValue['displayValue']; - } - return row.getValue(propName); - } - - return undefined; - }; - - var renderChartSVG = function(renderTo, queryConfig, chartConfig) { - // Before we load the data, validate some information about the chart config - var messages = []; - var validation = validateChartConfig(chartConfig); - if (validation.message != null) - { - messages.push(validation.message); - } - if (!validation.success) - { - _renderMessages(renderTo, messages); - return; - } - - var nounSingular = 'Participant'; - var subjectColumnName = 'ParticipantId'; - if (LABKEY.moduleContext.study && LABKEY.moduleContext.study.subject) - { - nounSingular = LABKEY.moduleContext.study.subject.nounSingular; - subjectColumnName = LABKEY.moduleContext.study.subject.columnName; - } - - // When all the dependencies are loaded, we load the data using time chart helper getChartData - Ext4.applyIf(queryConfig, { - chartInfo: chartConfig, - containerPath: LABKEY.container.path, - nounSingular: nounSingular, - subjectColumnName: subjectColumnName, - dataLimit: 10000, - maxCharts: 20, - defaultMultiChartHeight: 380, - defaultSingleChartHeight: 600, - defaultWidth: 1075, - defaultNumberFormat: function(v) { return v.toFixed(1); } - }); - - queryConfig.success = function(response) { - _getChartDataCallback(renderTo, queryConfig, chartConfig, response); - }; - queryConfig.failure = function(info) { - _renderMessages(renderTo, ['Error: ' + info.exception]); - }; - - LABKEY.vis.TimeChartHelper.getChartData(queryConfig); - }; - - var _getChartDataCallback = function(renderTo, queryConfig, chartConfig, responseData) { - var individualColumnAliases = responseData.individual ? responseData.individual.columnAliases : null; - var aggregateColumnAliases = responseData.aggregate ? responseData.aggregate.columnAliases : null; - var visitMap = responseData.individual ? responseData.individual.visitMap : responseData.aggregate.visitMap; - var intervalKey = generateIntervalKey(chartConfig, individualColumnAliases, aggregateColumnAliases, queryConfig.nounSingular); - var aes = generateAes(chartConfig, visitMap, individualColumnAliases, intervalKey, queryConfig.subjectColumnName); - var tickMap = generateTickMap(visitMap); - var seriesList = generateSeriesList(chartConfig.measures); - var applyClipRect = generateApplyClipRect(chartConfig); - - // Once we have the data, we can set all of the axis min/max range values - generateAcrossChartAxisRanges(chartConfig, responseData, seriesList, queryConfig.nounSingular); - var scales = generateScales(chartConfig, tickMap, responseData.numberFormats); - - // Validate that the chart data has expected values and give warnings if certain elements are not present - var messages = []; - var validation = validateChartData(responseData, chartConfig, seriesList, queryConfig.dataLimit, false); - if (validation.message != null) - { - messages.push(validation.message); - } - if (!validation.success) - { - _renderMessages(renderTo, messages); - return; - } - - // For time charts, we allow multiple plots to be displayed by participant, group, or measure/dimension - var plotConfigsArr = generatePlotConfigs(chartConfig, responseData, seriesList, applyClipRect, queryConfig.maxCharts, queryConfig.subjectColumnName); - for (var configIndex = 0; configIndex < plotConfigsArr.length; configIndex++) - { - var clipRect = plotConfigsArr[configIndex].applyClipRect; - var series = plotConfigsArr[configIndex].series; - var height = chartConfig.height || (plotConfigsArr.length > 1 ? queryConfig.defaultMultiChartHeight : queryConfig.defaultSingleChartHeight); - var width = chartConfig.width || queryConfig.defaultWidth; - var labels = generateLabels(plotConfigsArr[configIndex].title, chartConfig.axis, plotConfigsArr[configIndex].subtitle); - var layers = generateLayers(chartConfig, visitMap, individualColumnAliases, aggregateColumnAliases, plotConfigsArr[configIndex].aggregateData, series, intervalKey, queryConfig.subjectColumnName); - var data = plotConfigsArr[configIndex].individualData ? plotConfigsArr[configIndex].individualData : plotConfigsArr[configIndex].aggregateData; - - var plotConfig = { - renderTo: renderTo, - rendererType: 'd3', - clipRect: clipRect, - width: width, - height: height, - labels: labels, - aes: aes, - scales: scales, - layers: layers, - data: data - }; - - var plot = new LABKEY.vis.Plot(plotConfig); - plot.render(); - } - - // Give a warning if the max number of charts has been exceeded - if (plotConfigsArr.length >= queryConfig.maxCharts) - messages.push('Only showing the first ' + queryConfig.maxCharts + ' charts.'); - - _renderMessages(renderTo, messages); - }; - - var _renderMessages = function(id, messages) { - var messageDiv; - var el = document.getElementById(id); - var child; - if (el && el.children.length > 0) - child = el.children[0]; - - for (var i = 0; i < messages.length; i++) - { - messageDiv = document.createElement('div'); - messageDiv.setAttribute('style', 'font-style:italic'); - messageDiv.innerHTML = messages[i]; - if (child) - el.insertBefore(messageDiv, child); - else - el.appendChild(messageDiv); - } - }; - - return { - /** - * Loads all of the required dependencies for a Time Chart. - * @param {Function} callback The callback to be executed when all of the visualization dependencies have been loaded. - * @param {Object} scope The scope to be used when executing the callback. - */ - loadVisDependencies: LABKEY.requiresVisualization, - generateAcrossChartAxisRanges : generateAcrossChartAxisRanges, - generateAes : generateAes, - generateApplyClipRect : generateApplyClipRect, - generateIntervalKey : generateIntervalKey, - generateLabels : generateLabels, - generateLayers : generateLayers, - generatePlotConfigs : generatePlotConfigs, - generateScales : generateScales, - generateSeriesList : generateSeriesList, - generateTickMap : generateTickMap, - generateNumberFormats : generateNumberFormats, - getAxisIndex : getAxisIndex, - getMeasureAlias : getMeasureAlias, - getMeasuresLabelBySide : getMeasuresLabelBySide, - getDistinctYAxisSides : getDistinctYAxisSides, - getStudyTimepointType : getStudyTimepointType, - getStudySubjectInfo : getStudySubjectInfo, - getStudyMeasures : getStudyMeasures, - getChartData : getChartData, - validateChartConfig : validateChartConfig, - validateChartData : validateChartData, - convertSavedReportConfig : convertSavedReportConfig, - renderChartSVG: renderChartSVG - }; -}; +/* + * Copyright (c) 2013-2019 LabKey Corporation + * + * Licensed under the Apache License, Version 2.0: http://www.apache.org/licenses/LICENSE-2.0 + */ +if(!LABKEY.vis) { + LABKEY.vis = {}; +} + +/** + * @namespace Namespace used to encapsulate functions related to creating study Time Charts. + * Used in the Chart Wizard and when exporting Time Charts as scripts. + */ +LABKEY.vis.TimeChartHelper = new function() { + + var $ = jQuery; + var defaultVisitProperty = 'displayOrder'; + + /** + * Generate the main title and axis labels for the chart based on the specified x-axis and y-axis (left and right) labels. + * @param {String} mainTitle The label to be used as the main chart title. + * @param {String} subtitle The label to be used as the chart subtitle. + * @param {Array} axisArr An array of axis information including the x-axis and y-axis (left and right) labels. + * @returns {Object} + */ + var generateLabels = function(mainTitle, axisArr, subtitle) { + var xTitle = '', yLeftTitle = '', yRightTitle = ''; + for (var i = 0; i < axisArr.length; i++) + { + var axis = axisArr[i]; + if (axis.name == "y-axis") + { + if (axis.side == "left") + yLeftTitle = axis.label; + else + yRightTitle = axis.label; + } + else + { + xTitle = axis.label; + } + } + + return { + main : { value : mainTitle }, + subtitle : { value : subtitle, color: '#404040' }, + x : { value : xTitle }, + yLeft : { value : yLeftTitle }, + yRight : { value : yRightTitle } + }; + }; + + /** + * Generates an object containing {@link LABKEY.vis.Scale} objects used for the chart. + * @param {Object} config The chart configuration object that defines the selected measures, axis info, subjects/groups, etc. + * @param {Object} tickMap For visit based charts, the x-axis tick mark mapping, from generateTickMap. + * @param {Object} numberFormats The number format functions to use for the x-axis and y-axis (left and right) tick marks. + * @returns {Object} + */ + var generateScales = function(config, tickMap, numberFormats) { + if (config.measures.length == 0) + throw "There must be at least one specified measure in the chartInfo config!"; + + var xMin = null, xMax = null, xTrans = null, xTickFormat, xTickHoverText, + yLeftMin = null, yLeftMax = null, yLeftTrans = null, yLeftTickFormat, + yRightMin = null, yRightMax = null, yRightTrans = null, yRightTickFormat, + valExponentialDigits = 6; + + for (var i = 0; i < config.axis.length; i++) + { + var axis = config.axis[i]; + if (axis.name == "y-axis") + { + if (axis.side == "left") + { + yLeftMin = typeof axis.range.min == "number" ? axis.range.min : (config.hasNoData ? 0 : null); + yLeftMax = typeof axis.range.max == "number" ? axis.range.max : (config.hasNoData ? 10 : null); + yLeftTrans = axis.scale ? axis.scale : "linear"; + yLeftTickFormat = function(value) { + if (LABKEY.Utils.isNumber(value) && Math.abs(Math.round(value)).toString().length >= valExponentialDigits) { + return value.toExponential(); + } + else if (LABKEY.Utils.isFunction(numberFormats.left)) { + return numberFormats.left(value); + } + return value; + } + } + else + { + yRightMin = typeof axis.range.min == "number" ? axis.range.min : (config.hasNoData ? 0 : null); + yRightMax = typeof axis.range.max == "number" ? axis.range.max : (config.hasNoData ? 10 : null); + yRightTrans = axis.scale ? axis.scale : "linear"; + yRightTickFormat = function(value) { + if (LABKEY.Utils.isNumber(value) && Math.abs(Math.round(value)).toString().length >= valExponentialDigits) { + return value.toExponential(); + } + else if (LABKEY.Utils.isFunction(numberFormats.right)) { + return numberFormats.right(value); + } + return value; + } + } + } + else + { + xMin = typeof axis.range.min == "number" ? axis.range.min : null; + xMax = typeof axis.range.max == "number" ? axis.range.max : null; + xTrans = axis.scale ? axis.scale : "linear"; + } + } + + if (config.measures[0].time == "visit" && (config.measures[0].visitOptions === undefined || config.measures[0].visitOptions.visitDisplayProperty === defaultVisitProperty)) + { + xTickFormat = function(value) { + return tickMap[value] ? tickMap[value].label : ""; + }; + + xTickHoverText = function(value) { + return tickMap[value] ? tickMap[value].description : ""; + }; + } + // Issue 27309: Don't show decimal values on x-axis for date-based time charts with interval = "Days" + else if (config.measures[0].time == 'date' && config.measures[0].dateOptions.interval == 'Days') + { + xTickFormat = function(value) { + return LABKEY.Utils.isNumber(value) && value % 1 != 0 ? null : value; + }; + } + + return { + x: { + scaleType : 'continuous', + trans : xTrans, + domain : [xMin, xMax], + tickFormat : xTickFormat ? xTickFormat : null, + tickHoverText : xTickHoverText ? xTickHoverText : null + }, + yLeft: { + scaleType : 'continuous', + trans : yLeftTrans, + domain : [yLeftMin, yLeftMax], + tickFormat : yLeftTickFormat ? yLeftTickFormat : null + }, + yRight: { + scaleType : 'continuous', + trans : yRightTrans, + domain : [yRightMin, yRightMax], + tickFormat : yRightTickFormat ? yRightTickFormat : null + }, + shape: { + scaleType : 'discrete' + } + }; + }; + + /** + * Generate the x-axis interval column alias key. For date based charts, this will be a time interval (i.e. Days, Weeks, etc.) + * and for visit based charts, this will be the column alias for the visit field. + * @param {Object} config The chart configuration object that defines the selected measures, axis info, subjects/groups, etc. + * @param {Array} individualColumnAliases The array of column aliases for the individual subject data. + * @param {Array} aggregateColumnAliases The array of column aliases for the group/cohort aggregate data. + * @param {String} nounSingular The singular name of the study subject noun (i.e. Participant). + * @returns {String} + */ + var generateIntervalKey = function(config, individualColumnAliases, aggregateColumnAliases, nounSingular) + { + nounSingular = nounSingular || getStudySubjectInfo().nounSingular; + + if (config.measures.length == 0) + throw "There must be at least one specified measure in the chartInfo config!"; + if (!individualColumnAliases && !aggregateColumnAliases) + throw "We expect to either be displaying individual series lines or aggregate data!"; + + if (config.measures[0].time == "date") + { + return config.measures[0].dateOptions.interval; + } + else + { + return individualColumnAliases ? + LABKEY.vis.getColumnAlias(individualColumnAliases, nounSingular + "Visit/Visit") : + LABKEY.vis.getColumnAlias(aggregateColumnAliases, nounSingular + "Visit/Visit"); + } + }; + + /** + * Generate that x-axis tick mark mapping for a visit based chart. + * @param {Object} visitMap For visit based charts, the study visit information map. + * @returns {Object} + */ + var generateTickMap = function(visitMap) { + var tickMap = {}; + for (var rowId in visitMap) + { + if (visitMap.hasOwnProperty(rowId)) + { + tickMap[visitMap[rowId][defaultVisitProperty]] = { + label: visitMap[rowId].displayName, + description: visitMap[rowId].description || visitMap[rowId].displayName + }; + } + } + + return tickMap; + }; + + /** + * Generates the aesthetic map object needed by the visualization API to render the chart. See {@link LABKEY.vis.Plot} + * and {@link LABKEY.vis.Layer}. + * @param {Object} config The chart configuration object that defines the selected measures, axis info, subjects/groups, etc. + * @param {Object} visitMap For visit based charts, the study visit information map. + * @param {Array} individualColumnAliases The array of column aliases for the individual subject data. + * @param {String} intervalKey The x-axis interval column alias key (i.e. Days, Weeks, etc.), from generateIntervalKey. + * @param {String} nounColumnName The name of the study subject noun column (i.e. ParticipantId). + * @returns {Object} + */ + var generateAes = function(config, visitMap, individualColumnAliases, intervalKey, nounColumnName) + { + nounColumnName = nounColumnName || getStudySubjectInfo().columnName; + + if (config.measures.length == 0) + throw "There must be at least one specified measure in the chartInfo config!"; + + var xAes; + if (config.measures[0].time == "date") { + xAes = function(row) { + return _getRowValue(row, intervalKey); + }; + } + else { + xAes = function(row) { + var displayProp = config.measures[0].visitOptions ? config.measures[0].visitOptions.visitDisplayProperty : defaultVisitProperty; + return visitMap[_getRowValue(row, intervalKey, 'value')][displayProp]; + }; + } + + var individualSubjectColumn = individualColumnAliases ? LABKEY.vis.getColumnAlias(individualColumnAliases, nounColumnName) : null; + + return { + x: xAes, + color: function(row) { + return _getRowValue(row, individualSubjectColumn); + }, + group: function(row) { + return _getRowValue(row, individualSubjectColumn); + }, + shape: function(row) { + return _getRowValue(row, individualSubjectColumn); + }, + pathColor: function(rows) { + return LABKEY.Utils.isArray(rows) && rows.length > 0 ? _getRowValue(rows[0], individualSubjectColumn) : null; + } + }; + }; + + /** + * Generate an array of {@link LABKEY.vis.Layer} objects based on the selected chart series list. + * @param {Object} config The chart configuration object that defines the selected measures, axis info, subjects/groups, etc. + * @param {Object} visitMap For visit based charts, the study visit information map. + * @param {Array} individualColumnAliases The array of column aliases for the individual subject data. + * @param {Array} aggregateColumnAliases The array of column aliases for the group/cohort aggregate data. + * @param {Array} aggregateData The array of group/cohort aggregate data, from getChartData. + * @param {Array} seriesList The list of series that will be plotted for a given chart, from generateSeriesList. + * @param {String} intervalKey The x-axis interval column alias key (i.e. Days, Weeks, etc.), from generateIntervalKey. + * @param {String} nounColumnName The name of the study subject noun column (i.e. ParticipantId). + * @returns {Array} + */ + var generateLayers = function(config, visitMap, individualColumnAliases, aggregateColumnAliases, aggregateData, seriesList, intervalKey, nounColumnName) + { + nounColumnName = nounColumnName || getStudySubjectInfo().columnName; + + if (config.measures.length == 0) + throw "There must be at least one specified measure in the chartInfo config!"; + if (!individualColumnAliases && !aggregateColumnAliases) + throw "We expect to either be displaying individual series lines or aggregate data!"; + + var layers = []; + var isDateBased = config.measures[0].time == "date"; + var individualSubjectColumn = individualColumnAliases ? LABKEY.vis.getColumnAlias(individualColumnAliases, nounColumnName) : null; + var aggregateSubjectColumn = "UniqueId"; + + var generateLayerAes = function(name, yAxisSide, columnName){ + var yName = yAxisSide == "left" ? "yLeft" : "yRight"; + var aes = {}; + aes[yName] = function(row) { + // Have to parseFloat because for some reason ObsCon from Luminex was returning strings not floats/ints. + return row[columnName] ? parseFloat(_getRowValue(row, columnName)) : null; + }; + return aes; + }; + + var generateAggregateLayerAes = function(name, yAxisSide, columnName, intervalKey, subjectColumn, errorColumn){ + var yName = yAxisSide == "left" ? "yLeft" : "yRight"; + var aes = {}; + aes[yName] = function(row) { + // Have to parseFloat because for some reason ObsCon from Luminex was returning strings not floats/ints. + return row[columnName] ? parseFloat(_getRowValue(row, columnName)) : null; + }; + aes.group = aes.color = aes.shape = function(row) { + return _getRowValue(row, subjectColumn); + }; + aes.pathColor = function(rows) { + return LABKEY.Utils.isArray(rows) && rows.length > 0 ? _getRowValue(rows[0], subjectColumn) : null; + }; + aes.error = function(row) { + return row[errorColumn] ? _getRowValue(row, errorColumn) : null; + }; + return aes; + }; + + var hoverTextFn = function(subjectColumn, intervalKey, name, columnName, visitMap, errorColumn, errorType){ + if (visitMap) + { + if (errorColumn) + { + return function(row){ + var subject = _getRowValue(row, subjectColumn); + var errorVal = _getRowValue(row, errorColumn) || 'n/a'; + return ' ' + subject + ',\n '+ visitMap[_getRowValue(row, intervalKey, 'value')].displayName + + ',\n ' + name + ': ' + _getRowValue(row, columnName) + + ',\n ' + errorType + ': ' + errorVal; + } + } + else + { + return function(row){ + var subject = _getRowValue(row, subjectColumn); + return ' ' + subject + ',\n '+ visitMap[_getRowValue(row, intervalKey, 'value')].displayName + + ',\n ' + name + ': ' + _getRowValue(row, columnName); + }; + } + } + else + { + if (errorColumn) + { + return function(row){ + var subject = _getRowValue(row, subjectColumn); + var errorVal = _getRowValue(row, errorColumn) || 'n/a'; + return ' ' + subject + ',\n ' + intervalKey + ': ' + _getRowValue(row, intervalKey) + + ',\n ' + name + ': ' + _getRowValue(row, columnName) + + ',\n ' + errorType + ': ' + errorVal; + }; + } + else + { + return function(row){ + var subject = _getRowValue(row, subjectColumn); + return ' ' + subject + ',\n ' + intervalKey + ': ' + _getRowValue(row, intervalKey) + + ',\n ' + name + ': ' + _getRowValue(row, columnName); + }; + } + } + }; + + // Issue 15369: if two measures have the same name, use the alias for the subsequent series names (which will be unique) + // Issue 12369: if rendering two measures of the same pivoted value, use measure and pivot name for series names (which will be unique) + var useUniqueSeriesNames = false; + var uniqueChartSeriesNames = []; + for (var i = 0; i < seriesList.length; i++) + { + if (uniqueChartSeriesNames.indexOf(seriesList[i].name) > -1) + { + useUniqueSeriesNames = true; + break; + } + uniqueChartSeriesNames.push(seriesList[i].name); + } + + for (var i = seriesList.length -1; i >= 0; i--) + { + var chartSeries = seriesList[i]; + + var chartSeriesName = chartSeries.label; + if (useUniqueSeriesNames) + { + if (chartSeries.aliasLookupInfo.pivotValue) + chartSeriesName = chartSeries.aliasLookupInfo.measureName + " " + chartSeries.aliasLookupInfo.pivotValue; + else + chartSeriesName = chartSeries.aliasLookupInfo.alias; + } + + var columnName = individualColumnAliases ? LABKEY.vis.getColumnAlias(individualColumnAliases, chartSeries.aliasLookupInfo) : LABKEY.vis.getColumnAlias(aggregateColumnAliases, chartSeries.aliasLookupInfo); + if (individualColumnAliases) + { + if (!config.hideTrendLine) { + var pathLayerConfig = { + geom: new LABKEY.vis.Geom.Path({size: config.lineWidth}), + aes: generateLayerAes(chartSeriesName, chartSeries.yAxisSide, columnName) + }; + + if (seriesList.length > 1) + pathLayerConfig.name = chartSeriesName; + + layers.push(new LABKEY.vis.Layer(pathLayerConfig)); + } + + if (!config.hideDataPoints) + { + var pointLayerConfig = { + geom: new LABKEY.vis.Geom.Point(), + aes: generateLayerAes(chartSeriesName, chartSeries.yAxisSide, columnName) + }; + + if (seriesList.length > 1) + pointLayerConfig.name = chartSeriesName; + + if (isDateBased) + pointLayerConfig.aes.hoverText = hoverTextFn(individualSubjectColumn, intervalKey, chartSeriesName, columnName, null, null, null); + else + pointLayerConfig.aes.hoverText = hoverTextFn(individualSubjectColumn, intervalKey, chartSeriesName, columnName, visitMap, null, null); + + if (config.pointClickFn) + { + pointLayerConfig.aes.pointClickFn = generatePointClickFn( + config.pointClickFn, + {participant: individualSubjectColumn, interval: intervalKey, measure: columnName}, + {schemaName: chartSeries.schemaName, queryName: chartSeries.queryName, name: chartSeriesName} + ); + } + + layers.push(new LABKEY.vis.Layer(pointLayerConfig)); + } + } + + if (aggregateData && aggregateColumnAliases) + { + var errorBarType = null; + if (config.errorBars == 'SD') + errorBarType = '_STDDEV'; + else if (config.errorBars == 'SEM') + errorBarType = '_STDERR'; + + var errorColumnName = errorBarType ? LABKEY.vis.getColumnAlias(aggregateColumnAliases, chartSeries.aliasLookupInfo) + errorBarType : null; + + if (!config.hideTrendLine) { + var aggregatePathLayerConfig = { + data: aggregateData, + geom: new LABKEY.vis.Geom.Path({size: config.lineWidth}), + aes: generateAggregateLayerAes(chartSeriesName, chartSeries.yAxisSide, columnName, intervalKey, aggregateSubjectColumn, errorColumnName) + }; + + if (seriesList.length > 1) + aggregatePathLayerConfig.name = chartSeriesName; + + layers.push(new LABKEY.vis.Layer(aggregatePathLayerConfig)); + } + + if (errorColumnName) + { + var aggregateErrorLayerConfig = { + data: aggregateData, + geom: new LABKEY.vis.Geom.ErrorBar({ showVertical: true }), + aes: generateAggregateLayerAes(chartSeriesName, chartSeries.yAxisSide, columnName, intervalKey, aggregateSubjectColumn, errorColumnName) + }; + + if (seriesList.length > 1) + aggregateErrorLayerConfig.name = chartSeriesName; + + layers.push(new LABKEY.vis.Layer(aggregateErrorLayerConfig)); + } + + if (!config.hideDataPoints) + { + var aggregatePointLayerConfig = { + data: aggregateData, + geom: new LABKEY.vis.Geom.Point(), + aes: generateAggregateLayerAes(chartSeriesName, chartSeries.yAxisSide, columnName, intervalKey, aggregateSubjectColumn, errorColumnName) + }; + + if (seriesList.length > 1) + aggregatePointLayerConfig.name = chartSeriesName; + + if (isDateBased) + aggregatePointLayerConfig.aes.hoverText = hoverTextFn(aggregateSubjectColumn, intervalKey, chartSeriesName, columnName, null, errorColumnName, config.errorBars) + else + aggregatePointLayerConfig.aes.hoverText = hoverTextFn(aggregateSubjectColumn, intervalKey, chartSeriesName, columnName, visitMap, errorColumnName, config.errorBars); + + if (config.pointClickFn) + { + aggregatePointLayerConfig.aes.pointClickFn = generatePointClickFn( + config.pointClickFn, + {group: aggregateSubjectColumn, interval: intervalKey, measure: columnName}, + {schemaName: chartSeries.schemaName, queryName: chartSeries.queryName, name: chartSeriesName} + ); + } + + layers.push(new LABKEY.vis.Layer(aggregatePointLayerConfig)); + } + } + } + + return layers; + }; + + // private function + var generatePointClickFn = function(fnString, columnMap, measureInfo){ + // the developer is expected to return a function, so we encapalate it within the anonymous function + // (note: the function should have already be validated in a try/catch when applied via the developerOptionsPanel) + + // using new Function is quicker than eval(), even in IE. + var pointClickFn = new Function('return ' + fnString)(); + return function(clickEvent, data) { + pointClickFn(data, columnMap, measureInfo, clickEvent); + }; + }; + + /** + * Generate the list of series to be plotted in a given Time Chart. A series will be created for each measure and + * dimension that is selected in the chart. + * @param {Array} measures The array of selected measures from the chart config. + * @returns {Array} + */ + var generateSeriesList = function(measures) { + var arr = []; + for (var i = 0; i < measures.length; i++) + { + var md = measures[i]; + + if (md.dimension && md.dimension.values) + { + Ext4.each(md.dimension.values, function(val) { + arr.push({ + schemaName: md.dimension.schemaName, + queryName: md.dimension.queryName, + name: val, + label: val, + measureIndex: i, + yAxisSide: md.measure.yAxis, + aliasLookupInfo: {measureName: md.measure.name, pivotValue: val} + }); + }); + } + else + { + arr.push({ + schemaName: md.measure.schemaName, + queryName: md.measure.queryName, + name: md.measure.name, + label: md.measure.label, + measureIndex: i, + yAxisSide: md.measure.yAxis, + aliasLookupInfo: md.measure.alias ? {alias: md.measure.alias} : {measureName: md.measure.name} + }); + } + } + return arr; + }; + + // private function + var generateDataSortArray = function(subject, firstMeasure, isDateBased, nounSingular) + { + nounSingular = nounSingular || getStudySubjectInfo().nounSingular; + var hasDateCol = firstMeasure.dateOptions && firstMeasure.dateOptions.dateCol; + + return [ + subject, + { + schemaName : hasDateCol ? firstMeasure.dateOptions.dateCol.schemaName : firstMeasure.measure.schemaName, + queryName : hasDateCol ? firstMeasure.dateOptions.dateCol.queryName : firstMeasure.measure.queryName, + name : isDateBased && hasDateCol ? firstMeasure.dateOptions.dateCol.name : getSubjectVisitColName(nounSingular, 'DisplayOrder') + }, + { + schemaName : hasDateCol ? firstMeasure.dateOptions.dateCol.schemaName : firstMeasure.measure.schemaName, + queryName : hasDateCol ? firstMeasure.dateOptions.dateCol.queryName : firstMeasure.measure.queryName, + name : (isDateBased ? nounSingular + "Visit/Visit" : getSubjectVisitColName(nounSingular, 'SequenceNumMin')) + } + ]; + }; + + var getSubjectVisitColName = function(nounSingular, suffix) + { + var nounSingular = nounSingular || getStudySubjectInfo().nounSingular; + return nounSingular + 'Visit/Visit/' + suffix; + }; + + /** + * Determine whether or not the chart needs to clip the plotted lines and points based on manually set axis ranges. + * @param {Object} config The chart configuration object that defines the selected measures, axis info, subjects/groups, etc. + * @returns {boolean} + */ + var generateApplyClipRect = function(config) { + var xAxisIndex = getAxisIndex(config.axis, "x-axis"); + var leftAxisIndex = getAxisIndex(config.axis, "y-axis", "left"); + var rightAxisIndex = getAxisIndex(config.axis, "y-axis", "right"); + + return ( + xAxisIndex > -1 && (config.axis[xAxisIndex].range.min != null || config.axis[xAxisIndex].range.max != null) || + leftAxisIndex > -1 && (config.axis[leftAxisIndex].range.min != null || config.axis[leftAxisIndex].range.max != null) || + rightAxisIndex > -1 && (config.axis[rightAxisIndex].range.min != null || config.axis[rightAxisIndex].range.max != null) + ); + }; + + /** + * Generates axis range min and max values based on the full Time Chart data. This will be used when plotting multiple + * charts that are set to use the same axis ranges across all charts in the report. + * @param {Object} config The chart configuration object that defines the selected measures, axis info, subjects/groups, etc. + * @param {Object} data The data object, from getChartData. + * @param {Array} seriesList The list of series that will be plotted for a given chart, from generateSeriesList. + * @param {String} nounSingular The singular name of the study subject noun (i.e. Participant). + */ + var generateAcrossChartAxisRanges = function(config, data, seriesList, nounSingular) + { + nounSingular = nounSingular || getStudySubjectInfo().nounSingular; + + if (config.measures.length == 0) + throw "There must be at least one specified measure in the chartInfo config!"; + if (!data.individual && !data.aggregate) + throw "We expect to either be displaying individual series lines or aggregate data!"; + + var rows = []; + if (LABKEY.Utils.isDefined(data.individual)) { + rows = data.individual.measureStore.records(); + } + else if (LABKEY.Utils.isDefined(data.aggregate)) { + rows = data.aggregate.measureStore.records(); + } + + config.hasNoData = rows.length == 0; + + // In multi-chart case, we need to pre-compute the default axis ranges so that all charts share them + // (if 'automatic across charts' is selected for the given axis) + if (config.chartLayout != "single") + { + var leftMeasures = [], + rightMeasures = [], + xName, xFunc, + min, max, tempMin, tempMax, errorBarType, + leftAccessor, leftAccessorMax, leftAccessorMin, rightAccessorMax, rightAccessorMin, rightAccessor, + columnAliases = data.individual ? data.individual.columnAliases : (data.aggregate ? data.aggregate.columnAliases : null), + isDateBased = config.measures[0].time == "date", + xAxisIndex = getAxisIndex(config.axis, "x-axis"), + leftAxisIndex = getAxisIndex(config.axis, "y-axis", "left"), + rightAxisIndex = getAxisIndex(config.axis, "y-axis", "right"); + + for (var i = 0; i < seriesList.length; i++) + { + var columnName = LABKEY.vis.getColumnAlias(columnAliases, seriesList[i].aliasLookupInfo); + if (seriesList[i].yAxisSide == "left") + leftMeasures.push(columnName); + else if (seriesList[i].yAxisSide == "right") + rightMeasures.push(columnName); + } + + if (isDateBased) + { + xName = config.measures[0].dateOptions.interval; + xFunc = function(row){ + return _getRowValue(row, xName); + }; + } + else + { + var visitMap = data.individual ? data.individual.visitMap : data.aggregate.visitMap; + xName = LABKEY.vis.getColumnAlias(columnAliases, nounSingular + "Visit/Visit"); + xFunc = function(row){ + var displayProp = config.measures[0].visitOptions ? config.measures[0].visitOptions.visitDisplayProperty : defaultVisitProperty; + return visitMap[_getRowValue(row, xName, 'value')][displayProp]; + }; + } + + if (config.axis[xAxisIndex].range.type != 'automatic_per_chart') + { + if (config.axis[xAxisIndex].range.min == null) + config.axis[xAxisIndex].range.min = d3.min(rows, xFunc); + + if (config.axis[xAxisIndex].range.max == null) + config.axis[xAxisIndex].range.max = d3.max(rows, xFunc); + } + + if (config.errorBars !== 'None') + errorBarType = config.errorBars == 'SD' ? '_STDDEV' : '_STDERR'; + + if (leftAxisIndex > -1) + { + // If we have a left axis then we need to find the min/max + min = null; max = null; tempMin = null; tempMax = null; + leftAccessor = function(row) { + return _getRowValue(row, leftMeasures[i]); + }; + + if (errorBarType) + { + // If we have error bars we need to calculate min/max with the error values in mind. + leftAccessorMin = function(row) { + if (row.hasOwnProperty(leftMeasures[i] + errorBarType)) + { + var error = _getRowValue(row, leftMeasures[i] + errorBarType); + return _getRowValue(row, leftMeasures[i]) - error; + } + else + return null; + }; + + leftAccessorMax = function(row) { + if (row.hasOwnProperty(leftMeasures[i] + errorBarType)) + { + var error = _getRowValue(row, leftMeasures[i] + errorBarType); + return _getRowValue(row, leftMeasures[i]) + error; + } + else + return null; + }; + } + + if (config.axis[leftAxisIndex].range.type != 'automatic_per_chart') + { + if (config.axis[leftAxisIndex].range.min == null) + { + for (var i = 0; i < leftMeasures.length; i++) + { + tempMin = d3.min(rows, leftAccessorMin ? leftAccessorMin : leftAccessor); + min = min == null ? tempMin : tempMin < min ? tempMin : min; + } + config.axis[leftAxisIndex].range.min = min; + } + + if (config.axis[leftAxisIndex].range.max == null) + { + for (var i = 0; i < leftMeasures.length; i++) + { + tempMax = d3.max(rows, leftAccessorMax ? leftAccessorMax : leftAccessor); + max = max == null ? tempMax : tempMax > max ? tempMax : max; + } + config.axis[leftAxisIndex].range.max = max; + } + } + } + + if (rightAxisIndex > -1) + { + // If we have a right axis then we need to find the min/max + min = null; max = null; tempMin = null; tempMax = null; + rightAccessor = function(row){ + return _getRowValue(row, rightMeasures[i]); + }; + + if (errorBarType) + { + rightAccessorMin = function(row) { + if (row.hasOwnProperty(rightMeasures[i] + errorBarType)) + { + var error = _getRowValue(row, rightMeasures[i] + errorBarType); + return _getRowValue(row, rightMeasures[i]) - error; + } + else + return null; + }; + + rightAccessorMax = function(row) { + if (row.hasOwnProperty(rightMeasures[i] + errorBarType)) + { + var error = _getRowValue(row, rightMeasures[i] + errorBarType); + return _getRowValue(row, rightMeasures[i]) + error; + } + else + return null; + }; + } + + if (config.axis[rightAxisIndex].range.type != 'automatic_per_chart') + { + if (config.axis[rightAxisIndex].range.min == null) + { + for (var i = 0; i < rightMeasures.length; i++) + { + tempMin = d3.min(rows, rightAccessorMin ? rightAccessorMin : rightAccessor); + min = min == null ? tempMin : tempMin < min ? tempMin : min; + } + config.axis[rightAxisIndex].range.min = min; + } + + if (config.axis[rightAxisIndex].range.max == null) + { + for (var i = 0; i < rightMeasures.length; i++) + { + tempMax = d3.max(rows, rightAccessorMax ? rightAccessorMax : rightAccessor); + max = max == null ? tempMax : tempMax > max ? tempMax : max; + } + config.axis[rightAxisIndex].range.max = max; + } + } + } + } + }; + + /** + * Generates plot configs to be passed to the {@link LABKEY.vis.Plot} function for each chart in the report. + * @param {Object} config The chart configuration object that defines the selected measures, axis info, subjects/groups, etc. + * @param {Object} data The data object, from getChartData. + * @param {Array} seriesList The list of series that will be plotted for a given chart, from generateSeriesList. + * @param {boolean} applyClipRect A boolean indicating whether or not to clip the plotted data region, from generateApplyClipRect. + * @param {int} maxCharts The maximum number of charts to display in one report. + * @param {String} nounColumnName The name of the study subject noun column (i.e. ParticipantId). + * @returns {Array} + */ + var generatePlotConfigs = function(config, data, seriesList, applyClipRect, maxCharts, nounColumnName) { + var plotConfigInfoArr = [], + subjectColumnName = null; + + nounColumnName = nounColumnName || getStudySubjectInfo().columnName; + if (data.individual) + subjectColumnName = LABKEY.vis.getColumnAlias(data.individual.columnAliases, nounColumnName); + + var generateGroupSeries = function(rows, groups, subjectColumn) { + // subjectColumn is the aliasColumnName looked up from the getData response columnAliases array + // groups is config.subject.groups + var dataByGroup = {}; + + for (var i = 0; i < rows.length; i++) + { + var rowSubject = _getRowValue(rows[i], subjectColumn); + for (var j = 0; j < groups.length; j++) + { + if (groups[j].participantIds.indexOf(rowSubject) > -1) + { + if (!dataByGroup[groups[j].label]) + dataByGroup[groups[j].label] = []; + + dataByGroup[groups[j].label].push(rows[i]); + } + } + } + + return dataByGroup; + }; + + // four options: all series on one chart, one chart per subject, one chart per group, or one chart per measure/dimension + if (config.chartLayout == "per_subject") + { + var groupAccessor = function(row) { + return _getRowValue(row, subjectColumnName); + }; + + var dataPerParticipant = getDataWithSeriesCheck(data.individual.measureStore.records(), groupAccessor, seriesList, data.individual.columnAliases); + for (var participant in dataPerParticipant) + { + if (dataPerParticipant.hasOwnProperty(participant)) + { + // skip the group if there is no data for it + if (!dataPerParticipant[participant].hasSeriesData) + continue; + + plotConfigInfoArr.push({ + title: config.title ? config.title : participant, + subtitle: config.title ? participant : undefined, + series: seriesList, + individualData: dataPerParticipant[participant].data, + applyClipRect: applyClipRect + }); + + if (plotConfigInfoArr.length >= maxCharts) + break; + } + } + } + else if (config.chartLayout == "per_group") + { + var groupedIndividualData = null, groupedAggregateData = null; + + //Display individual lines + if (data.individual) { + groupedIndividualData = generateGroupSeries(data.individual.measureStore.records(), config.subject.groups, subjectColumnName); + } + + // Display aggregate lines + if (data.aggregate) { + var groupAccessor = function(row) { + return _getRowValue(row, 'UniqueId'); + }; + + groupedAggregateData = getDataWithSeriesCheck(data.aggregate.measureStore.records(), groupAccessor, seriesList, data.aggregate.columnAliases); + } + + for (var i = 0; i < (config.subject.groups.length > maxCharts ? maxCharts : config.subject.groups.length); i++) + { + var group = config.subject.groups[i]; + + // skip the group if there is no data for it + if ((groupedIndividualData != null && !groupedIndividualData[group.label]) + || (groupedAggregateData != null && (!groupedAggregateData[group.label] || !groupedAggregateData[group.label].hasSeriesData))) + { + continue; + } + + plotConfigInfoArr.push({ + title: config.title ? config.title : group.label, + subtitle: config.title ? group.label : undefined, + series: seriesList, + individualData: groupedIndividualData && groupedIndividualData[group.label] ? groupedIndividualData[group.label] : null, + aggregateData: groupedAggregateData && groupedAggregateData[group.label] ? groupedAggregateData[group.label].data : null, + applyClipRect: applyClipRect + }); + + if (plotConfigInfoArr.length > maxCharts) + break; + } + } + else if (config.chartLayout == "per_dimension") + { + for (var i = 0; i < (seriesList.length > maxCharts ? maxCharts : seriesList.length); i++) + { + // skip the measure/dimension if there is no data for it + if ((data.aggregate && !data.aggregate.hasData[seriesList[i].name]) + || (data.individual && !data.individual.hasData[seriesList[i].name])) + { + continue; + } + + plotConfigInfoArr.push({ + title: config.title ? config.title : seriesList[i].label, + subtitle: config.title ? seriesList[i].label : undefined, + series: [seriesList[i]], + individualData: data.individual ? data.individual.measureStore.records() : null, + aggregateData: data.aggregate ? data.aggregate.measureStore.records() : null, + applyClipRect: applyClipRect + }); + + if (plotConfigInfoArr.length > maxCharts) + break; + } + } + else if (config.chartLayout == "single") + { + //Single Line Chart, with all participants or groups. + plotConfigInfoArr.push({ + title: config.title, + series: seriesList, + individualData: data.individual ? data.individual.measureStore.records() : null, + aggregateData: data.aggregate ? data.aggregate.measureStore.records() : null, + height: 610, + style: null, + applyClipRect: applyClipRect + }); + } + + return plotConfigInfoArr; + }; + + // private function + var getDataWithSeriesCheck = function(data, groupAccessor, seriesList, columnAliases) { + /* + Groups data by the groupAccessor passed in. Also, checks for the existance of any series data for that groupAccessor. + Returns an object where each attribute will be a groupAccessor with an array of data rows and a boolean for hasSeriesData + */ + var groupedData = {}; + for (var i = 0; i < data.length; i++) + { + var value = groupAccessor(data[i]); + if (!groupedData[value]) + { + groupedData[value] = {data: [], hasSeriesData: false}; + } + groupedData[value].data.push(data[i]); + + for (var j = 0; j < seriesList.length; j++) + { + var seriesAlias = LABKEY.vis.getColumnAlias(columnAliases, seriesList[j].aliasLookupInfo); + if (seriesAlias && _getRowValue(data[i], seriesAlias) != null) + { + groupedData[value].hasSeriesData = true; + break; + } + } + } + return groupedData; + }; + + /** + * Get the index in the axes array for a given axis (ie left y-axis). + * @param {Array} axes The array of specified axis information for this chart. + * @param {String} axisName The chart axis (i.e. x-axis or y-axis). + * @param {String} [side] The y-axis side (i.e. left or right). + * @returns {number} + */ + var getAxisIndex = function(axes, axisName, side) { + var index = -1; + for (var i = 0; i < axes.length; i++) + { + if (!side && axes[i].name == axisName) + { + index = i; + break; + } + else if (axes[i].name == axisName && axes[i].side == side) + { + index = i; + break; + } + } + return index; + }; + + /** + * Get the data needed for the specified Time Chart based on the chart config. Makes calls to the + * {@link LABKEY.Query.Visualization.getData} to get the individual subject data and grouped aggregate data. + * Calls the success callback function in the config when it has received all of the requested data. + * @param {Object} config The chart configuration object that defines the selected measures, axis info, subjects/groups, etc. + */ + var getChartData = function(config) { + if (!config.success) + throw "You must specify a success callback function!"; + if (!config.failure) + throw "You must specify a failure callback function!"; + if (!config.chartInfo) + throw "You must specify a chartInfo config!"; + if (config.chartInfo.measures.length == 0) + throw "There must be at least one specified measure in the chartInfo config!"; + if (!config.chartInfo.displayIndividual && !config.chartInfo.displayAggregate) + throw "We expect to either be displaying individual series lines or aggregate data!"; + + // issue 22254: perf issues if we try to show individual lines for a group with a large number of subjects + var subjectLength = config.chartInfo.subject.values ? config.chartInfo.subject.values.length : 0; + if (config.chartInfo.displayIndividual && subjectLength > 10000) + { + config.chartInfo.displayIndividual = false; + config.chartInfo.subject.values = undefined; + } + + var chartData = {numberFormats: {}}; + var counter = config.chartInfo.displayIndividual && config.chartInfo.displayAggregate ? 2 : 1; + var isDateBased = config.chartInfo.measures[0].time == "date"; + var seriesList = generateSeriesList(config.chartInfo.measures); + + // get the visit map info for those visits in the response data + var trimVisitMapDomain = function(origVisitMap, visitsInDataArr) { + var trimmedVisits = []; + for (var v in origVisitMap) { + if (origVisitMap.hasOwnProperty(v)) { + if (visitsInDataArr.indexOf(parseInt(v)) != -1) { + trimmedVisits.push(Ext4.apply({id: v}, origVisitMap[v])); + } + } + } + // sort the trimmed visit list by displayOrder and then reset displayOrder starting at 1 + trimmedVisits.sort(function(a,b){return a.displayOrder - b.displayOrder}); + var newVisitMap = {}; + for (var i = 0; i < trimmedVisits.length; i++) + { + trimmedVisits[i].displayOrder = i + 1; + newVisitMap[trimmedVisits[i].id] = trimmedVisits[i]; + } + + return newVisitMap; + }; + + var successCallback = function(response, dataType) { + // check for success=false + if (LABKEY.Utils.isDefined(response.success) && LABKEY.Utils.isBoolean(response.success) && !response.success) + { + config.failure.call(config.scope, response); + return; + } + + // Issue 16156: for date based charts, give error message if there are no calculated interval values + if (isDateBased) { + var intervalAlias = config.chartInfo.measures[0].dateOptions.interval; + var uniqueNonNullValues = Ext4.Array.clean(response.measureStore.members(intervalAlias)); + chartData.hasIntervalData = uniqueNonNullValues.length > 0; + } + else { + chartData.hasIntervalData = true; + } + + // make sure each measure/dimension has at least some data, and get a list of which visits are in the data response + // also keep track of which measure/dimensions have negative values (for log scale) + response.hasData = {}; + response.hasNegativeValues = {}; + Ext4.each(seriesList, function(s) { + var alias = LABKEY.vis.getColumnAlias(response.columnAliases, s.aliasLookupInfo); + var uniqueNonNullValues = Ext4.Array.clean(response.measureStore.members(alias)); + + response.hasData[s.name] = uniqueNonNullValues.length > 0; + response.hasNegativeValues[s.name] = Ext4.Array.min(uniqueNonNullValues) < 0; + }); + + // trim the visit map domain to just those visits in the response data + if (!isDateBased) { + var nounSingular = config.nounSingular || getStudySubjectInfo().nounSingular; + var visitMappedName = LABKEY.vis.getColumnAlias(response.columnAliases, nounSingular + "Visit/Visit"); + var visitsInData = response.measureStore.members(visitMappedName); + response.visitMap = trimVisitMapDomain(response.visitMap, visitsInData); + } + else { + response.visitMap = {}; + } + + chartData[dataType] = response; + + generateNumberFormats(config.chartInfo, chartData, config.defaultNumberFormat); + + // if we have all request data back, return the result + counter--; + if (counter == 0) + config.success.call(config.scope, chartData); + }; + + var getSelectRowsSort = function(response, dataType) + { + var nounSingular = config.nounSingular || getStudySubjectInfo().nounSingular, + sort = dataType == 'aggregate' ? 'GroupingOrder,UniqueId' : response.measureToColumn[config.chartInfo.subject.name]; + + if (isDateBased) + { + sort += ',' + config.chartInfo.measures[0].dateOptions.interval; + } + else + { + // Issue 28529: if we have a SubjectVisit/sequencenum column, use that instead of SubjectVisit/Visit/SequenceNumMin + var sequenceNumCol = response.measureToColumn[nounSingular + 'Visit/sequencenum']; + if (!LABKEY.Utils.isDefined(sequenceNumCol)) + sequenceNumCol = response.measureToColumn[getSubjectVisitColName(nounSingular, 'SequenceNumMin')]; + + sort += ',' + response.measureToColumn[getSubjectVisitColName(nounSingular, 'DisplayOrder')] + ',' + sequenceNumCol; + } + + return sort; + }; + + var queryTempResultsForRows = function(response, dataType) + { + // Issue 28529: re-query for the actual data off of the temp query results + LABKEY.Query.MeasureStore.selectRows({ + containerPath: config.containerPath, + schemaName: response.schemaName, + queryName: response.queryName, + requiredVersion : 13.2, + maxRows: -1, + sort: getSelectRowsSort(response, dataType), + success: function(measureStore) { + response.measureStore = measureStore; + successCallback(response, dataType); + } + }); + }; + + if (config.chartInfo.displayIndividual) + { + //Get data for individual lines. + LABKEY.Query.Visualization.getData({ + metaDataOnly: true, + containerPath: config.containerPath, + success: function(response) { + queryTempResultsForRows(response, "individual"); + }, + failure : function(info, response, options) { + config.failure.call(config.scope, info, Ext4.JSON.decode(response.responseText)); + }, + measures: config.chartInfo.measures, + sorts: generateDataSortArray(config.chartInfo.subject, config.chartInfo.measures[0], isDateBased, config.nounSingular), + limit : config.dataLimit || 10000, + parameters : config.chartInfo.parameters, + filterUrl: config.chartInfo.filterUrl, + filterQuery: config.chartInfo.filterQuery + }); + } + + if (config.chartInfo.displayAggregate) + { + //Get data for Aggregates lines. + var groups = []; + for (var i = 0; i < config.chartInfo.subject.groups.length; i++) + { + var group = config.chartInfo.subject.groups[i]; + // encode the group id & type, so we can distinguish between cohort and participant group in the union table + groups.push(group.id + '-' + group.type); + } + + LABKEY.Query.Visualization.getData({ + metaDataOnly: true, + containerPath: config.containerPath, + success: function(response) { + queryTempResultsForRows(response, "aggregate"); + }, + failure : function(info) { + config.failure.call(config.scope, info); + }, + measures: config.chartInfo.measures, + groupBys: [ + // Issue 18747: if grouping by cohorts and ptid groups, order it so the cohorts are first + {schemaName: 'study', queryName: 'ParticipantGroupCohortUnion', name: 'GroupingOrder', values: [0,1]}, + {schemaName: 'study', queryName: 'ParticipantGroupCohortUnion', name: 'UniqueId', values: groups} + ], + sorts: generateDataSortArray(config.chartInfo.subject, config.chartInfo.measures[0], isDateBased, config.nounSingular), + limit : config.dataLimit || 10000, + parameters : config.chartInfo.parameters, + filterUrl: config.chartInfo.filterUrl, + filterQuery: config.chartInfo.filterQuery + }); + } + }; + + /** + * Get the set of measures from the tables/queries in the study schema. + * @param successCallback + * @param callbackScope + */ + var getStudyMeasures = function(successCallback, callbackScope) + { + if (getStudyTimepointType() != null) + { + LABKEY.Query.Visualization.getMeasures({ + filters: ['study|~'], + dateMeasures: false, + success: function (measures, response) + { + var o = Ext4.JSON.decode(response.responseText); + successCallback.call(callbackScope, o.measures); + }, + failure: this.onFailure, + scope: this + }); + } + else + { + successCallback.call(callbackScope, []); + } + }; + + /** + * If this is a container with a configured study, get the timepoint type from the study module context. + * @returns {String|null} + */ + var getStudyTimepointType = function() + { + var studyCtx = LABKEY.getModuleContext("study") || {}; + return LABKEY.Utils.isDefined(studyCtx.timepointType) ? studyCtx.timepointType : null; + }; + + /** + * Generate the number format functions for the left and right y-axis and attach them to the chart data object + * @param {Object} config The chart configuration object that defines the selected measures, axis info, subjects/groups, etc. + * @param {Object} data The data object, from getChartData. + * @param {Object} defaultNumberFormat + */ + var generateNumberFormats = function(config, data, defaultNumberFormat) { + var fields = data.individual ? data.individual.metaData.fields : data.aggregate.metaData.fields; + + for (var i = 0; i < config.axis.length; i++) + { + var axis = config.axis[i]; + if (axis.side) + { + // Find the first measure with the matching side that has a numberFormat. + for (var j = 0; j < config.measures.length; j++) + { + var measure = config.measures[j].measure; + + if (data.numberFormats[axis.side]) + break; + + if (measure.yAxis == axis.side) + { + var metaDataName = measure.alias; + for (var k = 0; k < fields.length; k++) + { + var field = fields[k]; + if (field.name == metaDataName) + { + if (field.extFormatFn) + { + data.numberFormats[axis.side] = eval(field.extFormatFn); + break; + } + } + } + } + } + + if (!data.numberFormats[axis.side]) + { + // If after all the searching we still don't have a numberformat use the default number format. + data.numberFormats[axis.side] = defaultNumberFormat; + } + } + } + }; + + /** + * Verifies the information in the chart config to make sure it has proper measures, axis info, subjects/groups, etc. + * Returns an object with a success parameter (boolean) and a message parameter (string). If the success pararameter + * is false there is a critical error and the chart cannot be rendered. If success is true the chart can be rendered. + * Message will contain an error or warning message if applicable. If message is not null and success is true, there is a warning. + * @param {Object} config The chart configuration object that defines the selected measures, axis info, subjects/groups, etc. + * @returns {Object} + */ + var validateChartConfig = function(config) { + var message = ""; + + if (!config.measures || config.measures.length == 0) + { + message = "No measure selected. Please select at lease one measure."; + return {success: false, message: message}; + } + + if (!config.axis || getAxisIndex(config.axis, "x-axis") == -1) + { + message = "Could not find x-axis in chart measure information."; + return {success: false, message: message}; + } + + if (config.chartSubjectSelection == "subjects" && config.subject.values.length == 0) + { + var nounSingular = getStudySubjectInfo().nounSingular; + message = "No " + nounSingular.toLowerCase() + " selected. " + + "Please select at least one " + nounSingular.toLowerCase() + "."; + return {success: false, message: message}; + } + + if (config.chartSubjectSelection == "groups" && config.subject.groups.length < 1) + { + message = "No group selected. Please select at least one group."; + return {success: false, message: message}; + } + + if (generateSeriesList(config.measures).length == 0) + { + message = "No series or dimension selected. Please select at least one series/dimension value."; + return {success: false, message: message}; + } + + if (!(config.displayIndividual || config.displayAggregate)) + { + message = "Please select either \"Show Individual Lines\" or \"Show Mean\"."; + return {success: false, message: message}; + } + + // issue 22254: perf issues if we try to show individual lines for a group with a large number of subjects + var subjectLength = config.subject.values ? config.subject.values.length : 0; + if (config.displayIndividual && subjectLength > 10000) + { + var nounPlural = getStudySubjectInfo().nounPlural; + message = "Unable to display individual series lines for greater than 10,000 total " + nounPlural.toLowerCase() + "."; + return {success: false, message: message}; + } + + return {success: true, message: message}; + }; + + /** + * Verifies that the chart data contains the expected interval values and measure/dimension data. Also checks to make + * sure that data can be used in a log scale (if applicable). Returns an object with a success parameter (boolean) + * and a message parameter (string). If the success pararameter is false there is a critical error and the chart + * cannot be rendered. If success is true the chart can be rendered. Message will contain an error or warning + * message if applicable. If message is not null and success is true, there is a warning. + * @param {Object} data The data object, from getChartData. + * @param {Object} config The chart configuration object that defines the selected measures, axis info, subjects/groups, etc. + * @param {Array} seriesList The list of series that will be plotted for a given chart, from generateSeriesList. + * @param {int} limit The data limit for a single report. + * @returns {Object} + */ + var validateChartData = function(data, config, seriesList, limit) { + var message = "", + sep = "", + msg = "", + commaSep = "", + noDataCounter = 0; + + // warn the user if the data limit has been reached + var individualDataCount = LABKEY.Utils.isDefined(data.individual) ? data.individual.measureStore.records().length : null; + var aggregateDataCount = LABKEY.Utils.isDefined(data.aggregate) ? data.aggregate.measureStore.records().length : null; + if (individualDataCount >= limit || aggregateDataCount >= limit) { + message += sep + "The data limit for plotting has been reached. Consider filtering your data."; + sep = "
"; + } + + // for date based charts, give error message if there are no calculated interval values + if (!data.hasIntervalData) + { + message += sep + "No calculated interval values (i.e. Days, Months, etc.) for the selected 'Measure Date' and 'Interval Start Date'."; + sep = "
"; + } + + // check to see if any of the measures don't have data + Ext4.iterate(data.aggregate ? data.aggregate.hasData : data.individual.hasData, function(key, value) { + if (!value) + { + noDataCounter++; + msg += commaSep + key; + commaSep = ", "; + } + }, this); + if (msg.length > 0) + { + msg = "No data found for the following measures/dimensions: " + msg; + + // if there is no data for any series, add to explanation + if (noDataCounter == seriesList.length) + { + var isDateBased = config && config.measures[0].time == "date"; + if (isDateBased) + msg += ". This may be the result of a missing start date value for the selected subject(s)."; + } + + message += sep + msg; + sep = "
"; + } + + // check to make sure that data can be used in a log scale (if applicable) + if (config) + { + var leftAxisIndex = getAxisIndex(config.axis, "y-axis", "left"); + var rightAxisIndex = getAxisIndex(config.axis, "y-axis", "right"); + + Ext4.each(config.measures, function(md){ + var m = md.measure; + + // check the left y-axis + if (m.yAxis == "left" && leftAxisIndex > -1 && config.axis[leftAxisIndex].scale == "log" + && ((data.individual && data.individual.hasNegativeValues && data.individual.hasNegativeValues[m.name]) + || (data.aggregate && data.aggregate.hasNegativeValues && data.aggregate.hasNegativeValues[m.name]))) + { + config.axis[leftAxisIndex].scale = "linear"; + message += sep + "Unable to use a log scale on the left y-axis. All y-axis values must be >= 0. Reverting to linear scale on left y-axis."; + sep = "
"; + } + + // check the right y-axis + if (m.yAxis == "right" && rightAxisIndex > -1 && config.axis[rightAxisIndex].scale == "log" + && ((data.individual && data.individual.hasNegativeValues[m.name]) + || (data.aggregate && data.aggregate.hasNegativeValues[m.name]))) + { + config.axis[rightAxisIndex].scale = "linear"; + message += sep + "Unable to use a log scale on the right y-axis. All y-axis values must be >= 0. Reverting to linear scale on right y-axis."; + sep = "
"; + } + + }); + } + + return {success: true, message: message}; + }; + + /** + * Support backwards compatibility for charts saved prior to chartInfo reconfiguration (2011-08-31). + * Support backwards compatibility for save thumbnail options (2012-06-19). + * @param chartInfo + * @param savedReportInfo + */ + var convertSavedReportConfig = function(chartInfo, savedReportInfo) + { + if (LABKEY.Utils.isDefined(chartInfo)) + { + Ext4.applyIf(chartInfo, { + axis: [], + //This is for charts saved prior to 2011-10-07 + chartSubjectSelection: chartInfo.chartLayout == 'per_group' ? 'groups' : 'subjects', + displayIndividual: true, + displayAggregate: false + }); + for (var i = 0; i < chartInfo.measures.length; i++) + { + var md = chartInfo.measures[i]; + + Ext4.applyIf(md.measure, {yAxis: "left"}); + + // if the axis info is in md, move it to the axis array + if (md.axis) + { + // default the y-axis to the left side if not specified + if (md.axis.name == "y-axis") + Ext4.applyIf(md.axis, {side: "left"}); + + // move the axis info to the axis array + if (getAxisIndex(chartInfo.axis, md.axis.name, md.axis.side) == -1) + chartInfo.axis.push(Ext4.apply({}, md.axis)); + + // if the chartInfo has an x-axis measure, move the date info it to the related y-axis measures + if (md.axis.name == "x-axis") + { + for (var j = 0; j < chartInfo.measures.length; j++) + { + var schema = md.measure.schemaName; + var query = md.measure.queryName; + if (chartInfo.measures[j].axis && chartInfo.measures[j].axis.name == "y-axis" + && chartInfo.measures[j].measure.schemaName == schema + && chartInfo.measures[j].measure.queryName == query) + { + chartInfo.measures[j].dateOptions = { + dateCol: Ext4.apply({}, md.measure), + zeroDateCol: Ext4.apply({}, md.dateOptions.zeroDateCol), + interval: md.dateOptions.interval + }; + } + } + + // remove the x-axis date measure from the measures array + chartInfo.measures.splice(i, 1); + i--; + } + else + { + // remove the axis property from the measure + delete md.axis; + } + } + } + } + + if (LABKEY.Utils.isObject(chartInfo) && LABKEY.Utils.isObject(savedReportInfo)) + { + if (chartInfo.saveThumbnail != undefined) + { + if (savedReportInfo.reportProps == null) + savedReportInfo.reportProps = {}; + + Ext4.applyIf(savedReportInfo.reportProps, { + thumbnailType: !chartInfo.saveThumbnail ? 'NONE' : 'AUTO' + }); + } + } + }; + + var getStudySubjectInfo = function() + { + var studyCtx = LABKEY.getModuleContext("study") || {}; + return LABKEY.Utils.isObject(studyCtx.subject) ? studyCtx.subject : { + tableName: 'Participant', + columnName: 'ParticipantId', + nounPlural: 'Participants', + nounSingular: 'Participant' + }; + }; + + var getMeasureAlias = function(measure) + { + if (LABKEY.Utils.isString(measure.alias)) + return measure.alias; + else + return measure.schemaName + '_' + measure.queryName + '_' + measure.name; + }; + + var getMeasuresLabelBySide = function(measures, side) + { + var labels = []; + Ext4.each(measures, function(measure) + { + if (measure.yAxis == side && labels.indexOf(measure.label) == -1) + labels.push(measure.label); + }); + + return labels.join(', '); + }; + + var getDistinctYAxisSides = function(measures) + { + return Ext4.Array.unique(Ext4.Array.pluck(measures, 'yAxis')); + }; + + var _getRowValue = function(row, propName, valueName) + { + if (row.hasOwnProperty(propName)) { + // backwards compatibility for response row that is not a LABKEY.Query.Row + if (!(row instanceof LABKEY.Query.Row)) { + return row[propName].displayValue || row[propName].value; + } + + var propValue = row.get(propName); + if (valueName != undefined && propValue.hasOwnProperty(valueName)) { + return propValue[valueName]; + } + else if (propValue.hasOwnProperty('displayValue')) { + return propValue['displayValue']; + } + return row.getValue(propName); + } + + return undefined; + }; + + var renderChartSVG = function(renderTo, queryConfig, chartConfig) { + // Before we load the data, validate some information about the chart config + var messages = []; + var validation = validateChartConfig(chartConfig); + if (validation.message != null) + { + messages.push(validation.message); + } + if (!validation.success) + { + _renderMessages(renderTo, messages); + return; + } + + var nounSingular = 'Participant'; + var subjectColumnName = 'ParticipantId'; + if (LABKEY.moduleContext.study && LABKEY.moduleContext.study.subject) + { + nounSingular = LABKEY.moduleContext.study.subject.nounSingular; + subjectColumnName = LABKEY.moduleContext.study.subject.columnName; + } + + // When all the dependencies are loaded, we load the data using time chart helper getChartData + Ext4.applyIf(queryConfig, { + chartInfo: chartConfig, + containerPath: LABKEY.container.path, + nounSingular: nounSingular, + subjectColumnName: subjectColumnName, + dataLimit: 10000, + maxCharts: 20, + defaultMultiChartHeight: 380, + defaultSingleChartHeight: 600, + defaultWidth: 1075, + defaultNumberFormat: function(v) { return v.toFixed(1); } + }); + + queryConfig.success = function(response) { + _getChartDataCallback(renderTo, queryConfig, chartConfig, response); + }; + queryConfig.failure = function(info) { + _renderMessages(renderTo, ['Error: ' + info.exception]); + }; + + LABKEY.vis.TimeChartHelper.getChartData(queryConfig); + }; + + var _getChartDataCallback = function(renderTo, queryConfig, chartConfig, responseData) { + var individualColumnAliases = responseData.individual ? responseData.individual.columnAliases : null; + var aggregateColumnAliases = responseData.aggregate ? responseData.aggregate.columnAliases : null; + var visitMap = responseData.individual ? responseData.individual.visitMap : responseData.aggregate.visitMap; + var intervalKey = generateIntervalKey(chartConfig, individualColumnAliases, aggregateColumnAliases, queryConfig.nounSingular); + var aes = generateAes(chartConfig, visitMap, individualColumnAliases, intervalKey, queryConfig.subjectColumnName); + var tickMap = generateTickMap(visitMap); + var seriesList = generateSeriesList(chartConfig.measures); + var applyClipRect = generateApplyClipRect(chartConfig); + + // Once we have the data, we can set all of the axis min/max range values + generateAcrossChartAxisRanges(chartConfig, responseData, seriesList, queryConfig.nounSingular); + var scales = generateScales(chartConfig, tickMap, responseData.numberFormats); + + // Validate that the chart data has expected values and give warnings if certain elements are not present + var messages = []; + var validation = validateChartData(responseData, chartConfig, seriesList, queryConfig.dataLimit, false); + if (validation.message != null) + { + messages.push(validation.message); + } + if (!validation.success) + { + _renderMessages(renderTo, messages); + return; + } + + // For time charts, we allow multiple plots to be displayed by participant, group, or measure/dimension + var plotConfigsArr = generatePlotConfigs(chartConfig, responseData, seriesList, applyClipRect, queryConfig.maxCharts, queryConfig.subjectColumnName); + for (var configIndex = 0; configIndex < plotConfigsArr.length; configIndex++) + { + var clipRect = plotConfigsArr[configIndex].applyClipRect; + var series = plotConfigsArr[configIndex].series; + var height = chartConfig.height || (plotConfigsArr.length > 1 ? queryConfig.defaultMultiChartHeight : queryConfig.defaultSingleChartHeight); + var width = chartConfig.width || queryConfig.defaultWidth; + var labels = generateLabels(plotConfigsArr[configIndex].title, chartConfig.axis, plotConfigsArr[configIndex].subtitle); + var layers = generateLayers(chartConfig, visitMap, individualColumnAliases, aggregateColumnAliases, plotConfigsArr[configIndex].aggregateData, series, intervalKey, queryConfig.subjectColumnName); + var data = plotConfigsArr[configIndex].individualData ? plotConfigsArr[configIndex].individualData : plotConfigsArr[configIndex].aggregateData; + + var plotConfig = { + renderTo: renderTo, + rendererType: 'd3', + clipRect: clipRect, + width: width, + height: height, + labels: labels, + aes: aes, + scales: scales, + layers: layers, + data: data + }; + + var plot = new LABKEY.vis.Plot(plotConfig); + plot.render(); + } + + // Give a warning if the max number of charts has been exceeded + if (plotConfigsArr.length >= queryConfig.maxCharts) + messages.push('Only showing the first ' + queryConfig.maxCharts + ' charts.'); + + _renderMessages(renderTo, messages); + }; + + var _renderMessages = function(id, messages) { + var messageDiv; + var el = document.getElementById(id); + var child; + if (el && el.children.length > 0) + child = el.children[0]; + + for (var i = 0; i < messages.length; i++) + { + messageDiv = document.createElement('div'); + messageDiv.setAttribute('style', 'font-style:italic'); + messageDiv.innerHTML = messages[i]; + if (child) + el.insertBefore(messageDiv, child); + else + el.appendChild(messageDiv); + } + }; + + return { + /** + * Loads all of the required dependencies for a Time Chart. + * @param {Function} callback The callback to be executed when all of the visualization dependencies have been loaded. + * @param {Object} scope The scope to be used when executing the callback. + */ + loadVisDependencies: LABKEY.requiresVisualization, + generateAcrossChartAxisRanges : generateAcrossChartAxisRanges, + generateAes : generateAes, + generateApplyClipRect : generateApplyClipRect, + generateIntervalKey : generateIntervalKey, + generateLabels : generateLabels, + generateLayers : generateLayers, + generatePlotConfigs : generatePlotConfigs, + generateScales : generateScales, + generateSeriesList : generateSeriesList, + generateTickMap : generateTickMap, + generateNumberFormats : generateNumberFormats, + getAxisIndex : getAxisIndex, + getMeasureAlias : getMeasureAlias, + getMeasuresLabelBySide : getMeasuresLabelBySide, + getDistinctYAxisSides : getDistinctYAxisSides, + getStudyTimepointType : getStudyTimepointType, + getStudySubjectInfo : getStudySubjectInfo, + getStudyMeasures : getStudyMeasures, + getChartData : getChartData, + validateChartConfig : validateChartConfig, + validateChartData : validateChartData, + convertSavedReportConfig : convertSavedReportConfig, + renderChartSVG: renderChartSVG + }; +}; From f638dd676231c426430dde51fd0868367fa9d7cf Mon Sep 17 00:00:00 2001 From: cnathe Date: Wed, 8 Oct 2025 08:44:19 -0500 Subject: [PATCH 03/40] LABKEY.vis.getAggregateData updates to support errorBarType calculations for SD and SEM --- core/src/client/vis/utils.test.ts | 118 ++++++++++++++++++++++++++++++ core/webapp/vis/src/utils.js | 36 +++++++-- 2 files changed, 147 insertions(+), 7 deletions(-) create mode 100644 core/src/client/vis/utils.test.ts diff --git a/core/src/client/vis/utils.test.ts b/core/src/client/vis/utils.test.ts new file mode 100644 index 00000000000..72b1ba5211b --- /dev/null +++ b/core/src/client/vis/utils.test.ts @@ -0,0 +1,118 @@ +LABKEY.vis = {}; +require('../../../webapp/vis/src/statistics.js'); +require('../../../webapp/vis/src/utils.js'); + +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 }, + ]); + }); +}); \ No newline at end of file diff --git a/core/webapp/vis/src/utils.js b/core/webapp/vis/src/utils.js index 7f2091e7895..5bd158dce09 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 @@ -222,9 +218,11 @@ 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. * @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) { var results = [], subgroupAccessor, groupAccessor = typeof dimensionName === 'function' ? dimensionName : function(row){ return LABKEY.vis.getValue(row[dimensionName]);}, @@ -258,11 +256,11 @@ LABKEY.vis.getAggregateData = function(data, dimensionName, subDimensionName, me 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']; } @@ -270,15 +268,39 @@ LABKEY.vis.getAggregateData = function(data, dimensionName, subDimensionName, me { try { row.value = LABKEY.vis.Stat[aggregate](values); + 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); } From a3455373670d9135508975088ba0800938b7ef51 Mon Sep 17 00:00:00 2001 From: cnathe Date: Wed, 8 Oct 2025 10:43:03 -0500 Subject: [PATCH 04/40] vis/demo fix to make sure LABKEY object is declared --- core/webapp/vis/demo/index.html | 5 +++++ 1 file changed, 5 insertions(+) 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 @@
+ From 5a9eacb572b3d9c6aac5e6d2b021134e0f5e2d7d Mon Sep 17 00:00:00 2001 From: cnathe Date: Wed, 8 Oct 2025 11:27:32 -0500 Subject: [PATCH 05/40] Plot x-axis tick rotate vs wrap text behavior (default to 35deg rotate if > 10 labels and wrapping text otherwise) --- core/webapp/vis/src/internal/D3Renderer.js | 83 +- core/webapp/vis/src/plot.js | 2 +- .../vis/genericChart/genericChartHelper.js | 3987 +++++++++-------- 3 files changed, 2060 insertions(+), 2012 deletions(-) diff --git a/core/webapp/vis/src/internal/D3Renderer.js b/core/webapp/vis/src/internal/D3Renderer.js index 8818d720967..1fbd98b44e3 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; @@ -460,26 +460,73 @@ LABKEY.vis.internal.Axis = function() { if (orientation == 'bottom') { if (hasOverlap) { - textEls.attr('transform', function(v) {return 'rotate(' + tickOverlapRotation + ',' + textXFn(v) + ',' + textYFn(v) + ')';}) - .attr('text-anchor', 'start'); + // if we have a large number of ticks, rotate the text by the specified amount, else wrap text + if (tickOverlapRotation !== undefined || textEls[0].length > 10) { + if (!tickOverlapRotation) { + tickOverlapRotation = 35; + } - if (tickHover || tickClick || tickMouseOver || tickMouseOut) - { - addTickAreaRects(textAnchors); - textAnchors.selectAll("rect." + (tickRectCls ? tickRectCls : "tick-rect")) - .attr('transform', function (v) - { - return 'rotate(' + tickOverlapRotation + ',' + textXFn(v) + ',' + textYFn(v) + ')'; - }); + textEls.attr('transform', function(v) {return 'rotate(' + tickOverlapRotation + ',' + textXFn(v) + ',' + textYFn(v) + ')';}) + .attr('text-anchor', 'start'); - addHighlightRects(textAnchors); - textAnchors.selectAll('rect.highlight') - .attr('transform', function (v) - { - return 'rotate(' + tickOverlapRotation + ',' + textXFn(v) + ',' + textYFn(v) + ')'; - }); - } + if (tickHover || tickClick || tickMouseOver || tickMouseOut) + { + addTickAreaRects(textAnchors); + textAnchors.selectAll("rect." + (tickRectCls ? tickRectCls : "tick-rect")) + .attr('transform', function (v) + { + return 'rotate(' + tickOverlapRotation + ',' + textXFn(v) + ',' + textYFn(v) + ')'; + }); + + addHighlightRects(textAnchors); + textAnchors.selectAll('rect.highlight') + .attr('transform', function (v) + { + return 'rotate(' + tickOverlapRotation + ',' + textXFn(v) + ',' + textYFn(v) + ')'; + }); + } + } else { + function wrapAxisTickLabel(text) { + var width; + text.each(function(v) { + if (!width) + width = scale(v) - grid.leftEdge; + + let text = d3.select(this), + words = text.text().split(/[\s]+/).reverse(), + word, + line = [], + lineNumber = 0, + lineHeight = 1.1, // ems + x = this.getAttribute("x"), + y = this.getAttribute("y"), + dy = 0, + tspan = text.text(null) + .append("tspan") + .attr("x", x) + .attr("y", y) + .attr("dy", dy + "em"); + + 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 = text.append("tspan") + .attr("x", x) + .attr("y", y) + .attr("dy", ++lineNumber * lineHeight + dy + "em") + .text(word); + } + } + }); + } + textEls.attr('transform', '').call(wrapAxisTickLabel); + textAnchors.selectAll('rect.highlight').attr('transform', ''); + } } else { textEls.attr('transform', ''); textAnchors.selectAll('rect.highlight').attr('transform', ''); 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/visualization/resources/web/vis/genericChart/genericChartHelper.js b/visualization/resources/web/vis/genericChart/genericChartHelper.js index 0f2105cc784..0cfcbc69c4c 100644 --- a/visualization/resources/web/vis/genericChart/genericChartHelper.js +++ b/visualization/resources/web/vis/genericChart/genericChartHelper.js @@ -1,1994 +1,1995 @@ -/* - * Copyright (c) 2013-2019 LabKey Corporation - * - * Licensed under the Apache License, Version 2.0: http://www.apache.org/licenses/LICENSE-2.0 - */ -if(!LABKEY.vis) { - LABKEY.vis = {}; -} - -/** - * @namespace Namespace used to encapsulate functions related to creating Generic Charts (Box, Scatter, etc.). Used in the - * Generic Chart Wizard and when exporting Generic Charts as Scripts. - */ -LABKEY.vis.GenericChartHelper = new function(){ - - var DEFAULT_TICK_LABEL_MAX = 25; - var $ = jQuery; - - var getRenderTypes = function() { - return [ - { - name: 'bar_chart', - title: 'Bar', - imgUrl: LABKEY.contextPath + '/visualization/images/barchart.png', - fields: [ - {name: 'x', label: 'X Axis', required: true, nonNumericOnly: true}, - {name: 'xSub', label: 'Group By', required: false, nonNumericOnly: true}, - {name: 'y', label: 'Y Axis', numericOnly: true} - ], - layoutOptions: {line: true, opacity: true, axisBased: true} - }, - { - name: 'box_plot', - title: 'Box', - imgUrl: LABKEY.contextPath + '/visualization/images/boxplot.png', - fields: [ - {name: 'x', label: 'X Axis'}, - {name: 'y', label: 'Y Axis', required: true, numericOnly: true}, - {name: 'color', label: 'Color', nonNumericOnly: true}, - {name: 'shape', label: 'Shape', nonNumericOnly: true} - ], - layoutOptions: {point: true, box: true, line: true, opacity: true, axisBased: true} - }, - { - name: 'line_plot', - title: 'Line', - imgUrl: LABKEY.contextPath + '/visualization/images/timechart.png', - fields: [ - {name: 'x', label: 'X Axis', required: true, numericOrDateOnly: true}, - {name: 'y', label: 'Y Axis', required: true, numericOnly: true, allowMultiple: true}, - {name: 'series', label: 'Series', nonNumericOnly: true}, - {name: 'trendline', label: 'Trendline', required: false, altSelectionOnly: true, altFieldType: 'LABKEY.vis.TrendlineField'}, - ], - layoutOptions: {opacity: true, axisBased: true, series: true, chartLayout: true} - }, - { - name: 'pie_chart', - title: 'Pie', - imgUrl: LABKEY.contextPath + '/visualization/images/piechart.png', - fields: [ - {name: 'x', label: 'Categories', required: true, nonNumericOnly: true}, - // Issue #29046 'Remove "measure" option from pie chart' - // {name: 'y', label: 'Measure', numericOnly: true} - ], - layoutOptions: {pie: true} - }, - { - name: 'scatter_plot', - title: 'Scatter', - imgUrl: LABKEY.contextPath + '/visualization/images/scatterplot.png', - fields: [ - {name: 'x', label: 'X Axis', required: true}, - {name: 'y', label: 'Y Axis', required: true, numericOnly: true, allowMultiple: true}, - {name: 'color', label: 'Color', nonNumericOnly: true}, - {name: 'shape', label: 'Shape', nonNumericOnly: true} - ], - layoutOptions: {point: true, opacity: true, axisBased: true, binnable: true, chartLayout: true} - }, - { - name: 'time_chart', - title: 'Time', - hidden: _getStudyTimepointType() == null, - imgUrl: LABKEY.contextPath + '/visualization/images/timechart.png', - fields: [ - {name: 'x', label: 'X Axis', required: true, altSelectionOnly: true, altFieldType: 'LABKEY.vis.TimeChartXAxisField'}, - {name: 'y', label: 'Y Axis', required: true, numericOnly: true, allowMultiple: true} - ], - layoutOptions: {time: true, axisBased: true, chartLayout: true} - } - ]; - }; - - /** - * Gets the chart type (i.e. box or scatter) based on the chartConfig object. - */ - const getChartType = function(chartConfig) - { - const renderType = chartConfig.renderType - const xAxisType = chartConfig.measures.x ? (chartConfig.measures.x.normalizedType || chartConfig.measures.x.type) : null; - - if (renderType === 'time_chart' || renderType === "bar_chart" || renderType === "pie_chart" - || renderType === "box_plot" || renderType === "scatter_plot" || renderType === "line_plot") - { - return renderType; - } - - if (!xAxisType) - { - // On some charts (non-study box plots) we don't require an x-axis, instead we generate one box plot for - // all of the data of your y-axis. If there is no xAxisType, then we have a box plot. Scatter plots require - // an x-axis measure. - return 'box_plot'; - } - - return (xAxisType === 'string' || xAxisType === 'boolean') ? 'box_plot' : 'scatter_plot'; - }; - - /** - * Generate a default label for the selected measure for the given renderType. - * @param renderType - * @param measureName - the chart type's measure name - * @param properties - properties for the selected column, note that this can be an array of properties - */ - var getSelectedMeasureLabel = function(renderType, measureName, properties) - { - var label = getDefaultMeasuresLabel(properties); - - if (label !== '' && measureName === 'y' && (renderType === 'bar_chart' || renderType === 'pie_chart')) { - var aggregateProps = LABKEY.Utils.isArray(properties) && properties.length === 1 - ? properties[0].aggregate : properties.aggregate; - - if (LABKEY.Utils.isDefined(aggregateProps)) { - var aggLabel = LABKEY.Utils.isObject(aggregateProps) ? aggregateProps.name : LABKEY.Utils.capitalize(aggregateProps.toLowerCase()); - label = aggLabel + ' of ' + label; - } - else { - label = 'Sum of ' + label; - } - } - - return label; - }; - - /** - * Generate a plot title based on the selected measures array or object. - * @param renderType - * @param measures - * @returns {string} - */ - var getTitleFromMeasures = function(renderType, measures) - { - var queryLabels = []; - - if (LABKEY.Utils.isObject(measures)) - { - if (LABKEY.Utils.isArray(measures.y)) - { - $.each(measures.y, function(idx, m) - { - var measureQueryLabel = m.queryLabel || m.queryName; - if (queryLabels.indexOf(measureQueryLabel) === -1) - queryLabels.push(measureQueryLabel); - }); - } - else - { - var m = measures.x || measures.y; - queryLabels.push(m.queryLabel || m.queryName); - } - } - - return queryLabels.join(', '); - }; - - /** - * Get the sorted set of column metadata for the given schema/query/view. - * @param queryConfig - * @param successCallback - * @param callbackScope - */ - var getQueryColumns = function(queryConfig, successCallback, callbackScope) - { - LABKEY.Ajax.request({ - url: LABKEY.ActionURL.buildURL('visualization', 'getGenericReportColumns.api'), - method: 'GET', - params: { - schemaName: queryConfig.schemaName, - queryName: queryConfig.queryName, - viewName: queryConfig.viewName, - dataRegionName: queryConfig.dataRegionName, - includeCohort: true, - includeParticipantCategory : true - }, - success : function(response){ - var columnList = LABKEY.Utils.decode(response.responseText); - _queryColumnMetadata(queryConfig, columnList, successCallback, callbackScope) - }, - scope : this - }); - }; - - var _queryColumnMetadata = function(queryConfig, columnList, successCallback, callbackScope) - { - var columns = columnList.columns.all; - if (queryConfig.savedColumns) { - // make sure all savedColumns from the chart are included as options, they may not be in the view anymore - columns = columns.concat(queryConfig.savedColumns); - } - - LABKEY.Query.selectRows({ - maxRows: 0, // use maxRows 0 so that we just get the query metadata - schemaName: queryConfig.schemaName, - queryName: queryConfig.queryName, - viewName: queryConfig.viewName, - parameters: queryConfig.parameters, - requiredVersion: 9.1, - columns: columns, - method: 'POST', // Issue 31744: use POST as the columns list can be very long and cause a 400 error - success: function(response){ - var columnMetadata = _updateAndSortQueryFields(queryConfig, columnList, response.metaData.fields); - successCallback.call(callbackScope, columnMetadata); - }, - failure : function(response) { - // this likely means that the query no longer exists - successCallback.call(callbackScope, columnList, []); - }, - scope : this - }); - }; - - var _updateAndSortQueryFields = function(queryConfig, columnList, columnMetadata) - { - var queryFields = [], - queryFieldKeys = [], - columnTypes = LABKEY.Utils.isDefined(columnList.columns) ? columnList.columns : {}; - - $.each(columnMetadata, function(idx, column) - { - var f = $.extend(true, {}, column); - f.schemaName = queryConfig.schemaName; - f.queryName = queryConfig.queryName; - f.isCohortColumn = false; - f.isSubjectGroupColumn = false; - - // issue 23224: distinguish cohort and subject group fields in the list of query columns - if (columnTypes['cohort'] && columnTypes['cohort'].indexOf(f.fieldKey) > -1) - { - f.shortCaption = 'Study: ' + f.shortCaption; - f.isCohortColumn = true; - } - else if (columnTypes['subjectGroup'] && columnTypes['subjectGroup'].indexOf(f.fieldKey) > -1) - { - f.shortCaption = columnList.subject.nounSingular + ' Group: ' + f.shortCaption; - f.isSubjectGroupColumn = true; - } - - // Issue 31672: keep track of the distinct query field keys so we don't get duplicates - if (f.fieldKey.toLowerCase() != 'lsid' && queryFieldKeys.indexOf(f.fieldKey) == -1) { - queryFields.push(f); - queryFieldKeys.push(f.fieldKey); - } - }, this); - - // Sorts fields by their shortCaption, but put subject groups/categories/cohort at the end. - queryFields.sort(function(a, b) - { - if (a.isSubjectGroupColumn != b.isSubjectGroupColumn) - return a.isSubjectGroupColumn ? 1 : -1; - else if (a.isCohortColumn != b.isCohortColumn) - return a.isCohortColumn ? 1 : -1; - else if (a.shortCaption != b.shortCaption) - return a.shortCaption < b.shortCaption ? -1 : 1; - - return 0; - }); - - return queryFields; - }; - - /** - * Determine a reasonable width for the chart based on the chart type and selected measures / data. - * @param chartType - * @param measures - * @param measureStore - * @param defaultWidth - * @returns {int} - */ - 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); - - 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); - } - - return width; - }; - - /** - * Return the distinct set of y-axis sides for the given measures object. - * @param measures - */ - var getDistinctYAxisSides = function(measures) - { - var distinctSides = []; - $.each(ensureMeasuresAsArray(measures.y), function (idx, measure) { - if (LABKEY.Utils.isObject(measure)) { - var side = measure.yAxis || 'left'; - if (distinctSides.indexOf(side) === -1) { - distinctSides.push(side); - } - } - }, this); - return distinctSides; - }; - - /** - * Generate a default label for an array of measures by concatenating each meaures label together. - * @param measures - * @returns string concatenation of all measure labels - */ - var getDefaultMeasuresLabel = function(measures) - { - if (LABKEY.Utils.isDefined(measures)) { - if (!LABKEY.Utils.isArray(measures)) { - return measures.label || measures.queryName || ''; - } - - var label = '', sep = ''; - $.each(measures, function(idx, m) { - label += sep + (m.label || m.queryName); - sep = ', '; - }); - return label; - } - - return ''; - }; - - /** - * Given the saved labels object we convert it to include all label types (main, x, and y). Each label type defaults - * to empty string (''). - * @param {Object} labels The saved labels object. - * @returns {Object} - */ - var generateLabels = function(labels) { - return { - main: { value: labels.main || '' }, - subtitle: { value: labels.subtitle || '' }, - footer: { value: labels.footer || '' }, - x: { value: labels.x || '' }, - y: { value: labels.y || '' }, - yRight: { value: labels.yRight || '' } - }; - }; - - /** - * Generates an object containing {@link LABKEY.vis.Scale} objects used for the chart. - * @param {String} chartType The chartType from getChartType. - * @param {Object} measures The measures from generateMeasures. - * @param {Object} savedScales The scales object from the saved chart config. - * @param {Object} aes The aesthetic map object from genereateAes. - * @param {Object} measureStore The MeasureStore data using a selectRows API call. - * @param {Function} defaultFormatFn used to format values for tick marks. - * @returns {Object} - */ - var generateScales = function(chartType, measures, savedScales, aes, measureStore, defaultFormatFn) { - var scales = {}; - var data = LABKEY.Utils.isArray(measureStore.rows) ? measureStore.rows : measureStore.records(); - var fields = LABKEY.Utils.isObject(measureStore.metaData) ? measureStore.metaData.fields : measureStore.getResponseMetadata().fields; - var subjectColumn = getStudySubjectInfo().columnName; - var visitTableName = getStudySubjectInfo().tableName + 'Visit'; - var visitColName = visitTableName + '/Visit'; - var valExponentialDigits = 6; - - // Issue 38105: For plots with study visit labels on the x-axis, don't sort alphabetically - var sortFnX = measures.x && measures.x.fieldKey === visitColName ? undefined : LABKEY.vis.discreteSortFn; - - if (chartType === "box_plot") - { - scales.x = { - scaleType: 'discrete', // Force discrete x-axis scale for box plots. - sortFn: sortFnX, - tickLabelMax: DEFAULT_TICK_LABEL_MAX - }; - - var yMin = d3.min(data, aes.y); - var yMax = d3.max(data, aes.y); - var yPadding = ((yMax - yMin) * .1); - if (savedScales.y && savedScales.y.trans == "log") - { - // When subtracting padding we have to make sure we still produce valid values for a log scale. - // log([value less than 0]) = NaN. - // log(0) = -Infinity. - if (yMin - yPadding > 0) - { - yMin = yMin - yPadding; - } - } - else - { - yMin = yMin - yPadding; - } - - scales.y = { - min: yMin, - max: yMax + yPadding, - scaleType: 'continuous', - trans: savedScales.y ? savedScales.y.trans : 'linear' - }; - } - else - { - var xMeasureType = getMeasureType(measures.x); - - // Force discrete x-axis scale for bar plots. - var useContinuousScale = chartType != 'bar_chart' && isNumericType(xMeasureType); - - if (useContinuousScale) - { - scales.x = { - scaleType: 'continuous', - trans: savedScales.x ? savedScales.x.trans : 'linear' - }; - } - else - { - scales.x = { - scaleType: 'discrete', - sortFn: sortFnX, - tickLabelMax: DEFAULT_TICK_LABEL_MAX - }; - - //bar chart x-axis subcategories support - if (LABKEY.Utils.isDefined(measures.xSub)) { - scales.xSub = { - scaleType: 'discrete', - sortFn: LABKEY.vis.discreteSortFn, - tickLabelMax: DEFAULT_TICK_LABEL_MAX - }; - } - } - - // add both y (i.e. yLeft) and yRight, in case multiple y-axis measures are being plotted - scales.y = { - scaleType: 'continuous', - trans: savedScales.y ? savedScales.y.trans : 'linear' - }; - scales.yRight = { - scaleType: 'continuous', - trans: savedScales.yRight ? savedScales.yRight.trans : 'linear' - }; - } - - // if we have no data, show a default y-axis domain - if (scales.x && data.length == 0 && scales.x.scaleType == 'continuous') - scales.x.domain = [0,1]; - if (scales.y && data.length == 0) - scales.y.domain = [0,1]; - - // apply the field formatFn to the tick marks on the scales object - for (var i = 0; i < fields.length; i++) { - var type = fields[i].displayFieldJsonType ? fields[i].displayFieldJsonType : fields[i].type; - - var isMeasureXMatch = measures.x && _isFieldKeyMatch(measures.x, fields[i].fieldKey); - if (isMeasureXMatch && measures.x.name === subjectColumn && LABKEY.demoMode) { - scales.x.tickFormat = function(){return '******'}; - } - else if (isMeasureXMatch && isNumericType(type)) { - scales.x.tickFormat = _getNumberFormatFn(fields[i], defaultFormatFn); - } - - var yMeasures = ensureMeasuresAsArray(measures.y); - $.each(yMeasures, function(idx, yMeasure) { - var isMeasureYMatch = yMeasure && _isFieldKeyMatch(yMeasure, fields[i].fieldKey); - var isConvertedYMeasure = isMeasureYMatch && yMeasure.converted; - if (isMeasureYMatch && (isNumericType(type) || isConvertedYMeasure)) { - var tickFormatFn = _getNumberFormatFn(fields[i], defaultFormatFn); - - var ySide = yMeasure.yAxis === 'right' ? 'yRight' : 'y'; - scales[ySide].tickFormat = function(value) { - if (LABKEY.Utils.isNumber(value) && Math.abs(Math.round(value)).toString().length >= valExponentialDigits) { - return value.toExponential(); - } - else if (LABKEY.Utils.isFunction(tickFormatFn)) { - return tickFormatFn(value); - } - return value; - }; - } - }, this); - } - - _applySavedScaleDomain(scales, savedScales, 'x'); - if (LABKEY.Utils.isDefined(measures.xSub)) { - _applySavedScaleDomain(scales, savedScales, 'xSub'); - } - if (LABKEY.Utils.isDefined(measures.y)) { - _applySavedScaleDomain(scales, savedScales, 'y'); - _applySavedScaleDomain(scales, savedScales, 'yRight'); - } - - return scales; - }; - - // Issue 36227: if Ext4 is not available, try to generate our own number format function based on the "format" field metadata - var _getNumberFormatFn = function(field, defaultFormatFn) { - if (field.extFormatFn) { - if (window.Ext4) { - return eval(field.extFormatFn); - } - else if (field.format && LABKEY.Utils.isString(field.format) && field.format.indexOf('.') > -1) { - var precision = field.format.length - field.format.indexOf('.') - 1; - return function(v) { - return LABKEY.Utils.isNumber(v) ? v.toFixed(precision) : v; - } - } - } - - return defaultFormatFn; - }; - - var _isFieldKeyMatch = function(measure, fieldKey) { - if (LABKEY.Utils.isFunction(fieldKey.getName)) { - return fieldKey.getName() === measure.name || fieldKey.getName() === measure.fieldKey; - } else if (LABKEY.Utils.isArray(fieldKey)) { - fieldKey = fieldKey.join('/') - } - - return fieldKey === measure.name || fieldKey === measure.fieldKey; - }; - - var ensureMeasuresAsArray = function(measures) { - if (LABKEY.Utils.isDefined(measures)) { - return LABKEY.Utils.isArray(measures) ? $.extend(true, [], measures) : [$.extend(true, {}, measures)]; - } - return []; - }; - - var _applySavedScaleDomain = function(scales, savedScales, scaleName) { - if (savedScales[scaleName] && (savedScales[scaleName].min != null || savedScales[scaleName].max != null)) { - scales[scaleName].domain = [savedScales[scaleName].min, savedScales[scaleName].max]; - } - }; - - /** - * Generates the aesthetic map object needed by the visualization API to render the chart. See {@link LABKEY.vis.Plot} - * and {@link LABKEY.vis.Layer}. - * @param {String} chartType The chartType from getChartType. - * @param {Object} measures The measures from getMeasures. - * @param {String} schemaName The schemaName from the saved queryConfig. - * @param {String} queryName The queryName from the saved queryConfig. - * @returns {Object} - */ - 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); - - if (chartType === "box_plot") { - 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); - } - } else if (isNumericType(xMeasureType) || (chartType === 'scatter_plot' && measures.x.measure)) { - aes.x = generateContinuousAcc(xMeasureName); - } else { - aes.x = generateDiscreteAcc(xMeasureName, measures.x.label); - } - - // charts that have multiple y-measures selected will need to put the aes.y function on their specific layer - if (LABKEY.Utils.isDefined(measures.y) && !LABKEY.Utils.isArray(measures.y)) - { - var sideAesName = (measures.y.yAxis || 'left') === 'left' ? 'y' : 'yRight'; - var yMeasureName = measures.y.converted ? measures.y.convertedName : measures.y.name; - aes[sideAesName] = generateContinuousAcc(yMeasureName); - } - - if (chartType === "scatter_plot" || chartType === "line_plot") - { - aes.hoverText = generatePointHover(measures); - } - - if (chartType === "box_plot") - { - if (measures.color) { - aes.outlierColor = generateGroupingAcc(measures.color.name); - } - - if (measures.shape) { - aes.outlierShape = generateGroupingAcc(measures.shape.name); - } - - aes.hoverText = generateBoxplotHover(); - aes.outlierHoverText = generatePointHover(measures); - } - else if (chartType === 'bar_chart') - { - var xSubMeasureType = measures.xSub ? getMeasureType(measures.xSub) : null; - if (xSubMeasureType) - { - if (isNumericType(xSubMeasureType)) - aes.xSub = generateContinuousAcc(measures.xSub.name); - else - aes.xSub = generateDiscreteAcc(measures.xSub.name, measures.xSub.label); - } - } - - // color/shape aes are not dependent on chart type. If we have a box plot with all points enabled, then we - // create a second layer for points. So we'll need this no matter what. - if (measures.color) { - aes.color = generateGroupingAcc(measures.color.name); - } - - if (measures.shape) { - aes.shape = generateGroupingAcc(measures.shape.name); - } - - // also add the color and shape for the line plot series. - if (measures.series) { - aes.color = generateGroupingAcc(measures.series.name); - aes.shape = generateGroupingAcc(measures.series.name); - } - - if (measures.pointClickFn) { - aes.pointClickFn = generatePointClickFn( - measures, - schemaName, - queryName, - measures.pointClickFn - ); - } - - return aes; - }; - - var getYMeasureAes = function(measure) { - var yMeasureName = measure.converted ? measure.convertedName : measure.name; - return generateContinuousAcc(yMeasureName); - }; - - /** - * Generates a function that returns the text used for point hovers. - * @param {Object} measures The measures object from the saved chart config. - * @returns {Function} - */ - var generatePointHover = function(measures) - { - return function(row) { - var hover = '', sep = '', distinctNames = []; - - $.each(measures, function(key, measureObj) { - var measureArr = ensureMeasuresAsArray(measureObj); - $.each(measureArr, function(idx, measure) { - if (LABKEY.Utils.isObject(measure) && !LABKEY.Utils.isEmptyObj(measure) && distinctNames.indexOf(measure.name) == -1) { - hover += sep + measure.label + ': ' + _getRowValue(row, measure.name); - sep = ', \n'; - - distinctNames.push(measure.name); - } - }, this); - }); - - return hover; - }; - }; - - /** - * Backwards compatibility for function that has been moved to LABKEY.vis.getAggregateData. - */ - var generateAggregateData = function(data, dimensionName, measureName, aggregate, nullDisplayValue) { - return LABKEY.vis.getAggregateData(data, dimensionName, null, measureName, aggregate, nullDisplayValue, false); - }; - - var _getRowValue = function(row, propName, valueName) - { - if (row.hasOwnProperty(propName)) { - // backwards compatibility for response row that is not a LABKEY.Query.Row - if (!(row instanceof LABKEY.Query.Row)) { - return row[propName].formattedValue || row[propName].displayValue || row[propName].value; - } - - var propValue = row.get(propName); - if (valueName != undefined && propValue.hasOwnProperty(valueName)) { - return propValue[valueName]; - } - else if (propValue.hasOwnProperty('formattedValue')) { - return propValue['formattedValue']; - } - else if (propValue.hasOwnProperty('displayValue')) { - return propValue['displayValue']; - } - return row.getValue(propName); - } - - return undefined; - }; - - /** - * Returns a function used to generate the hover text for box plots. - * @returns {Function} - */ - var generateBoxplotHover = function() { - return function(xValue, stats) { - return xValue + ':\nMin: ' + stats.min + '\nMax: ' + stats.max + '\nQ1: ' + stats.Q1 + '\nQ2: ' + stats.Q2 + - '\nQ3: ' + stats.Q3; - }; - }; - - /** - * Generates an accessor function that returns a discrete value from a row of data for a given measure and label. - * Used when an axis has a discrete measure (i.e. string). - * @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 - * @returns {Function} - */ - var generateDiscreteAcc = function(measureName, measureLabel, nullValueLabel) - { - return function(row) - { - var value = _getRowValue(row, measureName); - if (value === null) - value = nullValueLabel !== undefined ? nullValueLabel : "Not in " + measureLabel; - - return value; - }; - }; - - /** - * Generates an accessor function that returns a value from a row of data for a given measure. - * @param {String} measureName The name of the measure. - * @returns {Function} - */ - var generateContinuousAcc = function(measureName) - { - return function(row) - { - var value = _getRowValue(row, measureName, 'value'); - - if (value !== undefined) - { - if (Math.abs(value) === Infinity) - value = null; - - if (value === false || value === true) - value = value.toString(); - - return value; - } - - return undefined; - } - }; - - /** - * Generates an accesssor function for shape and color measures. - * @param {String} measureName The name of the measure. - * @returns {Function} - */ - var generateGroupingAcc = function(measureName) - { - return function(row) - { - var value = null; - if (LABKEY.Utils.isArray(row) && row.length > 0) { - value = _getRowValue(row[0], measureName); - } - else { - value = _getRowValue(row, measureName); - } - - if (value === null || value === undefined) - value = "n/a"; - - return value; - }; - }; - - /** - * Generates an accessor for boxplots that do not have an x-axis measure. Generally the measureName passed in is the - * queryName. - * @param {String} measureName The name of the measure. In this case it is generally the query name. - * @returns {Function} - */ - var generateMeasurelessAcc = function(measureName) { - // Used for box plots that do not have an x-axis measure. Instead we just return the queryName for every row. - return function(row) { - return measureName; - } - }; - - /** - * Generates the function to be executed when a user clicks a point. - * @param {Object} measures The measures from the saved chart config. - * @param {String} schemaName The schema name from the saved query config. - * @param {String} queryName The query name from the saved query config. - * @param {String} fnString The string value of the user-provided function to be executed when a point is clicked. - * @returns {Function} - */ - var generatePointClickFn = function(measures, schemaName, queryName, fnString){ - var measureInfo = { - schemaName: schemaName, - queryName: queryName - }; - - _addPointClickMeasureInfo(measureInfo, measures, 'x', 'xAxis'); - _addPointClickMeasureInfo(measureInfo, measures, 'y', 'yAxis'); - $.each(['color', 'shape', 'series'], function(idx, name) { - _addPointClickMeasureInfo(measureInfo, measures, name, name + 'Name'); - }, this); - - // using new Function is quicker than eval(), even in IE. - var pointClickFn = new Function('return ' + fnString)(); - return function(clickEvent, data){ - pointClickFn(data, measureInfo, clickEvent); - }; - }; - - var _addPointClickMeasureInfo = function(measureInfo, measures, name, key) { - if (LABKEY.Utils.isDefined(measures[name])) { - var measuresArr = ensureMeasuresAsArray(measures[name]); - $.each(measuresArr, function(idx, measure) { - if (!LABKEY.Utils.isDefined(measureInfo[key])) { - measureInfo[key] = measure.name; - } - else if (!LABKEY.Utils.isDefined(measureInfo[measure.name])) { - measureInfo[measure.name] = measure.name; - } - }, this); - } - }; - - /** - * Generates the Point Geom used for scatter plots and box plots with all points visible. - * @param {Object} chartOptions The saved chartOptions object from the chart config. - * @returns {LABKEY.vis.Geom.Point} - */ - var generatePointGeom = function(chartOptions){ - return new LABKEY.vis.Geom.Point({ - opacity: chartOptions.opacity, - size: chartOptions.pointSize, - color: '#' + chartOptions.pointFillColor, - position: chartOptions.position - }); - }; - - /** - * Generates the Boxplot Geom used for box plots. - * @param {Object} chartOptions The saved chartOptions object from the chart config. - * @returns {LABKEY.vis.Geom.Boxplot} - */ - var generateBoxplotGeom = function(chartOptions){ - return new LABKEY.vis.Geom.Boxplot({ - lineWidth: chartOptions.lineWidth, - outlierOpacity: chartOptions.opacity, - outlierFill: '#' + chartOptions.pointFillColor, - outlierSize: chartOptions.pointSize, - color: '#' + chartOptions.lineColor, - fill: chartOptions.boxFillColor == 'none' ? chartOptions.boxFillColor : '#' + chartOptions.boxFillColor, - position: chartOptions.position, - showOutliers: chartOptions.showOutliers - }); - }; - - /** - * Generates the Barplot Geom used for bar charts. - * @param {Object} chartOptions The saved chartOptions object from the chart config. - * @returns {LABKEY.vis.Geom.BarPlot} - */ - var generateBarGeom = function(chartOptions){ - return new LABKEY.vis.Geom.BarPlot({ - opacity: chartOptions.opacity, - color: '#' + chartOptions.lineColor, - fill: '#' + chartOptions.boxFillColor, - lineWidth: chartOptions.lineWidth - }); - }; - - /** - * Generates the Bin Geom used to bin a set of points. - * @param {Object} chartOptions The saved chartOptions object from the chart config. - * @returns {LABKEY.vis.Geom.Bin} - */ - var generateBinGeom = function(chartOptions) { - var colorRange = ["#e6e6e6", "#085D90"]; //light-gray and labkey blue is default - if (chartOptions.binColorGroup == 'SingleColor') { - var color = '#' + chartOptions.binSingleColor; - colorRange = ["#FFFFFF", color]; - } - else if (chartOptions.binColorGroup == 'Heat') { - colorRange = ["#fff6bc", "#e23202"]; - } - - return new LABKEY.vis.Geom.Bin({ - shape: chartOptions.binShape, - colorRange: colorRange, - size: chartOptions.binShape == 'square' ? 10 : 5 - }) - }; - - /** - * Generates a Geom based on the chartType. - * @param {String} chartType The chart type from getChartType. - * @param {Object} chartOptions The chartOptions object from the saved chart config. - * @returns {LABKEY.vis.Geom} - */ - var generateGeom = function(chartType, chartOptions) { - if (chartType == "box_plot") - return generateBoxplotGeom(chartOptions); - else if (chartType == "scatter_plot" || chartType == "line_plot") - return chartOptions.binned ? generateBinGeom(chartOptions) : generatePointGeom(chartOptions); - else if (chartType == "bar_chart") - return generateBarGeom(chartOptions); - }; - - /** - * Generate an array of plot configs for the given chart renderType and config options. - * @param renderTo - * @param chartConfig - * @param labels - * @param aes - * @param scales - * @param geom - * @param data - * @param trendlineData - * @returns {Array} array of plot config objects - */ - var generatePlotConfigs = function(renderTo, chartConfig, labels, aes, scales, geom, data, trendlineData) - { - var plotConfigArr = []; - - // if we have multiple y-measures and the request is to plot them separately, call the generatePlotConfig function - // for each y-measure separately with its own copy of the chartConfig object - if (chartConfig.geomOptions.chartLayout === 'per_measure' && LABKEY.Utils.isArray(chartConfig.measures.y)) { - - // if 'automatic across charts' scales are requested, need to manually calculate the min and max - if (chartConfig.scales.y && chartConfig.scales.y.type === 'automatic') { - scales.y = $.extend(scales.y, _getScaleDomainValuesForAllMeasures(data, chartConfig.measures.y, 'left')); - } - if (chartConfig.scales.yRight && chartConfig.scales.yRight.type === 'automatic') { - scales.yRight = $.extend(scales.yRight, _getScaleDomainValuesForAllMeasures(data, chartConfig.measures.y, 'right')); - } - - $.each(chartConfig.measures.y, function(idx, yMeasure) { - // copy the config and reset the measures.y array with the single measure - var newChartConfig = $.extend(true, {}, chartConfig); - newChartConfig.measures.y = $.extend(true, {}, yMeasure); - - // copy the labels object so that we can set the subtitle based on the y-measure - var newLabels = $.extend(true, {}, labels); - newLabels.subtitle = {value: yMeasure.label || yMeasure.name}; - - // only copy over the scales that are needed for this measures - var side = yMeasure.yAxis || 'left'; - var newScales = {x: $.extend(true, {}, scales.x)}; - if (side === 'left') { - newScales.y = $.extend(true, {}, scales.y); - } - else { - newScales.yRight = $.extend(true, {}, scales.yRight); - } - - plotConfigArr.push(generatePlotConfig(renderTo, newChartConfig, newLabels, aes, newScales, geom, data, trendlineData)); - }, this); - } - else { - plotConfigArr.push(generatePlotConfig(renderTo, chartConfig, labels, aes, scales, geom, data, trendlineData)); - } - - return plotConfigArr; - }; - - var _getScaleDomainValuesForAllMeasures = function(data, measures, side) { - var min = null, max = null; - - $.each(measures, function(idx, measure) { - var measureSide = measure.yAxis || 'left'; - if (side === measureSide) { - var accFn = LABKEY.vis.GenericChartHelper.getYMeasureAes(measure); - var tempMin = d3.min(data, accFn); - var tempMax = d3.max(data, accFn); - - if (min == null || tempMin < min) { - min = tempMin; - } - if (max == null || tempMax > max) { - max = tempMax; - } - } - }, this); - - return {domain: [min, max]}; - }; - - /** - * Generate the plot config for the given chart renderType and config options. - * @param renderTo - * @param chartConfig - * @param labels - * @param aes - * @param scales - * @param geom - * @param data - * @param trendlineData - * @returns {Object} - */ - var generatePlotConfig = function(renderTo, chartConfig, labels, aes, scales, geom, data, trendlineData) - { - var renderType = chartConfig.renderType, - layers = [], clipRect, - emptyTextFn = function(){return '';}, - plotConfig = { - renderTo: renderTo, - rendererType: 'd3', - width: chartConfig.width, - height: chartConfig.height, - gridLinesVisible: chartConfig.gridLinesVisible, - }; - - if (renderType === 'pie_chart') { - return _generatePieChartConfig(plotConfig, chartConfig, labels, data); - } - - clipRect = (scales.x && LABKEY.Utils.isArray(scales.x.domain)) || (scales.y && LABKEY.Utils.isArray(scales.y.domain)); - - // account for line chart hiding points - if (chartConfig.geomOptions.hideDataPoints) { - geom = null; - } - - // account for one or many y-measures by ensuring that we have an array of y-measures - var yMeasures = ensureMeasuresAsArray(chartConfig.measures.y); - - if (renderType === 'bar_chart') { - aes = { x: 'label', y: 'value' }; - - if (LABKEY.Utils.isDefined(chartConfig.measures.xSub)) - { - aes.xSub = 'subLabel'; - aes.color = 'label'; - } - - if (!scales.y) { - 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)); - - scales.y.domain = [min, max]; - } - } - else if (renderType === 'box_plot' && chartConfig.pointType === 'all') - { - layers.push( - new LABKEY.vis.Layer({ - geom: LABKEY.vis.GenericChartHelper.generatePointGeom(chartConfig.geomOptions), - aes: {hoverText: LABKEY.vis.GenericChartHelper.generatePointHover(chartConfig.measures)} - }) - ); - } - else if (renderType === 'line_plot') { - var xName = chartConfig.measures.x.name, - isDate = isDateType(getMeasureType(chartConfig.measures.x)); - - $.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); - } - return aVal - bVal; - }, - hoverText: emptyTextFn(), - }; - - pathAes[yMeasure.yAxis === 'right' ? 'yRight' : 'yLeft'] = getYMeasureAes(yMeasure); - - // use the series measure's values for the distinct colors and grouping - const hasSeries = chartConfig.measures.series !== undefined; - if (hasSeries) { - pathAes.pathColor = generateGroupingAcc(chartConfig.measures.series.name); - pathAes.group = generateGroupingAcc(chartConfig.measures.series.name); - pathAes.hoverText = function (row) { return chartConfig.measures.series.label + ': ' + row.group }; - } - // if no series measures but we have multiple y-measures, force the color and grouping to be distinct for each measure - else if (yMeasures.length > 1) { - pathAes.pathColor = emptyTextFn; - pathAes.group = emptyTextFn; - } - - if (trendlineData) { - trendlineData.forEach(trendline => { - if (trendline.data) { - const layerAes = { x: 'x', y: 'y' }; - if (hasSeries) { - layerAes.pathColor = function () { return trendline.name }; - } - - layerAes.hoverText = generateTrendlinePathHover(trendline); - - layers.push( - new LABKEY.vis.Layer({ - geom: new LABKEY.vis.Geom.Path({ - color: '#' + chartConfig.geomOptions.pointFillColor, - size: chartConfig.geomOptions.lineWidth ? chartConfig.geomOptions.lineWidth : 3, - opacity:chartConfig.geomOptions.opacity, - }), - aes: layerAes, - data: trendline.data.generatedPoints, - }) - ); - } - }); - } else { - layers.push( - new LABKEY.vis.Layer({ - name: yMeasures.length > 1 ? yMeasure.label || yMeasure.name : undefined, - geom: new LABKEY.vis.Geom.Path({ - color: '#' + chartConfig.geomOptions.pointFillColor, - size: chartConfig.geomOptions.lineWidth?chartConfig.geomOptions.lineWidth:3, - opacity:chartConfig.geomOptions.opacity - }), - aes: pathAes - }) - ); - } - }, this); - } - - // Issue 34711: better guess at the max number of discrete x-axis tick mark labels to show based on the plot width - if (scales.x && scales.x.scaleType === 'discrete' && scales.x.tickLabelMax) { - // approx 30 px for a 45 degree rotated tick label - scales.x.tickLabelMax = Math.floor((plotConfig.width - 300) / 30); - } - - var margins = _getPlotMargins(renderType, scales, aes, data, plotConfig, chartConfig); - if (LABKEY.Utils.isObject(margins)) { - plotConfig.margins = margins; - } - - if (chartConfig.measures.color) - { - scales.color = { - colorType: chartConfig.geomOptions.colorPaletteScale, - scaleType: 'discrete' - } - } - - if ((renderType === 'line_plot' || renderType === 'scatter_plot') && yMeasures.length > 0) { - $.each(yMeasures, function (idx, yMeasure) { - var layerAes = {}; - layerAes[yMeasure.yAxis === 'right' ? 'yRight' : 'yLeft'] = getYMeasureAes(yMeasure); - - // if no series measures but we have multiple y-measures, force the color and shape to be distinct for each measure - if (!aes.color && yMeasures.length > 1) { - layerAes.color = emptyTextFn; - } - if (!aes.shape && yMeasures.length > 1) { - layerAes.shape = emptyTextFn; - } - - layers.push( - new LABKEY.vis.Layer({ - name: yMeasures.length > 1 ? yMeasure.label || yMeasure.name : undefined, - geom: geom, - aes: layerAes - }) - ); - }, this); - } - else { - layers.push( - new LABKEY.vis.Layer({ - data: data, - geom: geom - }) - ); - } - - plotConfig = $.extend(plotConfig, { - clipRect: clipRect, - data: data, - labels: labels, - aes: aes, - scales: scales, - layers: layers - }); - - return plotConfig; - }; - - const hasPremiumModule = function() { - return LABKEY.getModuleContext('api').moduleNames.indexOf('premium') > -1; - }; - - const TRENDLINE_OPTIONS = { - '': { label: 'Point-to-Point', value: '' }, - 'Linear': { label: 'Linear Regression', value: 'Linear', equation: 'y = x * slope + intercept' }, - 'Polynomial': { label: 'Polynomial', value: 'Polynomial', equation: 'y = a0 + a1 * x + a2 * x^2' }, - '3 Parameter': { label: 'Nonlinear 3PL', value: '3 Parameter', showMax: true, schemaPrefix: 'assay', equation: 'y = max * abs(x/inflection)^abs(slope) / [1 + abs(x/inflection)^abs(slope)]' }, - 'Three Parameter': { label: 'Nonlinear 3PL (Alternate)', value: 'Three Parameter', showMax: true, schemaPrefix: 'assay', equation: 'y = max / [1 + (inflection - x) * slope]' }, - '4 Parameter': { label: 'Nonlinear 4PL', value: '4 Parameter', schemaPrefix: 'assay', equation: 'y = max + (min - max) / [1 + (x/inflection)^slope]' }, - 'Four Parameter': { label: 'Nonlinear 4PL (Alternate)', value: 'Four Parameter', showMin: true, showMax: true, schemaPrefix: 'assay', equation: 'y = min + (max - min) / [1 + (inflection - x) * slope]' }, - 'Five Parameter': { label: 'Nonlinear 5PL', value: 'Five Parameter', showMin: true, showMax: true, schemaPrefix: 'assay', equation: 'y = min + (max - min) / [[1 + (inflection - x) * slope]^asymmetry]' }, - } - - const generateTrendlinePathHover = function(trendline) { - let hoverText = trendline.name + '\n'; - hoverText += '\n' + TRENDLINE_OPTIONS[trendline.data.curveFit.type].label + ':\n'; - Object.entries(trendline.data.curveFit).forEach(([key, value]) => { - if (key === 'coefficients') { - hoverText += key + ': '; - value.forEach((v, i) => { - hoverText += (i > 0 ? ', ' : '') + LABKEY.Utils.roundNumber(v, 4); - }); - hoverText += '\n'; - } - else if (key !== 'type') { - hoverText += key + ': ' + LABKEY.Utils.roundNumber(value, 4) + '\n'; - } - }); - hoverText += '\nStatistics:\n'; - Object.entries(trendline.data.stats).forEach(([key, value]) => { - const label = key === 'RSquared' ? 'R-Squared' : (key === 'adjustedRSquared' ? 'Adjusted R-Squared' : key); - hoverText += label + ': ' + LABKEY.Utils.roundNumber(value, 4) + '\n'; - }); - - return function () { return hoverText }; - }; - - // support for y-axis trendline data when a single y-axis measure is selected - const queryTrendlineData = async function(chartConfig, data) { - const chartType = getChartType(chartConfig); - const yMeasures = ensureMeasuresAsArray(chartConfig.measures.y); - if (chartType === 'line_plot' && chartConfig.geomOptions?.trendlineType && chartConfig.geomOptions.trendlineType !== '' && yMeasures.length === 1) { - const xName = chartConfig.measures.x.name; - const trendlineConfig = getTrendlineConfig(chartConfig, data); - try { - await _queryTrendlineData(trendlineConfig, xName, yMeasures[0].name); - return trendlineConfig.data; - } catch (reason) { - // skip this series and render without trendline - return trendlineConfig.data; - } - } - - return undefined; - }; - - const getTrendlineConfig = function(chartConfig, data) { - const config = { - type: chartConfig.geomOptions.trendlineType, - logXScale: chartConfig.scales.x && chartConfig.scales.x.trans === 'log', - asymptoteMin: chartConfig.geomOptions.trendlineAsymptoteMin, - asymptoteMax: chartConfig.geomOptions.trendlineAsymptoteMax, - data: chartConfig.measures.series - ? LABKEY.vis.groupCountData(data, generateGroupingAcc(chartConfig.measures.series.name)) - : [{name: 'All', rawData: data}], - }; - - // special case to only use logXScale for linear trendlines - if (config.type === 'Linear') { - config.logXScale = false; - } - - return config; - }; - - const _queryTrendlineData = async function(trendlineConfig, xName, yName) { - for (let series of trendlineConfig.data) { - try { - // we need at least 2 data points for curve fitting - if (series.rawData.length > 1) { - series.data = await _querySeriesTrendlineData(trendlineConfig, series, xName, yName); - } - } catch (e) { - console.error(e); - } - } - }; - - const _querySeriesTrendlineData = function(trendlineConfig, seriesData, xName, yName) { - return new Promise(function(resolve, reject) { - if (!hasPremiumModule()) { - reject('Premium module required for curve fitting.'); - return; - } - - const points = seriesData.rawData.map(function(row) { - return { - x: _getRowValue(row, xName, 'value'), - y: _getRowValue(row, yName, 'value'), - }; - }); - const xAcc = function(row) { return row.x }; - const xMin = d3.min(points, xAcc); - const xMax = d3.max(points, xAcc); - - LABKEY.Ajax.request({ - url: LABKEY.ActionURL.buildURL('premium', 'calculateCurveFit.api'), - method: 'POST', - jsonData: { - curveFitType: trendlineConfig.type, - points: points, - logXScale: trendlineConfig.logXScale, - asymptoteMin: trendlineConfig.asymptoteMin, - asymptoteMax: trendlineConfig.asymptoteMax, - xMin: xMin, - xMax: xMax, - numberOfPoints: 1000, - }, - success : LABKEY.Utils.getCallbackWrapper(function(response) { - resolve(response); - }), - failure : LABKEY.Utils.getCallbackWrapper(function(reason) { - reject(reason); - }, this, true), - }); - }); - }; - - var _willRotateXAxisTickText = 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); - } - - return false; - }; - - var _getPlotMargins = function(renderType, scales, aes, data, plotConfig, chartConfig) { - var margins = {}; - - // issue 29690: for bar and box plots, set default bottom margin based on the number of labels and the max label length - if (LABKEY.Utils.isArray(data)) { - var maxLen = 0; - $.each(data, function(idx, d) { - var val = LABKEY.Utils.isFunction(aes.x) ? aes.x(d) : d[aes.x]; - var subVal = LABKEY.Utils.isFunction(aes.xSub) ? aes.xSub(d) : d[aes.xSub]; - if (LABKEY.Utils.isString(subVal)) { - maxLen = Math.max(maxLen, subVal.length); - } else if (LABKEY.Utils.isString(val)) { - maxLen = Math.max(maxLen, val.length); - } - }); - - if (_willRotateXAxisTickText(scales, plotConfig, maxLen, data)) { - // min bottom margin: 50, max bottom margin: 150 - margins.bottom = Math.min(Math.max(50, maxLen*5), 175); - } - } - - // issue 31857: allow custom margins to be set in Chart Layout dialog - if (chartConfig && chartConfig.geomOptions) { - if (chartConfig.geomOptions.marginTop !== null) { - margins.top = chartConfig.geomOptions.marginTop; - } - if (chartConfig.geomOptions.marginRight !== null) { - margins.right = chartConfig.geomOptions.marginRight; - } - if (chartConfig.geomOptions.marginBottom !== null) { - margins.bottom = chartConfig.geomOptions.marginBottom; - } - if (chartConfig.geomOptions.marginLeft !== null) { - margins.left = chartConfig.geomOptions.marginLeft; - } - } - - return !LABKEY.Utils.isEmptyObj(margins) ? margins : null; - }; - - var _generatePieChartConfig = function(baseConfig, chartConfig, labels, data) - { - var hasData = data.length > 0; - - return $.extend(baseConfig, { - data: hasData ? data : [{label: '', value: 1}], - header: { - title: { text: labels.main.value }, - subtitle: { text: labels.subtitle.value }, - titleSubtitlePadding: 1 - }, - footer: { - text: hasData ? labels.footer.value : 'No data to display', - location: 'bottom-center' - }, - labels: { - mainLabel: { fontSize: 14 }, - percentage: { - fontSize: 14, - color: chartConfig.geomOptions.piePercentagesColor != null ? '#' + chartConfig.geomOptions.piePercentagesColor : undefined - }, - outer: { pieDistance: 20 }, - inner: { - format: hasData && chartConfig.geomOptions.showPiePercentages ? 'percentage' : 'none', - hideWhenLessThanPercentage: chartConfig.geomOptions.pieHideWhenLessThanPercentage - } - }, - size: { - pieInnerRadius: hasData ? chartConfig.geomOptions.pieInnerRadius + '%' : '100%', - pieOuterRadius: hasData ? chartConfig.geomOptions.pieOuterRadius + '%' : '90%' - }, - misc: { - gradient: { - enabled: chartConfig.geomOptions.gradientPercentage != 0, - percentage: chartConfig.geomOptions.gradientPercentage, - color: '#' + chartConfig.geomOptions.gradientColor - }, - colors: { - segments: hasData ? LABKEY.vis.Scale[chartConfig.geomOptions.colorPaletteScale]() : ['#333333'] - } - }, - effects: { highlightSegmentOnMouseover: false }, - tooltips: { enabled: true } - }); - }; - - /** - * Check if the MeasureStore selectRows API response has data. Return an error string if no data exists. - * @param measureStore - * @param includeFilterMsg true to include a message about removing filters - * @returns {String} - */ - var validateResponseHasData = function(measureStore, includeFilterMsg) - { - var dataArray = getMeasureStoreRecords(measureStore); - if (dataArray.length == 0) - { - return 'The response returned 0 rows of data. The query may be empty or the applied filters may be too strict.' - + (includeFilterMsg ? 'Try removing or adjusting any filters if possible.' : ''); - } - - return null; - }; - - var getMeasureStoreRecords = function(measureStore) { - return LABKEY.Utils.isDefined(measureStore) ? measureStore.rows || measureStore.records() : []; - } - - /** - * Verifies that the axis measure is actually present and has data. Also checks to make sure that data can be used in a log - * scale (if applicable). Returns an object with a success parameter (boolean) and a message parameter (string). If the - * success parameter is false there is a critical error and the chart cannot be rendered. If success is true the chart - * can be rendered. Message will contain an error or warning message if applicable. If message is not null and success - * is true, there is a warning. - * @param {String} chartType The chartType from getChartType. - * @param {Object} chartConfigOrMeasure The saved chartConfig object or a specific measure object. - * @param {String} measureName The name of the axis measure property. - * @param {Object} aes The aes object from generateAes. - * @param {Object} scales The scales object from generateScales. - * @param {Array} data The response data from selectRows. - * @param {Boolean} dataConversionHappened Whether we converted any values in the measure data - * @returns {Object} - */ - var validateAxisMeasure = function(chartType, chartConfigOrMeasure, measureName, aes, scales, data, dataConversionHappened) { - var measure = LABKEY.Utils.isObject(chartConfigOrMeasure) && chartConfigOrMeasure.measures ? chartConfigOrMeasure.measures[measureName] : chartConfigOrMeasure; - return _validateAxisMeasure(chartType, measure, measureName, aes, scales, data, dataConversionHappened); - }; - - var _validateAxisMeasure = function(chartType, measure, measureName, aes, scales, data, dataConversionHappened) { - var dataIsNull = true, measureUndefined = true, invalidLogValues = false, hasZeroes = false, message = null; - - // no need to check measures if we have no data - if (data.length === 0) { - return {success: true, message: message}; - } - - for (var i = 0; i < data.length; i ++) - { - var value = aes[measureName](data[i]); - - if (value !== undefined) - measureUndefined = false; - - if (value !== null) - dataIsNull = false; - - if (value && value < 0) - invalidLogValues = true; - - if (value === 0 ) - hasZeroes = true; - } - - if (measureUndefined) - { - message = 'The measure, ' + measure.name + ', was not found. It may have been renamed or removed.'; - return {success: false, message: message}; - } - - if ((chartType == 'scatter_plot' || chartType == 'line_plot' || measureName == 'y') && dataIsNull && !dataConversionHappened) - { - message = 'All data values for ' + measure.label + ' are null. Please choose a different measure or review/remove data filters.'; - return {success: true, message: message}; - } - - if (scales[measureName] && scales[measureName].trans == "log") - { - if (invalidLogValues) - { - message = "Unable to use a log scale on the " + measureName + "-axis. All " + measureName - + "-axis values must be >= 0. Reverting to linear scale on " + measureName + "-axis."; - scales[measureName].trans = 'linear'; - } - else if (hasZeroes) - { - message = "Some " + measureName + "-axis values are 0. Plotting all " + measureName + "-axis values as value+1."; - var accFn = aes[measureName]; - aes[measureName] = function(row){return accFn(row) + 1}; - } - } - - return {success: true, message: message}; - }; - - /** - * Deprecated - use validateAxisMeasure - */ - var validateXAxis = function(chartType, chartConfig, aes, scales, data){ - return this.validateAxisMeasure(chartType, chartConfig, 'x', aes, scales, data); - }; - /** - * Deprecated - use validateAxisMeasure - */ - var validateYAxis = function(chartType, chartConfig, aes, scales, data){ - return this.validateAxisMeasure(chartType, chartConfig, 'y', aes, scales, data); - }; - - var getMeasureType = function(measure) { - return LABKEY.Utils.isObject(measure) ? (measure.normalizedType || measure.type) : null; - }; - - var isNumericType = function(type) - { - var t = LABKEY.Utils.isString(type) ? type.toLowerCase() : null; - return t == 'int' || t == 'integer' || t == 'float' || t == 'double'; - }; - - var isDateType = function(type) - { - var t = LABKEY.Utils.isString(type) ? type.toLowerCase() : null; - return t == 'date'; - }; - - var getAllowableTypes = function(field) { - var numericTypes = ['int', 'float', 'double', 'INTEGER', 'DOUBLE'], - nonNumericTypes = ['string', 'date', 'boolean', 'STRING', 'TEXT', 'DATE', 'BOOLEAN'], - numericAndDateTypes = numericTypes.concat(['date','DATE']); - - if (field.altSelectionOnly) - return []; - else if (field.numericOnly) - return numericTypes; - else if (field.nonNumericOnly) - return nonNumericTypes; - else if (field.numericOrDateOnly) - return numericAndDateTypes; - else - return numericTypes.concat(nonNumericTypes); - } - - var isMeasureDimensionMatch = function(chartType, field, isMeasure, isDimension) { - if ((chartType === 'box_plot' || chartType === 'bar_chart')) { - //x-axis does not support 'measure' column types for these plot types - if (field.name === 'x' || field.name === 'xSub') - return isDimension; - else - return isMeasure; - } - - return (field.numericOnly && isMeasure) || (field.nonNumericOnly && isDimension); - } - - var getQueryConfigSortKey = function(measures) { - var sortKey = 'lsid'; // needed to keep expected ordering for legend data - - // Issue 38105: For plots with study visit labels on the x-axis, sort by visit display order and then sequenceNum - var visitTableName = LABKEY.vis.GenericChartHelper.getStudySubjectInfo().tableName + 'Visit'; - if (measures.x && measures.x.fieldKey === visitTableName + '/Visit') { - var displayOrderColName = visitTableName + '/Visit/DisplayOrder'; - var seqNumColName = visitTableName + '/SequenceNum'; - sortKey = displayOrderColName + ', ' + seqNumColName; - } - - return sortKey; - } - - var getStudySubjectInfo = function() - { - var studyCtx = LABKEY.getModuleContext("study") || {}; - return LABKEY.Utils.isObject(studyCtx.subject) ? studyCtx.subject : { - tableName: 'Participant', - columnName: 'ParticipantId', - nounPlural: 'Participants', - nounSingular: 'Participant' - }; - }; - - var _getStudyTimepointType = function() - { - var studyCtx = LABKEY.getModuleContext("study") || {}; - return LABKEY.Utils.isDefined(studyCtx.timepointType) ? studyCtx.timepointType : null; - }; - - var _getMeasureRestrictions = function (chartType, measure) - { - var measureRestrictions = {}; - $.each(getRenderTypes(), function (idx, renderType) - { - if (renderType.name === chartType) - { - $.each(renderType.fields, function (idx2, field) - { - if (field.name === measure) - { - measureRestrictions.numericOnly = field.numericOnly; - measureRestrictions.nonNumericOnly = field.nonNumericOnly; - return false; - } - }); - return false; - } - }); - - return measureRestrictions; - }; - - /** - * Converts data values passed in to the appropriate type based on measure/dimension information. - * @param chartConfig Chart configuration object - * @param aes Aesthetic mapping functions for each measure/axis - * @param renderType The type of plot or chart (e.g. scatter_plot, bar_chart) - * @param data The response data from SelectRows - * @returns {{processed: {}, warningMessage: *}} - */ - var doValueConversion = function(chartConfig, aes, renderType, data) - { - var measuresForProcessing = {}, measureRestrictions = {}, configMeasure; - for (var measureName in chartConfig.measures) { - if (chartConfig.measures.hasOwnProperty(measureName) && LABKEY.Utils.isObject(chartConfig.measures[measureName])) { - configMeasure = chartConfig.measures[measureName]; - $.extend(measureRestrictions, _getMeasureRestrictions(renderType, measureName)); - - var isGroupingMeasure = measureName === 'color' || measureName === 'shape' || measureName === 'series'; - var isXAxis = measureName === 'x' || measureName === 'xSub'; - var isScatterOrLine = renderType === 'scatter_plot' || renderType === 'line_plot'; - var isBarYCount = renderType === 'bar_chart' && configMeasure.aggregate && (configMeasure.aggregate === 'COUNT' || configMeasure.aggregate.value === 'COUNT'); - - if (configMeasure.measure && !isGroupingMeasure && !isBarYCount - && ((!isXAxis && measureRestrictions.numericOnly ) || isScatterOrLine) && !isNumericType(configMeasure.type)) { - measuresForProcessing[measureName] = {}; - measuresForProcessing[measureName].name = configMeasure.name; - measuresForProcessing[measureName].convertedName = configMeasure.name + "_converted"; - measuresForProcessing[measureName].label = configMeasure.label; - configMeasure.normalizedType = 'float'; - configMeasure.type = 'float'; - } - } - } - - var response = {processed: {}}; - if (!LABKEY.Utils.isEmptyObj(measuresForProcessing)) { - response = _processMeasureData(data, aes, measuresForProcessing); - } - - //generate error message for dropped values - var warningMessage = ''; - for (var measure in response.droppedValues) { - if (response.droppedValues.hasOwnProperty(measure) && response.droppedValues[measure].numDropped) { - warningMessage += " The " - + measure + "-axis measure '" - + response.droppedValues[measure].label + "' had " - + response.droppedValues[measure].numDropped + - " value(s) that could not be converted to a number and are not included in the plot."; - } - } - - return {processed: response.processed, warningMessage: warningMessage}; - }; - - /** - * Does the explicit type conversion for each measure deemed suitable to convert. Currently we only - * attempt to convert strings to numbers for measures. - * @param rows Data from SelectRows - * @param aes Aesthetic mapping function for the measure/dimensions - * @param measuresForProcessing The measures to be converted, if any - * @returns {{droppedValues: {}, processed: {}}} - */ - var _processMeasureData = function(rows, aes, measuresForProcessing) { - var droppedValues = {}, processedMeasures = {}, dataIsNull; - rows.forEach(function(row) { - //convert measures if applicable - if (!LABKEY.Utils.isEmptyObj(measuresForProcessing)) { - for (var measure in measuresForProcessing) { - if (measuresForProcessing.hasOwnProperty(measure)) { - dataIsNull = true; - if (!droppedValues[measure]) { - droppedValues[measure] = {}; - droppedValues[measure].label = measuresForProcessing[measure].label; - droppedValues[measure].numDropped = 0; - } - - if (aes.hasOwnProperty(measure)) { - var value = aes[measure](row); - if (value !== null) { - dataIsNull = false; - } - row[measuresForProcessing[measure].convertedName] = {value: null}; - if (typeof value !== 'number' && value !== null) { - - //only try to convert strings to numbers - if (typeof value === 'string') { - value = value.trim(); - } - else { - //dates, objects, booleans etc. to be assigned value: NULL - value = ''; - } - - var n = Number(value); - // empty strings convert to 0, which we must explicitly deny - if (value === '' || isNaN(n)) { - droppedValues[measure].numDropped++; - } - else { - row[measuresForProcessing[measure].convertedName].value = n; - } - } - } - - if (!processedMeasures[measure]) { - processedMeasures[measure] = { - converted: false, - convertedName: measuresForProcessing[measure].convertedName, - type: 'float', - normalizedType: 'float' - } - } - - processedMeasures[measure].converted = processedMeasures[measure].converted || !dataIsNull; - } - } - } - }); - - return {droppedValues: droppedValues, processed: processedMeasures}; - }; - - /** - * removes all traces of String -> Numeric Conversion from the given chart config - * @param chartConfig - * @returns {updated ChartConfig} - */ - var removeNumericConversionConfig = function(chartConfig) { - if (chartConfig && chartConfig.measures) { - for (var measureName in chartConfig.measures) { - if (chartConfig.measures.hasOwnProperty(measureName)) { - var measure = chartConfig.measures[measureName]; - if (measure && measure.converted && measure.convertedName) { - measure.converted = null; - measure.convertedName = null; - if (LABKEY.vis.GenericChartHelper.isNumericType(measure.type)) { - measure.type = 'string'; - measure.normalizedType = 'string'; - } - } - } - } - } - - return chartConfig; - }; - - var renderChartSVG = function(renderTo, queryConfig, chartConfig) { - queryChartData(renderTo, queryConfig, chartConfig, function(measureStore, trendlineData) { - generateChartSVG(renderTo, chartConfig, measureStore, trendlineData); - }); - }; - - var queryChartData = function(renderTo, queryConfig, chartConfig, callback) { - queryConfig.containerPath = LABKEY.container.path; - - if (queryConfig.filterArray && queryConfig.filterArray.length > 0) { - var filters = []; - - for (var i = 0; i < queryConfig.filterArray.length; i++) { - var f = queryConfig.filterArray[i]; - // Issue 37191: Check to see if 'f' is already a filter instance (either labkey-api-js/src/filter/Filter.ts or clientapi/core/Query.js) - if (f.hasOwnProperty('getValue') || f.getValue instanceof Function) { - filters.push(f); - } - else { - filters.push(LABKEY.Filter.create(f.name, f.value, LABKEY.Filter.getFilterTypeForURLSuffix(f.type))); - } - } - - queryConfig.filterArray = filters; - } - - queryConfig.success = async function(measureStore) { - const trendlineData = await queryTrendlineData(chartConfig, measureStore.records()); - callback.call(this, measureStore, trendlineData); - }; - - LABKEY.Query.MeasureStore.selectRows(queryConfig); - }; - - var generateChartSVG = function(renderTo, chartConfig, measureStore, trendlineData) { - var responseMetaData = measureStore.getResponseMetadata(); - - // explicitly set the chart width/height if not set in the config - if (!chartConfig.hasOwnProperty('width') || chartConfig.width == null) chartConfig.width = 1000; - if (!chartConfig.hasOwnProperty('height') || chartConfig.height == null) chartConfig.height = 600; - - var chartType = getChartType(chartConfig); - var aes = generateAes(chartType, chartConfig.measures, responseMetaData.schemaName, responseMetaData.queryName); - var valueConversionResponse = doValueConversion(chartConfig, aes, chartType, measureStore.records()); - if (!LABKEY.Utils.isEmptyObj(valueConversionResponse.processed)) { - $.extend(true, chartConfig.measures, valueConversionResponse.processed); - aes = generateAes(chartType, chartConfig.measures, responseMetaData.schemaName, responseMetaData.queryName); - } - var data = measureStore.records(); - if (chartType === 'scatter_plot' && data.length > chartConfig.geomOptions.binThreshold) { - chartConfig.geomOptions.binned = true; - } - var scales = generateScales(chartType, chartConfig.measures, chartConfig.scales, aes, measureStore); - 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); - } - - var validation = _validateChartConfig(chartConfig, aes, scales, measureStore); - _renderMessages(renderTo, validation.messages); - if (!validation.success) - return; - - var plotConfigArr = generatePlotConfigs(renderTo, chartConfig, labels, aes, scales, geom, data, trendlineData); - $.each(plotConfigArr, function(idx, plotConfig) { - if (chartType === 'pie_chart') { - new LABKEY.vis.PieChart(plotConfig); - } - else { - new LABKEY.vis.Plot(plotConfig).render(); - } - }, this); - } - - var _renderMessages = function(divId, messages) { - if (messages && messages.length > 0) { - var errorDiv = document.createElement('div'); - errorDiv.innerHTML = '

Error rendering chart:

' + messages.join('
') + '
'; - document.getElementById(divId).appendChild(errorDiv); - } - }; - - var _validateChartConfig = function(chartConfig, aes, scales, measureStore) { - var hasNoDataMsg = validateResponseHasData(measureStore, false); - if (hasNoDataMsg != null) - return {success: false, messages: [hasNoDataMsg]}; - - var messages = [], firstRecord = measureStore.records()[0], measureNames = Object.keys(chartConfig.measures); - for (var i = 0; i < measureNames.length; i++) { - var measuresArr = ensureMeasuresAsArray(chartConfig.measures[measureNames[i]]); - for (var j = 0; j < measuresArr.length; j++) { - var measure = measuresArr[j]; - if (LABKEY.Utils.isObject(measure)) { - if (measure.name && !LABKEY.Utils.isDefined(firstRecord[measure.name])) { - return {success: false, messages: ['The measure, ' + measure.name + ', is not available. It may have been renamed or removed.']}; - } - - var validation; - if (measureNames[i] === 'y') { - var yAes = {y: getYMeasureAes(measure)}; - validation = validateAxisMeasure(chartConfig.renderType, measure, 'y', yAes, scales, measureStore.records()); - } - else if (measureNames[i] === 'x' || measureNames[i] === 'xSub') { - validation = validateAxisMeasure(chartConfig.renderType, measure, measureNames[i], aes, scales, measureStore.records()); - } - - if (LABKEY.Utils.isObject(validation)) { - if (validation.message != null) - messages.push(validation.message); - if (!validation.success) - return {success: false, messages: messages}; - } - } - } - } - - return {success: true, messages: messages}; - }; - - return { - // NOTE: the @function below is needed or JSDoc will not include the documentation for loadVisDependencies. Don't - // ask me why, I do not know. - /** - * @function - */ - getRenderTypes: getRenderTypes, - getChartType: getChartType, - getSelectedMeasureLabel: getSelectedMeasureLabel, - getTitleFromMeasures: getTitleFromMeasures, - getMeasureType: getMeasureType, - getAllowableTypes: getAllowableTypes, - getQueryColumns : getQueryColumns, - getChartTypeBasedWidth : getChartTypeBasedWidth, - getDistinctYAxisSides : getDistinctYAxisSides, - getYMeasureAes : getYMeasureAes, - getDefaultMeasuresLabel: getDefaultMeasuresLabel, - getStudySubjectInfo: getStudySubjectInfo, - getQueryConfigSortKey: getQueryConfigSortKey, - ensureMeasuresAsArray: ensureMeasuresAsArray, - isNumericType: isNumericType, - isMeasureDimensionMatch: isMeasureDimensionMatch, - generateLabels: generateLabels, - generateScales: generateScales, - generateAes: generateAes, - doValueConversion: doValueConversion, - removeNumericConversionConfig: removeNumericConversionConfig, - generateAggregateData: generateAggregateData, - generatePointHover: generatePointHover, - generateBoxplotHover: generateBoxplotHover, - generateDiscreteAcc: generateDiscreteAcc, - generateContinuousAcc: generateContinuousAcc, - generateGroupingAcc: generateGroupingAcc, - generatePointClickFn: generatePointClickFn, - generateGeom: generateGeom, - generateBoxplotGeom: generateBoxplotGeom, - generatePointGeom: generatePointGeom, - generatePlotConfigs: generatePlotConfigs, - generatePlotConfig: generatePlotConfig, - validateResponseHasData: validateResponseHasData, - validateAxisMeasure: validateAxisMeasure, - validateXAxis: validateXAxis, - validateYAxis: validateYAxis, - renderChartSVG: renderChartSVG, - queryChartData: queryChartData, - generateChartSVG: generateChartSVG, - getMeasureStoreRecords: getMeasureStoreRecords, - queryTrendlineData: queryTrendlineData, - TRENDLINE_OPTIONS: TRENDLINE_OPTIONS, - /** - * Loads all of the required dependencies for a Generic Chart. - * @param {Function} callback The callback to be executed when all of the visualization dependencies have been loaded. - * @param {Object} scope The scope to be used when executing the callback. - */ - loadVisDependencies: LABKEY.requiresVisualization - }; +/* + * Copyright (c) 2013-2019 LabKey Corporation + * + * Licensed under the Apache License, Version 2.0: http://www.apache.org/licenses/LICENSE-2.0 + */ +if(!LABKEY.vis) { + LABKEY.vis = {}; +} + +/** + * @namespace Namespace used to encapsulate functions related to creating Generic Charts (Box, Scatter, etc.). Used in the + * Generic Chart Wizard and when exporting Generic Charts as Scripts. + */ +LABKEY.vis.GenericChartHelper = new function(){ + + var DEFAULT_TICK_LABEL_MAX = 25; + var $ = jQuery; + + var getRenderTypes = function() { + return [ + { + name: 'bar_chart', + title: 'Bar', + imgUrl: LABKEY.contextPath + '/visualization/images/barchart.png', + fields: [ + {name: 'x', label: 'X Axis', required: true, nonNumericOnly: true}, + {name: 'xSub', label: 'Group By', required: false, nonNumericOnly: true}, + {name: 'y', label: 'Y Axis', numericOnly: true} + ], + layoutOptions: {line: true, opacity: true, axisBased: true} + }, + { + name: 'box_plot', + title: 'Box', + imgUrl: LABKEY.contextPath + '/visualization/images/boxplot.png', + fields: [ + {name: 'x', label: 'X Axis'}, + {name: 'y', label: 'Y Axis', required: true, numericOnly: true}, + {name: 'color', label: 'Color', nonNumericOnly: true}, + {name: 'shape', label: 'Shape', nonNumericOnly: true} + ], + layoutOptions: {point: true, box: true, line: true, opacity: true, axisBased: true} + }, + { + name: 'line_plot', + title: 'Line', + imgUrl: LABKEY.contextPath + '/visualization/images/timechart.png', + fields: [ + {name: 'x', label: 'X Axis', required: true, numericOrDateOnly: true}, + {name: 'y', label: 'Y Axis', required: true, numericOnly: true, allowMultiple: true}, + {name: 'series', label: 'Series', nonNumericOnly: true}, + {name: 'trendline', label: 'Trendline', required: false, altSelectionOnly: true, altFieldType: 'LABKEY.vis.TrendlineField'}, + ], + layoutOptions: {opacity: true, axisBased: true, series: true, chartLayout: true} + }, + { + name: 'pie_chart', + title: 'Pie', + imgUrl: LABKEY.contextPath + '/visualization/images/piechart.png', + fields: [ + {name: 'x', label: 'Categories', required: true, nonNumericOnly: true}, + // Issue #29046 'Remove "measure" option from pie chart' + // {name: 'y', label: 'Measure', numericOnly: true} + ], + layoutOptions: {pie: true} + }, + { + name: 'scatter_plot', + title: 'Scatter', + imgUrl: LABKEY.contextPath + '/visualization/images/scatterplot.png', + fields: [ + {name: 'x', label: 'X Axis', required: true}, + {name: 'y', label: 'Y Axis', required: true, numericOnly: true, allowMultiple: true}, + {name: 'color', label: 'Color', nonNumericOnly: true}, + {name: 'shape', label: 'Shape', nonNumericOnly: true} + ], + layoutOptions: {point: true, opacity: true, axisBased: true, binnable: true, chartLayout: true} + }, + { + name: 'time_chart', + title: 'Time', + hidden: _getStudyTimepointType() == null, + imgUrl: LABKEY.contextPath + '/visualization/images/timechart.png', + fields: [ + {name: 'x', label: 'X Axis', required: true, altSelectionOnly: true, altFieldType: 'LABKEY.vis.TimeChartXAxisField'}, + {name: 'y', label: 'Y Axis', required: true, numericOnly: true, allowMultiple: true} + ], + layoutOptions: {time: true, axisBased: true, chartLayout: true} + } + ]; + }; + + /** + * Gets the chart type (i.e. box or scatter) based on the chartConfig object. + */ + const getChartType = function(chartConfig) + { + const renderType = chartConfig.renderType + const xAxisType = chartConfig.measures.x ? (chartConfig.measures.x.normalizedType || chartConfig.measures.x.type) : null; + + if (renderType === 'time_chart' || renderType === "bar_chart" || renderType === "pie_chart" + || renderType === "box_plot" || renderType === "scatter_plot" || renderType === "line_plot") + { + return renderType; + } + + if (!xAxisType) + { + // On some charts (non-study box plots) we don't require an x-axis, instead we generate one box plot for + // all of the data of your y-axis. If there is no xAxisType, then we have a box plot. Scatter plots require + // an x-axis measure. + return 'box_plot'; + } + + return (xAxisType === 'string' || xAxisType === 'boolean') ? 'box_plot' : 'scatter_plot'; + }; + + /** + * Generate a default label for the selected measure for the given renderType. + * @param renderType + * @param measureName - the chart type's measure name + * @param properties - properties for the selected column, note that this can be an array of properties + */ + var getSelectedMeasureLabel = function(renderType, measureName, properties) + { + var label = getDefaultMeasuresLabel(properties); + + if (label !== '' && measureName === 'y' && (renderType === 'bar_chart' || renderType === 'pie_chart')) { + var aggregateProps = LABKEY.Utils.isArray(properties) && properties.length === 1 + ? properties[0].aggregate : properties.aggregate; + + if (LABKEY.Utils.isDefined(aggregateProps)) { + var aggLabel = LABKEY.Utils.isObject(aggregateProps) ? aggregateProps.name : LABKEY.Utils.capitalize(aggregateProps.toLowerCase()); + label = aggLabel + ' of ' + label; + } + else { + label = 'Sum of ' + label; + } + } + + return label; + }; + + /** + * Generate a plot title based on the selected measures array or object. + * @param renderType + * @param measures + * @returns {string} + */ + var getTitleFromMeasures = function(renderType, measures) + { + var queryLabels = []; + + if (LABKEY.Utils.isObject(measures)) + { + if (LABKEY.Utils.isArray(measures.y)) + { + $.each(measures.y, function(idx, m) + { + var measureQueryLabel = m.queryLabel || m.queryName; + if (queryLabels.indexOf(measureQueryLabel) === -1) + queryLabels.push(measureQueryLabel); + }); + } + else + { + var m = measures.x || measures.y; + queryLabels.push(m.queryLabel || m.queryName); + } + } + + return queryLabels.join(', '); + }; + + /** + * Get the sorted set of column metadata for the given schema/query/view. + * @param queryConfig + * @param successCallback + * @param callbackScope + */ + var getQueryColumns = function(queryConfig, successCallback, callbackScope) + { + LABKEY.Ajax.request({ + url: LABKEY.ActionURL.buildURL('visualization', 'getGenericReportColumns.api'), + method: 'GET', + params: { + schemaName: queryConfig.schemaName, + queryName: queryConfig.queryName, + viewName: queryConfig.viewName, + dataRegionName: queryConfig.dataRegionName, + includeCohort: true, + includeParticipantCategory : true + }, + success : function(response){ + var columnList = LABKEY.Utils.decode(response.responseText); + _queryColumnMetadata(queryConfig, columnList, successCallback, callbackScope) + }, + scope : this + }); + }; + + var _queryColumnMetadata = function(queryConfig, columnList, successCallback, callbackScope) + { + var columns = columnList.columns.all; + if (queryConfig.savedColumns) { + // make sure all savedColumns from the chart are included as options, they may not be in the view anymore + columns = columns.concat(queryConfig.savedColumns); + } + + LABKEY.Query.selectRows({ + maxRows: 0, // use maxRows 0 so that we just get the query metadata + schemaName: queryConfig.schemaName, + queryName: queryConfig.queryName, + viewName: queryConfig.viewName, + parameters: queryConfig.parameters, + requiredVersion: 9.1, + columns: columns, + method: 'POST', // Issue 31744: use POST as the columns list can be very long and cause a 400 error + success: function(response){ + var columnMetadata = _updateAndSortQueryFields(queryConfig, columnList, response.metaData.fields); + successCallback.call(callbackScope, columnMetadata); + }, + failure : function(response) { + // this likely means that the query no longer exists + successCallback.call(callbackScope, columnList, []); + }, + scope : this + }); + }; + + var _updateAndSortQueryFields = function(queryConfig, columnList, columnMetadata) + { + var queryFields = [], + queryFieldKeys = [], + columnTypes = LABKEY.Utils.isDefined(columnList.columns) ? columnList.columns : {}; + + $.each(columnMetadata, function(idx, column) + { + var f = $.extend(true, {}, column); + f.schemaName = queryConfig.schemaName; + f.queryName = queryConfig.queryName; + f.isCohortColumn = false; + f.isSubjectGroupColumn = false; + + // issue 23224: distinguish cohort and subject group fields in the list of query columns + if (columnTypes['cohort'] && columnTypes['cohort'].indexOf(f.fieldKey) > -1) + { + f.shortCaption = 'Study: ' + f.shortCaption; + f.isCohortColumn = true; + } + else if (columnTypes['subjectGroup'] && columnTypes['subjectGroup'].indexOf(f.fieldKey) > -1) + { + f.shortCaption = columnList.subject.nounSingular + ' Group: ' + f.shortCaption; + f.isSubjectGroupColumn = true; + } + + // Issue 31672: keep track of the distinct query field keys so we don't get duplicates + if (f.fieldKey.toLowerCase() != 'lsid' && queryFieldKeys.indexOf(f.fieldKey) == -1) { + queryFields.push(f); + queryFieldKeys.push(f.fieldKey); + } + }, this); + + // Sorts fields by their shortCaption, but put subject groups/categories/cohort at the end. + queryFields.sort(function(a, b) + { + if (a.isSubjectGroupColumn != b.isSubjectGroupColumn) + return a.isSubjectGroupColumn ? 1 : -1; + else if (a.isCohortColumn != b.isCohortColumn) + return a.isCohortColumn ? 1 : -1; + else if (a.shortCaption != b.shortCaption) + return a.shortCaption < b.shortCaption ? -1 : 1; + + return 0; + }); + + return queryFields; + }; + + /** + * Determine a reasonable width for the chart based on the chart type and selected measures / data. + * @param chartType + * @param measures + * @param measureStore + * @param defaultWidth + * @returns {int} + */ + 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); + + 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); + } + + return width; + }; + + /** + * Return the distinct set of y-axis sides for the given measures object. + * @param measures + */ + var getDistinctYAxisSides = function(measures) + { + var distinctSides = []; + $.each(ensureMeasuresAsArray(measures.y), function (idx, measure) { + if (LABKEY.Utils.isObject(measure)) { + var side = measure.yAxis || 'left'; + if (distinctSides.indexOf(side) === -1) { + distinctSides.push(side); + } + } + }, this); + return distinctSides; + }; + + /** + * Generate a default label for an array of measures by concatenating each meaures label together. + * @param measures + * @returns string concatenation of all measure labels + */ + var getDefaultMeasuresLabel = function(measures) + { + if (LABKEY.Utils.isDefined(measures)) { + if (!LABKEY.Utils.isArray(measures)) { + return measures.label || measures.queryName || ''; + } + + var label = '', sep = ''; + $.each(measures, function(idx, m) { + label += sep + (m.label || m.queryName); + sep = ', '; + }); + return label; + } + + return ''; + }; + + /** + * Given the saved labels object we convert it to include all label types (main, x, and y). Each label type defaults + * to empty string (''). + * @param {Object} labels The saved labels object. + * @returns {Object} + */ + var generateLabels = function(labels) { + return { + main: { value: labels.main || '' }, + subtitle: { value: labels.subtitle || '' }, + footer: { value: labels.footer || '' }, + x: { value: labels.x || '' }, + y: { value: labels.y || '' }, + yRight: { value: labels.yRight || '' } + }; + }; + + /** + * Generates an object containing {@link LABKEY.vis.Scale} objects used for the chart. + * @param {String} chartType The chartType from getChartType. + * @param {Object} measures The measures from generateMeasures. + * @param {Object} savedScales The scales object from the saved chart config. + * @param {Object} aes The aesthetic map object from genereateAes. + * @param {Object} measureStore The MeasureStore data using a selectRows API call. + * @param {Function} defaultFormatFn used to format values for tick marks. + * @returns {Object} + */ + var generateScales = function(chartType, measures, savedScales, aes, measureStore, defaultFormatFn) { + var scales = {}; + var data = LABKEY.Utils.isArray(measureStore.rows) ? measureStore.rows : measureStore.records(); + var fields = LABKEY.Utils.isObject(measureStore.metaData) ? measureStore.metaData.fields : measureStore.getResponseMetadata().fields; + var subjectColumn = getStudySubjectInfo().columnName; + var visitTableName = getStudySubjectInfo().tableName + 'Visit'; + var visitColName = visitTableName + '/Visit'; + var valExponentialDigits = 6; + + // Issue 38105: For plots with study visit labels on the x-axis, don't sort alphabetically + var sortFnX = measures.x && measures.x.fieldKey === visitColName ? undefined : LABKEY.vis.discreteSortFn; + + if (chartType === "box_plot") + { + scales.x = { + scaleType: 'discrete', // Force discrete x-axis scale for box plots. + sortFn: sortFnX, + tickLabelMax: DEFAULT_TICK_LABEL_MAX + }; + + var yMin = d3.min(data, aes.y); + var yMax = d3.max(data, aes.y); + var yPadding = ((yMax - yMin) * .1); + if (savedScales.y && savedScales.y.trans == "log") + { + // When subtracting padding we have to make sure we still produce valid values for a log scale. + // log([value less than 0]) = NaN. + // log(0) = -Infinity. + if (yMin - yPadding > 0) + { + yMin = yMin - yPadding; + } + } + else + { + yMin = yMin - yPadding; + } + + scales.y = { + min: yMin, + max: yMax + yPadding, + scaleType: 'continuous', + trans: savedScales.y ? savedScales.y.trans : 'linear' + }; + } + else + { + var xMeasureType = getMeasureType(measures.x); + + // Force discrete x-axis scale for bar plots. + var useContinuousScale = chartType != 'bar_chart' && isNumericType(xMeasureType); + + if (useContinuousScale) + { + scales.x = { + scaleType: 'continuous', + trans: savedScales.x ? savedScales.x.trans : 'linear' + }; + } + else + { + scales.x = { + scaleType: 'discrete', + sortFn: sortFnX, + tickLabelMax: DEFAULT_TICK_LABEL_MAX + }; + + //bar chart x-axis subcategories support + if (LABKEY.Utils.isDefined(measures.xSub)) { + scales.xSub = { + scaleType: 'discrete', + sortFn: LABKEY.vis.discreteSortFn, + tickLabelMax: DEFAULT_TICK_LABEL_MAX + }; + } + } + + // add both y (i.e. yLeft) and yRight, in case multiple y-axis measures are being plotted + scales.y = { + scaleType: 'continuous', + trans: savedScales.y ? savedScales.y.trans : 'linear' + }; + scales.yRight = { + scaleType: 'continuous', + trans: savedScales.yRight ? savedScales.yRight.trans : 'linear' + }; + } + + // if we have no data, show a default y-axis domain + if (scales.x && data.length == 0 && scales.x.scaleType == 'continuous') + scales.x.domain = [0,1]; + if (scales.y && data.length == 0) + scales.y.domain = [0,1]; + + // apply the field formatFn to the tick marks on the scales object + for (var i = 0; i < fields.length; i++) { + var type = fields[i].displayFieldJsonType ? fields[i].displayFieldJsonType : fields[i].type; + + var isMeasureXMatch = measures.x && _isFieldKeyMatch(measures.x, fields[i].fieldKey); + if (isMeasureXMatch && measures.x.name === subjectColumn && LABKEY.demoMode) { + scales.x.tickFormat = function(){return '******'}; + } + else if (isMeasureXMatch && isNumericType(type)) { + scales.x.tickFormat = _getNumberFormatFn(fields[i], defaultFormatFn); + } + + var yMeasures = ensureMeasuresAsArray(measures.y); + $.each(yMeasures, function(idx, yMeasure) { + var isMeasureYMatch = yMeasure && _isFieldKeyMatch(yMeasure, fields[i].fieldKey); + var isConvertedYMeasure = isMeasureYMatch && yMeasure.converted; + if (isMeasureYMatch && (isNumericType(type) || isConvertedYMeasure)) { + var tickFormatFn = _getNumberFormatFn(fields[i], defaultFormatFn); + + var ySide = yMeasure.yAxis === 'right' ? 'yRight' : 'y'; + scales[ySide].tickFormat = function(value) { + if (LABKEY.Utils.isNumber(value) && Math.abs(Math.round(value)).toString().length >= valExponentialDigits) { + return value.toExponential(); + } + else if (LABKEY.Utils.isFunction(tickFormatFn)) { + return tickFormatFn(value); + } + return value; + }; + } + }, this); + } + + _applySavedScaleDomain(scales, savedScales, 'x'); + if (LABKEY.Utils.isDefined(measures.xSub)) { + _applySavedScaleDomain(scales, savedScales, 'xSub'); + } + if (LABKEY.Utils.isDefined(measures.y)) { + _applySavedScaleDomain(scales, savedScales, 'y'); + _applySavedScaleDomain(scales, savedScales, 'yRight'); + } + + return scales; + }; + + // Issue 36227: if Ext4 is not available, try to generate our own number format function based on the "format" field metadata + var _getNumberFormatFn = function(field, defaultFormatFn) { + if (field.extFormatFn) { + if (window.Ext4) { + return eval(field.extFormatFn); + } + else if (field.format && LABKEY.Utils.isString(field.format) && field.format.indexOf('.') > -1) { + var precision = field.format.length - field.format.indexOf('.') - 1; + return function(v) { + return LABKEY.Utils.isNumber(v) ? v.toFixed(precision) : v; + } + } + } + + return defaultFormatFn; + }; + + var _isFieldKeyMatch = function(measure, fieldKey) { + if (LABKEY.Utils.isFunction(fieldKey.getName)) { + return fieldKey.getName() === measure.name || fieldKey.getName() === measure.fieldKey; + } else if (LABKEY.Utils.isArray(fieldKey)) { + fieldKey = fieldKey.join('/') + } + + return fieldKey === measure.name || fieldKey === measure.fieldKey; + }; + + var ensureMeasuresAsArray = function(measures) { + if (LABKEY.Utils.isDefined(measures)) { + return LABKEY.Utils.isArray(measures) ? $.extend(true, [], measures) : [$.extend(true, {}, measures)]; + } + return []; + }; + + var _applySavedScaleDomain = function(scales, savedScales, scaleName) { + if (savedScales[scaleName] && (savedScales[scaleName].min != null || savedScales[scaleName].max != null)) { + scales[scaleName].domain = [savedScales[scaleName].min, savedScales[scaleName].max]; + } + }; + + /** + * Generates the aesthetic map object needed by the visualization API to render the chart. See {@link LABKEY.vis.Plot} + * and {@link LABKEY.vis.Layer}. + * @param {String} chartType The chartType from getChartType. + * @param {Object} measures The measures from getMeasures. + * @param {String} schemaName The schemaName from the saved queryConfig. + * @param {String} queryName The queryName from the saved queryConfig. + * @returns {Object} + */ + 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); + + if (chartType === "box_plot") { + 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); + } + } else if (isNumericType(xMeasureType) || (chartType === 'scatter_plot' && measures.x.measure)) { + aes.x = generateContinuousAcc(xMeasureName); + } else { + aes.x = generateDiscreteAcc(xMeasureName, measures.x.label); + } + + // charts that have multiple y-measures selected will need to put the aes.y function on their specific layer + if (LABKEY.Utils.isDefined(measures.y) && !LABKEY.Utils.isArray(measures.y)) + { + var sideAesName = (measures.y.yAxis || 'left') === 'left' ? 'y' : 'yRight'; + var yMeasureName = measures.y.converted ? measures.y.convertedName : measures.y.name; + aes[sideAesName] = generateContinuousAcc(yMeasureName); + } + + if (chartType === "scatter_plot" || chartType === "line_plot") + { + aes.hoverText = generatePointHover(measures); + } + + if (chartType === "box_plot") + { + if (measures.color) { + aes.outlierColor = generateGroupingAcc(measures.color.name); + } + + if (measures.shape) { + aes.outlierShape = generateGroupingAcc(measures.shape.name); + } + + aes.hoverText = generateBoxplotHover(); + aes.outlierHoverText = generatePointHover(measures); + } + else if (chartType === 'bar_chart') + { + var xSubMeasureType = measures.xSub ? getMeasureType(measures.xSub) : null; + if (xSubMeasureType) + { + if (isNumericType(xSubMeasureType)) + aes.xSub = generateContinuousAcc(measures.xSub.name); + else + aes.xSub = generateDiscreteAcc(measures.xSub.name, measures.xSub.label); + } + } + + // color/shape aes are not dependent on chart type. If we have a box plot with all points enabled, then we + // create a second layer for points. So we'll need this no matter what. + if (measures.color) { + aes.color = generateGroupingAcc(measures.color.name); + } + + if (measures.shape) { + aes.shape = generateGroupingAcc(measures.shape.name); + } + + // also add the color and shape for the line plot series. + if (measures.series) { + aes.color = generateGroupingAcc(measures.series.name); + aes.shape = generateGroupingAcc(measures.series.name); + } + + if (measures.pointClickFn) { + aes.pointClickFn = generatePointClickFn( + measures, + schemaName, + queryName, + measures.pointClickFn + ); + } + + return aes; + }; + + var getYMeasureAes = function(measure) { + var yMeasureName = measure.converted ? measure.convertedName : measure.name; + return generateContinuousAcc(yMeasureName); + }; + + /** + * Generates a function that returns the text used for point hovers. + * @param {Object} measures The measures object from the saved chart config. + * @returns {Function} + */ + var generatePointHover = function(measures) + { + return function(row) { + var hover = '', sep = '', distinctNames = []; + + $.each(measures, function(key, measureObj) { + var measureArr = ensureMeasuresAsArray(measureObj); + $.each(measureArr, function(idx, measure) { + if (LABKEY.Utils.isObject(measure) && !LABKEY.Utils.isEmptyObj(measure) && distinctNames.indexOf(measure.name) == -1) { + hover += sep + measure.label + ': ' + _getRowValue(row, measure.name); + sep = ', \n'; + + distinctNames.push(measure.name); + } + }, this); + }); + + return hover; + }; + }; + + /** + * Backwards compatibility for function that has been moved to LABKEY.vis.getAggregateData. + */ + var generateAggregateData = function(data, dimensionName, measureName, aggregate, nullDisplayValue) { + return LABKEY.vis.getAggregateData(data, dimensionName, null, measureName, aggregate, nullDisplayValue, false); + }; + + var _getRowValue = function(row, propName, valueName) + { + if (row.hasOwnProperty(propName)) { + // backwards compatibility for response row that is not a LABKEY.Query.Row + if (!(row instanceof LABKEY.Query.Row)) { + return row[propName].formattedValue || row[propName].displayValue || row[propName].value; + } + + var propValue = row.get(propName); + if (valueName != undefined && propValue.hasOwnProperty(valueName)) { + return propValue[valueName]; + } + else if (propValue.hasOwnProperty('formattedValue')) { + return propValue['formattedValue']; + } + else if (propValue.hasOwnProperty('displayValue')) { + return propValue['displayValue']; + } + return row.getValue(propName); + } + + return undefined; + }; + + /** + * Returns a function used to generate the hover text for box plots. + * @returns {Function} + */ + var generateBoxplotHover = function() { + return function(xValue, stats) { + return xValue + ':\nMin: ' + stats.min + '\nMax: ' + stats.max + '\nQ1: ' + stats.Q1 + '\nQ2: ' + stats.Q2 + + '\nQ3: ' + stats.Q3; + }; + }; + + /** + * Generates an accessor function that returns a discrete value from a row of data for a given measure and label. + * Used when an axis has a discrete measure (i.e. string). + * @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 + * @returns {Function} + */ + var generateDiscreteAcc = function(measureName, measureLabel, nullValueLabel) + { + return function(row) + { + var value = _getRowValue(row, measureName); + if (value === null) + value = nullValueLabel !== undefined ? nullValueLabel : "Not in " + measureLabel; + + return value; + }; + }; + + /** + * Generates an accessor function that returns a value from a row of data for a given measure. + * @param {String} measureName The name of the measure. + * @returns {Function} + */ + var generateContinuousAcc = function(measureName) + { + return function(row) + { + var value = _getRowValue(row, measureName, 'value'); + + if (value !== undefined) + { + if (Math.abs(value) === Infinity) + value = null; + + if (value === false || value === true) + value = value.toString(); + + return value; + } + + return undefined; + } + }; + + /** + * Generates an accesssor function for shape and color measures. + * @param {String} measureName The name of the measure. + * @returns {Function} + */ + var generateGroupingAcc = function(measureName) + { + return function(row) + { + var value = null; + if (LABKEY.Utils.isArray(row) && row.length > 0) { + value = _getRowValue(row[0], measureName); + } + else { + value = _getRowValue(row, measureName); + } + + if (value === null || value === undefined) + value = "n/a"; + + return value; + }; + }; + + /** + * Generates an accessor for boxplots that do not have an x-axis measure. Generally the measureName passed in is the + * queryName. + * @param {String} measureName The name of the measure. In this case it is generally the query name. + * @returns {Function} + */ + var generateMeasurelessAcc = function(measureName) { + // Used for box plots that do not have an x-axis measure. Instead we just return the queryName for every row. + return function(row) { + return measureName; + } + }; + + /** + * Generates the function to be executed when a user clicks a point. + * @param {Object} measures The measures from the saved chart config. + * @param {String} schemaName The schema name from the saved query config. + * @param {String} queryName The query name from the saved query config. + * @param {String} fnString The string value of the user-provided function to be executed when a point is clicked. + * @returns {Function} + */ + var generatePointClickFn = function(measures, schemaName, queryName, fnString){ + var measureInfo = { + schemaName: schemaName, + queryName: queryName + }; + + _addPointClickMeasureInfo(measureInfo, measures, 'x', 'xAxis'); + _addPointClickMeasureInfo(measureInfo, measures, 'y', 'yAxis'); + $.each(['color', 'shape', 'series'], function(idx, name) { + _addPointClickMeasureInfo(measureInfo, measures, name, name + 'Name'); + }, this); + + // using new Function is quicker than eval(), even in IE. + var pointClickFn = new Function('return ' + fnString)(); + return function(clickEvent, data){ + pointClickFn(data, measureInfo, clickEvent); + }; + }; + + var _addPointClickMeasureInfo = function(measureInfo, measures, name, key) { + if (LABKEY.Utils.isDefined(measures[name])) { + var measuresArr = ensureMeasuresAsArray(measures[name]); + $.each(measuresArr, function(idx, measure) { + if (!LABKEY.Utils.isDefined(measureInfo[key])) { + measureInfo[key] = measure.name; + } + else if (!LABKEY.Utils.isDefined(measureInfo[measure.name])) { + measureInfo[measure.name] = measure.name; + } + }, this); + } + }; + + /** + * Generates the Point Geom used for scatter plots and box plots with all points visible. + * @param {Object} chartOptions The saved chartOptions object from the chart config. + * @returns {LABKEY.vis.Geom.Point} + */ + var generatePointGeom = function(chartOptions){ + return new LABKEY.vis.Geom.Point({ + opacity: chartOptions.opacity, + size: chartOptions.pointSize, + color: '#' + chartOptions.pointFillColor, + position: chartOptions.position + }); + }; + + /** + * Generates the Boxplot Geom used for box plots. + * @param {Object} chartOptions The saved chartOptions object from the chart config. + * @returns {LABKEY.vis.Geom.Boxplot} + */ + var generateBoxplotGeom = function(chartOptions){ + return new LABKEY.vis.Geom.Boxplot({ + lineWidth: chartOptions.lineWidth, + outlierOpacity: chartOptions.opacity, + outlierFill: '#' + chartOptions.pointFillColor, + outlierSize: chartOptions.pointSize, + color: '#' + chartOptions.lineColor, + fill: chartOptions.boxFillColor == 'none' ? chartOptions.boxFillColor : '#' + chartOptions.boxFillColor, + position: chartOptions.position, + showOutliers: chartOptions.showOutliers + }); + }; + + /** + * Generates the Barplot Geom used for bar charts. + * @param {Object} chartOptions The saved chartOptions object from the chart config. + * @returns {LABKEY.vis.Geom.BarPlot} + */ + var generateBarGeom = function(chartOptions){ + return new LABKEY.vis.Geom.BarPlot({ + opacity: chartOptions.opacity, + color: '#' + chartOptions.lineColor, + fill: '#' + chartOptions.boxFillColor, + lineWidth: chartOptions.lineWidth + }); + }; + + /** + * Generates the Bin Geom used to bin a set of points. + * @param {Object} chartOptions The saved chartOptions object from the chart config. + * @returns {LABKEY.vis.Geom.Bin} + */ + var generateBinGeom = function(chartOptions) { + var colorRange = ["#e6e6e6", "#085D90"]; //light-gray and labkey blue is default + if (chartOptions.binColorGroup == 'SingleColor') { + var color = '#' + chartOptions.binSingleColor; + colorRange = ["#FFFFFF", color]; + } + else if (chartOptions.binColorGroup == 'Heat') { + colorRange = ["#fff6bc", "#e23202"]; + } + + return new LABKEY.vis.Geom.Bin({ + shape: chartOptions.binShape, + colorRange: colorRange, + size: chartOptions.binShape == 'square' ? 10 : 5 + }) + }; + + /** + * Generates a Geom based on the chartType. + * @param {String} chartType The chart type from getChartType. + * @param {Object} chartOptions The chartOptions object from the saved chart config. + * @returns {LABKEY.vis.Geom} + */ + var generateGeom = function(chartType, chartOptions) { + if (chartType == "box_plot") + return generateBoxplotGeom(chartOptions); + else if (chartType == "scatter_plot" || chartType == "line_plot") + return chartOptions.binned ? generateBinGeom(chartOptions) : generatePointGeom(chartOptions); + else if (chartType == "bar_chart") + return generateBarGeom(chartOptions); + }; + + /** + * Generate an array of plot configs for the given chart renderType and config options. + * @param renderTo + * @param chartConfig + * @param labels + * @param aes + * @param scales + * @param geom + * @param data + * @param trendlineData + * @returns {Array} array of plot config objects + */ + var generatePlotConfigs = function(renderTo, chartConfig, labels, aes, scales, geom, data, trendlineData) + { + var plotConfigArr = []; + + // if we have multiple y-measures and the request is to plot them separately, call the generatePlotConfig function + // for each y-measure separately with its own copy of the chartConfig object + if (chartConfig.geomOptions.chartLayout === 'per_measure' && LABKEY.Utils.isArray(chartConfig.measures.y)) { + + // if 'automatic across charts' scales are requested, need to manually calculate the min and max + if (chartConfig.scales.y && chartConfig.scales.y.type === 'automatic') { + scales.y = $.extend(scales.y, _getScaleDomainValuesForAllMeasures(data, chartConfig.measures.y, 'left')); + } + if (chartConfig.scales.yRight && chartConfig.scales.yRight.type === 'automatic') { + scales.yRight = $.extend(scales.yRight, _getScaleDomainValuesForAllMeasures(data, chartConfig.measures.y, 'right')); + } + + $.each(chartConfig.measures.y, function(idx, yMeasure) { + // copy the config and reset the measures.y array with the single measure + var newChartConfig = $.extend(true, {}, chartConfig); + newChartConfig.measures.y = $.extend(true, {}, yMeasure); + + // copy the labels object so that we can set the subtitle based on the y-measure + var newLabels = $.extend(true, {}, labels); + newLabels.subtitle = {value: yMeasure.label || yMeasure.name}; + + // only copy over the scales that are needed for this measures + var side = yMeasure.yAxis || 'left'; + var newScales = {x: $.extend(true, {}, scales.x)}; + if (side === 'left') { + newScales.y = $.extend(true, {}, scales.y); + } + else { + newScales.yRight = $.extend(true, {}, scales.yRight); + } + + plotConfigArr.push(generatePlotConfig(renderTo, newChartConfig, newLabels, aes, newScales, geom, data, trendlineData)); + }, this); + } + else { + plotConfigArr.push(generatePlotConfig(renderTo, chartConfig, labels, aes, scales, geom, data, trendlineData)); + } + + return plotConfigArr; + }; + + var _getScaleDomainValuesForAllMeasures = function(data, measures, side) { + var min = null, max = null; + + $.each(measures, function(idx, measure) { + var measureSide = measure.yAxis || 'left'; + if (side === measureSide) { + var accFn = LABKEY.vis.GenericChartHelper.getYMeasureAes(measure); + var tempMin = d3.min(data, accFn); + var tempMax = d3.max(data, accFn); + + if (min == null || tempMin < min) { + min = tempMin; + } + if (max == null || tempMax > max) { + max = tempMax; + } + } + }, this); + + return {domain: [min, max]}; + }; + + /** + * Generate the plot config for the given chart renderType and config options. + * @param renderTo + * @param chartConfig + * @param labels + * @param aes + * @param scales + * @param geom + * @param data + * @param trendlineData + * @returns {Object} + */ + var generatePlotConfig = function(renderTo, chartConfig, labels, aes, scales, geom, data, trendlineData) + { + var renderType = chartConfig.renderType, + layers = [], clipRect, + emptyTextFn = function(){return '';}, + plotConfig = { + renderTo: renderTo, + rendererType: 'd3', + width: chartConfig.width, + height: chartConfig.height, + gridLinesVisible: chartConfig.gridLinesVisible, + }; + + if (renderType === 'pie_chart') { + return _generatePieChartConfig(plotConfig, chartConfig, labels, data); + } + + clipRect = (scales.x && LABKEY.Utils.isArray(scales.x.domain)) || (scales.y && LABKEY.Utils.isArray(scales.y.domain)); + + // account for line chart hiding points + if (chartConfig.geomOptions.hideDataPoints) { + geom = null; + } + + // account for one or many y-measures by ensuring that we have an array of y-measures + var yMeasures = ensureMeasuresAsArray(chartConfig.measures.y); + + if (renderType === 'bar_chart') { + aes = { x: 'label', y: 'value' }; + + if (LABKEY.Utils.isDefined(chartConfig.measures.xSub)) + { + aes.xSub = 'subLabel'; + aes.color = 'label'; + } + + if (!scales.y) { + 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)); + + scales.y.domain = [min, max]; + } + } + else if (renderType === 'box_plot' && chartConfig.pointType === 'all') + { + layers.push( + new LABKEY.vis.Layer({ + geom: LABKEY.vis.GenericChartHelper.generatePointGeom(chartConfig.geomOptions), + aes: {hoverText: LABKEY.vis.GenericChartHelper.generatePointHover(chartConfig.measures)} + }) + ); + } + else if (renderType === 'line_plot') { + var xName = chartConfig.measures.x.name, + isDate = isDateType(getMeasureType(chartConfig.measures.x)); + + $.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); + } + return aVal - bVal; + }, + hoverText: emptyTextFn(), + }; + + pathAes[yMeasure.yAxis === 'right' ? 'yRight' : 'yLeft'] = getYMeasureAes(yMeasure); + + // use the series measure's values for the distinct colors and grouping + const hasSeries = chartConfig.measures.series !== undefined; + if (hasSeries) { + pathAes.pathColor = generateGroupingAcc(chartConfig.measures.series.name); + pathAes.group = generateGroupingAcc(chartConfig.measures.series.name); + pathAes.hoverText = function (row) { return chartConfig.measures.series.label + ': ' + row.group }; + } + // if no series measures but we have multiple y-measures, force the color and grouping to be distinct for each measure + else if (yMeasures.length > 1) { + pathAes.pathColor = emptyTextFn; + pathAes.group = emptyTextFn; + } + + if (trendlineData) { + trendlineData.forEach(trendline => { + if (trendline.data) { + const layerAes = { x: 'x', y: 'y' }; + if (hasSeries) { + layerAes.pathColor = function () { return trendline.name }; + } + + layerAes.hoverText = generateTrendlinePathHover(trendline); + + layers.push( + new LABKEY.vis.Layer({ + geom: new LABKEY.vis.Geom.Path({ + color: '#' + chartConfig.geomOptions.pointFillColor, + size: chartConfig.geomOptions.lineWidth ? chartConfig.geomOptions.lineWidth : 3, + opacity:chartConfig.geomOptions.opacity, + }), + aes: layerAes, + data: trendline.data.generatedPoints, + }) + ); + } + }); + } else { + layers.push( + new LABKEY.vis.Layer({ + name: yMeasures.length > 1 ? yMeasure.label || yMeasure.name : undefined, + geom: new LABKEY.vis.Geom.Path({ + color: '#' + chartConfig.geomOptions.pointFillColor, + size: chartConfig.geomOptions.lineWidth?chartConfig.geomOptions.lineWidth:3, + opacity:chartConfig.geomOptions.opacity + }), + aes: pathAes + }) + ); + } + }, this); + } + + // Issue 34711: better guess at the max number of discrete x-axis tick mark labels to show based on the plot width + if (scales.x && scales.x.scaleType === 'discrete' && scales.x.tickLabelMax) { + // approx 30 px for a 45 degree rotated tick label + scales.x.tickLabelMax = Math.floor((plotConfig.width - 300) / 30); + } + + var margins = _getPlotMargins(renderType, scales, aes, data, plotConfig, chartConfig); + if (LABKEY.Utils.isObject(margins)) { + plotConfig.margins = margins; + } + + if (chartConfig.measures.color) + { + scales.color = { + colorType: chartConfig.geomOptions.colorPaletteScale, + scaleType: 'discrete' + } + } + + if ((renderType === 'line_plot' || renderType === 'scatter_plot') && yMeasures.length > 0) { + $.each(yMeasures, function (idx, yMeasure) { + var layerAes = {}; + layerAes[yMeasure.yAxis === 'right' ? 'yRight' : 'yLeft'] = getYMeasureAes(yMeasure); + + // if no series measures but we have multiple y-measures, force the color and shape to be distinct for each measure + if (!aes.color && yMeasures.length > 1) { + layerAes.color = emptyTextFn; + } + if (!aes.shape && yMeasures.length > 1) { + layerAes.shape = emptyTextFn; + } + + layers.push( + new LABKEY.vis.Layer({ + name: yMeasures.length > 1 ? yMeasure.label || yMeasure.name : undefined, + geom: geom, + aes: layerAes + }) + ); + }, this); + } + else { + layers.push( + new LABKEY.vis.Layer({ + data: data, + geom: geom + }) + ); + } + + plotConfig = $.extend(plotConfig, { + clipRect: clipRect, + data: data, + labels: labels, + aes: aes, + scales: scales, + layers: layers + }); + + return plotConfig; + }; + + const hasPremiumModule = function() { + return LABKEY.getModuleContext('api').moduleNames.indexOf('premium') > -1; + }; + + const TRENDLINE_OPTIONS = { + '': { label: 'Point-to-Point', value: '' }, + 'Linear': { label: 'Linear Regression', value: 'Linear', equation: 'y = x * slope + intercept' }, + 'Polynomial': { label: 'Polynomial', value: 'Polynomial', equation: 'y = a0 + a1 * x + a2 * x^2' }, + '3 Parameter': { label: 'Nonlinear 3PL', value: '3 Parameter', showMax: true, schemaPrefix: 'assay', equation: 'y = max * abs(x/inflection)^abs(slope) / [1 + abs(x/inflection)^abs(slope)]' }, + 'Three Parameter': { label: 'Nonlinear 3PL (Alternate)', value: 'Three Parameter', showMax: true, schemaPrefix: 'assay', equation: 'y = max / [1 + (inflection - x) * slope]' }, + '4 Parameter': { label: 'Nonlinear 4PL', value: '4 Parameter', schemaPrefix: 'assay', equation: 'y = max + (min - max) / [1 + (x/inflection)^slope]' }, + 'Four Parameter': { label: 'Nonlinear 4PL (Alternate)', value: 'Four Parameter', showMin: true, showMax: true, schemaPrefix: 'assay', equation: 'y = min + (max - min) / [1 + (inflection - x) * slope]' }, + 'Five Parameter': { label: 'Nonlinear 5PL', value: 'Five Parameter', showMin: true, showMax: true, schemaPrefix: 'assay', equation: 'y = min + (max - min) / [[1 + (inflection - x) * slope]^asymmetry]' }, + } + + const generateTrendlinePathHover = function(trendline) { + let hoverText = trendline.name + '\n'; + hoverText += '\n' + TRENDLINE_OPTIONS[trendline.data.curveFit.type].label + ':\n'; + Object.entries(trendline.data.curveFit).forEach(([key, value]) => { + if (key === 'coefficients') { + hoverText += key + ': '; + value.forEach((v, i) => { + hoverText += (i > 0 ? ', ' : '') + LABKEY.Utils.roundNumber(v, 4); + }); + hoverText += '\n'; + } + else if (key !== 'type') { + hoverText += key + ': ' + LABKEY.Utils.roundNumber(value, 4) + '\n'; + } + }); + hoverText += '\nStatistics:\n'; + Object.entries(trendline.data.stats).forEach(([key, value]) => { + const label = key === 'RSquared' ? 'R-Squared' : (key === 'adjustedRSquared' ? 'Adjusted R-Squared' : key); + hoverText += label + ': ' + LABKEY.Utils.roundNumber(value, 4) + '\n'; + }); + + return function () { return hoverText }; + }; + + // support for y-axis trendline data when a single y-axis measure is selected + const queryTrendlineData = async function(chartConfig, data) { + const chartType = getChartType(chartConfig); + const yMeasures = ensureMeasuresAsArray(chartConfig.measures.y); + if (chartType === 'line_plot' && chartConfig.geomOptions?.trendlineType && chartConfig.geomOptions.trendlineType !== '' && yMeasures.length === 1) { + const xName = chartConfig.measures.x.name; + const trendlineConfig = getTrendlineConfig(chartConfig, data); + try { + await _queryTrendlineData(trendlineConfig, xName, yMeasures[0].name); + return trendlineConfig.data; + } catch (reason) { + // skip this series and render without trendline + return trendlineConfig.data; + } + } + + return undefined; + }; + + const getTrendlineConfig = function(chartConfig, data) { + const config = { + type: chartConfig.geomOptions.trendlineType, + logXScale: chartConfig.scales.x && chartConfig.scales.x.trans === 'log', + asymptoteMin: chartConfig.geomOptions.trendlineAsymptoteMin, + asymptoteMax: chartConfig.geomOptions.trendlineAsymptoteMax, + data: chartConfig.measures.series + ? LABKEY.vis.groupCountData(data, generateGroupingAcc(chartConfig.measures.series.name)) + : [{name: 'All', rawData: data}], + }; + + // special case to only use logXScale for linear trendlines + if (config.type === 'Linear') { + config.logXScale = false; + } + + return config; + }; + + const _queryTrendlineData = async function(trendlineConfig, xName, yName) { + for (let series of trendlineConfig.data) { + try { + // we need at least 2 data points for curve fitting + if (series.rawData.length > 1) { + series.data = await _querySeriesTrendlineData(trendlineConfig, series, xName, yName); + } + } catch (e) { + console.error(e); + } + } + }; + + const _querySeriesTrendlineData = function(trendlineConfig, seriesData, xName, yName) { + return new Promise(function(resolve, reject) { + if (!hasPremiumModule()) { + reject('Premium module required for curve fitting.'); + return; + } + + const points = seriesData.rawData.map(function(row) { + return { + x: _getRowValue(row, xName, 'value'), + y: _getRowValue(row, yName, 'value'), + }; + }); + const xAcc = function(row) { return row.x }; + const xMin = d3.min(points, xAcc); + const xMax = d3.max(points, xAcc); + + LABKEY.Ajax.request({ + url: LABKEY.ActionURL.buildURL('premium', 'calculateCurveFit.api'), + method: 'POST', + jsonData: { + curveFitType: trendlineConfig.type, + points: points, + logXScale: trendlineConfig.logXScale, + asymptoteMin: trendlineConfig.asymptoteMin, + asymptoteMax: trendlineConfig.asymptoteMax, + xMin: xMin, + xMax: xMax, + numberOfPoints: 1000, + }, + success : LABKEY.Utils.getCallbackWrapper(function(response) { + resolve(response); + }), + failure : LABKEY.Utils.getCallbackWrapper(function(reason) { + reject(reason); + }, this, true), + }); + }); + }; + + 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; + // 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 1; + }; + + var _getPlotMargins = function(renderType, scales, aes, data, plotConfig, chartConfig) { + var margins = {}; + + // issue 29690: for bar and box plots, set default bottom margin based on the number of labels and the max label length + if (LABKEY.Utils.isArray(data)) { + var maxLen = 0; + $.each(data, function(idx, d) { + var val = LABKEY.Utils.isFunction(aes.x) ? aes.x(d) : d[aes.x]; + var subVal = LABKEY.Utils.isFunction(aes.xSub) ? aes.xSub(d) : d[aes.xSub]; + if (LABKEY.Utils.isString(subVal)) { + maxLen = Math.max(maxLen, subVal.length); + } else if (LABKEY.Utils.isString(val)) { + maxLen = Math.max(maxLen, val.length); + } + }); + + var wrapLines = _wrapXAxisTickTextLines(scales, plotConfig, maxLen, data); + margins.bottom = 60 + ((wrapLines - 1) * 25); + } + + // issue 31857: allow custom margins to be set in Chart Layout dialog + if (chartConfig && chartConfig.geomOptions) { + if (chartConfig.geomOptions.marginTop !== null) { + margins.top = chartConfig.geomOptions.marginTop; + } + if (chartConfig.geomOptions.marginRight !== null) { + margins.right = chartConfig.geomOptions.marginRight; + } + if (chartConfig.geomOptions.marginBottom !== null) { + margins.bottom = chartConfig.geomOptions.marginBottom; + } + if (chartConfig.geomOptions.marginLeft !== null) { + margins.left = chartConfig.geomOptions.marginLeft; + } + } + + return !LABKEY.Utils.isEmptyObj(margins) ? margins : null; + }; + + var _generatePieChartConfig = function(baseConfig, chartConfig, labels, data) + { + var hasData = data.length > 0; + + return $.extend(baseConfig, { + data: hasData ? data : [{label: '', value: 1}], + header: { + title: { text: labels.main.value }, + subtitle: { text: labels.subtitle.value }, + titleSubtitlePadding: 1 + }, + footer: { + text: hasData ? labels.footer.value : 'No data to display', + location: 'bottom-center' + }, + labels: { + mainLabel: { fontSize: 14 }, + percentage: { + fontSize: 14, + color: chartConfig.geomOptions.piePercentagesColor != null ? '#' + chartConfig.geomOptions.piePercentagesColor : undefined + }, + outer: { pieDistance: 20 }, + inner: { + format: hasData && chartConfig.geomOptions.showPiePercentages ? 'percentage' : 'none', + hideWhenLessThanPercentage: chartConfig.geomOptions.pieHideWhenLessThanPercentage + } + }, + size: { + pieInnerRadius: hasData ? chartConfig.geomOptions.pieInnerRadius + '%' : '100%', + pieOuterRadius: hasData ? chartConfig.geomOptions.pieOuterRadius + '%' : '90%' + }, + misc: { + gradient: { + enabled: chartConfig.geomOptions.gradientPercentage != 0, + percentage: chartConfig.geomOptions.gradientPercentage, + color: '#' + chartConfig.geomOptions.gradientColor + }, + colors: { + segments: hasData ? LABKEY.vis.Scale[chartConfig.geomOptions.colorPaletteScale]() : ['#333333'] + } + }, + effects: { highlightSegmentOnMouseover: false }, + tooltips: { enabled: true } + }); + }; + + /** + * Check if the MeasureStore selectRows API response has data. Return an error string if no data exists. + * @param measureStore + * @param includeFilterMsg true to include a message about removing filters + * @returns {String} + */ + var validateResponseHasData = function(measureStore, includeFilterMsg) + { + var dataArray = getMeasureStoreRecords(measureStore); + if (dataArray.length == 0) + { + return 'The response returned 0 rows of data. The query may be empty or the applied filters may be too strict.' + + (includeFilterMsg ? 'Try removing or adjusting any filters if possible.' : ''); + } + + return null; + }; + + var getMeasureStoreRecords = function(measureStore) { + return LABKEY.Utils.isDefined(measureStore) ? measureStore.rows || measureStore.records() : []; + } + + /** + * Verifies that the axis measure is actually present and has data. Also checks to make sure that data can be used in a log + * scale (if applicable). Returns an object with a success parameter (boolean) and a message parameter (string). If the + * success parameter is false there is a critical error and the chart cannot be rendered. If success is true the chart + * can be rendered. Message will contain an error or warning message if applicable. If message is not null and success + * is true, there is a warning. + * @param {String} chartType The chartType from getChartType. + * @param {Object} chartConfigOrMeasure The saved chartConfig object or a specific measure object. + * @param {String} measureName The name of the axis measure property. + * @param {Object} aes The aes object from generateAes. + * @param {Object} scales The scales object from generateScales. + * @param {Array} data The response data from selectRows. + * @param {Boolean} dataConversionHappened Whether we converted any values in the measure data + * @returns {Object} + */ + var validateAxisMeasure = function(chartType, chartConfigOrMeasure, measureName, aes, scales, data, dataConversionHappened) { + var measure = LABKEY.Utils.isObject(chartConfigOrMeasure) && chartConfigOrMeasure.measures ? chartConfigOrMeasure.measures[measureName] : chartConfigOrMeasure; + return _validateAxisMeasure(chartType, measure, measureName, aes, scales, data, dataConversionHappened); + }; + + var _validateAxisMeasure = function(chartType, measure, measureName, aes, scales, data, dataConversionHappened) { + var dataIsNull = true, measureUndefined = true, invalidLogValues = false, hasZeroes = false, message = null; + + // no need to check measures if we have no data + if (data.length === 0) { + return {success: true, message: message}; + } + + for (var i = 0; i < data.length; i ++) + { + var value = aes[measureName](data[i]); + + if (value !== undefined) + measureUndefined = false; + + if (value !== null) + dataIsNull = false; + + if (value && value < 0) + invalidLogValues = true; + + if (value === 0 ) + hasZeroes = true; + } + + if (measureUndefined) + { + message = 'The measure, ' + measure.name + ', was not found. It may have been renamed or removed.'; + return {success: false, message: message}; + } + + if ((chartType == 'scatter_plot' || chartType == 'line_plot' || measureName == 'y') && dataIsNull && !dataConversionHappened) + { + message = 'All data values for ' + measure.label + ' are null. Please choose a different measure or review/remove data filters.'; + return {success: true, message: message}; + } + + if (scales[measureName] && scales[measureName].trans == "log") + { + if (invalidLogValues) + { + message = "Unable to use a log scale on the " + measureName + "-axis. All " + measureName + + "-axis values must be >= 0. Reverting to linear scale on " + measureName + "-axis."; + scales[measureName].trans = 'linear'; + } + else if (hasZeroes) + { + message = "Some " + measureName + "-axis values are 0. Plotting all " + measureName + "-axis values as value+1."; + var accFn = aes[measureName]; + aes[measureName] = function(row){return accFn(row) + 1}; + } + } + + return {success: true, message: message}; + }; + + /** + * Deprecated - use validateAxisMeasure + */ + var validateXAxis = function(chartType, chartConfig, aes, scales, data){ + return this.validateAxisMeasure(chartType, chartConfig, 'x', aes, scales, data); + }; + /** + * Deprecated - use validateAxisMeasure + */ + var validateYAxis = function(chartType, chartConfig, aes, scales, data){ + return this.validateAxisMeasure(chartType, chartConfig, 'y', aes, scales, data); + }; + + var getMeasureType = function(measure) { + return LABKEY.Utils.isObject(measure) ? (measure.normalizedType || measure.type) : null; + }; + + var isNumericType = function(type) + { + var t = LABKEY.Utils.isString(type) ? type.toLowerCase() : null; + return t == 'int' || t == 'integer' || t == 'float' || t == 'double'; + }; + + var isDateType = function(type) + { + var t = LABKEY.Utils.isString(type) ? type.toLowerCase() : null; + return t == 'date'; + }; + + var getAllowableTypes = function(field) { + var numericTypes = ['int', 'float', 'double', 'INTEGER', 'DOUBLE'], + nonNumericTypes = ['string', 'date', 'boolean', 'STRING', 'TEXT', 'DATE', 'BOOLEAN'], + numericAndDateTypes = numericTypes.concat(['date','DATE']); + + if (field.altSelectionOnly) + return []; + else if (field.numericOnly) + return numericTypes; + else if (field.nonNumericOnly) + return nonNumericTypes; + else if (field.numericOrDateOnly) + return numericAndDateTypes; + else + return numericTypes.concat(nonNumericTypes); + } + + var isMeasureDimensionMatch = function(chartType, field, isMeasure, isDimension) { + if ((chartType === 'box_plot' || chartType === 'bar_chart')) { + //x-axis does not support 'measure' column types for these plot types + if (field.name === 'x' || field.name === 'xSub') + return isDimension; + else + return isMeasure; + } + + return (field.numericOnly && isMeasure) || (field.nonNumericOnly && isDimension); + } + + var getQueryConfigSortKey = function(measures) { + var sortKey = 'lsid'; // needed to keep expected ordering for legend data + + // Issue 38105: For plots with study visit labels on the x-axis, sort by visit display order and then sequenceNum + var visitTableName = LABKEY.vis.GenericChartHelper.getStudySubjectInfo().tableName + 'Visit'; + if (measures.x && measures.x.fieldKey === visitTableName + '/Visit') { + var displayOrderColName = visitTableName + '/Visit/DisplayOrder'; + var seqNumColName = visitTableName + '/SequenceNum'; + sortKey = displayOrderColName + ', ' + seqNumColName; + } + + return sortKey; + } + + var getStudySubjectInfo = function() + { + var studyCtx = LABKEY.getModuleContext("study") || {}; + return LABKEY.Utils.isObject(studyCtx.subject) ? studyCtx.subject : { + tableName: 'Participant', + columnName: 'ParticipantId', + nounPlural: 'Participants', + nounSingular: 'Participant' + }; + }; + + var _getStudyTimepointType = function() + { + var studyCtx = LABKEY.getModuleContext("study") || {}; + return LABKEY.Utils.isDefined(studyCtx.timepointType) ? studyCtx.timepointType : null; + }; + + var _getMeasureRestrictions = function (chartType, measure) + { + var measureRestrictions = {}; + $.each(getRenderTypes(), function (idx, renderType) + { + if (renderType.name === chartType) + { + $.each(renderType.fields, function (idx2, field) + { + if (field.name === measure) + { + measureRestrictions.numericOnly = field.numericOnly; + measureRestrictions.nonNumericOnly = field.nonNumericOnly; + return false; + } + }); + return false; + } + }); + + return measureRestrictions; + }; + + /** + * Converts data values passed in to the appropriate type based on measure/dimension information. + * @param chartConfig Chart configuration object + * @param aes Aesthetic mapping functions for each measure/axis + * @param renderType The type of plot or chart (e.g. scatter_plot, bar_chart) + * @param data The response data from SelectRows + * @returns {{processed: {}, warningMessage: *}} + */ + var doValueConversion = function(chartConfig, aes, renderType, data) + { + var measuresForProcessing = {}, measureRestrictions = {}, configMeasure; + for (var measureName in chartConfig.measures) { + if (chartConfig.measures.hasOwnProperty(measureName) && LABKEY.Utils.isObject(chartConfig.measures[measureName])) { + configMeasure = chartConfig.measures[measureName]; + $.extend(measureRestrictions, _getMeasureRestrictions(renderType, measureName)); + + var isGroupingMeasure = measureName === 'color' || measureName === 'shape' || measureName === 'series'; + var isXAxis = measureName === 'x' || measureName === 'xSub'; + var isScatterOrLine = renderType === 'scatter_plot' || renderType === 'line_plot'; + var isBarYCount = renderType === 'bar_chart' && configMeasure.aggregate && (configMeasure.aggregate === 'COUNT' || configMeasure.aggregate.value === 'COUNT'); + + if (configMeasure.measure && !isGroupingMeasure && !isBarYCount + && ((!isXAxis && measureRestrictions.numericOnly ) || isScatterOrLine) && !isNumericType(configMeasure.type)) { + measuresForProcessing[measureName] = {}; + measuresForProcessing[measureName].name = configMeasure.name; + measuresForProcessing[measureName].convertedName = configMeasure.name + "_converted"; + measuresForProcessing[measureName].label = configMeasure.label; + configMeasure.normalizedType = 'float'; + configMeasure.type = 'float'; + } + } + } + + var response = {processed: {}}; + if (!LABKEY.Utils.isEmptyObj(measuresForProcessing)) { + response = _processMeasureData(data, aes, measuresForProcessing); + } + + //generate error message for dropped values + var warningMessage = ''; + for (var measure in response.droppedValues) { + if (response.droppedValues.hasOwnProperty(measure) && response.droppedValues[measure].numDropped) { + warningMessage += " The " + + measure + "-axis measure '" + + response.droppedValues[measure].label + "' had " + + response.droppedValues[measure].numDropped + + " value(s) that could not be converted to a number and are not included in the plot."; + } + } + + return {processed: response.processed, warningMessage: warningMessage}; + }; + + /** + * Does the explicit type conversion for each measure deemed suitable to convert. Currently we only + * attempt to convert strings to numbers for measures. + * @param rows Data from SelectRows + * @param aes Aesthetic mapping function for the measure/dimensions + * @param measuresForProcessing The measures to be converted, if any + * @returns {{droppedValues: {}, processed: {}}} + */ + var _processMeasureData = function(rows, aes, measuresForProcessing) { + var droppedValues = {}, processedMeasures = {}, dataIsNull; + rows.forEach(function(row) { + //convert measures if applicable + if (!LABKEY.Utils.isEmptyObj(measuresForProcessing)) { + for (var measure in measuresForProcessing) { + if (measuresForProcessing.hasOwnProperty(measure)) { + dataIsNull = true; + if (!droppedValues[measure]) { + droppedValues[measure] = {}; + droppedValues[measure].label = measuresForProcessing[measure].label; + droppedValues[measure].numDropped = 0; + } + + if (aes.hasOwnProperty(measure)) { + var value = aes[measure](row); + if (value !== null) { + dataIsNull = false; + } + row[measuresForProcessing[measure].convertedName] = {value: null}; + if (typeof value !== 'number' && value !== null) { + + //only try to convert strings to numbers + if (typeof value === 'string') { + value = value.trim(); + } + else { + //dates, objects, booleans etc. to be assigned value: NULL + value = ''; + } + + var n = Number(value); + // empty strings convert to 0, which we must explicitly deny + if (value === '' || isNaN(n)) { + droppedValues[measure].numDropped++; + } + else { + row[measuresForProcessing[measure].convertedName].value = n; + } + } + } + + if (!processedMeasures[measure]) { + processedMeasures[measure] = { + converted: false, + convertedName: measuresForProcessing[measure].convertedName, + type: 'float', + normalizedType: 'float' + } + } + + processedMeasures[measure].converted = processedMeasures[measure].converted || !dataIsNull; + } + } + } + }); + + return {droppedValues: droppedValues, processed: processedMeasures}; + }; + + /** + * removes all traces of String -> Numeric Conversion from the given chart config + * @param chartConfig + * @returns {updated ChartConfig} + */ + var removeNumericConversionConfig = function(chartConfig) { + if (chartConfig && chartConfig.measures) { + for (var measureName in chartConfig.measures) { + if (chartConfig.measures.hasOwnProperty(measureName)) { + var measure = chartConfig.measures[measureName]; + if (measure && measure.converted && measure.convertedName) { + measure.converted = null; + measure.convertedName = null; + if (LABKEY.vis.GenericChartHelper.isNumericType(measure.type)) { + measure.type = 'string'; + measure.normalizedType = 'string'; + } + } + } + } + } + + return chartConfig; + }; + + var renderChartSVG = function(renderTo, queryConfig, chartConfig) { + queryChartData(renderTo, queryConfig, chartConfig, function(measureStore, trendlineData) { + generateChartSVG(renderTo, chartConfig, measureStore, trendlineData); + }); + }; + + var queryChartData = function(renderTo, queryConfig, chartConfig, callback) { + queryConfig.containerPath = LABKEY.container.path; + + if (queryConfig.filterArray && queryConfig.filterArray.length > 0) { + var filters = []; + + for (var i = 0; i < queryConfig.filterArray.length; i++) { + var f = queryConfig.filterArray[i]; + // Issue 37191: Check to see if 'f' is already a filter instance (either labkey-api-js/src/filter/Filter.ts or clientapi/core/Query.js) + if (f.hasOwnProperty('getValue') || f.getValue instanceof Function) { + filters.push(f); + } + else { + filters.push(LABKEY.Filter.create(f.name, f.value, LABKEY.Filter.getFilterTypeForURLSuffix(f.type))); + } + } + + queryConfig.filterArray = filters; + } + + queryConfig.success = async function(measureStore) { + const trendlineData = await queryTrendlineData(chartConfig, measureStore.records()); + callback.call(this, measureStore, trendlineData); + }; + + LABKEY.Query.MeasureStore.selectRows(queryConfig); + }; + + var generateChartSVG = function(renderTo, chartConfig, measureStore, trendlineData) { + var responseMetaData = measureStore.getResponseMetadata(); + + // explicitly set the chart width/height if not set in the config + if (!chartConfig.hasOwnProperty('width') || chartConfig.width == null) chartConfig.width = 1000; + if (!chartConfig.hasOwnProperty('height') || chartConfig.height == null) chartConfig.height = 600; + + var chartType = getChartType(chartConfig); + var aes = generateAes(chartType, chartConfig.measures, responseMetaData.schemaName, responseMetaData.queryName); + var valueConversionResponse = doValueConversion(chartConfig, aes, chartType, measureStore.records()); + if (!LABKEY.Utils.isEmptyObj(valueConversionResponse.processed)) { + $.extend(true, chartConfig.measures, valueConversionResponse.processed); + aes = generateAes(chartType, chartConfig.measures, responseMetaData.schemaName, responseMetaData.queryName); + } + var data = measureStore.records(); + if (chartType === 'scatter_plot' && data.length > chartConfig.geomOptions.binThreshold) { + chartConfig.geomOptions.binned = true; + } + var scales = generateScales(chartType, chartConfig.measures, chartConfig.scales, aes, measureStore); + 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); + } + + var validation = _validateChartConfig(chartConfig, aes, scales, measureStore); + _renderMessages(renderTo, validation.messages); + if (!validation.success) + return; + + var plotConfigArr = generatePlotConfigs(renderTo, chartConfig, labels, aes, scales, geom, data, trendlineData); + $.each(plotConfigArr, function(idx, plotConfig) { + if (chartType === 'pie_chart') { + new LABKEY.vis.PieChart(plotConfig); + } + else { + new LABKEY.vis.Plot(plotConfig).render(); + } + }, this); + } + + var _renderMessages = function(divId, messages) { + if (messages && messages.length > 0) { + var errorDiv = document.createElement('div'); + errorDiv.innerHTML = '

Error rendering chart:

' + messages.join('
') + '
'; + document.getElementById(divId).appendChild(errorDiv); + } + }; + + var _validateChartConfig = function(chartConfig, aes, scales, measureStore) { + var hasNoDataMsg = validateResponseHasData(measureStore, false); + if (hasNoDataMsg != null) + return {success: false, messages: [hasNoDataMsg]}; + + var messages = [], firstRecord = measureStore.records()[0], measureNames = Object.keys(chartConfig.measures); + for (var i = 0; i < measureNames.length; i++) { + var measuresArr = ensureMeasuresAsArray(chartConfig.measures[measureNames[i]]); + for (var j = 0; j < measuresArr.length; j++) { + var measure = measuresArr[j]; + if (LABKEY.Utils.isObject(measure)) { + if (measure.name && !LABKEY.Utils.isDefined(firstRecord[measure.name])) { + return {success: false, messages: ['The measure, ' + measure.name + ', is not available. It may have been renamed or removed.']}; + } + + var validation; + if (measureNames[i] === 'y') { + var yAes = {y: getYMeasureAes(measure)}; + validation = validateAxisMeasure(chartConfig.renderType, measure, 'y', yAes, scales, measureStore.records()); + } + else if (measureNames[i] === 'x' || measureNames[i] === 'xSub') { + validation = validateAxisMeasure(chartConfig.renderType, measure, measureNames[i], aes, scales, measureStore.records()); + } + + if (LABKEY.Utils.isObject(validation)) { + if (validation.message != null) + messages.push(validation.message); + if (!validation.success) + return {success: false, messages: messages}; + } + } + } + } + + return {success: true, messages: messages}; + }; + + return { + // NOTE: the @function below is needed or JSDoc will not include the documentation for loadVisDependencies. Don't + // ask me why, I do not know. + /** + * @function + */ + getRenderTypes: getRenderTypes, + getChartType: getChartType, + getSelectedMeasureLabel: getSelectedMeasureLabel, + getTitleFromMeasures: getTitleFromMeasures, + getMeasureType: getMeasureType, + getAllowableTypes: getAllowableTypes, + getQueryColumns : getQueryColumns, + getChartTypeBasedWidth : getChartTypeBasedWidth, + getDistinctYAxisSides : getDistinctYAxisSides, + getYMeasureAes : getYMeasureAes, + getDefaultMeasuresLabel: getDefaultMeasuresLabel, + getStudySubjectInfo: getStudySubjectInfo, + getQueryConfigSortKey: getQueryConfigSortKey, + ensureMeasuresAsArray: ensureMeasuresAsArray, + isNumericType: isNumericType, + isMeasureDimensionMatch: isMeasureDimensionMatch, + generateLabels: generateLabels, + generateScales: generateScales, + generateAes: generateAes, + doValueConversion: doValueConversion, + removeNumericConversionConfig: removeNumericConversionConfig, + generateAggregateData: generateAggregateData, + generatePointHover: generatePointHover, + generateBoxplotHover: generateBoxplotHover, + generateDiscreteAcc: generateDiscreteAcc, + generateContinuousAcc: generateContinuousAcc, + generateGroupingAcc: generateGroupingAcc, + generatePointClickFn: generatePointClickFn, + generateGeom: generateGeom, + generateBoxplotGeom: generateBoxplotGeom, + generatePointGeom: generatePointGeom, + generatePlotConfigs: generatePlotConfigs, + generatePlotConfig: generatePlotConfig, + validateResponseHasData: validateResponseHasData, + validateAxisMeasure: validateAxisMeasure, + validateXAxis: validateXAxis, + validateYAxis: validateYAxis, + renderChartSVG: renderChartSVG, + queryChartData: queryChartData, + generateChartSVG: generateChartSVG, + getMeasureStoreRecords: getMeasureStoreRecords, + queryTrendlineData: queryTrendlineData, + TRENDLINE_OPTIONS: TRENDLINE_OPTIONS, + /** + * Loads all of the required dependencies for a Generic Chart. + * @param {Function} callback The callback to be executed when all of the visualization dependencies have been loaded. + * @param {Object} scope The scope to be used when executing the callback. + */ + loadVisDependencies: LABKEY.requiresVisualization + }; }; \ No newline at end of file From 924a34d3193a894d4e1755a9ef9ce00ae954145e Mon Sep 17 00:00:00 2001 From: cnathe Date: Wed, 8 Oct 2025 11:41:14 -0500 Subject: [PATCH 06/40] Render error bars for line chart points and bar charts if errorAes is provided --- core/webapp/vis/src/internal/D3Renderer.js | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/core/webapp/vis/src/internal/D3Renderer.js b/core/webapp/vis/src/internal/D3Renderer.js index 1fbd98b44e3..238c992c5d1 100644 --- a/core/webapp/vis/src/internal/D3Renderer.js +++ b/core/webapp/vis/src/internal/D3Renderer.js @@ -2127,6 +2127,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); @@ -2217,7 +2223,6 @@ LABKEY.vis.internal.D3Renderer = function(plot) { var errorLineWidth = geom.errorWidth ?? geom.width; 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) { var x, y, value, error; x = geom.getX(d); @@ -3226,6 +3231,12 @@ LABKEY.vis.internal.D3Renderer = function(plot) { yZero = {}; yZero[geom.yAes.value] = 0; + if (geom.errorAes !== undefined) { + 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); + } + // group each bar with an a tag for hover barWrappers = layer.selectAll('a.bar-individual').data(data); barWrappers.exit().remove(); From 777054a94c0c6e7d63c0d084c36d8735244fc3b2 Mon Sep 17 00:00:00 2001 From: cnathe Date: Wed, 8 Oct 2025 11:52:42 -0500 Subject: [PATCH 07/40] Include error bar value in the point and bar hover text --- core/webapp/vis/src/internal/D3Renderer.js | 1 + .../resources/web/vis/genericChart/genericChartHelper.js | 7 ++++++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/core/webapp/vis/src/internal/D3Renderer.js b/core/webapp/vis/src/internal/D3Renderer.js index 238c992c5d1..c0adeb7489e 100644 --- a/core/webapp/vis/src/internal/D3Renderer.js +++ b/core/webapp/vis/src/internal/D3Renderer.js @@ -3199,6 +3199,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); }; diff --git a/visualization/resources/web/vis/genericChart/genericChartHelper.js b/visualization/resources/web/vis/genericChart/genericChartHelper.js index 0cfcbc69c4c..c768685e4dd 100644 --- a/visualization/resources/web/vis/genericChart/genericChartHelper.js +++ b/visualization/resources/web/vis/genericChart/genericChartHelper.js @@ -670,6 +670,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].error !== undefined && row[measure.name].errorType !== undefined) { + hover += sep + row[measure.name].errorType + ': ' + row[measure.name].error; + } + distinctNames.push(measure.name); } }, this); @@ -1060,7 +1065,7 @@ LABKEY.vis.GenericChartHelper = new function(){ } if (!scales.y.domain) { - var values = $.map(data, function(d) {return d.value;}), + var values = $.map(data, function(d) {return d.value + (d.error ?? 0);}), min = Math.min(0, Math.min.apply(Math, values)), max = Math.max(0, Math.max.apply(Math, values)); From ef04f43cf9e96b867b39c5e62bc1a743f0f05ba6 Mon Sep 17 00:00:00 2001 From: cnathe Date: Wed, 8 Oct 2025 14:26:26 -0500 Subject: [PATCH 08/40] LABKEY.vis.getValue optional param for preferredProp to be used in getAggregateData() for measure value --- core/src/client/vis/utils.test.ts | 36 +++++++++++++++++++++++++++++++ core/webapp/vis/src/utils.js | 8 ++++--- 2 files changed, 41 insertions(+), 3 deletions(-) diff --git a/core/src/client/vis/utils.test.ts b/core/src/client/vis/utils.test.ts index 72b1ba5211b..289c6e830c6 100644 --- a/core/src/client/vis/utils.test.ts +++ b/core/src/client/vis/utils.test.ts @@ -2,6 +2,42 @@ 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 }, diff --git a/core/webapp/vis/src/utils.js b/core/webapp/vis/src/utils.js index 5bd158dce09..d1d4a7b8480 100644 --- a/core/webapp/vis/src/utils.js +++ b/core/webapp/vis/src/utils.js @@ -228,7 +228,7 @@ LABKEY.vis.getAggregateData = function(data, dimensionName, subDimensionName, me groupAccessor = typeof dimensionName === 'function' ? dimensionName : function(row){ return LABKEY.vis.getValue(row[dimensionName]);}, 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') { @@ -389,9 +389,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; From f1f3377887f9fb94200716ef798e441c0f823bc4 Mon Sep 17 00:00:00 2001 From: cnathe Date: Wed, 8 Oct 2025 14:27:46 -0500 Subject: [PATCH 09/40] LABKEY.vis.GenericChartHelper.generateDataForChartType() to account for line chart and bar chart case with aggregate and aggErrorType --- .../web/vis/chartWizard/genericChartPanel.js | 4457 ++++++++--------- .../vis/genericChart/genericChartHelper.js | 66 +- 2 files changed, 2262 insertions(+), 2261 deletions(-) diff --git a/visualization/resources/web/vis/chartWizard/genericChartPanel.js b/visualization/resources/web/vis/chartWizard/genericChartPanel.js index 577ecc4d732..613f53c9c7b 100644 --- a/visualization/resources/web/vis/chartWizard/genericChartPanel.js +++ b/visualization/resources/web/vis/chartWizard/genericChartPanel.js @@ -1,2239 +1,2218 @@ -/* - * Copyright (c) 2016-2019 LabKey Corporation - * - * Licensed under the Apache License, Version 2.0: http://www.apache.org/licenses/LICENSE-2.0 - */ -Ext4.define('LABKEY.ext4.GenericChartPanel', { - extend : 'Ext.panel.Panel', - - cls : 'generic-chart-panel', - layout : 'fit', - editable : false, - minWidth : 900, - - initialSelection : null, - savedReportInfo : null, - hideViewData : false, - reportLoaded : true, - hideSave: false, - dataPointLimit: 10000, - - constructor : function(config) - { - Ext4.QuickTips.init(); - this.callParent([config]); - }, - - queryGenericChartColumns : function() - { - LABKEY.vis.GenericChartHelper.getQueryColumns(this, function(columnMetadata) - { - this.getChartTypePanel().loadQueryColumns(columnMetadata); - this.requestData(); - }, this); - }, - - initComponent : function() - { - this.measures = {}; - this.options = {}; - this.userFilters = []; - - // boolean to check if we should allow things like export to PDF - this.supportedBrowser = !(Ext4.isIE6 || Ext4.isIE7 || Ext4.isIE8); - - var params = LABKEY.ActionURL.getParameters(); - this.editMode = params.edit == "true" || !this.savedReportInfo; - this.parameters = LABKEY.Filter.getQueryParamsFromUrl(params['filterUrl'], this.dataRegionName); - - // Issue 19163 - Ext4.each(['autoColumnXName', 'autoColumnYName', 'autoColumnName'], function(autoColPropName) - { - if (this[autoColPropName]) - this[autoColPropName] = LABKEY.FieldKey.fromString(this[autoColPropName]); - }, this); - - // for backwards compatibility, map auto_plot to box_plot - if (this.renderType === 'auto_plot') - this.setRenderType('box_plot'); - - this.chartDefinitionChanged = new Ext4.util.DelayedTask(function(){ - this.markDirty(true); - this.requestRender(); - }, this); - - // delayed task to redraw the chart - this.updateChartTask = new Ext4.util.DelayedTask(function() - { - if (this.hasConfigurationChanged()) - { - this.getEl().mask('Loading Data...'); - - if (this.editMode && this.getChartTypePanel().getQueryColumnNames().length == 0) - this.queryGenericChartColumns(); - else - this.requestData(); - } - - }, this); - - // only linear for now but could expand in the future - this.lineRenderers = { - linear : { - createRenderer : function(params){ - if (params && params.length >= 2) { - return function(x){return x * params[0] + params[1];} - } - return function(x) {return x;} - } - } - }; - - this.items = [this.getCenterPanel()]; - - this.callParent(); - - if (this.savedReportInfo) - this.loadSavedConfig(); - else - this.loadInitialSelection(); - - window.onbeforeunload = LABKEY.beforeunload(this.beforeUnload, this); - }, - - getViewPanel : function() - { - if (!this.viewPanel) - { - this.viewPanel = Ext4.create('Ext.panel.Panel', { - autoScroll : true, - ui : 'custom', - listeners : { - scope: this, - activate: function() - { - this.updateChartTask.delay(500); - }, - resize: function(p) - { - // only re-render after the initial chart rendering - if (this.hasChartData()) { - this.clearMessagePanel(); - this.requestRender(); - } - } - } - }); - } - - return this.viewPanel; - }, - - getDataPanel : function() - { - if (!this.dataPanel) - { - this.dataPanel = Ext4.create('Ext.panel.Panel', { - flex : 1, - layout : 'fit', - border : false, - items : [ - Ext4.create('Ext.Component', { - autoScroll : true, - listeners : { - scope : this, - render : function(cmp){ - this.renderDataGrid(cmp.getId()); - } - } - }) - ] - }); - } - - return this.dataPanel; - }, - - getCenterPanel : function() - { - if (!this.centerPanel) - { - this.centerPanel = Ext4.create('Ext.panel.Panel', { - border: false, - layout: { - type: 'card', - deferredRender: true - }, - activeItem: 0, - items: [this.getViewPanel(), this.getDataPanel()], - dockedItems: [this.getTopButtonBar(), this.getMsgPanel()] - }); - } - - return this.centerPanel; - }, - - getChartTypeBtn : function() - { - if (!this.chartTypeBtn) - { - this.chartTypeBtn = Ext4.create('Ext.button.Button', { - text: 'Chart Type', - handler: this.showChartTypeWindow, - scope: this - }); - } - - return this.chartTypeBtn; - }, - - getChartLayoutBtn : function() - { - if (!this.chartLayoutBtn) - { - this.chartLayoutBtn = Ext4.create('Ext.button.Button', { - text: 'Chart Layout', - disabled: true, - handler: this.showChartLayoutWindow, - scope: this - }); - } - - return this.chartLayoutBtn; - }, - - getHelpBtn : function() - { - if (!this.helpBtn) - { - this.helpBtn = Ext4.create('Ext.button.Button', { - text: 'Help', - scope: this, - menu: { - showSeparator: false, - items: [{ - text: 'Reports and Visualizations', - iconCls: 'fa fa-table', - hrefTarget: '_blank', - href: LABKEY.Utils.getHelpTopicHref('reportsAndViews') - },{ - text: 'Bar Plots', - iconCls: 'fa fa-bar-chart', - hrefTarget: '_blank', - href: LABKEY.Utils.getHelpTopicHref('barchart') - },{ - text: 'Box Plots', - iconCls: 'fa fa-sliders fa-rotate-90', - hrefTarget: '_blank', - href: LABKEY.Utils.getHelpTopicHref('boxplot') - },{ - text: 'Line Plots', - iconCls: 'fa fa-line-chart', - hrefTarget: '_blank', - href: LABKEY.Utils.getHelpTopicHref('lineplot') - },{ - text: 'Pie Charts', - iconCls: 'fa fa-pie-chart', - hrefTarget: '_blank', - href: LABKEY.Utils.getHelpTopicHref('piechart') - },{ - text: 'Scatter Plots', - iconCls: 'fa fa-area-chart', - hrefTarget: '_blank', - href: LABKEY.Utils.getHelpTopicHref('scatterplot') - }] - } - }); - } - - return this.helpBtn; - }, - - getSaveBtn : function() - { - if (!this.saveBtn) - { - this.saveBtn = Ext4.create('Ext.button.Button', { - text: "Save", - hidden: LABKEY.user.isGuest || this.hideSave, - disabled: true, - handler: function(){ - this.onSaveBtnClicked(false) - }, - scope: this - }); - } - - return this.saveBtn; - }, - - getSaveAsBtn : function() - { - if (!this.saveAsBtn) - { - this.saveAsBtn = Ext4.create('Ext.button.Button', { - text: "Save As", - hidden : this.isNew() || LABKEY.user.isGuest || this.hideSave, - disabled: true, - handler: function(){ - this.onSaveBtnClicked(true); - }, - scope: this - }); - } - - return this.saveAsBtn; - }, - - getToggleViewBtn : function() - { - if (!this.toggleViewBtn) - { - this.toggleViewBtn = Ext4.create('Ext.button.Button', { - text:'View Data', - hidden: this.hideViewData, - scope: this, - handler: function() - { - if (this.getViewPanel().isHidden()) - { - this.getCenterPanel().getLayout().setActiveItem(0); - this.toggleViewBtn.setText('View Data'); - - this.getChartTypeBtn().show(); - this.getChartLayoutBtn().show(); - - if (Ext4.isArray(this.customButtons)) - { - for (var i = 0; i < this.customButtons.length; i++) - this.customButtons[i].show(); - } - } - else - { - this.getCenterPanel().getLayout().setActiveItem(1); - this.toggleViewBtn.setText('View Chart'); - - this.getMsgPanel().removeAll(); - this.getChartTypeBtn().hide(); - this.getChartLayoutBtn().hide(); - - if (Ext4.isArray(this.customButtons)) - { - for (var i = 0; i < this.customButtons.length; i++) - this.customButtons[i].hide(); - } - } - } - }); - } - - return this.toggleViewBtn; - }, - - getEditBtn : function() - { - if (!this.editBtn) - { - this.editBtn = Ext4.create('Ext.button.Button', { - xtype: 'button', - text: 'Edit', - scope: this, - handler: function() { - window.location = this.editModeURL; - } - }); - } - - return this.editBtn; - }, - - getTopButtonBar : function() - { - if (!this.topButtonBar) - { - this.topButtonBar = Ext4.create('Ext.toolbar.Toolbar', { - dock: 'top', - items: this.initTbarItems() - }); - } - - return this.topButtonBar; - }, - - initTbarItems : function() - { - var tbarItems = []; - tbarItems.push(this.getToggleViewBtn()); - tbarItems.push(this.getHelpBtn()); - tbarItems.push('->'); // rest of buttons will be right aligned - - if (this.editMode) - { - tbarItems.push(this.getChartTypeBtn()); - tbarItems.push(this.getChartLayoutBtn()); - - if (Ext4.isArray(this.customButtons)) - { - tbarItems.push(''); // horizontal spacer - for (var i = 0; i < this.customButtons.length; i++) - { - var btn = this.customButtons[i]; - btn.scope = this; - tbarItems.push(btn); - } - } - - if (!LABKEY.user.isGuest && !this.hideSave) - tbarItems.push(''); // horizontal spacer - if (this.canEdit) - tbarItems.push(this.getSaveBtn()); - tbarItems.push(this.getSaveAsBtn()); - } - else if (this.allowEditMode && this.editModeURL != null) - { - // add an "edit" button if the user is allowed to toggle to edit mode for this report - tbarItems.push(this.getEditBtn()); - } - - return tbarItems; - }, - - getMsgPanel : function() { - if (!this.msgPanel) { - this.msgPanel = Ext4.create('Ext.panel.Panel', { - hidden: true, - bodyStyle: 'border-width: 1px 0 0 0', - listeners: { - add: function(panel) { - panel.show(); - }, - remove: function(panel) { - if (panel.items.items.length == 0) { - panel.hide(); - } - } - } - }); - } - - return this.msgPanel; - }, - - showChartTypeWindow : function() - { - // make sure the chartTypePanel is shown in the window - if (this.getChartTypeWindow().items.items.length == 0) - this.getChartTypeWindow().add(this.getChartTypePanel()); - - this.getChartTypeWindow().show(); - }, - - showChartLayoutWindow : function() - { - this.getChartLayoutWindow().show(); - }, - - isNew : function() - { - return !this.savedReportInfo; - }, - - getChartTypeWindow : function() - { - if (!this.chartTypeWindow) - { - var panel = this.getChartTypePanel(); - - this.chartTypeWindow = Ext4.create('LABKEY.vis.ChartWizardWindow', { - panelToMask: this, - onEsc: function() { - panel.cancelHandler.call(panel); - }, - items: [panel] - }); - - // propagate the show event to the panel so it can stash the initial values - this.chartTypeWindow.on('show', function(window) - { - this.getChartTypePanel().fireEvent('show', this.getChartTypePanel()); - }, this); - } - - return this.chartTypeWindow; - }, - - getChartTypePanel : function() - { - if (!this.chartTypePanel) - { - this.chartTypePanel = Ext4.create('LABKEY.vis.ChartTypePanel', { - chartTypesToHide: ['time_chart'], - selectedType: this.getSelectedChartType(), - selectedFields: Ext4.apply(this.measures, { trendline: this.trendline }), - restrictColumnsEnabled: this.restrictColumnsEnabled, - customRenderTypes: this.customRenderTypes, - baseQueryKey: this.schemaName + '.' + this.queryName, - studyQueryName: this.schemaName == 'study' ? this.queryName : null - }); - } - - if (!this.hasAttachedChartTypeListeners) - { - this.chartTypePanel.on('cancel', this.closeChartTypeWindow, this); - this.chartTypePanel.on('apply', this.applyChartTypeSelection, this); - this.hasAttachedChartTypeListeners = true; - } - - return this.chartTypePanel; - }, - - closeChartTypeWindow : function(panel) - { - if (this.getChartTypeWindow().isVisible()) - this.getChartTypeWindow().hide(); - }, - - applyChartTypeSelection : function(panel, values, skipRender) - { - // close the window and clear any previous charts - this.closeChartTypeWindow(); - this.clearChartPanel(true); - - // only apply the values for the applicable chart type - if (Ext4.isObject(values) && values.type == 'time_chart') - return; - - this.setRenderType(values.type); - this.measures = values.fields; - if (values.fields.xSub) { - this.measures.color = this.measures.xSub; - } - - if (values.altValues.trendline) { - this.trendline = values.altValues.trendline; - // if the chart data has already been loaded then we only need to query the trendlineData - if (this.measureStore) this.queryTrendlineData(); - } - - this.getChartLayoutPanel().onMeasuresChange(this.measures, this.renderType); - this.getChartLayoutPanel().updateVisibleLayoutOptions(this.getSelectedChartTypeData(), this.measures); - this.ensureChartLayoutOptions(); - - if (!skipRender) - this.renderChart(); - }, - - getSelectedChartTypeData : function() - { - var selectedChartType = this.getChartTypePanel().getSelectedType(); - return selectedChartType ? selectedChartType.data : null; - }, - - getChartLayoutWindow : function() - { - if (!this.chartLayoutWindow) - { - var panel = this.getChartLayoutPanel(); - - this.chartLayoutWindow = Ext4.create('LABKEY.vis.ChartWizardWindow', { - panelToMask: this, - onEsc: function() { - panel.cancelHandler.call(panel); - }, - items: [panel] - }); - - // propagate the show event to the panel so it can stash the initial values - this.chartLayoutWindow.on('show', function(window) - { - this.getChartLayoutPanel().fireEvent('show', this.getChartLayoutPanel(), this.getSelectedChartTypeData(), this.measures); - }, this); - } - - return this.chartLayoutWindow; - }, - - getChartLayoutPanel : function() - { - if (!this.chartLayoutPanel) - { - this.chartLayoutPanel = Ext4.create('LABKEY.vis.ChartLayoutPanel', { - options: this.options, - isDeveloper: this.isDeveloper, - renderType: this.renderType, - initMeasures: this.measures, - multipleCharts: this.options && this.options.general && this.options.general.chartLayout !== 'single', - defaultChartLabel: this.getDefaultTitle(), - defaultOpacity: this.renderType == 'bar_chart' || this.renderType == 'line_plot' ? 100 : undefined, - defaultLineWidth: this.renderType == 'line_plot' ? 3 : undefined, - isSavedReport: !this.isNew(), - listeners: { - scope: this, - cancel: function(panel) - { - this.getChartLayoutWindow().hide(); - }, - apply: function(panel, values) - { - // special case for trendlineData: if there was a change to x-axis scale type or range, - // we need to reload the trendlineData - if (this.trendlineData) this.queryTrendlineData(); - - // note: this event will only fire if a change was made in the Chart Layout panel - this.ensureChartLayoutOptions(); - this.clearChartPanel(true); - this.renderChart(); - this.getChartLayoutWindow().hide(); - } - } - }); - } - - return this.chartLayoutPanel; - }, - - getExportScriptWindow : function() - { - if (!this.exportScriptWindow) - { - this.exportScriptWindow = Ext4.create('LABKEY.vis.ChartWizardWindow', { - panelToMask: this, - items: [this.getExportScriptPanel()] - }); - } - - return this.exportScriptWindow; - }, - - getExportScriptPanel : function() - { - if (!this.exportScriptPanel) - { - this.exportScriptPanel = Ext4.create('LABKEY.vis.GenericChartScriptPanel', { - width: Math.max(this.getViewPanel().getWidth() - 100, 800), - listeners: { - scope: this, - closeOptionsWindow: function(){ - this.getExportScriptWindow().hide(); - } - } - }); - } - - return this.exportScriptPanel; - }, - - getSaveWindow : function() - { - if (!this.saveWindow) - { - this.saveWindow = Ext4.create('LABKEY.vis.ChartWizardWindow', { - panelToMask: this, - items: [this.getSavePanel()] - }); - } - - return this.saveWindow; - }, - - getSavePanel : function() - { - if (!this.savePanel) - { - this.savePanel = Ext4.create('LABKEY.vis.SaveOptionsPanel', { - allowInherit: this.allowInherit, - canEdit: this.canEdit, - canShare: this.canShare, - listeners: { - scope: this, - closeOptionsWindow: function() - { - this.getSaveWindow().close() - }, - saveChart: this.saveReport - } - }); - } - - return this.savePanel; - }, - - ensureChartLayoutOptions : function() - { - // Make sure that we have the latest chart layout panel values. - // This will get the initial default values if the user has not yet opened the chart layout dialog. - // This will also preserve the developer pointClickFn if the user is not a developer. - Ext4.apply(this.options, this.getChartLayoutPanel().getValues()); - }, - - setRenderType : function(newRenderType) - { - if (this.renderType != newRenderType) - this.renderType = newRenderType; - }, - - renderDataGrid : function(renderTo) - { - var filters = []; - var removableFilters = this.userFilters; - - if (this.isDataRegionPresent()) - { - // If the region exists, then apply it's user filters as immutable filters to the QWP. - // They can be mutated on the data region directly. - filters = this.getDataRegionFilters(); - } - else - { - // If the region does not exist, then apply it's filters from the URL - // and allow them to be mutated on the QWP. - removableFilters = removableFilters.concat(this.getURLFilters()); - } - - var allFilters = this.getUniqueFilters(filters.concat(removableFilters)); - var userSort = LABKEY.Filter.getSortFromUrl(this.getFilterURL(), this.dataRegionName); - - this.currentFilterStr = this.createFilterString(allFilters); - this.currentParameterStr = Ext4.JSON.encode(this.parameters); - - var wpConfig = { - schemaName : this.schemaName, - queryName : this.queryName, - viewName : this.viewName, - columns : this.savedColumns, - parameters : this.parameters, - frame : 'none', - filters : filters, - disableAnalytics : true, - removeableFilters : removableFilters, - removeableSort : userSort, - showSurroundingBorder : false, - allowHeaderLock : false, - buttonBar : { - includeStandardButton: false, - items: [LABKEY.QueryWebPart.standardButtons.exportRows] - } - }; - - if (this.dataRegionName) { - wpConfig.dataRegionName = this.dataRegionName + '-chartdata'; - } - - var wp = new LABKEY.QueryWebPart(wpConfig); - - // save the dataregion - this.panelDataRegionName = wp.dataRegionName; - - // Issue 21418: support for parameterized queries - wp.on('render', function(){ - if (wp.parameters) - Ext4.apply(this.parameters, wp.parameters); - }, this); - - wp.render(renderTo); - }, - - /** - * Returns the filters applied to the associated Data Region. This region's presence is optional. - */ - getDataRegionFilters : function() - { - if (this.isDataRegionPresent()) - return LABKEY.DataRegions[this.dataRegionName].getUserFilterArray(); - - return []; - }, - - getFilterURL : function() - { - return LABKEY.ActionURL.getParameters(this.baseUrl)['filterUrl']; - }, - - /** - * Returns the filters applied to the associated QueryWebPart (QWP). The QWP's presence is optional. - * If this QWP is available, then this method will return any user modifiable filters from the QWP. - */ - getQWPFilters : function() - { - // The associated QWP may not exist yet if the user hasn't viewed it - if (this.isQWPPresent()) - return LABKEY.DataRegions[this.panelDataRegionName].getUserFilterArray(); - - return []; - }, - - getURLFilters : function() - { - return LABKEY.Filter.getFiltersFromUrl(this.getFilterURL(), this.dataRegionName); - }, - - isDataRegionPresent : function() - { - return LABKEY.DataRegions[this.dataRegionName] !== undefined; - }, - - isQWPPresent : function() - { - return LABKEY.DataRegions[this.panelDataRegionName] !== undefined; - }, - - // Returns a configuration based on the baseUrl plus any filters applied on the dataregion panel - // the configuration can be used to make a selectRows request - getQueryConfig : function(serialize) - { - var config = { - schemaName : this.schemaName, - queryName : this.queryName, - viewName : this.viewName, - dataRegionName: this.dataRegionName, - queryLabel : this.queryLabel, - parameters : this.parameters, - requiredVersion : 17.1, // Issue 49753 - maxRows: -1, - sort: LABKEY.vis.GenericChartHelper.getQueryConfigSortKey(this.measures), - method: 'POST' - }; - - config.columns = this.getQueryConfigColumns(); - - if (!serialize) - { - config.success = this.onSelectRowsSuccess; - config.failure = function(response, opts){ - var error, errorDiv; - - this.getEl().unmask(); - - if (response.exception) - { - error = '

' + response.exception + '

'; - if (response.exceptionClass == 'org.labkey.api.view.NotFoundException') - error = error + '

The source dataset, list, or query may have been deleted.

' - } - - errorDiv = Ext4.create('Ext.container.Container', { - border: 1, - autoEl: {tag: 'div'}, - padding: 10, - html: '

An unexpected error occurred while retrieving data.

' + error, - autoScroll: true - }); - - // Issue 18157 - this.getChartTypeBtn().disable(); - this.getChartLayoutBtn().disable(); - this.getToggleViewBtn().disable(); - this.getSaveBtn().disable(); - this.getSaveAsBtn().disable(); - - this.getViewPanel().add(errorDiv); - }; - config.scope = this; - } - - // Filter scenarios (Issue 37153, Issue 40384) - // 1. Filters defined explicitly on chart configuration (this.userFilters) - // 2. Filters defined on associated QWP (panelDataRegionName) - // 3. Filters defined on associated Data Region (dataRegionName) - // 4. Filters defined on URL for associated Data Region (overlap with #3) - var filters = this.getDataRegionFilters(); - - // If the QWP is present, then it is expected to have the "userFilters" and URL filters already applied. - // Additionally, they are removable from the QWP so respect the current filters on the QWP. - if (this.isQWPPresent()) - filters = filters.concat(this.getQWPFilters()); - else - filters = filters.concat(this.userFilters, this.getURLFilters()); - - filters = this.getUniqueFilters(filters); - - if (serialize) - { - var newFilters = []; - - for (var i=0; i < filters.length; i++) - { - var f = filters[i]; - newFilters.push({name : f.getColumnName(), value : f.getValue(), type : f.getFilterType().getURLSuffix()}); - } - - filters = newFilters; - } - - config.filterArray = filters; - - return config; - }, - - filterToString : function(filter) - { - return filter.getURLParameterName() + '=' + filter.getURLParameterValue(); - }, - - getUniqueFilters : function(filters) - { - var filterKeys = {}; - var filterSet = []; - - for (var x=0; x < filters.length; x++) - { - var ff = filters[x]; - var key = this.filterToString(ff); - - if (!filterKeys[key]) - { - filterKeys[key] = true; - filterSet.push(ff); - } - } - - return filterSet; - }, - - getQueryConfigColumns : function() - { - var columns = null; - - if (!this.editMode) - { - // If we're not in edit mode or if this is the first load we need to only load the minimum amount of data. - columns = []; - var measures = this.getChartConfig().measures; - - if (measures.x) - { - this.addMeasureForColumnQuery(columns, measures.x); - } - else if (this.autoColumnXName) - { - columns.push(this.autoColumnXName.toString()); - } - else - { - // Check if we have cohorts available - var queryColumnNames = this.getChartTypePanel().getQueryColumnNames(); - for (var i = 0; i < queryColumnNames.length; i++) - { - if (queryColumnNames[i].indexOf('Cohort') > -1) - columns.push(queryColumnNames[i]); - } - } - - if (measures.y) { - this.addMeasureForColumnQuery(columns, measures.y); - } - else if (this.autoColumnYName) { - columns.push(this.autoColumnYName.toString()); - } - - if (this.autoColumnName) { - columns.push(this.autoColumnName.toString()); - } - - Ext4.each(['ySub', 'xSub', 'color', 'shape', 'series'], function(name) { - if (measures[name]) { - this.addMeasureForColumnQuery(columns, measures[name]); - } - }, this); - } - else - { - // If we're in edit mode then we can load all of the columns. - columns = this.getChartTypePanel().getQueryColumnFieldKeys(); - } - - return columns; - }, - - addMeasureForColumnQuery : function(columns, initMeasure) - { - // account for the measure being a single object or an array of objects - var measures = Ext4.isArray(initMeasure) ? initMeasure : [initMeasure]; - Ext4.each(measures, function(measure) { - if (Ext4.isObject(measure)) - { - columns.push(measure.name); - - // Issue 27814: names with slashes need to be queried by encoded name - var encodedName = LABKEY.QueryKey.encodePart(measure.name); - if (measure.name !== encodedName) - columns.push(encodedName); - } - }); - }, - - getChartConfig : function() - { - var config = {}; - - config.renderType = this.renderType; - config.measures = Ext4.apply({}, this.measures); - config.scales = {}; - config.labels = {}; - - this.ensureChartLayoutOptions(); - if (this.options.general) - { - config.width = this.options.general.width; - config.height = this.options.general.height; - config.pointType = this.options.general.pointType; - config.labels.main = this.options.general.label; - config.labels.subtitle = this.options.general.subtitle; - config.labels.footer = this.options.general.footer; - - config.geomOptions = Ext4.apply({}, this.options.general); - config.geomOptions.showOutliers = config.pointType ? config.pointType == 'outliers' : true; - config.geomOptions.pieInnerRadius = this.options.general.pieInnerRadius; - config.geomOptions.pieOuterRadius = this.options.general.pieOuterRadius; - config.geomOptions.showPiePercentages = this.options.general.showPiePercentages; - config.geomOptions.piePercentagesColor = this.options.general.piePercentagesColor; - config.geomOptions.pieHideWhenLessThanPercentage = this.options.general.pieHideWhenLessThanPercentage; - config.geomOptions.gradientPercentage = this.options.general.gradientPercentage; - config.geomOptions.gradientColor = this.options.general.gradientColor; - config.geomOptions.colorPaletteScale = this.options.general.colorPaletteScale; - config.geomOptions.binShape = this.options.general.binShapeGroup; - config.geomOptions.binThreshold = this.options.general.binThreshold; - config.geomOptions.colorRange = this.options.general.binColorGroup; - config.geomOptions.binSingleColor = this.options.general.binSingleColor; - config.geomOptions.chartLayout = this.options.general.chartLayout; - config.geomOptions.marginTop = this.options.general.marginTop; - config.geomOptions.marginRight = this.options.general.marginRight; - config.geomOptions.marginBottom = this.options.general.marginBottom; - config.geomOptions.marginLeft = this.options.general.marginLeft; - } - - if (this.options.x) - { - this.applyAxisOptionsToConfig(this.options, config, 'x'); - if (this.measures.xSub) { - config.labels.xSub = this.measures.xSub.label; - } - } - - this.applyAxisOptionsToConfig(this.options, config, 'y'); - this.applyAxisOptionsToConfig(this.options, config, 'yRight'); - - if (this.options.developer) - config.measures.pointClickFn = this.options.developer.pointClickFn; - - if (this.curveFit) { - config.curveFit = this.curveFit; - } else if (this.trendline) { - config.geomOptions.trendlineType = this.trendline.trendlineType; - config.geomOptions.trendlineAsymptoteMin = this.trendline.trendlineAsymptoteMin; - config.geomOptions.trendlineAsymptoteMax = this.trendline.trendlineAsymptoteMax; - } - - if (this.getCustomChartOptions) - config.customOptions = this.getCustomChartOptions(); - - return config; - }, - - applyAxisOptionsToConfig : function(options, config, axisName) { - if (options[axisName]) - { - if (!config.labels[axisName]) { - config.labels[axisName] = options[axisName].label; - config.scales[axisName] = { - type: options[axisName].scaleRangeType || 'automatic', - trans: options[axisName].trans || options[axisName].scaleTrans - }; - } - - if (config.scales[axisName].type === "manual" && options[axisName].scaleRange) { - config.scales[axisName].min = options[axisName].scaleRange.min; - config.scales[axisName].max = options[axisName].scaleRange.max; - } - } - }, - - markDirty : function(dirty) - { - this.dirty = dirty; - LABKEY.Utils.signalWebDriverTest("genericChartDirty", dirty); - }, - - isDirty : function() - { - return !LABKEY.user.isGuest && !this.hideSave && this.canEdit && this.dirty; - }, - - beforeUnload : function() - { - if (this.isDirty()) { - return 'please save your changes'; - } - }, - - getCurrentReportConfig : function() - { - var reportConfig = { - reportId : this.savedReportInfo ? this.savedReportInfo.reportId : undefined, - schemaName : this.schemaName, - queryName : this.queryName, - viewName : this.viewName, - dataRegionName: this.dataRegionName, - renderType : this.renderType, - jsonData : { - queryConfig : this.getQueryConfig(true), - chartConfig : this.getChartConfig() - } - }; - - var chartConfig = reportConfig.jsonData.chartConfig; - LABKEY.vis.GenericChartHelper.removeNumericConversionConfig(chartConfig); - - return reportConfig; - }, - - saveReport : function(data) - { - var reportConfig = this.getCurrentReportConfig(); - reportConfig.name = data.reportName; - reportConfig.description = data.reportDescription; - - reportConfig["public"] = data.shared; - reportConfig.inheritable = data.inheritable; - reportConfig.thumbnailType = data.thumbnailType; - reportConfig.svg = this.chartSVG; - - if (data.isSaveAs) - reportConfig.reportId = null; - - LABKEY.Ajax.request({ - url : LABKEY.ActionURL.buildURL('visualization', 'saveGenericReport.api'), - method : 'POST', - headers : { - 'Content-Type' : 'application/json' - }, - jsonData: reportConfig, - success : function(resp) - { - this.getSaveWindow().close(); - this.markDirty(false); - - // show success message and then fade the window out - var msgbox = Ext4.create('Ext.window.Window', { - html : 'Report saved successfully.', - cls : 'chart-wizard-dialog', - bodyStyle : 'background: transparent;', - header : false, - border : false, - padding : 20, - resizable: false, - draggable: false - }); - - msgbox.show(); - msgbox.getEl().fadeOut({ - delay : 1500, - duration: 1000, - callback : function() - { - msgbox.hide(); - } - }); - - // if a new report was created, we need to refresh the page with the correct report id on the URL - if (this.isNew() || data.isSaveAs) - { - var o = Ext4.decode(resp.responseText); - window.location = LABKEY.ActionURL.buildURL('reports', 'runReport', null, {reportId: o.reportId}); - } - }, - failure : this.onFailure, - scope : this - }); - }, - - onFailure : function(resp) - { - var error = Ext4.isString(resp.responseText) ? Ext4.decode(resp.responseText).exception : resp.exception; - Ext4.Msg.show({ - title: 'Error', - msg: error || 'An unknown error has occurred.', - buttons: Ext4.MessageBox.OK, - icon: Ext4.MessageBox.ERROR, - scope: this - }); - }, - - loadReportFromId : function(reportId) - { - this.reportLoaded = false; - - LABKEY.Query.Visualization.get({ - reportId: reportId, - scope: this, - success: function(result) - { - this.savedReportInfo = result; - this.loadSavedConfig(); - } - }); - }, - - loadSavedConfig : function() - { - var config = this.savedReportInfo, - queryConfig = {}, - chartConfig = {}; - - if (config.type == LABKEY.Query.Visualization.Type.GenericChart) - { - queryConfig = config.visualizationConfig.queryConfig; - chartConfig = config.visualizationConfig.chartConfig; - } - - this.schemaName = queryConfig.schemaName; - this.queryName = queryConfig.queryName; - this.viewName = queryConfig.viewName; - this.dataRegionName = queryConfig.dataRegionName; - - if (this.reportName) - this.reportName.setValue(config.name); - - if (this.reportDescription && config.description != null) - this.reportDescription.setValue(config.description); - - // TODO is this needed/used anymore? - if (this.reportPermission) - this.reportPermission.setValue({"public" : config.shared}); - - this.getSavePanel().setReportInfo({ - name: config.name, - description: config.description, - shared: config.shared, - inheritable: config.inheritable, - reportProps: config.reportProps, - thumbnailURL: config.thumbnailURL - }); - - this.loadQueryInfoFromConfig(queryConfig); - this.loadMeasuresFromConfig(chartConfig); - this.loadOptionsFromConfig(chartConfig); - - // if the renderType was not saved with the report info, get it based off of the x-axis measure type - this.renderType = chartConfig.renderType || this.getRenderType(chartConfig); - - this.markDirty(false); - this.reportLoaded = true; - this.updateChartTask.delay(500); - }, - - loadQueryInfoFromConfig : function(queryConfig) - { - if (Ext4.isObject(queryConfig)) - { - if (Ext4.isArray(queryConfig.filterArray)) - { - var filters = []; - for (var i=0; i < queryConfig.filterArray.length; i++) - { - var f = queryConfig.filterArray[i]; - var type = LABKEY.Filter.getFilterTypeForURLSuffix(f.type); - if (type !== undefined) { - var value = type.isMultiValued() ? f.value : (Ext4.isArray(f.value) ? f.value[0]: f.value); - filters.push(LABKEY.Filter.create(f.name, value, type)); - } - } - this.userFilters = filters; - } - - if (queryConfig.columns) - this.savedColumns = queryConfig.columns; - - if (queryConfig.queryLabel) - this.queryLabel = queryConfig.queryLabel; - - if (queryConfig.parameters) - this.parameters = queryConfig.parameters; - } - }, - - loadMeasuresFromConfig : function(chartConfig) - { - this.measures = {}; - - if (Ext4.isObject(chartConfig)) - { - if (Ext4.isObject(chartConfig.measures)) - { - Ext4.each(['x', 'y', 'xSub', 'color', 'shape', 'series'], function(name) { - if (chartConfig.measures[name]) { - this.measures[name] = chartConfig.measures[name]; - } - }, this); - } - } - }, - - loadOptionsFromConfig : function(chartConfig) - { - this.options = {}; - - if (Ext4.isObject(chartConfig)) - { - this.options.general = {}; - if (chartConfig.height) - this.options.general.height = chartConfig.height; - if (chartConfig.width) - this.options.general.width = chartConfig.width; - if (chartConfig.pointType) - this.options.general.pointType = chartConfig.pointType; - if (chartConfig.geomOptions) - Ext4.apply(this.options.general, chartConfig.geomOptions); - - if (chartConfig.labels && LABKEY.Utils.isString(chartConfig.labels.main)) - this.options.general.label = chartConfig.labels.main; - else - this.options.general.label = this.getDefaultTitle(); - - if (chartConfig.labels && chartConfig.labels.subtitle) - this.options.general.subtitle = chartConfig.labels.subtitle; - if (chartConfig.labels && chartConfig.labels.footer) - this.options.general.footer = chartConfig.labels.footer; - - this.loadAxisOptionsFromConfig(chartConfig, 'x'); - this.loadAxisOptionsFromConfig(chartConfig, 'y'); - this.loadAxisOptionsFromConfig(chartConfig, 'yRight'); - - this.options.developer = {}; - if (chartConfig.measures && chartConfig.measures.pointClickFn) - this.options.developer.pointClickFn = chartConfig.measures.pointClickFn; - - if (chartConfig.curveFit) { - this.curveFit = chartConfig.curveFit; - } else if (chartConfig.geomOptions.trendlineType) { - this.trendline = { - trendlineType: chartConfig.geomOptions.trendlineType, - trendlineAsymptoteMin: chartConfig.geomOptions.trendlineAsymptoteMin, - trendlineAsymptoteMax: chartConfig.geomOptions.trendlineAsymptoteMax, - } - } - } - }, - - loadAxisOptionsFromConfig : function(chartConfig, axisName) { - this.options[axisName] = {}; - if (chartConfig.labels && chartConfig.labels[axisName]) { - this.options[axisName].label = chartConfig.labels[axisName]; - } - if (chartConfig.scales && chartConfig.scales[axisName]) { - Ext4.apply(this.options[axisName], chartConfig.scales[axisName]); - } - }, - - loadInitialSelection : function() - { - if (Ext4.isObject(this.initialSelection)) - { - this.applyChartTypeSelection(this.getChartTypePanel(), this.initialSelection, true); - // clear the initial selection object so it isn't loaded again - this.initialSelection = undefined; - - this.markDirty(false); - this.reportLoaded = true; - this.updateChartTask.delay(500); - } - }, - - handleNoData : function(errorMsg) - { - // Issue 18339 - this.setRenderRequested(false); - var errorDiv = Ext4.create('Ext.container.Container', { - border: 1, - autoEl: {tag: 'div'}, - html: '

An unexpected error occurred while retrieving data.

' + errorMsg, - autoScroll: true - }); - - this.getChartTypeBtn().disable(); - this.getChartLayoutBtn().disable(); - this.getSaveBtn().disable(); - this.getSaveAsBtn().disable(); - - // Keep the toggle button enabled so the user can remove filters - this.getToggleViewBtn().enable(); - - this.clearChartPanel(true); - this.getViewPanel().add(errorDiv); - this.getEl().unmask(); - }, - - renderPlot : function() - { - // Don't attempt to render if the view panel isn't visible or the chart type window is visible. - if (!this.isVisible() || this.getViewPanel().isHidden() || this.getChartTypeWindow().isVisible()) - return; - - // initMeasures returns false and opens the Chart Type panel if a required measure is not chosen by the user. - if (!this.initMeasures()) - return; - - this.clearChartPanel(false); - - var chartConfig = this.getChartConfig(); - var renderType = this.getRenderType(chartConfig); - - this.renderGenericChart(renderType, chartConfig); - - // We just rendered the plot, we don't need to request another render. - this.setRenderRequested(false); - }, - - getRenderType : function(chartConfig) - { - return LABKEY.vis.GenericChartHelper.getChartType(chartConfig); - }, - - renderGenericChart : function(chartType, chartConfig) - { - var aes, scales, customRenderType, hasNoDataMsg, newChartDiv, valueConversionResponse; - - hasNoDataMsg = LABKEY.vis.GenericChartHelper.validateResponseHasData(this.getMeasureStore(), true); - if (hasNoDataMsg != null) - this.addWarningText(hasNoDataMsg); - - this.getEl().mask('Rendering Chart...'); - - aes = LABKEY.vis.GenericChartHelper.generateAes(chartType, chartConfig.measures, this.getSchemaName(), this.getQueryName()); - - valueConversionResponse = LABKEY.vis.GenericChartHelper.doValueConversion(chartConfig, aes, this.renderType, this.getMeasureStoreRecords()); - if (!Ext4.Object.isEmpty(valueConversionResponse.processed)) - { - Ext4.Object.merge(chartConfig.measures, valueConversionResponse.processed); - //re-generate aes based on new converted values - aes = LABKEY.vis.GenericChartHelper.generateAes(chartType, chartConfig.measures, this.getSchemaName(), this.getQueryName()); - if (valueConversionResponse.warningMessage) { - this.addWarningText(valueConversionResponse.warningMessage); - } - } - - customRenderType = this.customRenderTypes ? this.customRenderTypes[this.renderType] : undefined; - if (customRenderType && customRenderType.generateAes) - aes = customRenderType.generateAes(this, chartConfig, aes); - - scales = LABKEY.vis.GenericChartHelper.generateScales(chartType, chartConfig.measures, chartConfig.scales, aes, this.getMeasureStore(), this.defaultNumberFormat); - if (customRenderType && customRenderType.generateScales) - scales = customRenderType.generateScales(this, chartConfig, scales); - - if (!this.isChartConfigValid(chartType, chartConfig, aes, scales)) - return; - - if (chartType == 'scatter_plot' && this.getMeasureStoreRecords().length > chartConfig.geomOptions.binThreshold) { - chartConfig.geomOptions.binned = true; - this.addWarningText("The number of individual points exceeds " - + Ext4.util.Format.number(chartConfig.geomOptions.binThreshold, '0,000') - + ". The data is now grouped by density, which overrides some layout options."); - } - else if (chartType == 'line_plot' && this.getMeasureStoreRecords().length > this.dataPointLimit) { - this.addWarningText("The number of individual points exceeds " - + Ext4.util.Format.number(this.dataPointLimit, '0,000') - + ". Data points will not be shown on this line plot."); - } - - this.beforeRenderPlotComplete(); - - chartConfig.width = this.getPerChartWidth(chartType, chartConfig); - chartConfig.height = this.getPerChartHeight(chartConfig); - - newChartDiv = this.getNewChartDisplayDiv(); - this.getViewPanel().add(newChartDiv); - - var plotConfigArr = this.getPlotConfigs(newChartDiv, chartType, chartConfig, aes, scales, customRenderType, this.trendlineData); - - Ext4.each(plotConfigArr, function(plotConfig) { - if (this.renderType === 'pie_chart') { - new LABKEY.vis.PieChart(plotConfig); - } - else { - var plot = new LABKEY.vis.Plot(plotConfig); - plot.render(); - } - }, this); - - this.afterRenderPlotComplete(newChartDiv, chartType, chartConfig); - }, - - getPerChartWidth : function(chartType, chartConfig) { - if (Ext4.isDefined(chartConfig.width) && chartConfig.width != null) { - return chartConfig.width; - } - else { - // default width based on the view panel width - return LABKEY.vis.GenericChartHelper.getChartTypeBasedWidth(chartType, chartConfig.measures, this.getMeasureStore(), this.getViewPanel().getWidth()) - } - }, - - getPerChartHeight : function(chartConfig) { - if (Ext4.isDefined(chartConfig.height) && chartConfig.height != null) { - return chartConfig.height; - } - else { - // default height based on the view panel height - var height = this.getViewPanel().getHeight() - 25; - if (chartConfig.geomOptions.chartLayout === 'per_measure') { - height = height / 1.25; - } - return height; - } - }, - - getNewChartDisplayDiv : function() - { - return Ext4.create('Ext.container.Container', { - cls: 'chart-render-div', - autoEl: {tag: 'div'} - }); - }, - - beforeRenderPlotComplete : function() - { - // add the warning msg before the plot so the plot has the proper height - if (this.warningText !== null) - this.addWarningMsg(this.warningText, true); - }, - - afterRenderPlotComplete : function(chartDiv, chartType, chartConfig) - { - this.getTopButtonBar().enable(); - this.getChartTypeBtn().enable(); - this.getChartLayoutBtn().enable(); - this.getSaveBtn().enable(); - this.getSaveAsBtn().enable(); - this.attachExportIcons(chartDiv, chartType, chartConfig); - this.getEl().unmask(); - - if (this.editMode && this.supportedBrowser) - this.updateSaveChartThumbnail(chartDiv, chartConfig); - }, - - addWarningMsg : function(warningText, allowDismiss) - { - var warningDivId = Ext4.id(); - var dismissLink = allowDismiss ? 'dismiss' : ''; - - var warningCmp = Ext4.create('Ext.container.Container', { - padding: 10, - cls: 'chart-warning', - html: warningText + ' ' + dismissLink, - listeners: { - scope: this, - render: function(cmp) { - Ext4.get('dismiss-link-' + warningDivId).on('click', function() { - // removing the warning message which will adjust the view panel height, so suspend events temporarily - this.getViewPanel().suspendEvents(); - this.getMsgPanel().remove(cmp); - this.getViewPanel().resumeEvents(); - }, this); - } - } - }); - - // add the warning message which will adjust the view panel height, so suspend events temporarily - this.getViewPanel().suspendEvents(); - this.getMsgPanel().add(warningCmp); - this.getViewPanel().resumeEvents(); - }, - - updateSaveChartThumbnail : function(chartDiv, chartConfig) - { - if (chartDiv.getEl()) { - var size = chartDiv.getEl().getSize(); - size.height = this.getPerChartHeight(chartConfig); - this.chartSVG = LABKEY.vis.SVGConverter.svgToStr(chartDiv.getEl().child('svg').dom); - this.getSavePanel().updateCurrentChartThumbnail(this.chartSVG, size); - } - }, - - isChartConfigValid : function(chartType, chartConfig, aes, scales) - { - var selectedMeasureNames = Object.keys(this.measures), - hasXMeasure = selectedMeasureNames.indexOf('x') > -1 && Ext4.isDefined(aes.x), - hasXSubMeasure = selectedMeasureNames.indexOf('xSub') > -1 && Ext4.isDefined(aes.xSub), - hasYMeasure = selectedMeasureNames.indexOf('y') > -1, - requiredMeasureNames = this.getChartTypePanel().getRequiredFieldNames(); - - // validate that all selected measures still exist by name in the query/dataset - if (!this.validateMeasuresExist(selectedMeasureNames, requiredMeasureNames)) - return false; - - // validate that the x axis measure exists and data is valid - if (hasXMeasure && !this.validateAxisMeasure(chartType, chartConfig, 'x', aes, scales, this.getMeasureStoreRecords())) - return false; - - // validate that the x subcategory axis measure exists and data is valid - if (hasXSubMeasure && !this.validateAxisMeasure(chartType, chartConfig, 'xSub', aes, scales, this.getMeasureStoreRecords())) - return false; - - // validate that the y axis measure exists and data is valid, handle case for single or multiple y-measures selected - if (hasYMeasure) { - var yMeasures = LABKEY.vis.GenericChartHelper.ensureMeasuresAsArray(this.measures['y']); - for (var i = 0; i < yMeasures.length; i++) { - var yMeasure = yMeasures[i]; - var yAes = {y: LABKEY.vis.GenericChartHelper.getYMeasureAes(yMeasure)}; - if (!this.validateAxisMeasure(chartType, yMeasure, 'y', yAes, scales, this.getMeasureStoreRecords(), yMeasure.converted)) { - return false; - } - } - } - - return true; - }, - - getPlotConfigs : function(newChartDiv, chartType, chartConfig, aes, scales, customRenderType, trendlineData) - { - var plotConfigArr = [], geom, labels, data = this.getMeasureStoreRecords(), me = this; - - geom = LABKEY.vis.GenericChartHelper.generateGeom(chartType, chartConfig.geomOptions); - if (chartType === 'line_plot' && data.length > this.dataPointLimit){ - chartConfig.geomOptions.hideDataPoints = true; - } - - 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); - - // convert any undefined values to zero for display purposes in Bar and Pie chart case - Ext4.each(data, function(d) { - if (d.hasOwnProperty('value') && (!Ext4.isDefined(d.value) || isNaN(d.value))) { - d.value = 0; - } - }); - } - - if (customRenderType && Ext4.isFunction(customRenderType.generatePlotConfig)) { - var plotConfig = customRenderType.generatePlotConfig( - this, chartConfig, newChartDiv.id, - chartConfig.width, chartConfig.height, - data, aes, scales, labels - ); - - plotConfig.rendererType = 'd3'; - plotConfigArr.push(plotConfig); - } - else { - plotConfigArr = LABKEY.vis.GenericChartHelper.generatePlotConfigs(newChartDiv.id, chartConfig, labels, aes, scales, geom, data, trendlineData); - - if (this.renderType === 'pie_chart') - { - if (this.checkForNegativeData(data)) - { - // adding warning text without shrinking height cuts off the footer text - Ext4.each(plotConfigArr, function(plotConfig) { - plotConfig.height = Math.floor(plotConfig.height * 0.95); - }, this); - } - - // because of the load delay, need to reset the thumbnail svg for pie charts - Ext4.each(plotConfigArr, function(plotConfig) { - plotConfig.callbacks = { - onload: function(){ - me.updateSaveChartThumbnail(newChartDiv); - } - }; - }, this); - } - // if client has specified a line type (only applicable for scatter plot), apply that as another layer - else if (this.curveFit && this.measures.x && this.isScatterPlot(this.renderType, this.getXAxisType(this.measures.x))) - { - var factory = this.lineRenderers[this.curveFit.type]; - if (factory) - { - Ext4.each(plotConfigArr, function(plotConfig) { - plotConfig.layers.push( - new LABKEY.vis.Layer({ - geom: new LABKEY.vis.Geom.Path({}), - aes: {x: 'x', y: 'y'}, - data: LABKEY.vis.Stat.fn(factory.createRenderer(this.curveFit.params), this.curveFit.points, this.curveFit.min, this.curveFit.max) - }) - ); - }, this); - } - } - } - - return plotConfigArr; - }, - - checkForNegativeData : function(data) { - var negativesFound = []; - Ext4.each(data, function(entry) { - if (entry.value < 0) { - negativesFound.push(entry.label) - } - }); - - if (negativesFound.length > 0) - { - this.addWarningText('There are negative values in the data that the Pie Chart cannot display. ' - + 'Omitted: ' + negativesFound.join(', ')); - } - - return negativesFound.length > 0; - }, - - initMeasures : function() - { - // Initialize the x and y measures on first chart load. Returns false if we're missing the x or y measure. - var measure, fk, - queryColumnStore = this.getChartTypePanel().getQueryColumnsStore(), - requiredFieldNames = this.getChartTypePanel().getRequiredFieldNames(), - requiresX = requiredFieldNames.indexOf('x') > -1, - requiresY = requiredFieldNames.indexOf('y') > -1; - - if (!this.measures.y) - { - if (this.autoColumnYName || (requiresY && this.autoColumnName)) - { - fk = this.autoColumnYName || this.autoColumnName; - measure = this.getMeasureFromFieldKey(fk); - if (measure) - this.setYAxisMeasure(measure); - } - - if (requiresY && !this.measures.y) - { - this.getEl().unmask(); - this.showChartTypeWindow(); - return false; - } - } - - if (!this.measures.x) - { - if (this.renderType !== "box_plot" && this.renderType !== "auto_plot") - { - if (this.autoColumnXName || (requiresX && this.autoColumnName)) - { - fk = this.autoColumnXName || this.autoColumnName; - measure = this.getMeasureFromFieldKey(fk); - if (measure) - this.setXAxisMeasure(measure); - } - - if (requiresX && !this.measures.x) - { - this.getEl().unmask(); - this.showChartTypeWindow(); - return false; - } - } - else if (this.autoColumnYName != null) - { - measure = queryColumnStore.findRecord('label', 'Study: Cohort', 0, false, true, true); - if (measure) - this.setXAxisMeasure(measure); - - this.autoColumnYName = null; - } - } - - return true; - }, - - getMeasureFromFieldKey : function(fk) - { - var queryColumnStore = this.getChartTypePanel().getQueryColumnsStore(); - - // first search by fk.toString(), for example Analyte.Name -> Analyte$PName - var measure = queryColumnStore.findRecord('fieldKey', fk.toString(), 0, false, true, true); - if (measure != null) { - return measure; - } - - // second look by fk.getName() - return queryColumnStore.findRecord('fieldKey', fk.getName(), 0, false, true, true); - }, - - setYAxisMeasure : function(measure) - { - if (measure) - { - this.measures.y = measure.data ? measure.data : measure; - this.getChartTypePanel().setFieldSelection('y', this.measures.y); - this.getChartLayoutPanel().onMeasuresChange(this.measures, this.renderType); - } - }, - - setXAxisMeasure : function(measure) - { - if (measure) - { - this.measures.x = measure.data ? measure.data : measure; - this.getChartTypePanel().setFieldSelection('x', this.measures.x); - this.getChartLayoutPanel().onMeasuresChange(this.measures, this.renderType); - } - }, - - validateMeasuresExist: function(measureNames, requiredMeasureNames) - { - var store = this.getChartTypePanel().getQueryColumnsStore(), - valid = true, - message = null, - sep = ''; - - // Checks to make sure the measures are still available, if not we show an error. - Ext4.each(measureNames, function(propName) { - if (this.measures[propName] && propName !== 'trendline') { - var propMeasures = this.measures[propName]; - - // some properties allowMultiple so treat all as arrays - propMeasures = LABKEY.vis.GenericChartHelper.ensureMeasuresAsArray(propMeasures); - - Ext4.each(propMeasures, function(propMeasure) { - var indexByFieldKey = store.find('fieldKey', propMeasure.fieldKey, 0, false, false, true), - indexByName = store.find('fieldKey', propMeasure.name, 0, false, false, true); - - if (indexByFieldKey === -1 && indexByName === -1) { - if (message == null) - message = ''; - - message += sep + 'The saved ' + propName + ' measure, ' + propMeasure.name + ', is not available. It may have been renamed or removed.'; - sep = ' '; - - this.removeMeasureFromSelection(propName, propMeasure); - this.getChartTypePanel().setToForceApplyChanges(); - - if (requiredMeasureNames.indexOf(propName) > -1) - valid = false; - } - }, this); - } - }, this); - - this.handleValidation({success: valid, message: Ext4.util.Format.htmlEncode(message)}); - - return valid; - }, - - removeMeasureFromSelection : function(propName, measure) { - if (this.measures[propName]) { - if (!Ext4.isArray(this.measures[propName])) { - delete this.measures[propName]; - } - else { - Ext4.Array.remove(this.measures[propName], measure); - } - } - }, - - validateAxisMeasure : function(chartType, chartConfig, measureName, aes, scales, data, dataConversionHappened) - { - var validation = LABKEY.vis.GenericChartHelper.validateAxisMeasure(chartType, chartConfig, measureName, aes, scales, data, dataConversionHappened); - if (!validation.success) { - this.removeMeasureFromSelection(measureName, chartConfig); - } - - this.handleValidation(validation); - return validation.success; - }, - - handleValidation : function(validation) - { - if (validation.success === true) - { - if (validation.message != null) - this.addWarningText(validation.message); - } - else - { - this.getEl().unmask(); - this.setRenderRequested(false); - - if (this.editMode) - { - this.getChartTypePanel().setToForceApplyChanges(); - - Ext4.Msg.show({ - title: 'Error', - msg: Ext4.util.Format.htmlEncode(validation.message), - buttons: Ext4.MessageBox.OK, - icon: Ext4.MessageBox.ERROR, - fn: this.showChartTypeWindow, - scope: this - }); - } - else - { - this.clearChartPanel(true); - var errorDiv = Ext4.create('Ext.container.Container', { - border: 1, - autoEl: {tag: 'div'}, - padding: 10, - html: '

Error rendering chart:

' + validation.message, - autoScroll: true - }); - this.getViewPanel().add(errorDiv); - } - } - }, - - isScatterPlot : function(renderType, xAxisType) - { - if (renderType === 'scatter_plot') - return true; - - return (renderType === 'auto_plot' && LABKEY.vis.GenericChartHelper.isNumericType(xAxisType)); - }, - - isBoxPlot: function(renderType, xAxisType) - { - if (renderType === 'box_plot') - return true; - - return (renderType == 'auto_plot' && !LABKEY.vis.GenericChartHelper.isNumericType(xAxisType)); - }, - - getSelectedChartType : function() - { - if (Ext4.isString(this.renderType) && this.renderType !== 'auto_plot') - return this.renderType; - else if (this.measures.x && this.isBoxPlot(this.renderType, this.getXAxisType(this.measures.x))) - return 'box_plot'; - else if (this.measures.x && this.isScatterPlot(this.renderType, this.getXAxisType(this.measures.x))) - return 'scatter_plot'; - - return 'bar_plot'; - }, - - getXAxisType : function(xMeasure) - { - return xMeasure ? (xMeasure.normalizedType || xMeasure.type) : null; - }, - - clearChartPanel : function(clearMessages) - { - this.clearWarningText(); - this.getViewPanel().removeAll(); - if (clearMessages) { - this.clearMessagePanel(); - } - }, - - clearMessagePanel : function() { - this.getViewPanel().suspendEvents(); - this.getMsgPanel().removeAll(); - this.getViewPanel().resumeEvents(); - }, - - clearWarningText : function() - { - this.warningText = null; - }, - - addWarningText : function(warning) - { - if (!this.warningText) - this.warningText = Ext4.util.Format.htmlEncode(warning); - else - this.warningText = this.warningText + '  ' + Ext4.util.Format.htmlEncode(warning); - }, - - attachExportIcons : function(chartDiv, chartType, chartConfig) - { - if (this.supportedBrowser) - { - var index = 0; - Ext4.each(chartDiv.getEl().select('svg').elements, function(svgEl) { - chartDiv.add(this.createExportIcon(chartType, chartConfig, 'fa-file-pdf-o', 'Export to PDF', index, 0, function(){ - this.exportChartToImage(svgEl, LABKEY.vis.SVGConverter.FORMAT_PDF); - })); - - chartDiv.add(this.createExportIcon(chartType, chartConfig, 'fa-file-image-o', 'Export to PNG', index, 1, function(){ - this.exportChartToImage(svgEl, LABKEY.vis.SVGConverter.FORMAT_PNG); - })); - - index++; - }, this); - } - if (this.isDeveloper) - { - chartDiv.add(this.createExportIcon(chartType, chartConfig, 'fa-file-code-o', 'Export as Script', 0, this.supportedBrowser ? 2 : 0, function(){ - this.exportChartToScript(); - })); - } - }, - - createExportIcon : function(chartType, chartConfig, iconCls, tooltip, chartIndex, iconIndexFromLeft, callbackFn) - { - var chartWidth = this.getPerChartWidth(chartType, chartConfig), - viewPortWidth = this.getViewPanel().getWidth(), - chartsPerRow = chartWidth > viewPortWidth ? 1 : Math.floor(viewPortWidth / chartWidth), - topPx = Math.floor(chartIndex / chartsPerRow) * this.getPerChartHeight(chartConfig), - leftPx = ((chartIndex % chartsPerRow) * chartWidth) + (iconIndexFromLeft * 30) + 20; - - return Ext4.create('Ext.Component', { - cls: 'export-icon', - style: 'top: ' + topPx + 'px; left: ' + leftPx + 'px;', - html: '', - listeners: { - scope: this, - render: function(cmp) - { - Ext4.create('Ext.tip.ToolTip', { - target: cmp.getEl(), - constrainTo: this.getEl(), - width: 110, - html: tooltip - }); - - cmp.getEl().on('click', callbackFn, this); - } - } - }); - }, - - exportChartToImage : function(svgEl, type) - { - if (svgEl) { - var fileName = this.getChartConfig().labels.main, - exportType = type || LABKEY.vis.SVGConverter.FORMAT_PDF; - - LABKEY.vis.SVGConverter.convert(svgEl, exportType, fileName); - } - }, - - exportChartToScript : function() - { - var chartConfig = LABKEY.vis.GenericChartHelper.removeNumericConversionConfig(this.getChartConfig()); - var queryConfig = this.getQueryConfig(true); - - // Only push the required columns. - queryConfig.columns = []; - - Ext4.each(['x', 'y', 'color', 'shape', 'series'], function(name) { - if (Ext4.isDefined(chartConfig.measures[name])) { - var measuresArr = LABKEY.vis.GenericChartHelper.ensureMeasuresAsArray(chartConfig.measures[name]); - Ext4.each(measuresArr, function(measure) { - queryConfig.columns.push(measure.name); - }, this); - } - }, this); - - var templateConfig = { - chartConfig: chartConfig, - queryConfig: queryConfig - }; - - this.getExportScriptPanel().setScriptValue(templateConfig); - this.getExportScriptWindow().show(); - }, - - createFilterString : function(filters) - { - var filterParams = []; - for (var i = 0; i < filters.length; i++) - { - filterParams.push(this.filterToString(filters[i])); - } - - filterParams.sort(); - return filterParams.join('&'); - }, - - hasChartData : function() - { - return Ext4.isDefined(this.getMeasureStore()) && Ext4.isArray(this.getMeasureStoreRecords()); - }, - - onSelectRowsSuccess : function(measureStore) { - this.measureStore = measureStore; - - // when not in edit mode, we'll use the column metadata from the data query - if (!this.editMode) - this.getChartTypePanel().loadQueryColumns(this.getMeasureStoreMetadata().fields); - - this.queryTrendlineData(); - }, - - queryTrendlineData : async function() { - const chartConfig = this.getChartConfig(); - if (chartConfig.geomOptions.trendlineType && chartConfig.geomOptions.trendlineType !== '') { - this.setDataLoading(true); - - const data = this.getMeasureStoreRecords(); - this.trendlineData = await LABKEY.vis.GenericChartHelper.queryTrendlineData(chartConfig, data); - this.onQueryDataComplete(); - } else { - // trendlineType of '' means use Point-to-Point, i.e. no trendlineData - this.trendlineData = undefined; - this.onQueryDataComplete(); - } - }, - - onQueryDataComplete : function() { - this.setDataLoading(false); - - this.getMsgPanel().removeAll(); - - // If it's already been requested then we just need to request it again, since this time we have the data to render. - if (this.isRenderRequested()) - this.requestRender(); - }, - - getMeasureStore : function() - { - return this.measureStore; - }, - - getMeasureStoreRecords : function() - { - if (!this.getMeasureStore()) - console.error('No measureStore object defined.'); - - return this.getMeasureStore().records(); - }, - - getMeasureStoreMetadata : function() - { - if (!this.getMeasureStore()) - console.error('No measureStore object defined.'); - - return this.getMeasureStore().getResponseMetadata(); - }, - - getSchemaName : function() - { - if (this.getMeasureStoreMetadata() && this.getMeasureStoreMetadata().schemaName) - { - if (Ext4.isArray(this.getMeasureStoreMetadata().schemaName)) - return this.getMeasureStoreMetadata().schemaName[0]; - - return this.getMeasureStoreMetadata().schemaName; - } - - return null; - }, - - getQueryName : function() - { - if (this.getMeasureStoreMetadata()) - return this.getMeasureStoreMetadata().queryName; - - return null; - }, - - getDefaultTitle : function() - { - if (this.defaultTitleFn) - return this.defaultTitleFn(this.queryName, this.queryLabel, LABKEY.vis.GenericChartHelper.getDefaultMeasuresLabel(this.measures.y), this.measures.x ? this.measures.x.label : null); - - return this.queryLabel || this.queryName; - }, - - /** - * used to determine if the new chart options are different from the currently rendered options - */ - hasConfigurationChanged : function() - { - var queryCfg = this.getQueryConfig(); - - if (!queryCfg.schemaName || !queryCfg.queryName) - return false; - - // ugly race condition, haven't loaded a saved report yet - if (!this.reportLoaded) - return false; - - if (!this.hasChartData()) - return true; - - var filterStr = this.createFilterString(queryCfg.filterArray); - - if (this.currentFilterStr != filterStr) { - this.currentFilterStr = filterStr; - return true; - } - - var parameterStr = Ext4.JSON.encode(queryCfg.parameters); - if (this.currentParameterStr != parameterStr) { - this.currentParameterStr = parameterStr; - return true; - } - - return false; - }, - - setRenderRequested : function(requested) - { - this.renderRequested = requested; - }, - - isRenderRequested : function() - { - return this.renderRequested; - }, - - setDataLoading : function(loading) - { - this.dataLoading = loading; - }, - - isDataLoading : function() - { - return this.dataLoading; - }, - - requestData : function() - { - this.setDataLoading(true); - - var config = this.getQueryConfig(); - LABKEY.Query.MeasureStore.selectRows(config); - - this.requestRender(); - }, - - requestRender : function() - { - if (this.isDataLoading()) - this.setRenderRequested(true); - else - this.renderPlot(); - }, - - renderChart : function() - { - this.getEl().mask('Rendering Chart...'); - this.chartDefinitionChanged.delay(500); - }, - - resizeToViewport : function() { - console.warn('DEPRECATED: As of Release 17.3 ' + this.$className + '.resizeToViewport() is no longer supported.'); - }, - - onSaveBtnClicked : function(isSaveAs) - { - this.getSavePanel().setNoneThumbnail(this.getChartTypePanel().getImgUrl()); - this.getSavePanel().setSaveAs(isSaveAs); - this.getSavePanel().setMainTitle(isSaveAs ? "Save as" : "Save"); - this.getSaveWindow().show(); - } -}); +/* + * Copyright (c) 2016-2019 LabKey Corporation + * + * Licensed under the Apache License, Version 2.0: http://www.apache.org/licenses/LICENSE-2.0 + */ +Ext4.define('LABKEY.ext4.GenericChartPanel', { + extend : 'Ext.panel.Panel', + + cls : 'generic-chart-panel', + layout : 'fit', + editable : false, + minWidth : 900, + + initialSelection : null, + savedReportInfo : null, + hideViewData : false, + reportLoaded : true, + hideSave: false, + dataPointLimit: 10000, + + constructor : function(config) + { + Ext4.QuickTips.init(); + this.callParent([config]); + }, + + queryGenericChartColumns : function() + { + LABKEY.vis.GenericChartHelper.getQueryColumns(this, function(columnMetadata) + { + this.getChartTypePanel().loadQueryColumns(columnMetadata); + this.requestData(); + }, this); + }, + + initComponent : function() + { + this.measures = {}; + this.options = {}; + this.userFilters = []; + + // boolean to check if we should allow things like export to PDF + this.supportedBrowser = !(Ext4.isIE6 || Ext4.isIE7 || Ext4.isIE8); + + var params = LABKEY.ActionURL.getParameters(); + this.editMode = params.edit == "true" || !this.savedReportInfo; + this.parameters = LABKEY.Filter.getQueryParamsFromUrl(params['filterUrl'], this.dataRegionName); + + // Issue 19163 + Ext4.each(['autoColumnXName', 'autoColumnYName', 'autoColumnName'], function(autoColPropName) + { + if (this[autoColPropName]) + this[autoColPropName] = LABKEY.FieldKey.fromString(this[autoColPropName]); + }, this); + + // for backwards compatibility, map auto_plot to box_plot + if (this.renderType === 'auto_plot') + this.setRenderType('box_plot'); + + this.chartDefinitionChanged = new Ext4.util.DelayedTask(function(){ + this.markDirty(true); + this.requestRender(); + }, this); + + // delayed task to redraw the chart + this.updateChartTask = new Ext4.util.DelayedTask(function() + { + if (this.hasConfigurationChanged()) + { + this.getEl().mask('Loading Data...'); + + if (this.editMode && this.getChartTypePanel().getQueryColumnNames().length == 0) + this.queryGenericChartColumns(); + else + this.requestData(); + } + + }, this); + + // only linear for now but could expand in the future + this.lineRenderers = { + linear : { + createRenderer : function(params){ + if (params && params.length >= 2) { + return function(x){return x * params[0] + params[1];} + } + return function(x) {return x;} + } + } + }; + + this.items = [this.getCenterPanel()]; + + this.callParent(); + + if (this.savedReportInfo) + this.loadSavedConfig(); + else + this.loadInitialSelection(); + + window.onbeforeunload = LABKEY.beforeunload(this.beforeUnload, this); + }, + + getViewPanel : function() + { + if (!this.viewPanel) + { + this.viewPanel = Ext4.create('Ext.panel.Panel', { + autoScroll : true, + ui : 'custom', + listeners : { + scope: this, + activate: function() + { + this.updateChartTask.delay(500); + }, + resize: function(p) + { + // only re-render after the initial chart rendering + if (this.hasChartData()) { + this.clearMessagePanel(); + this.requestRender(); + } + } + } + }); + } + + return this.viewPanel; + }, + + getDataPanel : function() + { + if (!this.dataPanel) + { + this.dataPanel = Ext4.create('Ext.panel.Panel', { + flex : 1, + layout : 'fit', + border : false, + items : [ + Ext4.create('Ext.Component', { + autoScroll : true, + listeners : { + scope : this, + render : function(cmp){ + this.renderDataGrid(cmp.getId()); + } + } + }) + ] + }); + } + + return this.dataPanel; + }, + + getCenterPanel : function() + { + if (!this.centerPanel) + { + this.centerPanel = Ext4.create('Ext.panel.Panel', { + border: false, + layout: { + type: 'card', + deferredRender: true + }, + activeItem: 0, + items: [this.getViewPanel(), this.getDataPanel()], + dockedItems: [this.getTopButtonBar(), this.getMsgPanel()] + }); + } + + return this.centerPanel; + }, + + getChartTypeBtn : function() + { + if (!this.chartTypeBtn) + { + this.chartTypeBtn = Ext4.create('Ext.button.Button', { + text: 'Chart Type', + handler: this.showChartTypeWindow, + scope: this + }); + } + + return this.chartTypeBtn; + }, + + getChartLayoutBtn : function() + { + if (!this.chartLayoutBtn) + { + this.chartLayoutBtn = Ext4.create('Ext.button.Button', { + text: 'Chart Layout', + disabled: true, + handler: this.showChartLayoutWindow, + scope: this + }); + } + + return this.chartLayoutBtn; + }, + + getHelpBtn : function() + { + if (!this.helpBtn) + { + this.helpBtn = Ext4.create('Ext.button.Button', { + text: 'Help', + scope: this, + menu: { + showSeparator: false, + items: [{ + text: 'Reports and Visualizations', + iconCls: 'fa fa-table', + hrefTarget: '_blank', + href: LABKEY.Utils.getHelpTopicHref('reportsAndViews') + },{ + text: 'Bar Plots', + iconCls: 'fa fa-bar-chart', + hrefTarget: '_blank', + href: LABKEY.Utils.getHelpTopicHref('barchart') + },{ + text: 'Box Plots', + iconCls: 'fa fa-sliders fa-rotate-90', + hrefTarget: '_blank', + href: LABKEY.Utils.getHelpTopicHref('boxplot') + },{ + text: 'Line Plots', + iconCls: 'fa fa-line-chart', + hrefTarget: '_blank', + href: LABKEY.Utils.getHelpTopicHref('lineplot') + },{ + text: 'Pie Charts', + iconCls: 'fa fa-pie-chart', + hrefTarget: '_blank', + href: LABKEY.Utils.getHelpTopicHref('piechart') + },{ + text: 'Scatter Plots', + iconCls: 'fa fa-area-chart', + hrefTarget: '_blank', + href: LABKEY.Utils.getHelpTopicHref('scatterplot') + }] + } + }); + } + + return this.helpBtn; + }, + + getSaveBtn : function() + { + if (!this.saveBtn) + { + this.saveBtn = Ext4.create('Ext.button.Button', { + text: "Save", + hidden: LABKEY.user.isGuest || this.hideSave, + disabled: true, + handler: function(){ + this.onSaveBtnClicked(false) + }, + scope: this + }); + } + + return this.saveBtn; + }, + + getSaveAsBtn : function() + { + if (!this.saveAsBtn) + { + this.saveAsBtn = Ext4.create('Ext.button.Button', { + text: "Save As", + hidden : this.isNew() || LABKEY.user.isGuest || this.hideSave, + disabled: true, + handler: function(){ + this.onSaveBtnClicked(true); + }, + scope: this + }); + } + + return this.saveAsBtn; + }, + + getToggleViewBtn : function() + { + if (!this.toggleViewBtn) + { + this.toggleViewBtn = Ext4.create('Ext.button.Button', { + text:'View Data', + hidden: this.hideViewData, + scope: this, + handler: function() + { + if (this.getViewPanel().isHidden()) + { + this.getCenterPanel().getLayout().setActiveItem(0); + this.toggleViewBtn.setText('View Data'); + + this.getChartTypeBtn().show(); + this.getChartLayoutBtn().show(); + + if (Ext4.isArray(this.customButtons)) + { + for (var i = 0; i < this.customButtons.length; i++) + this.customButtons[i].show(); + } + } + else + { + this.getCenterPanel().getLayout().setActiveItem(1); + this.toggleViewBtn.setText('View Chart'); + + this.getMsgPanel().removeAll(); + this.getChartTypeBtn().hide(); + this.getChartLayoutBtn().hide(); + + if (Ext4.isArray(this.customButtons)) + { + for (var i = 0; i < this.customButtons.length; i++) + this.customButtons[i].hide(); + } + } + } + }); + } + + return this.toggleViewBtn; + }, + + getEditBtn : function() + { + if (!this.editBtn) + { + this.editBtn = Ext4.create('Ext.button.Button', { + xtype: 'button', + text: 'Edit', + scope: this, + handler: function() { + window.location = this.editModeURL; + } + }); + } + + return this.editBtn; + }, + + getTopButtonBar : function() + { + if (!this.topButtonBar) + { + this.topButtonBar = Ext4.create('Ext.toolbar.Toolbar', { + dock: 'top', + items: this.initTbarItems() + }); + } + + return this.topButtonBar; + }, + + initTbarItems : function() + { + var tbarItems = []; + tbarItems.push(this.getToggleViewBtn()); + tbarItems.push(this.getHelpBtn()); + tbarItems.push('->'); // rest of buttons will be right aligned + + if (this.editMode) + { + tbarItems.push(this.getChartTypeBtn()); + tbarItems.push(this.getChartLayoutBtn()); + + if (Ext4.isArray(this.customButtons)) + { + tbarItems.push(''); // horizontal spacer + for (var i = 0; i < this.customButtons.length; i++) + { + var btn = this.customButtons[i]; + btn.scope = this; + tbarItems.push(btn); + } + } + + if (!LABKEY.user.isGuest && !this.hideSave) + tbarItems.push(''); // horizontal spacer + if (this.canEdit) + tbarItems.push(this.getSaveBtn()); + tbarItems.push(this.getSaveAsBtn()); + } + else if (this.allowEditMode && this.editModeURL != null) + { + // add an "edit" button if the user is allowed to toggle to edit mode for this report + tbarItems.push(this.getEditBtn()); + } + + return tbarItems; + }, + + getMsgPanel : function() { + if (!this.msgPanel) { + this.msgPanel = Ext4.create('Ext.panel.Panel', { + hidden: true, + bodyStyle: 'border-width: 1px 0 0 0', + listeners: { + add: function(panel) { + panel.show(); + }, + remove: function(panel) { + if (panel.items.items.length == 0) { + panel.hide(); + } + } + } + }); + } + + return this.msgPanel; + }, + + showChartTypeWindow : function() + { + // make sure the chartTypePanel is shown in the window + if (this.getChartTypeWindow().items.items.length == 0) + this.getChartTypeWindow().add(this.getChartTypePanel()); + + this.getChartTypeWindow().show(); + }, + + showChartLayoutWindow : function() + { + this.getChartLayoutWindow().show(); + }, + + isNew : function() + { + return !this.savedReportInfo; + }, + + getChartTypeWindow : function() + { + if (!this.chartTypeWindow) + { + var panel = this.getChartTypePanel(); + + this.chartTypeWindow = Ext4.create('LABKEY.vis.ChartWizardWindow', { + panelToMask: this, + onEsc: function() { + panel.cancelHandler.call(panel); + }, + items: [panel] + }); + + // propagate the show event to the panel so it can stash the initial values + this.chartTypeWindow.on('show', function(window) + { + this.getChartTypePanel().fireEvent('show', this.getChartTypePanel()); + }, this); + } + + return this.chartTypeWindow; + }, + + getChartTypePanel : function() + { + if (!this.chartTypePanel) + { + this.chartTypePanel = Ext4.create('LABKEY.vis.ChartTypePanel', { + chartTypesToHide: ['time_chart'], + selectedType: this.getSelectedChartType(), + selectedFields: Ext4.apply(this.measures, { trendline: this.trendline }), + restrictColumnsEnabled: this.restrictColumnsEnabled, + customRenderTypes: this.customRenderTypes, + baseQueryKey: this.schemaName + '.' + this.queryName, + studyQueryName: this.schemaName == 'study' ? this.queryName : null + }); + } + + if (!this.hasAttachedChartTypeListeners) + { + this.chartTypePanel.on('cancel', this.closeChartTypeWindow, this); + this.chartTypePanel.on('apply', this.applyChartTypeSelection, this); + this.hasAttachedChartTypeListeners = true; + } + + return this.chartTypePanel; + }, + + closeChartTypeWindow : function(panel) + { + if (this.getChartTypeWindow().isVisible()) + this.getChartTypeWindow().hide(); + }, + + applyChartTypeSelection : function(panel, values, skipRender) + { + // close the window and clear any previous charts + this.closeChartTypeWindow(); + this.clearChartPanel(true); + + // only apply the values for the applicable chart type + if (Ext4.isObject(values) && values.type == 'time_chart') + return; + + this.setRenderType(values.type); + this.measures = values.fields; + if (values.fields.xSub) { + this.measures.color = this.measures.xSub; + } + + if (values.altValues.trendline) { + this.trendline = values.altValues.trendline; + // if the chart data has already been loaded then we only need to query the trendlineData + if (this.measureStore) this.queryTrendlineData(); + } + + this.getChartLayoutPanel().onMeasuresChange(this.measures, this.renderType); + this.getChartLayoutPanel().updateVisibleLayoutOptions(this.getSelectedChartTypeData(), this.measures); + this.ensureChartLayoutOptions(); + + if (!skipRender) + this.renderChart(); + }, + + getSelectedChartTypeData : function() + { + var selectedChartType = this.getChartTypePanel().getSelectedType(); + return selectedChartType ? selectedChartType.data : null; + }, + + getChartLayoutWindow : function() + { + if (!this.chartLayoutWindow) + { + var panel = this.getChartLayoutPanel(); + + this.chartLayoutWindow = Ext4.create('LABKEY.vis.ChartWizardWindow', { + panelToMask: this, + onEsc: function() { + panel.cancelHandler.call(panel); + }, + items: [panel] + }); + + // propagate the show event to the panel so it can stash the initial values + this.chartLayoutWindow.on('show', function(window) + { + this.getChartLayoutPanel().fireEvent('show', this.getChartLayoutPanel(), this.getSelectedChartTypeData(), this.measures); + }, this); + } + + return this.chartLayoutWindow; + }, + + getChartLayoutPanel : function() + { + if (!this.chartLayoutPanel) + { + this.chartLayoutPanel = Ext4.create('LABKEY.vis.ChartLayoutPanel', { + options: this.options, + isDeveloper: this.isDeveloper, + renderType: this.renderType, + initMeasures: this.measures, + multipleCharts: this.options && this.options.general && this.options.general.chartLayout !== 'single', + defaultChartLabel: this.getDefaultTitle(), + defaultOpacity: this.renderType == 'bar_chart' || this.renderType == 'line_plot' ? 100 : undefined, + defaultLineWidth: this.renderType == 'line_plot' ? 3 : undefined, + isSavedReport: !this.isNew(), + listeners: { + scope: this, + cancel: function(panel) + { + this.getChartLayoutWindow().hide(); + }, + apply: function(panel, values) + { + // special case for trendlineData: if there was a change to x-axis scale type or range, + // we need to reload the trendlineData + if (this.trendlineData) this.queryTrendlineData(); + + // note: this event will only fire if a change was made in the Chart Layout panel + this.ensureChartLayoutOptions(); + this.clearChartPanel(true); + this.renderChart(); + this.getChartLayoutWindow().hide(); + } + } + }); + } + + return this.chartLayoutPanel; + }, + + getExportScriptWindow : function() + { + if (!this.exportScriptWindow) + { + this.exportScriptWindow = Ext4.create('LABKEY.vis.ChartWizardWindow', { + panelToMask: this, + items: [this.getExportScriptPanel()] + }); + } + + return this.exportScriptWindow; + }, + + getExportScriptPanel : function() + { + if (!this.exportScriptPanel) + { + this.exportScriptPanel = Ext4.create('LABKEY.vis.GenericChartScriptPanel', { + width: Math.max(this.getViewPanel().getWidth() - 100, 800), + listeners: { + scope: this, + closeOptionsWindow: function(){ + this.getExportScriptWindow().hide(); + } + } + }); + } + + return this.exportScriptPanel; + }, + + getSaveWindow : function() + { + if (!this.saveWindow) + { + this.saveWindow = Ext4.create('LABKEY.vis.ChartWizardWindow', { + panelToMask: this, + items: [this.getSavePanel()] + }); + } + + return this.saveWindow; + }, + + getSavePanel : function() + { + if (!this.savePanel) + { + this.savePanel = Ext4.create('LABKEY.vis.SaveOptionsPanel', { + allowInherit: this.allowInherit, + canEdit: this.canEdit, + canShare: this.canShare, + listeners: { + scope: this, + closeOptionsWindow: function() + { + this.getSaveWindow().close() + }, + saveChart: this.saveReport + } + }); + } + + return this.savePanel; + }, + + ensureChartLayoutOptions : function() + { + // Make sure that we have the latest chart layout panel values. + // This will get the initial default values if the user has not yet opened the chart layout dialog. + // This will also preserve the developer pointClickFn if the user is not a developer. + Ext4.apply(this.options, this.getChartLayoutPanel().getValues()); + }, + + setRenderType : function(newRenderType) + { + if (this.renderType != newRenderType) + this.renderType = newRenderType; + }, + + renderDataGrid : function(renderTo) + { + var filters = []; + var removableFilters = this.userFilters; + + if (this.isDataRegionPresent()) + { + // If the region exists, then apply it's user filters as immutable filters to the QWP. + // They can be mutated on the data region directly. + filters = this.getDataRegionFilters(); + } + else + { + // If the region does not exist, then apply it's filters from the URL + // and allow them to be mutated on the QWP. + removableFilters = removableFilters.concat(this.getURLFilters()); + } + + var allFilters = this.getUniqueFilters(filters.concat(removableFilters)); + var userSort = LABKEY.Filter.getSortFromUrl(this.getFilterURL(), this.dataRegionName); + + this.currentFilterStr = this.createFilterString(allFilters); + this.currentParameterStr = Ext4.JSON.encode(this.parameters); + + var wpConfig = { + schemaName : this.schemaName, + queryName : this.queryName, + viewName : this.viewName, + columns : this.savedColumns, + parameters : this.parameters, + frame : 'none', + filters : filters, + disableAnalytics : true, + removeableFilters : removableFilters, + removeableSort : userSort, + showSurroundingBorder : false, + allowHeaderLock : false, + buttonBar : { + includeStandardButton: false, + items: [LABKEY.QueryWebPart.standardButtons.exportRows] + } + }; + + if (this.dataRegionName) { + wpConfig.dataRegionName = this.dataRegionName + '-chartdata'; + } + + var wp = new LABKEY.QueryWebPart(wpConfig); + + // save the dataregion + this.panelDataRegionName = wp.dataRegionName; + + // Issue 21418: support for parameterized queries + wp.on('render', function(){ + if (wp.parameters) + Ext4.apply(this.parameters, wp.parameters); + }, this); + + wp.render(renderTo); + }, + + /** + * Returns the filters applied to the associated Data Region. This region's presence is optional. + */ + getDataRegionFilters : function() + { + if (this.isDataRegionPresent()) + return LABKEY.DataRegions[this.dataRegionName].getUserFilterArray(); + + return []; + }, + + getFilterURL : function() + { + return LABKEY.ActionURL.getParameters(this.baseUrl)['filterUrl']; + }, + + /** + * Returns the filters applied to the associated QueryWebPart (QWP). The QWP's presence is optional. + * If this QWP is available, then this method will return any user modifiable filters from the QWP. + */ + getQWPFilters : function() + { + // The associated QWP may not exist yet if the user hasn't viewed it + if (this.isQWPPresent()) + return LABKEY.DataRegions[this.panelDataRegionName].getUserFilterArray(); + + return []; + }, + + getURLFilters : function() + { + return LABKEY.Filter.getFiltersFromUrl(this.getFilterURL(), this.dataRegionName); + }, + + isDataRegionPresent : function() + { + return LABKEY.DataRegions[this.dataRegionName] !== undefined; + }, + + isQWPPresent : function() + { + return LABKEY.DataRegions[this.panelDataRegionName] !== undefined; + }, + + // Returns a configuration based on the baseUrl plus any filters applied on the dataregion panel + // the configuration can be used to make a selectRows request + getQueryConfig : function(serialize) + { + var config = { + schemaName : this.schemaName, + queryName : this.queryName, + viewName : this.viewName, + dataRegionName: this.dataRegionName, + queryLabel : this.queryLabel, + parameters : this.parameters, + requiredVersion : 17.1, // Issue 49753 + maxRows: -1, + sort: LABKEY.vis.GenericChartHelper.getQueryConfigSortKey(this.measures), + method: 'POST' + }; + + config.columns = this.getQueryConfigColumns(); + + if (!serialize) + { + config.success = this.onSelectRowsSuccess; + config.failure = function(response, opts){ + var error, errorDiv; + + this.getEl().unmask(); + + if (response.exception) + { + error = '

' + response.exception + '

'; + if (response.exceptionClass == 'org.labkey.api.view.NotFoundException') + error = error + '

The source dataset, list, or query may have been deleted.

' + } + + errorDiv = Ext4.create('Ext.container.Container', { + border: 1, + autoEl: {tag: 'div'}, + padding: 10, + html: '

An unexpected error occurred while retrieving data.

' + error, + autoScroll: true + }); + + // Issue 18157 + this.getChartTypeBtn().disable(); + this.getChartLayoutBtn().disable(); + this.getToggleViewBtn().disable(); + this.getSaveBtn().disable(); + this.getSaveAsBtn().disable(); + + this.getViewPanel().add(errorDiv); + }; + config.scope = this; + } + + // Filter scenarios (Issue 37153, Issue 40384) + // 1. Filters defined explicitly on chart configuration (this.userFilters) + // 2. Filters defined on associated QWP (panelDataRegionName) + // 3. Filters defined on associated Data Region (dataRegionName) + // 4. Filters defined on URL for associated Data Region (overlap with #3) + var filters = this.getDataRegionFilters(); + + // If the QWP is present, then it is expected to have the "userFilters" and URL filters already applied. + // Additionally, they are removable from the QWP so respect the current filters on the QWP. + if (this.isQWPPresent()) + filters = filters.concat(this.getQWPFilters()); + else + filters = filters.concat(this.userFilters, this.getURLFilters()); + + filters = this.getUniqueFilters(filters); + + if (serialize) + { + var newFilters = []; + + for (var i=0; i < filters.length; i++) + { + var f = filters[i]; + newFilters.push({name : f.getColumnName(), value : f.getValue(), type : f.getFilterType().getURLSuffix()}); + } + + filters = newFilters; + } + + config.filterArray = filters; + + return config; + }, + + filterToString : function(filter) + { + return filter.getURLParameterName() + '=' + filter.getURLParameterValue(); + }, + + getUniqueFilters : function(filters) + { + var filterKeys = {}; + var filterSet = []; + + for (var x=0; x < filters.length; x++) + { + var ff = filters[x]; + var key = this.filterToString(ff); + + if (!filterKeys[key]) + { + filterKeys[key] = true; + filterSet.push(ff); + } + } + + return filterSet; + }, + + getQueryConfigColumns : function() + { + var columns = null; + + if (!this.editMode) + { + // If we're not in edit mode or if this is the first load we need to only load the minimum amount of data. + columns = []; + var measures = this.getChartConfig().measures; + + if (measures.x) + { + this.addMeasureForColumnQuery(columns, measures.x); + } + else if (this.autoColumnXName) + { + columns.push(this.autoColumnXName.toString()); + } + else + { + // Check if we have cohorts available + var queryColumnNames = this.getChartTypePanel().getQueryColumnNames(); + for (var i = 0; i < queryColumnNames.length; i++) + { + if (queryColumnNames[i].indexOf('Cohort') > -1) + columns.push(queryColumnNames[i]); + } + } + + if (measures.y) { + this.addMeasureForColumnQuery(columns, measures.y); + } + else if (this.autoColumnYName) { + columns.push(this.autoColumnYName.toString()); + } + + if (this.autoColumnName) { + columns.push(this.autoColumnName.toString()); + } + + Ext4.each(['ySub', 'xSub', 'color', 'shape', 'series'], function(name) { + if (measures[name]) { + this.addMeasureForColumnQuery(columns, measures[name]); + } + }, this); + } + else + { + // If we're in edit mode then we can load all of the columns. + columns = this.getChartTypePanel().getQueryColumnFieldKeys(); + } + + return columns; + }, + + addMeasureForColumnQuery : function(columns, initMeasure) + { + // account for the measure being a single object or an array of objects + var measures = Ext4.isArray(initMeasure) ? initMeasure : [initMeasure]; + Ext4.each(measures, function(measure) { + if (Ext4.isObject(measure)) + { + columns.push(measure.name); + + // Issue 27814: names with slashes need to be queried by encoded name + var encodedName = LABKEY.QueryKey.encodePart(measure.name); + if (measure.name !== encodedName) + columns.push(encodedName); + } + }); + }, + + getChartConfig : function() + { + var config = {}; + + config.renderType = this.renderType; + config.measures = Ext4.apply({}, this.measures); + config.scales = {}; + config.labels = {}; + + this.ensureChartLayoutOptions(); + if (this.options.general) + { + config.width = this.options.general.width; + config.height = this.options.general.height; + config.pointType = this.options.general.pointType; + config.labels.main = this.options.general.label; + config.labels.subtitle = this.options.general.subtitle; + config.labels.footer = this.options.general.footer; + + config.geomOptions = Ext4.apply({}, this.options.general); + config.geomOptions.showOutliers = config.pointType ? config.pointType == 'outliers' : true; + config.geomOptions.pieInnerRadius = this.options.general.pieInnerRadius; + config.geomOptions.pieOuterRadius = this.options.general.pieOuterRadius; + config.geomOptions.showPiePercentages = this.options.general.showPiePercentages; + config.geomOptions.piePercentagesColor = this.options.general.piePercentagesColor; + config.geomOptions.pieHideWhenLessThanPercentage = this.options.general.pieHideWhenLessThanPercentage; + config.geomOptions.gradientPercentage = this.options.general.gradientPercentage; + config.geomOptions.gradientColor = this.options.general.gradientColor; + config.geomOptions.colorPaletteScale = this.options.general.colorPaletteScale; + config.geomOptions.binShape = this.options.general.binShapeGroup; + config.geomOptions.binThreshold = this.options.general.binThreshold; + config.geomOptions.colorRange = this.options.general.binColorGroup; + config.geomOptions.binSingleColor = this.options.general.binSingleColor; + config.geomOptions.chartLayout = this.options.general.chartLayout; + config.geomOptions.marginTop = this.options.general.marginTop; + config.geomOptions.marginRight = this.options.general.marginRight; + config.geomOptions.marginBottom = this.options.general.marginBottom; + config.geomOptions.marginLeft = this.options.general.marginLeft; + } + + if (this.options.x) + { + this.applyAxisOptionsToConfig(this.options, config, 'x'); + if (this.measures.xSub) { + config.labels.xSub = this.measures.xSub.label; + } + } + + this.applyAxisOptionsToConfig(this.options, config, 'y'); + this.applyAxisOptionsToConfig(this.options, config, 'yRight'); + + if (this.options.developer) + config.measures.pointClickFn = this.options.developer.pointClickFn; + + if (this.curveFit) { + config.curveFit = this.curveFit; + } else if (this.trendline) { + config.geomOptions.trendlineType = this.trendline.trendlineType; + config.geomOptions.trendlineAsymptoteMin = this.trendline.trendlineAsymptoteMin; + config.geomOptions.trendlineAsymptoteMax = this.trendline.trendlineAsymptoteMax; + } + + if (this.getCustomChartOptions) + config.customOptions = this.getCustomChartOptions(); + + return config; + }, + + applyAxisOptionsToConfig : function(options, config, axisName) { + if (options[axisName]) + { + if (!config.labels[axisName]) { + config.labels[axisName] = options[axisName].label; + config.scales[axisName] = { + type: options[axisName].scaleRangeType || 'automatic', + trans: options[axisName].trans || options[axisName].scaleTrans + }; + } + + if (config.scales[axisName].type === "manual" && options[axisName].scaleRange) { + config.scales[axisName].min = options[axisName].scaleRange.min; + config.scales[axisName].max = options[axisName].scaleRange.max; + } + } + }, + + markDirty : function(dirty) + { + this.dirty = dirty; + LABKEY.Utils.signalWebDriverTest("genericChartDirty", dirty); + }, + + isDirty : function() + { + return !LABKEY.user.isGuest && !this.hideSave && this.canEdit && this.dirty; + }, + + beforeUnload : function() + { + if (this.isDirty()) { + return 'please save your changes'; + } + }, + + getCurrentReportConfig : function() + { + var reportConfig = { + reportId : this.savedReportInfo ? this.savedReportInfo.reportId : undefined, + schemaName : this.schemaName, + queryName : this.queryName, + viewName : this.viewName, + dataRegionName: this.dataRegionName, + renderType : this.renderType, + jsonData : { + queryConfig : this.getQueryConfig(true), + chartConfig : this.getChartConfig() + } + }; + + var chartConfig = reportConfig.jsonData.chartConfig; + LABKEY.vis.GenericChartHelper.removeNumericConversionConfig(chartConfig); + + return reportConfig; + }, + + saveReport : function(data) + { + var reportConfig = this.getCurrentReportConfig(); + reportConfig.name = data.reportName; + reportConfig.description = data.reportDescription; + + reportConfig["public"] = data.shared; + reportConfig.inheritable = data.inheritable; + reportConfig.thumbnailType = data.thumbnailType; + reportConfig.svg = this.chartSVG; + + if (data.isSaveAs) + reportConfig.reportId = null; + + LABKEY.Ajax.request({ + url : LABKEY.ActionURL.buildURL('visualization', 'saveGenericReport.api'), + method : 'POST', + headers : { + 'Content-Type' : 'application/json' + }, + jsonData: reportConfig, + success : function(resp) + { + this.getSaveWindow().close(); + this.markDirty(false); + + // show success message and then fade the window out + var msgbox = Ext4.create('Ext.window.Window', { + html : 'Report saved successfully.', + cls : 'chart-wizard-dialog', + bodyStyle : 'background: transparent;', + header : false, + border : false, + padding : 20, + resizable: false, + draggable: false + }); + + msgbox.show(); + msgbox.getEl().fadeOut({ + delay : 1500, + duration: 1000, + callback : function() + { + msgbox.hide(); + } + }); + + // if a new report was created, we need to refresh the page with the correct report id on the URL + if (this.isNew() || data.isSaveAs) + { + var o = Ext4.decode(resp.responseText); + window.location = LABKEY.ActionURL.buildURL('reports', 'runReport', null, {reportId: o.reportId}); + } + }, + failure : this.onFailure, + scope : this + }); + }, + + onFailure : function(resp) + { + var error = Ext4.isString(resp.responseText) ? Ext4.decode(resp.responseText).exception : resp.exception; + Ext4.Msg.show({ + title: 'Error', + msg: error || 'An unknown error has occurred.', + buttons: Ext4.MessageBox.OK, + icon: Ext4.MessageBox.ERROR, + scope: this + }); + }, + + loadReportFromId : function(reportId) + { + this.reportLoaded = false; + + LABKEY.Query.Visualization.get({ + reportId: reportId, + scope: this, + success: function(result) + { + this.savedReportInfo = result; + this.loadSavedConfig(); + } + }); + }, + + loadSavedConfig : function() + { + var config = this.savedReportInfo, + queryConfig = {}, + chartConfig = {}; + + if (config.type == LABKEY.Query.Visualization.Type.GenericChart) + { + queryConfig = config.visualizationConfig.queryConfig; + chartConfig = config.visualizationConfig.chartConfig; + } + + this.schemaName = queryConfig.schemaName; + this.queryName = queryConfig.queryName; + this.viewName = queryConfig.viewName; + this.dataRegionName = queryConfig.dataRegionName; + + if (this.reportName) + this.reportName.setValue(config.name); + + if (this.reportDescription && config.description != null) + this.reportDescription.setValue(config.description); + + // TODO is this needed/used anymore? + if (this.reportPermission) + this.reportPermission.setValue({"public" : config.shared}); + + this.getSavePanel().setReportInfo({ + name: config.name, + description: config.description, + shared: config.shared, + inheritable: config.inheritable, + reportProps: config.reportProps, + thumbnailURL: config.thumbnailURL + }); + + this.loadQueryInfoFromConfig(queryConfig); + this.loadMeasuresFromConfig(chartConfig); + this.loadOptionsFromConfig(chartConfig); + + // if the renderType was not saved with the report info, get it based off of the x-axis measure type + this.renderType = chartConfig.renderType || this.getRenderType(chartConfig); + + this.markDirty(false); + this.reportLoaded = true; + this.updateChartTask.delay(500); + }, + + loadQueryInfoFromConfig : function(queryConfig) + { + if (Ext4.isObject(queryConfig)) + { + if (Ext4.isArray(queryConfig.filterArray)) + { + var filters = []; + for (var i=0; i < queryConfig.filterArray.length; i++) + { + var f = queryConfig.filterArray[i]; + var type = LABKEY.Filter.getFilterTypeForURLSuffix(f.type); + if (type !== undefined) { + var value = type.isMultiValued() ? f.value : (Ext4.isArray(f.value) ? f.value[0]: f.value); + filters.push(LABKEY.Filter.create(f.name, value, type)); + } + } + this.userFilters = filters; + } + + if (queryConfig.columns) + this.savedColumns = queryConfig.columns; + + if (queryConfig.queryLabel) + this.queryLabel = queryConfig.queryLabel; + + if (queryConfig.parameters) + this.parameters = queryConfig.parameters; + } + }, + + loadMeasuresFromConfig : function(chartConfig) + { + this.measures = {}; + + if (Ext4.isObject(chartConfig)) + { + if (Ext4.isObject(chartConfig.measures)) + { + Ext4.each(['x', 'y', 'xSub', 'color', 'shape', 'series'], function(name) { + if (chartConfig.measures[name]) { + this.measures[name] = chartConfig.measures[name]; + } + }, this); + } + } + }, + + loadOptionsFromConfig : function(chartConfig) + { + this.options = {}; + + if (Ext4.isObject(chartConfig)) + { + this.options.general = {}; + if (chartConfig.height) + this.options.general.height = chartConfig.height; + if (chartConfig.width) + this.options.general.width = chartConfig.width; + if (chartConfig.pointType) + this.options.general.pointType = chartConfig.pointType; + if (chartConfig.geomOptions) + Ext4.apply(this.options.general, chartConfig.geomOptions); + + if (chartConfig.labels && LABKEY.Utils.isString(chartConfig.labels.main)) + this.options.general.label = chartConfig.labels.main; + else + this.options.general.label = this.getDefaultTitle(); + + if (chartConfig.labels && chartConfig.labels.subtitle) + this.options.general.subtitle = chartConfig.labels.subtitle; + if (chartConfig.labels && chartConfig.labels.footer) + this.options.general.footer = chartConfig.labels.footer; + + this.loadAxisOptionsFromConfig(chartConfig, 'x'); + this.loadAxisOptionsFromConfig(chartConfig, 'y'); + this.loadAxisOptionsFromConfig(chartConfig, 'yRight'); + + this.options.developer = {}; + if (chartConfig.measures && chartConfig.measures.pointClickFn) + this.options.developer.pointClickFn = chartConfig.measures.pointClickFn; + + if (chartConfig.curveFit) { + this.curveFit = chartConfig.curveFit; + } else if (chartConfig.geomOptions.trendlineType) { + this.trendline = { + trendlineType: chartConfig.geomOptions.trendlineType, + trendlineAsymptoteMin: chartConfig.geomOptions.trendlineAsymptoteMin, + trendlineAsymptoteMax: chartConfig.geomOptions.trendlineAsymptoteMax, + } + } + } + }, + + loadAxisOptionsFromConfig : function(chartConfig, axisName) { + this.options[axisName] = {}; + if (chartConfig.labels && chartConfig.labels[axisName]) { + this.options[axisName].label = chartConfig.labels[axisName]; + } + if (chartConfig.scales && chartConfig.scales[axisName]) { + Ext4.apply(this.options[axisName], chartConfig.scales[axisName]); + } + }, + + loadInitialSelection : function() + { + if (Ext4.isObject(this.initialSelection)) + { + this.applyChartTypeSelection(this.getChartTypePanel(), this.initialSelection, true); + // clear the initial selection object so it isn't loaded again + this.initialSelection = undefined; + + this.markDirty(false); + this.reportLoaded = true; + this.updateChartTask.delay(500); + } + }, + + handleNoData : function(errorMsg) + { + // Issue 18339 + this.setRenderRequested(false); + var errorDiv = Ext4.create('Ext.container.Container', { + border: 1, + autoEl: {tag: 'div'}, + html: '

An unexpected error occurred while retrieving data.

' + errorMsg, + autoScroll: true + }); + + this.getChartTypeBtn().disable(); + this.getChartLayoutBtn().disable(); + this.getSaveBtn().disable(); + this.getSaveAsBtn().disable(); + + // Keep the toggle button enabled so the user can remove filters + this.getToggleViewBtn().enable(); + + this.clearChartPanel(true); + this.getViewPanel().add(errorDiv); + this.getEl().unmask(); + }, + + renderPlot : function() + { + // Don't attempt to render if the view panel isn't visible or the chart type window is visible. + if (!this.isVisible() || this.getViewPanel().isHidden() || this.getChartTypeWindow().isVisible()) + return; + + // initMeasures returns false and opens the Chart Type panel if a required measure is not chosen by the user. + if (!this.initMeasures()) + return; + + this.clearChartPanel(false); + + var chartConfig = this.getChartConfig(); + var renderType = this.getRenderType(chartConfig); + + this.renderGenericChart(renderType, chartConfig); + + // We just rendered the plot, we don't need to request another render. + this.setRenderRequested(false); + }, + + getRenderType : function(chartConfig) + { + return LABKEY.vis.GenericChartHelper.getChartType(chartConfig); + }, + + renderGenericChart : function(chartType, chartConfig) + { + var aes, scales, customRenderType, hasNoDataMsg, newChartDiv, valueConversionResponse; + + hasNoDataMsg = LABKEY.vis.GenericChartHelper.validateResponseHasData(this.getMeasureStore(), true); + if (hasNoDataMsg != null) + this.addWarningText(hasNoDataMsg); + + this.getEl().mask('Rendering Chart...'); + + aes = LABKEY.vis.GenericChartHelper.generateAes(chartType, chartConfig.measures, this.getSchemaName(), this.getQueryName()); + + valueConversionResponse = LABKEY.vis.GenericChartHelper.doValueConversion(chartConfig, aes, this.renderType, this.getMeasureStoreRecords()); + if (!Ext4.Object.isEmpty(valueConversionResponse.processed)) + { + Ext4.Object.merge(chartConfig.measures, valueConversionResponse.processed); + //re-generate aes based on new converted values + aes = LABKEY.vis.GenericChartHelper.generateAes(chartType, chartConfig.measures, this.getSchemaName(), this.getQueryName()); + if (valueConversionResponse.warningMessage) { + this.addWarningText(valueConversionResponse.warningMessage); + } + } + + customRenderType = this.customRenderTypes ? this.customRenderTypes[this.renderType] : undefined; + if (customRenderType && customRenderType.generateAes) + aes = customRenderType.generateAes(this, chartConfig, aes); + + scales = LABKEY.vis.GenericChartHelper.generateScales(chartType, chartConfig.measures, chartConfig.scales, aes, this.getMeasureStore(), this.defaultNumberFormat); + if (customRenderType && customRenderType.generateScales) + scales = customRenderType.generateScales(this, chartConfig, scales); + + if (!this.isChartConfigValid(chartType, chartConfig, aes, scales)) + return; + + if (chartType == 'scatter_plot' && this.getMeasureStoreRecords().length > chartConfig.geomOptions.binThreshold) { + chartConfig.geomOptions.binned = true; + this.addWarningText("The number of individual points exceeds " + + Ext4.util.Format.number(chartConfig.geomOptions.binThreshold, '0,000') + + ". The data is now grouped by density, which overrides some layout options."); + } + else if (chartType == 'line_plot' && this.getMeasureStoreRecords().length > this.dataPointLimit) { + this.addWarningText("The number of individual points exceeds " + + Ext4.util.Format.number(this.dataPointLimit, '0,000') + + ". Data points will not be shown on this line plot."); + } + + this.beforeRenderPlotComplete(); + + chartConfig.width = this.getPerChartWidth(chartType, chartConfig); + chartConfig.height = this.getPerChartHeight(chartConfig); + + newChartDiv = this.getNewChartDisplayDiv(); + this.getViewPanel().add(newChartDiv); + + var plotConfigArr = this.getPlotConfigs(newChartDiv, chartType, chartConfig, aes, scales, customRenderType, this.trendlineData); + + Ext4.each(plotConfigArr, function(plotConfig) { + if (this.renderType === 'pie_chart') { + new LABKEY.vis.PieChart(plotConfig); + } + else { + var plot = new LABKEY.vis.Plot(plotConfig); + plot.render(); + } + }, this); + + this.afterRenderPlotComplete(newChartDiv, chartType, chartConfig); + }, + + getPerChartWidth : function(chartType, chartConfig) { + if (Ext4.isDefined(chartConfig.width) && chartConfig.width != null) { + return chartConfig.width; + } + else { + // default width based on the view panel width + return LABKEY.vis.GenericChartHelper.getChartTypeBasedWidth(chartType, chartConfig.measures, this.getMeasureStore(), this.getViewPanel().getWidth()) + } + }, + + getPerChartHeight : function(chartConfig) { + if (Ext4.isDefined(chartConfig.height) && chartConfig.height != null) { + return chartConfig.height; + } + else { + // default height based on the view panel height + var height = this.getViewPanel().getHeight() - 25; + if (chartConfig.geomOptions.chartLayout === 'per_measure') { + height = height / 1.25; + } + return height; + } + }, + + getNewChartDisplayDiv : function() + { + return Ext4.create('Ext.container.Container', { + cls: 'chart-render-div', + autoEl: {tag: 'div'} + }); + }, + + beforeRenderPlotComplete : function() + { + // add the warning msg before the plot so the plot has the proper height + if (this.warningText !== null) + this.addWarningMsg(this.warningText, true); + }, + + afterRenderPlotComplete : function(chartDiv, chartType, chartConfig) + { + this.getTopButtonBar().enable(); + this.getChartTypeBtn().enable(); + this.getChartLayoutBtn().enable(); + this.getSaveBtn().enable(); + this.getSaveAsBtn().enable(); + this.attachExportIcons(chartDiv, chartType, chartConfig); + this.getEl().unmask(); + + if (this.editMode && this.supportedBrowser) + this.updateSaveChartThumbnail(chartDiv, chartConfig); + }, + + addWarningMsg : function(warningText, allowDismiss) + { + var warningDivId = Ext4.id(); + var dismissLink = allowDismiss ? 'dismiss' : ''; + + var warningCmp = Ext4.create('Ext.container.Container', { + padding: 10, + cls: 'chart-warning', + html: warningText + ' ' + dismissLink, + listeners: { + scope: this, + render: function(cmp) { + Ext4.get('dismiss-link-' + warningDivId).on('click', function() { + // removing the warning message which will adjust the view panel height, so suspend events temporarily + this.getViewPanel().suspendEvents(); + this.getMsgPanel().remove(cmp); + this.getViewPanel().resumeEvents(); + }, this); + } + } + }); + + // add the warning message which will adjust the view panel height, so suspend events temporarily + this.getViewPanel().suspendEvents(); + this.getMsgPanel().add(warningCmp); + this.getViewPanel().resumeEvents(); + }, + + updateSaveChartThumbnail : function(chartDiv, chartConfig) + { + if (chartDiv.getEl()) { + var size = chartDiv.getEl().getSize(); + size.height = this.getPerChartHeight(chartConfig); + this.chartSVG = LABKEY.vis.SVGConverter.svgToStr(chartDiv.getEl().child('svg').dom); + this.getSavePanel().updateCurrentChartThumbnail(this.chartSVG, size); + } + }, + + isChartConfigValid : function(chartType, chartConfig, aes, scales) + { + var selectedMeasureNames = Object.keys(this.measures), + hasXMeasure = selectedMeasureNames.indexOf('x') > -1 && Ext4.isDefined(aes.x), + hasXSubMeasure = selectedMeasureNames.indexOf('xSub') > -1 && Ext4.isDefined(aes.xSub), + hasYMeasure = selectedMeasureNames.indexOf('y') > -1, + requiredMeasureNames = this.getChartTypePanel().getRequiredFieldNames(); + + // validate that all selected measures still exist by name in the query/dataset + if (!this.validateMeasuresExist(selectedMeasureNames, requiredMeasureNames)) + return false; + + // validate that the x axis measure exists and data is valid + if (hasXMeasure && !this.validateAxisMeasure(chartType, chartConfig, 'x', aes, scales, this.getMeasureStoreRecords())) + return false; + + // validate that the x subcategory axis measure exists and data is valid + if (hasXSubMeasure && !this.validateAxisMeasure(chartType, chartConfig, 'xSub', aes, scales, this.getMeasureStoreRecords())) + return false; + + // validate that the y axis measure exists and data is valid, handle case for single or multiple y-measures selected + if (hasYMeasure) { + var yMeasures = LABKEY.vis.GenericChartHelper.ensureMeasuresAsArray(this.measures['y']); + for (var i = 0; i < yMeasures.length; i++) { + var yMeasure = yMeasures[i]; + var yAes = {y: LABKEY.vis.GenericChartHelper.getYMeasureAes(yMeasure)}; + if (!this.validateAxisMeasure(chartType, yMeasure, 'y', yAes, scales, this.getMeasureStoreRecords(), yMeasure.converted)) { + return false; + } + } + } + + return true; + }, + + getPlotConfigs : function(newChartDiv, chartType, chartConfig, aes, scales, customRenderType, trendlineData) + { + var plotConfigArr = [], geom, labels, data = this.getMeasureStoreRecords(), me = this; + + geom = LABKEY.vis.GenericChartHelper.generateGeom(chartType, chartConfig.geomOptions); + if (chartType === 'line_plot' && data.length > this.dataPointLimit){ + chartConfig.geomOptions.hideDataPoints = true; + } + + labels = LABKEY.vis.GenericChartHelper.generateLabels(chartConfig.labels); + + 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) { + if (d.hasOwnProperty('value') && (!Ext4.isDefined(d.value) || isNaN(d.value))) { + d.value = 0; + } + }); + } + + if (customRenderType && Ext4.isFunction(customRenderType.generatePlotConfig)) { + var plotConfig = customRenderType.generatePlotConfig( + this, chartConfig, newChartDiv.id, + chartConfig.width, chartConfig.height, + data, aes, scales, labels + ); + + plotConfig.rendererType = 'd3'; + plotConfigArr.push(plotConfig); + } + else { + plotConfigArr = LABKEY.vis.GenericChartHelper.generatePlotConfigs(newChartDiv.id, chartConfig, labels, aes, scales, geom, data, trendlineData); + + if (this.renderType === 'pie_chart') + { + if (this.checkForNegativeData(data)) + { + // adding warning text without shrinking height cuts off the footer text + Ext4.each(plotConfigArr, function(plotConfig) { + plotConfig.height = Math.floor(plotConfig.height * 0.95); + }, this); + } + + // because of the load delay, need to reset the thumbnail svg for pie charts + Ext4.each(plotConfigArr, function(plotConfig) { + plotConfig.callbacks = { + onload: function(){ + me.updateSaveChartThumbnail(newChartDiv); + } + }; + }, this); + } + // if client has specified a line type (only applicable for scatter plot), apply that as another layer + else if (this.curveFit && this.measures.x && this.isScatterPlot(this.renderType, this.getXAxisType(this.measures.x))) + { + var factory = this.lineRenderers[this.curveFit.type]; + if (factory) + { + Ext4.each(plotConfigArr, function(plotConfig) { + plotConfig.layers.push( + new LABKEY.vis.Layer({ + geom: new LABKEY.vis.Geom.Path({}), + aes: {x: 'x', y: 'y'}, + data: LABKEY.vis.Stat.fn(factory.createRenderer(this.curveFit.params), this.curveFit.points, this.curveFit.min, this.curveFit.max) + }) + ); + }, this); + } + } + } + + return plotConfigArr; + }, + + checkForNegativeData : function(data) { + var negativesFound = []; + Ext4.each(data, function(entry) { + if (entry.value < 0) { + negativesFound.push(entry.label) + } + }); + + if (negativesFound.length > 0) + { + this.addWarningText('There are negative values in the data that the Pie Chart cannot display. ' + + 'Omitted: ' + negativesFound.join(', ')); + } + + return negativesFound.length > 0; + }, + + initMeasures : function() + { + // Initialize the x and y measures on first chart load. Returns false if we're missing the x or y measure. + var measure, fk, + queryColumnStore = this.getChartTypePanel().getQueryColumnsStore(), + requiredFieldNames = this.getChartTypePanel().getRequiredFieldNames(), + requiresX = requiredFieldNames.indexOf('x') > -1, + requiresY = requiredFieldNames.indexOf('y') > -1; + + if (!this.measures.y) + { + if (this.autoColumnYName || (requiresY && this.autoColumnName)) + { + fk = this.autoColumnYName || this.autoColumnName; + measure = this.getMeasureFromFieldKey(fk); + if (measure) + this.setYAxisMeasure(measure); + } + + if (requiresY && !this.measures.y) + { + this.getEl().unmask(); + this.showChartTypeWindow(); + return false; + } + } + + if (!this.measures.x) + { + if (this.renderType !== "box_plot" && this.renderType !== "auto_plot") + { + if (this.autoColumnXName || (requiresX && this.autoColumnName)) + { + fk = this.autoColumnXName || this.autoColumnName; + measure = this.getMeasureFromFieldKey(fk); + if (measure) + this.setXAxisMeasure(measure); + } + + if (requiresX && !this.measures.x) + { + this.getEl().unmask(); + this.showChartTypeWindow(); + return false; + } + } + else if (this.autoColumnYName != null) + { + measure = queryColumnStore.findRecord('label', 'Study: Cohort', 0, false, true, true); + if (measure) + this.setXAxisMeasure(measure); + + this.autoColumnYName = null; + } + } + + return true; + }, + + getMeasureFromFieldKey : function(fk) + { + var queryColumnStore = this.getChartTypePanel().getQueryColumnsStore(); + + // first search by fk.toString(), for example Analyte.Name -> Analyte$PName + var measure = queryColumnStore.findRecord('fieldKey', fk.toString(), 0, false, true, true); + if (measure != null) { + return measure; + } + + // second look by fk.getName() + return queryColumnStore.findRecord('fieldKey', fk.getName(), 0, false, true, true); + }, + + setYAxisMeasure : function(measure) + { + if (measure) + { + this.measures.y = measure.data ? measure.data : measure; + this.getChartTypePanel().setFieldSelection('y', this.measures.y); + this.getChartLayoutPanel().onMeasuresChange(this.measures, this.renderType); + } + }, + + setXAxisMeasure : function(measure) + { + if (measure) + { + this.measures.x = measure.data ? measure.data : measure; + this.getChartTypePanel().setFieldSelection('x', this.measures.x); + this.getChartLayoutPanel().onMeasuresChange(this.measures, this.renderType); + } + }, + + validateMeasuresExist: function(measureNames, requiredMeasureNames) + { + var store = this.getChartTypePanel().getQueryColumnsStore(), + valid = true, + message = null, + sep = ''; + + // Checks to make sure the measures are still available, if not we show an error. + Ext4.each(measureNames, function(propName) { + if (this.measures[propName] && propName !== 'trendline') { + var propMeasures = this.measures[propName]; + + // some properties allowMultiple so treat all as arrays + propMeasures = LABKEY.vis.GenericChartHelper.ensureMeasuresAsArray(propMeasures); + + Ext4.each(propMeasures, function(propMeasure) { + var indexByFieldKey = store.find('fieldKey', propMeasure.fieldKey, 0, false, false, true), + indexByName = store.find('fieldKey', propMeasure.name, 0, false, false, true); + + if (indexByFieldKey === -1 && indexByName === -1) { + if (message == null) + message = ''; + + message += sep + 'The saved ' + propName + ' measure, ' + propMeasure.name + ', is not available. It may have been renamed or removed.'; + sep = ' '; + + this.removeMeasureFromSelection(propName, propMeasure); + this.getChartTypePanel().setToForceApplyChanges(); + + if (requiredMeasureNames.indexOf(propName) > -1) + valid = false; + } + }, this); + } + }, this); + + this.handleValidation({success: valid, message: Ext4.util.Format.htmlEncode(message)}); + + return valid; + }, + + removeMeasureFromSelection : function(propName, measure) { + if (this.measures[propName]) { + if (!Ext4.isArray(this.measures[propName])) { + delete this.measures[propName]; + } + else { + Ext4.Array.remove(this.measures[propName], measure); + } + } + }, + + validateAxisMeasure : function(chartType, chartConfig, measureName, aes, scales, data, dataConversionHappened) + { + var validation = LABKEY.vis.GenericChartHelper.validateAxisMeasure(chartType, chartConfig, measureName, aes, scales, data, dataConversionHappened); + if (!validation.success) { + this.removeMeasureFromSelection(measureName, chartConfig); + } + + this.handleValidation(validation); + return validation.success; + }, + + handleValidation : function(validation) + { + if (validation.success === true) + { + if (validation.message != null) + this.addWarningText(validation.message); + } + else + { + this.getEl().unmask(); + this.setRenderRequested(false); + + if (this.editMode) + { + this.getChartTypePanel().setToForceApplyChanges(); + + Ext4.Msg.show({ + title: 'Error', + msg: Ext4.util.Format.htmlEncode(validation.message), + buttons: Ext4.MessageBox.OK, + icon: Ext4.MessageBox.ERROR, + fn: this.showChartTypeWindow, + scope: this + }); + } + else + { + this.clearChartPanel(true); + var errorDiv = Ext4.create('Ext.container.Container', { + border: 1, + autoEl: {tag: 'div'}, + padding: 10, + html: '

Error rendering chart:

' + validation.message, + autoScroll: true + }); + this.getViewPanel().add(errorDiv); + } + } + }, + + isScatterPlot : function(renderType, xAxisType) + { + if (renderType === 'scatter_plot') + return true; + + return (renderType === 'auto_plot' && LABKEY.vis.GenericChartHelper.isNumericType(xAxisType)); + }, + + isBoxPlot: function(renderType, xAxisType) + { + if (renderType === 'box_plot') + return true; + + return (renderType == 'auto_plot' && !LABKEY.vis.GenericChartHelper.isNumericType(xAxisType)); + }, + + getSelectedChartType : function() + { + if (Ext4.isString(this.renderType) && this.renderType !== 'auto_plot') + return this.renderType; + else if (this.measures.x && this.isBoxPlot(this.renderType, this.getXAxisType(this.measures.x))) + return 'box_plot'; + else if (this.measures.x && this.isScatterPlot(this.renderType, this.getXAxisType(this.measures.x))) + return 'scatter_plot'; + + return 'bar_plot'; + }, + + getXAxisType : function(xMeasure) + { + return xMeasure ? (xMeasure.normalizedType || xMeasure.type) : null; + }, + + clearChartPanel : function(clearMessages) + { + this.clearWarningText(); + this.getViewPanel().removeAll(); + if (clearMessages) { + this.clearMessagePanel(); + } + }, + + clearMessagePanel : function() { + this.getViewPanel().suspendEvents(); + this.getMsgPanel().removeAll(); + this.getViewPanel().resumeEvents(); + }, + + clearWarningText : function() + { + this.warningText = null; + }, + + addWarningText : function(warning) + { + if (!this.warningText) + this.warningText = Ext4.util.Format.htmlEncode(warning); + else + this.warningText = this.warningText + '  ' + Ext4.util.Format.htmlEncode(warning); + }, + + attachExportIcons : function(chartDiv, chartType, chartConfig) + { + if (this.supportedBrowser) + { + var index = 0; + Ext4.each(chartDiv.getEl().select('svg').elements, function(svgEl) { + chartDiv.add(this.createExportIcon(chartType, chartConfig, 'fa-file-pdf-o', 'Export to PDF', index, 0, function(){ + this.exportChartToImage(svgEl, LABKEY.vis.SVGConverter.FORMAT_PDF); + })); + + chartDiv.add(this.createExportIcon(chartType, chartConfig, 'fa-file-image-o', 'Export to PNG', index, 1, function(){ + this.exportChartToImage(svgEl, LABKEY.vis.SVGConverter.FORMAT_PNG); + })); + + index++; + }, this); + } + if (this.isDeveloper) + { + chartDiv.add(this.createExportIcon(chartType, chartConfig, 'fa-file-code-o', 'Export as Script', 0, this.supportedBrowser ? 2 : 0, function(){ + this.exportChartToScript(); + })); + } + }, + + createExportIcon : function(chartType, chartConfig, iconCls, tooltip, chartIndex, iconIndexFromLeft, callbackFn) + { + var chartWidth = this.getPerChartWidth(chartType, chartConfig), + viewPortWidth = this.getViewPanel().getWidth(), + chartsPerRow = chartWidth > viewPortWidth ? 1 : Math.floor(viewPortWidth / chartWidth), + topPx = Math.floor(chartIndex / chartsPerRow) * this.getPerChartHeight(chartConfig), + leftPx = ((chartIndex % chartsPerRow) * chartWidth) + (iconIndexFromLeft * 30) + 20; + + return Ext4.create('Ext.Component', { + cls: 'export-icon', + style: 'top: ' + topPx + 'px; left: ' + leftPx + 'px;', + html: '', + listeners: { + scope: this, + render: function(cmp) + { + Ext4.create('Ext.tip.ToolTip', { + target: cmp.getEl(), + constrainTo: this.getEl(), + width: 110, + html: tooltip + }); + + cmp.getEl().on('click', callbackFn, this); + } + } + }); + }, + + exportChartToImage : function(svgEl, type) + { + if (svgEl) { + var fileName = this.getChartConfig().labels.main, + exportType = type || LABKEY.vis.SVGConverter.FORMAT_PDF; + + LABKEY.vis.SVGConverter.convert(svgEl, exportType, fileName); + } + }, + + exportChartToScript : function() + { + var chartConfig = LABKEY.vis.GenericChartHelper.removeNumericConversionConfig(this.getChartConfig()); + var queryConfig = this.getQueryConfig(true); + + // Only push the required columns. + queryConfig.columns = []; + + Ext4.each(['x', 'y', 'color', 'shape', 'series'], function(name) { + if (Ext4.isDefined(chartConfig.measures[name])) { + var measuresArr = LABKEY.vis.GenericChartHelper.ensureMeasuresAsArray(chartConfig.measures[name]); + Ext4.each(measuresArr, function(measure) { + queryConfig.columns.push(measure.name); + }, this); + } + }, this); + + var templateConfig = { + chartConfig: chartConfig, + queryConfig: queryConfig + }; + + this.getExportScriptPanel().setScriptValue(templateConfig); + this.getExportScriptWindow().show(); + }, + + createFilterString : function(filters) + { + var filterParams = []; + for (var i = 0; i < filters.length; i++) + { + filterParams.push(this.filterToString(filters[i])); + } + + filterParams.sort(); + return filterParams.join('&'); + }, + + hasChartData : function() + { + return Ext4.isDefined(this.getMeasureStore()) && Ext4.isArray(this.getMeasureStoreRecords()); + }, + + onSelectRowsSuccess : function(measureStore) { + this.measureStore = measureStore; + + // when not in edit mode, we'll use the column metadata from the data query + if (!this.editMode) + this.getChartTypePanel().loadQueryColumns(this.getMeasureStoreMetadata().fields); + + this.queryTrendlineData(); + }, + + queryTrendlineData : async function() { + const chartConfig = this.getChartConfig(); + if (chartConfig.geomOptions.trendlineType && chartConfig.geomOptions.trendlineType !== '') { + this.setDataLoading(true); + + const data = this.getMeasureStoreRecords(); + this.trendlineData = await LABKEY.vis.GenericChartHelper.queryTrendlineData(chartConfig, data); + this.onQueryDataComplete(); + } else { + // trendlineType of '' means use Point-to-Point, i.e. no trendlineData + this.trendlineData = undefined; + this.onQueryDataComplete(); + } + }, + + onQueryDataComplete : function() { + this.setDataLoading(false); + + this.getMsgPanel().removeAll(); + + // If it's already been requested then we just need to request it again, since this time we have the data to render. + if (this.isRenderRequested()) + this.requestRender(); + }, + + getMeasureStore : function() + { + return this.measureStore; + }, + + getMeasureStoreRecords : function() + { + if (!this.getMeasureStore()) + console.error('No measureStore object defined.'); + + return this.getMeasureStore().records(); + }, + + getMeasureStoreMetadata : function() + { + if (!this.getMeasureStore()) + console.error('No measureStore object defined.'); + + return this.getMeasureStore().getResponseMetadata(); + }, + + getSchemaName : function() + { + if (this.getMeasureStoreMetadata() && this.getMeasureStoreMetadata().schemaName) + { + if (Ext4.isArray(this.getMeasureStoreMetadata().schemaName)) + return this.getMeasureStoreMetadata().schemaName[0]; + + return this.getMeasureStoreMetadata().schemaName; + } + + return null; + }, + + getQueryName : function() + { + if (this.getMeasureStoreMetadata()) + return this.getMeasureStoreMetadata().queryName; + + return null; + }, + + getDefaultTitle : function() + { + if (this.defaultTitleFn) + return this.defaultTitleFn(this.queryName, this.queryLabel, LABKEY.vis.GenericChartHelper.getDefaultMeasuresLabel(this.measures.y), this.measures.x ? this.measures.x.label : null); + + return this.queryLabel || this.queryName; + }, + + /** + * used to determine if the new chart options are different from the currently rendered options + */ + hasConfigurationChanged : function() + { + var queryCfg = this.getQueryConfig(); + + if (!queryCfg.schemaName || !queryCfg.queryName) + return false; + + // ugly race condition, haven't loaded a saved report yet + if (!this.reportLoaded) + return false; + + if (!this.hasChartData()) + return true; + + var filterStr = this.createFilterString(queryCfg.filterArray); + + if (this.currentFilterStr != filterStr) { + this.currentFilterStr = filterStr; + return true; + } + + var parameterStr = Ext4.JSON.encode(queryCfg.parameters); + if (this.currentParameterStr != parameterStr) { + this.currentParameterStr = parameterStr; + return true; + } + + return false; + }, + + setRenderRequested : function(requested) + { + this.renderRequested = requested; + }, + + isRenderRequested : function() + { + return this.renderRequested; + }, + + setDataLoading : function(loading) + { + this.dataLoading = loading; + }, + + isDataLoading : function() + { + return this.dataLoading; + }, + + requestData : function() + { + this.setDataLoading(true); + + var config = this.getQueryConfig(); + LABKEY.Query.MeasureStore.selectRows(config); + + this.requestRender(); + }, + + requestRender : function() + { + if (this.isDataLoading()) + this.setRenderRequested(true); + else + this.renderPlot(); + }, + + renderChart : function() + { + this.getEl().mask('Rendering Chart...'); + this.chartDefinitionChanged.delay(500); + }, + + resizeToViewport : function() { + console.warn('DEPRECATED: As of Release 17.3 ' + this.$className + '.resizeToViewport() is no longer supported.'); + }, + + onSaveBtnClicked : function(isSaveAs) + { + this.getSavePanel().setNoneThumbnail(this.getChartTypePanel().getImgUrl()); + this.getSavePanel().setSaveAs(isSaveAs); + this.getSavePanel().setMainTitle(isSaveAs ? "Save as" : "Save"); + this.getSaveWindow().show(); + } +}); diff --git a/visualization/resources/web/vis/genericChart/genericChartHelper.js b/visualization/resources/web/vis/genericChart/genericChartHelper.js index c768685e4dd..17fc6b2fda6 100644 --- a/visualization/resources/web/vis/genericChart/genericChartHelper.js +++ b/visualization/resources/web/vis/genericChart/genericChartHelper.js @@ -1834,6 +1834,47 @@ LABKEY.vis.GenericChartHelper = new function(){ LABKEY.Query.MeasureStore.selectRows(queryConfig); }; + var generateDataForChartType = function(chartConfig, chartType, geom, data) { + var dimName = null; + var subDimName = null; + var measureName = null; + var aggType = chartType === 'bar_chart' || chartType === 'pie_chart' ? 'COUNT' : null; + var aggErrorType = null; + + 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; + } else if (chartConfig.measures.series) { + subDimName = chartConfig.measures.series.name; + } + if (chartConfig.measures.y) { + measureName = chartConfig.measures.y.converted ? chartConfig.measures.y.convertedName : chartConfig.measures.y.name; + + // chartConfig.measures.y.aggregate = 'MEAN'; // TODO get from chartConfig + + if (LABKEY.Utils.isDefined(chartConfig.measures.y.aggregate)) { + aggType = chartConfig.measures.y.aggregate.value || chartConfig.measures.y.aggregate; + aggType = LABKEY.Utils.isObject(aggType) ? aggType.value : aggType; + aggErrorType = aggType === 'MEAN' ? 'SEM' : null; // TODO get from chartConfig + } + 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, '[Blank]', false, aggErrorType, chartType === 'line_plot'); + if (aggErrorType) { + geom.errorAes = { getValue: function(d){ return d.error } }; + } + } + + return data; + } + var generateChartSVG = function(renderTo, chartConfig, measureStore, trendlineData) { var responseMetaData = measureStore.getResponseMetadata(); @@ -1856,28 +1897,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); @@ -1971,6 +1992,7 @@ LABKEY.vis.GenericChartHelper = new function(){ generateAggregateData: generateAggregateData, generatePointHover: generatePointHover, generateBoxplotHover: generateBoxplotHover, + generateDataForChartType: generateDataForChartType, generateDiscreteAcc: generateDiscreteAcc, generateContinuousAcc: generateContinuousAcc, generateGroupingAcc: generateGroupingAcc, From 1337be08087010de797f41beb7f973345965ec2b Mon Sep 17 00:00:00 2001 From: cnathe Date: Wed, 8 Oct 2025 14:38:31 -0500 Subject: [PATCH 10/40] null checks (found by LinePlotTest) --- .../resources/web/vis/genericChart/genericChartHelper.js | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/visualization/resources/web/vis/genericChart/genericChartHelper.js b/visualization/resources/web/vis/genericChart/genericChartHelper.js index 17fc6b2fda6..3ce102452a4 100644 --- a/visualization/resources/web/vis/genericChart/genericChartHelper.js +++ b/visualization/resources/web/vis/genericChart/genericChartHelper.js @@ -671,7 +671,7 @@ LABKEY.vis.GenericChartHelper = new function(){ sep = ', \n'; // include the std dev / SEM value in the hover display for a value if available - if (row[measure.name].error !== undefined && row[measure.name].errorType !== undefined) { + if (row[measure.name] && row[measure.name].error !== undefined && row[measure.name].errorType !== undefined) { hover += sep + row[measure.name].errorType + ': ' + row[measure.name].error; } @@ -1187,6 +1187,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, From 9a0761c3c00d62c9e4d19511da7ae95bb1127a36 Mon Sep 17 00:00:00 2001 From: cnathe Date: Wed, 8 Oct 2025 14:46:09 -0500 Subject: [PATCH 11/40] comment out testing code --- .../resources/web/vis/genericChart/genericChartHelper.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/visualization/resources/web/vis/genericChart/genericChartHelper.js b/visualization/resources/web/vis/genericChart/genericChartHelper.js index 3ce102452a4..53f77c15769 100644 --- a/visualization/resources/web/vis/genericChart/genericChartHelper.js +++ b/visualization/resources/web/vis/genericChart/genericChartHelper.js @@ -1862,7 +1862,7 @@ LABKEY.vis.GenericChartHelper = new function(){ if (LABKEY.Utils.isDefined(chartConfig.measures.y.aggregate)) { aggType = chartConfig.measures.y.aggregate.value || chartConfig.measures.y.aggregate; aggType = LABKEY.Utils.isObject(aggType) ? aggType.value : aggType; - aggErrorType = aggType === 'MEAN' ? 'SEM' : null; // TODO get from chartConfig + //aggErrorType = aggType === 'MEAN' ? 'SEM' : null; // TODO get from chartConfig } else if (measureName != null && (chartType === 'bar_chart' || chartType === 'pie_chart')) { // default to SUM for bar and pie charts From ef97612efa2e5bb47242644f52e2024a05e7346f Mon Sep 17 00:00:00 2001 From: cnathe Date: Thu, 9 Oct 2025 08:45:14 -0500 Subject: [PATCH 12/40] getAggregateData() fix for stat calc with no values (return null instead of NaN) --- core/webapp/vis/src/utils.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/core/webapp/vis/src/utils.js b/core/webapp/vis/src/utils.js index d1d4a7b8480..56524ff45ab 100644 --- a/core/webapp/vis/src/utils.js +++ b/core/webapp/vis/src/utils.js @@ -267,7 +267,11 @@ LABKEY.vis.getAggregateData = function(data, dimensionName, subDimensionName, me 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; From b5a5be1a2055c7499baf97504bb057f0f2ae6368 Mon Sep 17 00:00:00 2001 From: cnathe Date: Thu, 9 Oct 2025 08:58:10 -0500 Subject: [PATCH 13/40] jest test updates --- core/src/client/vis/utils.test.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/core/src/client/vis/utils.test.ts b/core/src/client/vis/utils.test.ts index 289c6e830c6..bdce37c01d3 100644 --- a/core/src/client/vis/utils.test.ts +++ b/core/src/client/vis/utils.test.ts @@ -151,4 +151,16 @@ describe('LABKEY.vis.getAggregateData', () => { { 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 }]); + }); }); \ No newline at end of file From 320409c6876feea6ceb2e759f27c0335d58889fe Mon Sep 17 00:00:00 2001 From: cnathe Date: Thu, 9 Oct 2025 15:00:52 -0500 Subject: [PATCH 14/40] Fix for bar chart error bars for grouped bar chart to use xAcc --- core/webapp/vis/src/internal/D3Renderer.js | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/core/webapp/vis/src/internal/D3Renderer.js b/core/webapp/vis/src/internal/D3Renderer.js index c0adeb7489e..6dafca699b9 100644 --- a/core/webapp/vis/src/internal/D3Renderer.js +++ b/core/webapp/vis/src/internal/D3Renderer.js @@ -2218,14 +2218,15 @@ LABKEY.vis.internal.D3Renderer = function(plot) { } }; - var renderErrorBar = function(layer, plot, geom, data) { + var renderErrorBar = function(layer, plot, geom, data, xAcc) { var colorAcc, sizeAcc, topFn, bottomFn, verticalFn, selection, newBars; var errorLineWidth = geom.errorWidth ?? geom.width; + var 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; 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); @@ -2233,7 +2234,7 @@ LABKEY.vis.internal.D3Renderer = function(plot) { }; 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); @@ -2245,7 +2246,7 @@ LABKEY.vis.internal.D3Renderer = function(plot) { }; verticalFn = function(d) { var x, y1, y2, value, error; - x = geom.getX(d); + x = xAcc_(d); value = geom.yAes.getValue(d); error = geom.errorAes.getValue(d); y1 = geom.yScale.scale(value + error); @@ -2260,7 +2261,7 @@ 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); }); @@ -3235,7 +3236,9 @@ LABKEY.vis.internal.D3Renderer = function(plot) { if (geom.errorAes !== undefined) { 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); + renderErrorBar(layer, plot, geom, data, function(d) { + return xAcc(d) + (barWidth / 2); + }); } // group each bar with an a tag for hover From 4cb3a6eba2f32ab62c9f439c258e1a0b4e11475d Mon Sep 17 00:00:00 2001 From: cnathe Date: Thu, 9 Oct 2025 15:01:41 -0500 Subject: [PATCH 15/40] get aggErrorType from chartConfig.measures.y.errorBars --- .../resources/web/vis/genericChart/genericChartHelper.js | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/visualization/resources/web/vis/genericChart/genericChartHelper.js b/visualization/resources/web/vis/genericChart/genericChartHelper.js index 53f77c15769..75240b3e7a1 100644 --- a/visualization/resources/web/vis/genericChart/genericChartHelper.js +++ b/visualization/resources/web/vis/genericChart/genericChartHelper.js @@ -1857,12 +1857,10 @@ LABKEY.vis.GenericChartHelper = new function(){ if (chartConfig.measures.y) { measureName = chartConfig.measures.y.converted ? chartConfig.measures.y.convertedName : chartConfig.measures.y.name; - // chartConfig.measures.y.aggregate = 'MEAN'; // TODO get from chartConfig - if (LABKEY.Utils.isDefined(chartConfig.measures.y.aggregate)) { aggType = chartConfig.measures.y.aggregate.value || chartConfig.measures.y.aggregate; aggType = LABKEY.Utils.isObject(aggType) ? aggType.value : aggType; - //aggErrorType = aggType === 'MEAN' ? 'SEM' : null; // TODO get from chartConfig + aggErrorType = aggType === 'MEAN' ? chartConfig.measures.y.errorBars : null; } else if (measureName != null && (chartType === 'bar_chart' || chartType === 'pie_chart')) { // default to SUM for bar and pie charts From 787f40594ae4c0f62bf1de14553f349a7217106e Mon Sep 17 00:00:00 2001 From: cnathe Date: Fri, 10 Oct 2025 16:00:10 -0500 Subject: [PATCH 16/40] LKS Chart wizard support for bar and line chart aggregate method and error bars options on yaxis layout panel --- .../genericChartAxisPanel.js | 111 +++++++++++++++++- .../web/vis/chartWizard/genericChartPanel.js | 13 ++ .../vis/genericChart/genericChartHelper.js | 2 +- 3 files changed, 122 insertions(+), 4 deletions(-) diff --git a/visualization/resources/web/vis/chartWizard/chartLayoutDialog/genericChartAxisPanel.js b/visualization/resources/web/vis/chartWizard/chartLayoutDialog/genericChartAxisPanel.js index e4de6d72547..720c55960bd 100644 --- a/visualization/resources/web/vis/chartWizard/chartLayoutDialog/genericChartAxisPanel.js +++ b/visualization/resources/web/vis/chartWizard/chartLayoutDialog/genericChartAxisPanel.js @@ -137,6 +137,59 @@ Ext4.define('LABKEY.vis.GenericChartAxisPanel', { } }); + var aggregateMethodData = []; + if (this.renderType === 'bar_chart') + aggregateMethodData.push(['COUNT', 'Count (non-blank)']); + if (this.renderType === 'line_plot') + aggregateMethodData.push(['', 'None']); + aggregateMethodData.push(['SUM', 'Sum']); + aggregateMethodData.push(['MIN', 'Min']); + aggregateMethodData.push(['MAX', 'Max']); + aggregateMethodData.push(['MEAN', 'Mean']); + aggregateMethodData.push(['MEDIAN', 'Median']); + + 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: aggregateMethodData + }), + forceSelection: 'true', + editable: false, + valueField: 'value', + displayField: 'display', + value: '' + }); + + 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 +200,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 +243,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 +279,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 +344,17 @@ Ext4.define('LABKEY.vis.GenericChartAxisPanel', { radioComp.setValue(true); }, + setAggregateMethod: function(value){ + this.aggregateMethodCombobox.setValue(value); + }, + + setErrorBars: function(value){ + this.errorBarsRadioGroup.setValue(value); + var radioComp = this.errorBarsRadioGroup.down('radio[inputValue="' + value + '"]'); + if (radioComp) + radioComp.setValue(true); + }, + validateManualScaleRange: function() { var range = this.getScaleRange(); @@ -295,11 +381,20 @@ Ext4.define('LABKEY.vis.GenericChartAxisPanel', { } //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 and error bars option for y-axis on bar and line charts + if (!(this.axisName === 'y' && (isBar || isLine))) { + this.setAggregateOptionVisible(false); + this.setErrorBarsOptionVisible(false); + } }, setRangeOptionVisible : function(visible) @@ -312,6 +407,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/genericChartPanel.js b/visualization/resources/web/vis/chartWizard/genericChartPanel.js index 613f53c9c7b..ff46b019ced 100644 --- a/visualization/resources/web/vis/chartWizard/genericChartPanel.js +++ b/visualization/resources/web/vis/chartWizard/genericChartPanel.js @@ -1046,6 +1046,13 @@ Ext4.define('LABKEY.ext4.GenericChartPanel', { config.scales[axisName].min = options[axisName].scaleRange.min; config.scales[axisName].max = options[axisName].scaleRange.max; } + + if (options[axisName].hasOwnProperty('aggregate')) { + config.measures[axisName].aggregate = options[axisName].aggregate; + } + if (options[axisName].hasOwnProperty('errorBars')) { + config.measures[axisName].errorBars = options[axisName].errorBars; + } } }, @@ -1324,6 +1331,12 @@ 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]) { + if (chartConfig.measures[axisName].aggregate) + this.options[axisName].aggregate = chartConfig.measures[axisName].aggregate.value ?? chartConfig.measures[axisName].aggregate; + if (chartConfig.measures[axisName].errorBars) + this.options[axisName].errorBars = chartConfig.measures[axisName].errorBars; + } }, loadInitialSelection : function() diff --git a/visualization/resources/web/vis/genericChart/genericChartHelper.js b/visualization/resources/web/vis/genericChart/genericChartHelper.js index 75240b3e7a1..a8ff504d0bd 100644 --- a/visualization/resources/web/vis/genericChart/genericChartHelper.js +++ b/visualization/resources/web/vis/genericChart/genericChartHelper.js @@ -130,7 +130,7 @@ 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 { From 2264e9aba11357db9895fdb3e8e1a47962dd7188 Mon Sep 17 00:00:00 2001 From: cnathe Date: Fri, 10 Oct 2025 16:37:35 -0500 Subject: [PATCH 17/40] D3Renderer.js to use tickOverlapRotation when hasTickAction (time chart test failure case) --- core/webapp/vis/src/internal/D3Renderer.js | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/core/webapp/vis/src/internal/D3Renderer.js b/core/webapp/vis/src/internal/D3Renderer.js index 6dafca699b9..b524cd54b27 100644 --- a/core/webapp/vis/src/internal/D3Renderer.js +++ b/core/webapp/vis/src/internal/D3Renderer.js @@ -453,7 +453,9 @@ LABKEY.vis.internal.Axis = function() { } } } - if (tickHover || tickClick || tickMouseOver || tickMouseOut) { + + var hasTickAction = tickHover || tickClick || tickMouseOver || tickMouseOut; + if (hasTickAction) { addTickAreaRects(textAnchors, !hasOverlap); addHighlightRects(textAnchors); } @@ -461,7 +463,7 @@ LABKEY.vis.internal.Axis = function() { if (orientation == 'bottom') { if (hasOverlap) { // if we have a large number of ticks, rotate the text by the specified amount, else wrap text - if (tickOverlapRotation !== undefined || textEls[0].length > 10) { + if (hasTickAction || tickOverlapRotation !== undefined || textEls[0].length > 10) { if (!tickOverlapRotation) { tickOverlapRotation = 35; } @@ -469,7 +471,7 @@ LABKEY.vis.internal.Axis = function() { textEls.attr('transform', function(v) {return 'rotate(' + tickOverlapRotation + ',' + textXFn(v) + ',' + textYFn(v) + ')';}) .attr('text-anchor', 'start'); - if (tickHover || tickClick || tickMouseOver || tickMouseOut) + if (hasTickAction) { addTickAreaRects(textAnchors); textAnchors.selectAll("rect." + (tickRectCls ? tickRectCls : "tick-rect")) From ebeb025a6484d360b35fd16411a921e5855b94a5 Mon Sep 17 00:00:00 2001 From: cnathe Date: Mon, 13 Oct 2025 08:46:45 -0500 Subject: [PATCH 18/40] Fix for JS error found in selenium tests --- .../resources/web/vis/chartWizard/genericChartPanel.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/visualization/resources/web/vis/chartWizard/genericChartPanel.js b/visualization/resources/web/vis/chartWizard/genericChartPanel.js index ff46b019ced..980e5c71f6f 100644 --- a/visualization/resources/web/vis/chartWizard/genericChartPanel.js +++ b/visualization/resources/web/vis/chartWizard/genericChartPanel.js @@ -1047,10 +1047,10 @@ Ext4.define('LABKEY.ext4.GenericChartPanel', { config.scales[axisName].max = options[axisName].scaleRange.max; } - if (options[axisName].hasOwnProperty('aggregate')) { + if (options[axisName].aggregate) { config.measures[axisName].aggregate = options[axisName].aggregate; } - if (options[axisName].hasOwnProperty('errorBars')) { + if (options[axisName].errorBars) { config.measures[axisName].errorBars = options[axisName].errorBars; } } From 8c19c449eac4582e893ad08daac7973ece0b6b77 Mon Sep 17 00:00:00 2001 From: cnathe Date: Mon, 13 Oct 2025 08:56:29 -0500 Subject: [PATCH 19/40] renderErrorBar fix for bar chart use of geom.topOnly --- core/webapp/vis/src/internal/D3Renderer.js | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/core/webapp/vis/src/internal/D3Renderer.js b/core/webapp/vis/src/internal/D3Renderer.js index b524cd54b27..2177c8dc7fa 100644 --- a/core/webapp/vis/src/internal/D3Renderer.js +++ b/core/webapp/vis/src/internal/D3Renderer.js @@ -2221,7 +2221,7 @@ LABKEY.vis.internal.D3Renderer = function(plot) { }; var renderErrorBar = function(layer, plot, geom, data, xAcc) { - var colorAcc, sizeAcc, topFn, bottomFn, verticalFn, selection, newBars; + var colorAcc, topFn, bottomFn, verticalFn, selection, newBars; var errorLineWidth = geom.errorWidth ?? geom.width; var xAcc_ = xAcc || function(row) {return geom.getX(row);}; @@ -2247,12 +2247,13 @@ LABKEY.vis.internal.D3Renderer = function(plot) { 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; + 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]; @@ -3236,6 +3237,7 @@ LABKEY.vis.internal.D3Renderer = function(plot) { 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) { From f10d9b7a5f1d0da9a9495e84abf097150f5933da Mon Sep 17 00:00:00 2001 From: cnathe Date: Mon, 13 Oct 2025 09:38:23 -0500 Subject: [PATCH 20/40] restore CRLF --- .../web/vis/chartWizard/genericChartPanel.js | 4462 ++++++++--------- .../vis/genericChart/genericChartHelper.js | 4048 +++++++-------- .../web/vis/timeChart/timeChartHelper.js | 3462 ++++++------- 3 files changed, 5986 insertions(+), 5986 deletions(-) diff --git a/visualization/resources/web/vis/chartWizard/genericChartPanel.js b/visualization/resources/web/vis/chartWizard/genericChartPanel.js index 980e5c71f6f..64939720a54 100644 --- a/visualization/resources/web/vis/chartWizard/genericChartPanel.js +++ b/visualization/resources/web/vis/chartWizard/genericChartPanel.js @@ -1,2231 +1,2231 @@ -/* - * Copyright (c) 2016-2019 LabKey Corporation - * - * Licensed under the Apache License, Version 2.0: http://www.apache.org/licenses/LICENSE-2.0 - */ -Ext4.define('LABKEY.ext4.GenericChartPanel', { - extend : 'Ext.panel.Panel', - - cls : 'generic-chart-panel', - layout : 'fit', - editable : false, - minWidth : 900, - - initialSelection : null, - savedReportInfo : null, - hideViewData : false, - reportLoaded : true, - hideSave: false, - dataPointLimit: 10000, - - constructor : function(config) - { - Ext4.QuickTips.init(); - this.callParent([config]); - }, - - queryGenericChartColumns : function() - { - LABKEY.vis.GenericChartHelper.getQueryColumns(this, function(columnMetadata) - { - this.getChartTypePanel().loadQueryColumns(columnMetadata); - this.requestData(); - }, this); - }, - - initComponent : function() - { - this.measures = {}; - this.options = {}; - this.userFilters = []; - - // boolean to check if we should allow things like export to PDF - this.supportedBrowser = !(Ext4.isIE6 || Ext4.isIE7 || Ext4.isIE8); - - var params = LABKEY.ActionURL.getParameters(); - this.editMode = params.edit == "true" || !this.savedReportInfo; - this.parameters = LABKEY.Filter.getQueryParamsFromUrl(params['filterUrl'], this.dataRegionName); - - // Issue 19163 - Ext4.each(['autoColumnXName', 'autoColumnYName', 'autoColumnName'], function(autoColPropName) - { - if (this[autoColPropName]) - this[autoColPropName] = LABKEY.FieldKey.fromString(this[autoColPropName]); - }, this); - - // for backwards compatibility, map auto_plot to box_plot - if (this.renderType === 'auto_plot') - this.setRenderType('box_plot'); - - this.chartDefinitionChanged = new Ext4.util.DelayedTask(function(){ - this.markDirty(true); - this.requestRender(); - }, this); - - // delayed task to redraw the chart - this.updateChartTask = new Ext4.util.DelayedTask(function() - { - if (this.hasConfigurationChanged()) - { - this.getEl().mask('Loading Data...'); - - if (this.editMode && this.getChartTypePanel().getQueryColumnNames().length == 0) - this.queryGenericChartColumns(); - else - this.requestData(); - } - - }, this); - - // only linear for now but could expand in the future - this.lineRenderers = { - linear : { - createRenderer : function(params){ - if (params && params.length >= 2) { - return function(x){return x * params[0] + params[1];} - } - return function(x) {return x;} - } - } - }; - - this.items = [this.getCenterPanel()]; - - this.callParent(); - - if (this.savedReportInfo) - this.loadSavedConfig(); - else - this.loadInitialSelection(); - - window.onbeforeunload = LABKEY.beforeunload(this.beforeUnload, this); - }, - - getViewPanel : function() - { - if (!this.viewPanel) - { - this.viewPanel = Ext4.create('Ext.panel.Panel', { - autoScroll : true, - ui : 'custom', - listeners : { - scope: this, - activate: function() - { - this.updateChartTask.delay(500); - }, - resize: function(p) - { - // only re-render after the initial chart rendering - if (this.hasChartData()) { - this.clearMessagePanel(); - this.requestRender(); - } - } - } - }); - } - - return this.viewPanel; - }, - - getDataPanel : function() - { - if (!this.dataPanel) - { - this.dataPanel = Ext4.create('Ext.panel.Panel', { - flex : 1, - layout : 'fit', - border : false, - items : [ - Ext4.create('Ext.Component', { - autoScroll : true, - listeners : { - scope : this, - render : function(cmp){ - this.renderDataGrid(cmp.getId()); - } - } - }) - ] - }); - } - - return this.dataPanel; - }, - - getCenterPanel : function() - { - if (!this.centerPanel) - { - this.centerPanel = Ext4.create('Ext.panel.Panel', { - border: false, - layout: { - type: 'card', - deferredRender: true - }, - activeItem: 0, - items: [this.getViewPanel(), this.getDataPanel()], - dockedItems: [this.getTopButtonBar(), this.getMsgPanel()] - }); - } - - return this.centerPanel; - }, - - getChartTypeBtn : function() - { - if (!this.chartTypeBtn) - { - this.chartTypeBtn = Ext4.create('Ext.button.Button', { - text: 'Chart Type', - handler: this.showChartTypeWindow, - scope: this - }); - } - - return this.chartTypeBtn; - }, - - getChartLayoutBtn : function() - { - if (!this.chartLayoutBtn) - { - this.chartLayoutBtn = Ext4.create('Ext.button.Button', { - text: 'Chart Layout', - disabled: true, - handler: this.showChartLayoutWindow, - scope: this - }); - } - - return this.chartLayoutBtn; - }, - - getHelpBtn : function() - { - if (!this.helpBtn) - { - this.helpBtn = Ext4.create('Ext.button.Button', { - text: 'Help', - scope: this, - menu: { - showSeparator: false, - items: [{ - text: 'Reports and Visualizations', - iconCls: 'fa fa-table', - hrefTarget: '_blank', - href: LABKEY.Utils.getHelpTopicHref('reportsAndViews') - },{ - text: 'Bar Plots', - iconCls: 'fa fa-bar-chart', - hrefTarget: '_blank', - href: LABKEY.Utils.getHelpTopicHref('barchart') - },{ - text: 'Box Plots', - iconCls: 'fa fa-sliders fa-rotate-90', - hrefTarget: '_blank', - href: LABKEY.Utils.getHelpTopicHref('boxplot') - },{ - text: 'Line Plots', - iconCls: 'fa fa-line-chart', - hrefTarget: '_blank', - href: LABKEY.Utils.getHelpTopicHref('lineplot') - },{ - text: 'Pie Charts', - iconCls: 'fa fa-pie-chart', - hrefTarget: '_blank', - href: LABKEY.Utils.getHelpTopicHref('piechart') - },{ - text: 'Scatter Plots', - iconCls: 'fa fa-area-chart', - hrefTarget: '_blank', - href: LABKEY.Utils.getHelpTopicHref('scatterplot') - }] - } - }); - } - - return this.helpBtn; - }, - - getSaveBtn : function() - { - if (!this.saveBtn) - { - this.saveBtn = Ext4.create('Ext.button.Button', { - text: "Save", - hidden: LABKEY.user.isGuest || this.hideSave, - disabled: true, - handler: function(){ - this.onSaveBtnClicked(false) - }, - scope: this - }); - } - - return this.saveBtn; - }, - - getSaveAsBtn : function() - { - if (!this.saveAsBtn) - { - this.saveAsBtn = Ext4.create('Ext.button.Button', { - text: "Save As", - hidden : this.isNew() || LABKEY.user.isGuest || this.hideSave, - disabled: true, - handler: function(){ - this.onSaveBtnClicked(true); - }, - scope: this - }); - } - - return this.saveAsBtn; - }, - - getToggleViewBtn : function() - { - if (!this.toggleViewBtn) - { - this.toggleViewBtn = Ext4.create('Ext.button.Button', { - text:'View Data', - hidden: this.hideViewData, - scope: this, - handler: function() - { - if (this.getViewPanel().isHidden()) - { - this.getCenterPanel().getLayout().setActiveItem(0); - this.toggleViewBtn.setText('View Data'); - - this.getChartTypeBtn().show(); - this.getChartLayoutBtn().show(); - - if (Ext4.isArray(this.customButtons)) - { - for (var i = 0; i < this.customButtons.length; i++) - this.customButtons[i].show(); - } - } - else - { - this.getCenterPanel().getLayout().setActiveItem(1); - this.toggleViewBtn.setText('View Chart'); - - this.getMsgPanel().removeAll(); - this.getChartTypeBtn().hide(); - this.getChartLayoutBtn().hide(); - - if (Ext4.isArray(this.customButtons)) - { - for (var i = 0; i < this.customButtons.length; i++) - this.customButtons[i].hide(); - } - } - } - }); - } - - return this.toggleViewBtn; - }, - - getEditBtn : function() - { - if (!this.editBtn) - { - this.editBtn = Ext4.create('Ext.button.Button', { - xtype: 'button', - text: 'Edit', - scope: this, - handler: function() { - window.location = this.editModeURL; - } - }); - } - - return this.editBtn; - }, - - getTopButtonBar : function() - { - if (!this.topButtonBar) - { - this.topButtonBar = Ext4.create('Ext.toolbar.Toolbar', { - dock: 'top', - items: this.initTbarItems() - }); - } - - return this.topButtonBar; - }, - - initTbarItems : function() - { - var tbarItems = []; - tbarItems.push(this.getToggleViewBtn()); - tbarItems.push(this.getHelpBtn()); - tbarItems.push('->'); // rest of buttons will be right aligned - - if (this.editMode) - { - tbarItems.push(this.getChartTypeBtn()); - tbarItems.push(this.getChartLayoutBtn()); - - if (Ext4.isArray(this.customButtons)) - { - tbarItems.push(''); // horizontal spacer - for (var i = 0; i < this.customButtons.length; i++) - { - var btn = this.customButtons[i]; - btn.scope = this; - tbarItems.push(btn); - } - } - - if (!LABKEY.user.isGuest && !this.hideSave) - tbarItems.push(''); // horizontal spacer - if (this.canEdit) - tbarItems.push(this.getSaveBtn()); - tbarItems.push(this.getSaveAsBtn()); - } - else if (this.allowEditMode && this.editModeURL != null) - { - // add an "edit" button if the user is allowed to toggle to edit mode for this report - tbarItems.push(this.getEditBtn()); - } - - return tbarItems; - }, - - getMsgPanel : function() { - if (!this.msgPanel) { - this.msgPanel = Ext4.create('Ext.panel.Panel', { - hidden: true, - bodyStyle: 'border-width: 1px 0 0 0', - listeners: { - add: function(panel) { - panel.show(); - }, - remove: function(panel) { - if (panel.items.items.length == 0) { - panel.hide(); - } - } - } - }); - } - - return this.msgPanel; - }, - - showChartTypeWindow : function() - { - // make sure the chartTypePanel is shown in the window - if (this.getChartTypeWindow().items.items.length == 0) - this.getChartTypeWindow().add(this.getChartTypePanel()); - - this.getChartTypeWindow().show(); - }, - - showChartLayoutWindow : function() - { - this.getChartLayoutWindow().show(); - }, - - isNew : function() - { - return !this.savedReportInfo; - }, - - getChartTypeWindow : function() - { - if (!this.chartTypeWindow) - { - var panel = this.getChartTypePanel(); - - this.chartTypeWindow = Ext4.create('LABKEY.vis.ChartWizardWindow', { - panelToMask: this, - onEsc: function() { - panel.cancelHandler.call(panel); - }, - items: [panel] - }); - - // propagate the show event to the panel so it can stash the initial values - this.chartTypeWindow.on('show', function(window) - { - this.getChartTypePanel().fireEvent('show', this.getChartTypePanel()); - }, this); - } - - return this.chartTypeWindow; - }, - - getChartTypePanel : function() - { - if (!this.chartTypePanel) - { - this.chartTypePanel = Ext4.create('LABKEY.vis.ChartTypePanel', { - chartTypesToHide: ['time_chart'], - selectedType: this.getSelectedChartType(), - selectedFields: Ext4.apply(this.measures, { trendline: this.trendline }), - restrictColumnsEnabled: this.restrictColumnsEnabled, - customRenderTypes: this.customRenderTypes, - baseQueryKey: this.schemaName + '.' + this.queryName, - studyQueryName: this.schemaName == 'study' ? this.queryName : null - }); - } - - if (!this.hasAttachedChartTypeListeners) - { - this.chartTypePanel.on('cancel', this.closeChartTypeWindow, this); - this.chartTypePanel.on('apply', this.applyChartTypeSelection, this); - this.hasAttachedChartTypeListeners = true; - } - - return this.chartTypePanel; - }, - - closeChartTypeWindow : function(panel) - { - if (this.getChartTypeWindow().isVisible()) - this.getChartTypeWindow().hide(); - }, - - applyChartTypeSelection : function(panel, values, skipRender) - { - // close the window and clear any previous charts - this.closeChartTypeWindow(); - this.clearChartPanel(true); - - // only apply the values for the applicable chart type - if (Ext4.isObject(values) && values.type == 'time_chart') - return; - - this.setRenderType(values.type); - this.measures = values.fields; - if (values.fields.xSub) { - this.measures.color = this.measures.xSub; - } - - if (values.altValues.trendline) { - this.trendline = values.altValues.trendline; - // if the chart data has already been loaded then we only need to query the trendlineData - if (this.measureStore) this.queryTrendlineData(); - } - - this.getChartLayoutPanel().onMeasuresChange(this.measures, this.renderType); - this.getChartLayoutPanel().updateVisibleLayoutOptions(this.getSelectedChartTypeData(), this.measures); - this.ensureChartLayoutOptions(); - - if (!skipRender) - this.renderChart(); - }, - - getSelectedChartTypeData : function() - { - var selectedChartType = this.getChartTypePanel().getSelectedType(); - return selectedChartType ? selectedChartType.data : null; - }, - - getChartLayoutWindow : function() - { - if (!this.chartLayoutWindow) - { - var panel = this.getChartLayoutPanel(); - - this.chartLayoutWindow = Ext4.create('LABKEY.vis.ChartWizardWindow', { - panelToMask: this, - onEsc: function() { - panel.cancelHandler.call(panel); - }, - items: [panel] - }); - - // propagate the show event to the panel so it can stash the initial values - this.chartLayoutWindow.on('show', function(window) - { - this.getChartLayoutPanel().fireEvent('show', this.getChartLayoutPanel(), this.getSelectedChartTypeData(), this.measures); - }, this); - } - - return this.chartLayoutWindow; - }, - - getChartLayoutPanel : function() - { - if (!this.chartLayoutPanel) - { - this.chartLayoutPanel = Ext4.create('LABKEY.vis.ChartLayoutPanel', { - options: this.options, - isDeveloper: this.isDeveloper, - renderType: this.renderType, - initMeasures: this.measures, - multipleCharts: this.options && this.options.general && this.options.general.chartLayout !== 'single', - defaultChartLabel: this.getDefaultTitle(), - defaultOpacity: this.renderType == 'bar_chart' || this.renderType == 'line_plot' ? 100 : undefined, - defaultLineWidth: this.renderType == 'line_plot' ? 3 : undefined, - isSavedReport: !this.isNew(), - listeners: { - scope: this, - cancel: function(panel) - { - this.getChartLayoutWindow().hide(); - }, - apply: function(panel, values) - { - // special case for trendlineData: if there was a change to x-axis scale type or range, - // we need to reload the trendlineData - if (this.trendlineData) this.queryTrendlineData(); - - // note: this event will only fire if a change was made in the Chart Layout panel - this.ensureChartLayoutOptions(); - this.clearChartPanel(true); - this.renderChart(); - this.getChartLayoutWindow().hide(); - } - } - }); - } - - return this.chartLayoutPanel; - }, - - getExportScriptWindow : function() - { - if (!this.exportScriptWindow) - { - this.exportScriptWindow = Ext4.create('LABKEY.vis.ChartWizardWindow', { - panelToMask: this, - items: [this.getExportScriptPanel()] - }); - } - - return this.exportScriptWindow; - }, - - getExportScriptPanel : function() - { - if (!this.exportScriptPanel) - { - this.exportScriptPanel = Ext4.create('LABKEY.vis.GenericChartScriptPanel', { - width: Math.max(this.getViewPanel().getWidth() - 100, 800), - listeners: { - scope: this, - closeOptionsWindow: function(){ - this.getExportScriptWindow().hide(); - } - } - }); - } - - return this.exportScriptPanel; - }, - - getSaveWindow : function() - { - if (!this.saveWindow) - { - this.saveWindow = Ext4.create('LABKEY.vis.ChartWizardWindow', { - panelToMask: this, - items: [this.getSavePanel()] - }); - } - - return this.saveWindow; - }, - - getSavePanel : function() - { - if (!this.savePanel) - { - this.savePanel = Ext4.create('LABKEY.vis.SaveOptionsPanel', { - allowInherit: this.allowInherit, - canEdit: this.canEdit, - canShare: this.canShare, - listeners: { - scope: this, - closeOptionsWindow: function() - { - this.getSaveWindow().close() - }, - saveChart: this.saveReport - } - }); - } - - return this.savePanel; - }, - - ensureChartLayoutOptions : function() - { - // Make sure that we have the latest chart layout panel values. - // This will get the initial default values if the user has not yet opened the chart layout dialog. - // This will also preserve the developer pointClickFn if the user is not a developer. - Ext4.apply(this.options, this.getChartLayoutPanel().getValues()); - }, - - setRenderType : function(newRenderType) - { - if (this.renderType != newRenderType) - this.renderType = newRenderType; - }, - - renderDataGrid : function(renderTo) - { - var filters = []; - var removableFilters = this.userFilters; - - if (this.isDataRegionPresent()) - { - // If the region exists, then apply it's user filters as immutable filters to the QWP. - // They can be mutated on the data region directly. - filters = this.getDataRegionFilters(); - } - else - { - // If the region does not exist, then apply it's filters from the URL - // and allow them to be mutated on the QWP. - removableFilters = removableFilters.concat(this.getURLFilters()); - } - - var allFilters = this.getUniqueFilters(filters.concat(removableFilters)); - var userSort = LABKEY.Filter.getSortFromUrl(this.getFilterURL(), this.dataRegionName); - - this.currentFilterStr = this.createFilterString(allFilters); - this.currentParameterStr = Ext4.JSON.encode(this.parameters); - - var wpConfig = { - schemaName : this.schemaName, - queryName : this.queryName, - viewName : this.viewName, - columns : this.savedColumns, - parameters : this.parameters, - frame : 'none', - filters : filters, - disableAnalytics : true, - removeableFilters : removableFilters, - removeableSort : userSort, - showSurroundingBorder : false, - allowHeaderLock : false, - buttonBar : { - includeStandardButton: false, - items: [LABKEY.QueryWebPart.standardButtons.exportRows] - } - }; - - if (this.dataRegionName) { - wpConfig.dataRegionName = this.dataRegionName + '-chartdata'; - } - - var wp = new LABKEY.QueryWebPart(wpConfig); - - // save the dataregion - this.panelDataRegionName = wp.dataRegionName; - - // Issue 21418: support for parameterized queries - wp.on('render', function(){ - if (wp.parameters) - Ext4.apply(this.parameters, wp.parameters); - }, this); - - wp.render(renderTo); - }, - - /** - * Returns the filters applied to the associated Data Region. This region's presence is optional. - */ - getDataRegionFilters : function() - { - if (this.isDataRegionPresent()) - return LABKEY.DataRegions[this.dataRegionName].getUserFilterArray(); - - return []; - }, - - getFilterURL : function() - { - return LABKEY.ActionURL.getParameters(this.baseUrl)['filterUrl']; - }, - - /** - * Returns the filters applied to the associated QueryWebPart (QWP). The QWP's presence is optional. - * If this QWP is available, then this method will return any user modifiable filters from the QWP. - */ - getQWPFilters : function() - { - // The associated QWP may not exist yet if the user hasn't viewed it - if (this.isQWPPresent()) - return LABKEY.DataRegions[this.panelDataRegionName].getUserFilterArray(); - - return []; - }, - - getURLFilters : function() - { - return LABKEY.Filter.getFiltersFromUrl(this.getFilterURL(), this.dataRegionName); - }, - - isDataRegionPresent : function() - { - return LABKEY.DataRegions[this.dataRegionName] !== undefined; - }, - - isQWPPresent : function() - { - return LABKEY.DataRegions[this.panelDataRegionName] !== undefined; - }, - - // Returns a configuration based on the baseUrl plus any filters applied on the dataregion panel - // the configuration can be used to make a selectRows request - getQueryConfig : function(serialize) - { - var config = { - schemaName : this.schemaName, - queryName : this.queryName, - viewName : this.viewName, - dataRegionName: this.dataRegionName, - queryLabel : this.queryLabel, - parameters : this.parameters, - requiredVersion : 17.1, // Issue 49753 - maxRows: -1, - sort: LABKEY.vis.GenericChartHelper.getQueryConfigSortKey(this.measures), - method: 'POST' - }; - - config.columns = this.getQueryConfigColumns(); - - if (!serialize) - { - config.success = this.onSelectRowsSuccess; - config.failure = function(response, opts){ - var error, errorDiv; - - this.getEl().unmask(); - - if (response.exception) - { - error = '

' + response.exception + '

'; - if (response.exceptionClass == 'org.labkey.api.view.NotFoundException') - error = error + '

The source dataset, list, or query may have been deleted.

' - } - - errorDiv = Ext4.create('Ext.container.Container', { - border: 1, - autoEl: {tag: 'div'}, - padding: 10, - html: '

An unexpected error occurred while retrieving data.

' + error, - autoScroll: true - }); - - // Issue 18157 - this.getChartTypeBtn().disable(); - this.getChartLayoutBtn().disable(); - this.getToggleViewBtn().disable(); - this.getSaveBtn().disable(); - this.getSaveAsBtn().disable(); - - this.getViewPanel().add(errorDiv); - }; - config.scope = this; - } - - // Filter scenarios (Issue 37153, Issue 40384) - // 1. Filters defined explicitly on chart configuration (this.userFilters) - // 2. Filters defined on associated QWP (panelDataRegionName) - // 3. Filters defined on associated Data Region (dataRegionName) - // 4. Filters defined on URL for associated Data Region (overlap with #3) - var filters = this.getDataRegionFilters(); - - // If the QWP is present, then it is expected to have the "userFilters" and URL filters already applied. - // Additionally, they are removable from the QWP so respect the current filters on the QWP. - if (this.isQWPPresent()) - filters = filters.concat(this.getQWPFilters()); - else - filters = filters.concat(this.userFilters, this.getURLFilters()); - - filters = this.getUniqueFilters(filters); - - if (serialize) - { - var newFilters = []; - - for (var i=0; i < filters.length; i++) - { - var f = filters[i]; - newFilters.push({name : f.getColumnName(), value : f.getValue(), type : f.getFilterType().getURLSuffix()}); - } - - filters = newFilters; - } - - config.filterArray = filters; - - return config; - }, - - filterToString : function(filter) - { - return filter.getURLParameterName() + '=' + filter.getURLParameterValue(); - }, - - getUniqueFilters : function(filters) - { - var filterKeys = {}; - var filterSet = []; - - for (var x=0; x < filters.length; x++) - { - var ff = filters[x]; - var key = this.filterToString(ff); - - if (!filterKeys[key]) - { - filterKeys[key] = true; - filterSet.push(ff); - } - } - - return filterSet; - }, - - getQueryConfigColumns : function() - { - var columns = null; - - if (!this.editMode) - { - // If we're not in edit mode or if this is the first load we need to only load the minimum amount of data. - columns = []; - var measures = this.getChartConfig().measures; - - if (measures.x) - { - this.addMeasureForColumnQuery(columns, measures.x); - } - else if (this.autoColumnXName) - { - columns.push(this.autoColumnXName.toString()); - } - else - { - // Check if we have cohorts available - var queryColumnNames = this.getChartTypePanel().getQueryColumnNames(); - for (var i = 0; i < queryColumnNames.length; i++) - { - if (queryColumnNames[i].indexOf('Cohort') > -1) - columns.push(queryColumnNames[i]); - } - } - - if (measures.y) { - this.addMeasureForColumnQuery(columns, measures.y); - } - else if (this.autoColumnYName) { - columns.push(this.autoColumnYName.toString()); - } - - if (this.autoColumnName) { - columns.push(this.autoColumnName.toString()); - } - - Ext4.each(['ySub', 'xSub', 'color', 'shape', 'series'], function(name) { - if (measures[name]) { - this.addMeasureForColumnQuery(columns, measures[name]); - } - }, this); - } - else - { - // If we're in edit mode then we can load all of the columns. - columns = this.getChartTypePanel().getQueryColumnFieldKeys(); - } - - return columns; - }, - - addMeasureForColumnQuery : function(columns, initMeasure) - { - // account for the measure being a single object or an array of objects - var measures = Ext4.isArray(initMeasure) ? initMeasure : [initMeasure]; - Ext4.each(measures, function(measure) { - if (Ext4.isObject(measure)) - { - columns.push(measure.name); - - // Issue 27814: names with slashes need to be queried by encoded name - var encodedName = LABKEY.QueryKey.encodePart(measure.name); - if (measure.name !== encodedName) - columns.push(encodedName); - } - }); - }, - - getChartConfig : function() - { - var config = {}; - - config.renderType = this.renderType; - config.measures = Ext4.apply({}, this.measures); - config.scales = {}; - config.labels = {}; - - this.ensureChartLayoutOptions(); - if (this.options.general) - { - config.width = this.options.general.width; - config.height = this.options.general.height; - config.pointType = this.options.general.pointType; - config.labels.main = this.options.general.label; - config.labels.subtitle = this.options.general.subtitle; - config.labels.footer = this.options.general.footer; - - config.geomOptions = Ext4.apply({}, this.options.general); - config.geomOptions.showOutliers = config.pointType ? config.pointType == 'outliers' : true; - config.geomOptions.pieInnerRadius = this.options.general.pieInnerRadius; - config.geomOptions.pieOuterRadius = this.options.general.pieOuterRadius; - config.geomOptions.showPiePercentages = this.options.general.showPiePercentages; - config.geomOptions.piePercentagesColor = this.options.general.piePercentagesColor; - config.geomOptions.pieHideWhenLessThanPercentage = this.options.general.pieHideWhenLessThanPercentage; - config.geomOptions.gradientPercentage = this.options.general.gradientPercentage; - config.geomOptions.gradientColor = this.options.general.gradientColor; - config.geomOptions.colorPaletteScale = this.options.general.colorPaletteScale; - config.geomOptions.binShape = this.options.general.binShapeGroup; - config.geomOptions.binThreshold = this.options.general.binThreshold; - config.geomOptions.colorRange = this.options.general.binColorGroup; - config.geomOptions.binSingleColor = this.options.general.binSingleColor; - config.geomOptions.chartLayout = this.options.general.chartLayout; - config.geomOptions.marginTop = this.options.general.marginTop; - config.geomOptions.marginRight = this.options.general.marginRight; - config.geomOptions.marginBottom = this.options.general.marginBottom; - config.geomOptions.marginLeft = this.options.general.marginLeft; - } - - if (this.options.x) - { - this.applyAxisOptionsToConfig(this.options, config, 'x'); - if (this.measures.xSub) { - config.labels.xSub = this.measures.xSub.label; - } - } - - this.applyAxisOptionsToConfig(this.options, config, 'y'); - this.applyAxisOptionsToConfig(this.options, config, 'yRight'); - - if (this.options.developer) - config.measures.pointClickFn = this.options.developer.pointClickFn; - - if (this.curveFit) { - config.curveFit = this.curveFit; - } else if (this.trendline) { - config.geomOptions.trendlineType = this.trendline.trendlineType; - config.geomOptions.trendlineAsymptoteMin = this.trendline.trendlineAsymptoteMin; - config.geomOptions.trendlineAsymptoteMax = this.trendline.trendlineAsymptoteMax; - } - - if (this.getCustomChartOptions) - config.customOptions = this.getCustomChartOptions(); - - return config; - }, - - applyAxisOptionsToConfig : function(options, config, axisName) { - if (options[axisName]) - { - if (!config.labels[axisName]) { - config.labels[axisName] = options[axisName].label; - config.scales[axisName] = { - type: options[axisName].scaleRangeType || 'automatic', - trans: options[axisName].trans || options[axisName].scaleTrans - }; - } - - if (config.scales[axisName].type === "manual" && options[axisName].scaleRange) { - config.scales[axisName].min = options[axisName].scaleRange.min; - config.scales[axisName].max = options[axisName].scaleRange.max; - } - - if (options[axisName].aggregate) { - config.measures[axisName].aggregate = options[axisName].aggregate; - } - if (options[axisName].errorBars) { - config.measures[axisName].errorBars = options[axisName].errorBars; - } - } - }, - - markDirty : function(dirty) - { - this.dirty = dirty; - LABKEY.Utils.signalWebDriverTest("genericChartDirty", dirty); - }, - - isDirty : function() - { - return !LABKEY.user.isGuest && !this.hideSave && this.canEdit && this.dirty; - }, - - beforeUnload : function() - { - if (this.isDirty()) { - return 'please save your changes'; - } - }, - - getCurrentReportConfig : function() - { - var reportConfig = { - reportId : this.savedReportInfo ? this.savedReportInfo.reportId : undefined, - schemaName : this.schemaName, - queryName : this.queryName, - viewName : this.viewName, - dataRegionName: this.dataRegionName, - renderType : this.renderType, - jsonData : { - queryConfig : this.getQueryConfig(true), - chartConfig : this.getChartConfig() - } - }; - - var chartConfig = reportConfig.jsonData.chartConfig; - LABKEY.vis.GenericChartHelper.removeNumericConversionConfig(chartConfig); - - return reportConfig; - }, - - saveReport : function(data) - { - var reportConfig = this.getCurrentReportConfig(); - reportConfig.name = data.reportName; - reportConfig.description = data.reportDescription; - - reportConfig["public"] = data.shared; - reportConfig.inheritable = data.inheritable; - reportConfig.thumbnailType = data.thumbnailType; - reportConfig.svg = this.chartSVG; - - if (data.isSaveAs) - reportConfig.reportId = null; - - LABKEY.Ajax.request({ - url : LABKEY.ActionURL.buildURL('visualization', 'saveGenericReport.api'), - method : 'POST', - headers : { - 'Content-Type' : 'application/json' - }, - jsonData: reportConfig, - success : function(resp) - { - this.getSaveWindow().close(); - this.markDirty(false); - - // show success message and then fade the window out - var msgbox = Ext4.create('Ext.window.Window', { - html : 'Report saved successfully.', - cls : 'chart-wizard-dialog', - bodyStyle : 'background: transparent;', - header : false, - border : false, - padding : 20, - resizable: false, - draggable: false - }); - - msgbox.show(); - msgbox.getEl().fadeOut({ - delay : 1500, - duration: 1000, - callback : function() - { - msgbox.hide(); - } - }); - - // if a new report was created, we need to refresh the page with the correct report id on the URL - if (this.isNew() || data.isSaveAs) - { - var o = Ext4.decode(resp.responseText); - window.location = LABKEY.ActionURL.buildURL('reports', 'runReport', null, {reportId: o.reportId}); - } - }, - failure : this.onFailure, - scope : this - }); - }, - - onFailure : function(resp) - { - var error = Ext4.isString(resp.responseText) ? Ext4.decode(resp.responseText).exception : resp.exception; - Ext4.Msg.show({ - title: 'Error', - msg: error || 'An unknown error has occurred.', - buttons: Ext4.MessageBox.OK, - icon: Ext4.MessageBox.ERROR, - scope: this - }); - }, - - loadReportFromId : function(reportId) - { - this.reportLoaded = false; - - LABKEY.Query.Visualization.get({ - reportId: reportId, - scope: this, - success: function(result) - { - this.savedReportInfo = result; - this.loadSavedConfig(); - } - }); - }, - - loadSavedConfig : function() - { - var config = this.savedReportInfo, - queryConfig = {}, - chartConfig = {}; - - if (config.type == LABKEY.Query.Visualization.Type.GenericChart) - { - queryConfig = config.visualizationConfig.queryConfig; - chartConfig = config.visualizationConfig.chartConfig; - } - - this.schemaName = queryConfig.schemaName; - this.queryName = queryConfig.queryName; - this.viewName = queryConfig.viewName; - this.dataRegionName = queryConfig.dataRegionName; - - if (this.reportName) - this.reportName.setValue(config.name); - - if (this.reportDescription && config.description != null) - this.reportDescription.setValue(config.description); - - // TODO is this needed/used anymore? - if (this.reportPermission) - this.reportPermission.setValue({"public" : config.shared}); - - this.getSavePanel().setReportInfo({ - name: config.name, - description: config.description, - shared: config.shared, - inheritable: config.inheritable, - reportProps: config.reportProps, - thumbnailURL: config.thumbnailURL - }); - - this.loadQueryInfoFromConfig(queryConfig); - this.loadMeasuresFromConfig(chartConfig); - this.loadOptionsFromConfig(chartConfig); - - // if the renderType was not saved with the report info, get it based off of the x-axis measure type - this.renderType = chartConfig.renderType || this.getRenderType(chartConfig); - - this.markDirty(false); - this.reportLoaded = true; - this.updateChartTask.delay(500); - }, - - loadQueryInfoFromConfig : function(queryConfig) - { - if (Ext4.isObject(queryConfig)) - { - if (Ext4.isArray(queryConfig.filterArray)) - { - var filters = []; - for (var i=0; i < queryConfig.filterArray.length; i++) - { - var f = queryConfig.filterArray[i]; - var type = LABKEY.Filter.getFilterTypeForURLSuffix(f.type); - if (type !== undefined) { - var value = type.isMultiValued() ? f.value : (Ext4.isArray(f.value) ? f.value[0]: f.value); - filters.push(LABKEY.Filter.create(f.name, value, type)); - } - } - this.userFilters = filters; - } - - if (queryConfig.columns) - this.savedColumns = queryConfig.columns; - - if (queryConfig.queryLabel) - this.queryLabel = queryConfig.queryLabel; - - if (queryConfig.parameters) - this.parameters = queryConfig.parameters; - } - }, - - loadMeasuresFromConfig : function(chartConfig) - { - this.measures = {}; - - if (Ext4.isObject(chartConfig)) - { - if (Ext4.isObject(chartConfig.measures)) - { - Ext4.each(['x', 'y', 'xSub', 'color', 'shape', 'series'], function(name) { - if (chartConfig.measures[name]) { - this.measures[name] = chartConfig.measures[name]; - } - }, this); - } - } - }, - - loadOptionsFromConfig : function(chartConfig) - { - this.options = {}; - - if (Ext4.isObject(chartConfig)) - { - this.options.general = {}; - if (chartConfig.height) - this.options.general.height = chartConfig.height; - if (chartConfig.width) - this.options.general.width = chartConfig.width; - if (chartConfig.pointType) - this.options.general.pointType = chartConfig.pointType; - if (chartConfig.geomOptions) - Ext4.apply(this.options.general, chartConfig.geomOptions); - - if (chartConfig.labels && LABKEY.Utils.isString(chartConfig.labels.main)) - this.options.general.label = chartConfig.labels.main; - else - this.options.general.label = this.getDefaultTitle(); - - if (chartConfig.labels && chartConfig.labels.subtitle) - this.options.general.subtitle = chartConfig.labels.subtitle; - if (chartConfig.labels && chartConfig.labels.footer) - this.options.general.footer = chartConfig.labels.footer; - - this.loadAxisOptionsFromConfig(chartConfig, 'x'); - this.loadAxisOptionsFromConfig(chartConfig, 'y'); - this.loadAxisOptionsFromConfig(chartConfig, 'yRight'); - - this.options.developer = {}; - if (chartConfig.measures && chartConfig.measures.pointClickFn) - this.options.developer.pointClickFn = chartConfig.measures.pointClickFn; - - if (chartConfig.curveFit) { - this.curveFit = chartConfig.curveFit; - } else if (chartConfig.geomOptions.trendlineType) { - this.trendline = { - trendlineType: chartConfig.geomOptions.trendlineType, - trendlineAsymptoteMin: chartConfig.geomOptions.trendlineAsymptoteMin, - trendlineAsymptoteMax: chartConfig.geomOptions.trendlineAsymptoteMax, - } - } - } - }, - - loadAxisOptionsFromConfig : function(chartConfig, axisName) { - this.options[axisName] = {}; - if (chartConfig.labels && chartConfig.labels[axisName]) { - this.options[axisName].label = chartConfig.labels[axisName]; - } - if (chartConfig.scales && chartConfig.scales[axisName]) { - Ext4.apply(this.options[axisName], chartConfig.scales[axisName]); - } - if (chartConfig.measures && chartConfig.measures[axisName]) { - if (chartConfig.measures[axisName].aggregate) - this.options[axisName].aggregate = chartConfig.measures[axisName].aggregate.value ?? chartConfig.measures[axisName].aggregate; - if (chartConfig.measures[axisName].errorBars) - this.options[axisName].errorBars = chartConfig.measures[axisName].errorBars; - } - }, - - loadInitialSelection : function() - { - if (Ext4.isObject(this.initialSelection)) - { - this.applyChartTypeSelection(this.getChartTypePanel(), this.initialSelection, true); - // clear the initial selection object so it isn't loaded again - this.initialSelection = undefined; - - this.markDirty(false); - this.reportLoaded = true; - this.updateChartTask.delay(500); - } - }, - - handleNoData : function(errorMsg) - { - // Issue 18339 - this.setRenderRequested(false); - var errorDiv = Ext4.create('Ext.container.Container', { - border: 1, - autoEl: {tag: 'div'}, - html: '

An unexpected error occurred while retrieving data.

' + errorMsg, - autoScroll: true - }); - - this.getChartTypeBtn().disable(); - this.getChartLayoutBtn().disable(); - this.getSaveBtn().disable(); - this.getSaveAsBtn().disable(); - - // Keep the toggle button enabled so the user can remove filters - this.getToggleViewBtn().enable(); - - this.clearChartPanel(true); - this.getViewPanel().add(errorDiv); - this.getEl().unmask(); - }, - - renderPlot : function() - { - // Don't attempt to render if the view panel isn't visible or the chart type window is visible. - if (!this.isVisible() || this.getViewPanel().isHidden() || this.getChartTypeWindow().isVisible()) - return; - - // initMeasures returns false and opens the Chart Type panel if a required measure is not chosen by the user. - if (!this.initMeasures()) - return; - - this.clearChartPanel(false); - - var chartConfig = this.getChartConfig(); - var renderType = this.getRenderType(chartConfig); - - this.renderGenericChart(renderType, chartConfig); - - // We just rendered the plot, we don't need to request another render. - this.setRenderRequested(false); - }, - - getRenderType : function(chartConfig) - { - return LABKEY.vis.GenericChartHelper.getChartType(chartConfig); - }, - - renderGenericChart : function(chartType, chartConfig) - { - var aes, scales, customRenderType, hasNoDataMsg, newChartDiv, valueConversionResponse; - - hasNoDataMsg = LABKEY.vis.GenericChartHelper.validateResponseHasData(this.getMeasureStore(), true); - if (hasNoDataMsg != null) - this.addWarningText(hasNoDataMsg); - - this.getEl().mask('Rendering Chart...'); - - aes = LABKEY.vis.GenericChartHelper.generateAes(chartType, chartConfig.measures, this.getSchemaName(), this.getQueryName()); - - valueConversionResponse = LABKEY.vis.GenericChartHelper.doValueConversion(chartConfig, aes, this.renderType, this.getMeasureStoreRecords()); - if (!Ext4.Object.isEmpty(valueConversionResponse.processed)) - { - Ext4.Object.merge(chartConfig.measures, valueConversionResponse.processed); - //re-generate aes based on new converted values - aes = LABKEY.vis.GenericChartHelper.generateAes(chartType, chartConfig.measures, this.getSchemaName(), this.getQueryName()); - if (valueConversionResponse.warningMessage) { - this.addWarningText(valueConversionResponse.warningMessage); - } - } - - customRenderType = this.customRenderTypes ? this.customRenderTypes[this.renderType] : undefined; - if (customRenderType && customRenderType.generateAes) - aes = customRenderType.generateAes(this, chartConfig, aes); - - scales = LABKEY.vis.GenericChartHelper.generateScales(chartType, chartConfig.measures, chartConfig.scales, aes, this.getMeasureStore(), this.defaultNumberFormat); - if (customRenderType && customRenderType.generateScales) - scales = customRenderType.generateScales(this, chartConfig, scales); - - if (!this.isChartConfigValid(chartType, chartConfig, aes, scales)) - return; - - if (chartType == 'scatter_plot' && this.getMeasureStoreRecords().length > chartConfig.geomOptions.binThreshold) { - chartConfig.geomOptions.binned = true; - this.addWarningText("The number of individual points exceeds " - + Ext4.util.Format.number(chartConfig.geomOptions.binThreshold, '0,000') - + ". The data is now grouped by density, which overrides some layout options."); - } - else if (chartType == 'line_plot' && this.getMeasureStoreRecords().length > this.dataPointLimit) { - this.addWarningText("The number of individual points exceeds " - + Ext4.util.Format.number(this.dataPointLimit, '0,000') - + ". Data points will not be shown on this line plot."); - } - - this.beforeRenderPlotComplete(); - - chartConfig.width = this.getPerChartWidth(chartType, chartConfig); - chartConfig.height = this.getPerChartHeight(chartConfig); - - newChartDiv = this.getNewChartDisplayDiv(); - this.getViewPanel().add(newChartDiv); - - var plotConfigArr = this.getPlotConfigs(newChartDiv, chartType, chartConfig, aes, scales, customRenderType, this.trendlineData); - - Ext4.each(plotConfigArr, function(plotConfig) { - if (this.renderType === 'pie_chart') { - new LABKEY.vis.PieChart(plotConfig); - } - else { - var plot = new LABKEY.vis.Plot(plotConfig); - plot.render(); - } - }, this); - - this.afterRenderPlotComplete(newChartDiv, chartType, chartConfig); - }, - - getPerChartWidth : function(chartType, chartConfig) { - if (Ext4.isDefined(chartConfig.width) && chartConfig.width != null) { - return chartConfig.width; - } - else { - // default width based on the view panel width - return LABKEY.vis.GenericChartHelper.getChartTypeBasedWidth(chartType, chartConfig.measures, this.getMeasureStore(), this.getViewPanel().getWidth()) - } - }, - - getPerChartHeight : function(chartConfig) { - if (Ext4.isDefined(chartConfig.height) && chartConfig.height != null) { - return chartConfig.height; - } - else { - // default height based on the view panel height - var height = this.getViewPanel().getHeight() - 25; - if (chartConfig.geomOptions.chartLayout === 'per_measure') { - height = height / 1.25; - } - return height; - } - }, - - getNewChartDisplayDiv : function() - { - return Ext4.create('Ext.container.Container', { - cls: 'chart-render-div', - autoEl: {tag: 'div'} - }); - }, - - beforeRenderPlotComplete : function() - { - // add the warning msg before the plot so the plot has the proper height - if (this.warningText !== null) - this.addWarningMsg(this.warningText, true); - }, - - afterRenderPlotComplete : function(chartDiv, chartType, chartConfig) - { - this.getTopButtonBar().enable(); - this.getChartTypeBtn().enable(); - this.getChartLayoutBtn().enable(); - this.getSaveBtn().enable(); - this.getSaveAsBtn().enable(); - this.attachExportIcons(chartDiv, chartType, chartConfig); - this.getEl().unmask(); - - if (this.editMode && this.supportedBrowser) - this.updateSaveChartThumbnail(chartDiv, chartConfig); - }, - - addWarningMsg : function(warningText, allowDismiss) - { - var warningDivId = Ext4.id(); - var dismissLink = allowDismiss ? 'dismiss' : ''; - - var warningCmp = Ext4.create('Ext.container.Container', { - padding: 10, - cls: 'chart-warning', - html: warningText + ' ' + dismissLink, - listeners: { - scope: this, - render: function(cmp) { - Ext4.get('dismiss-link-' + warningDivId).on('click', function() { - // removing the warning message which will adjust the view panel height, so suspend events temporarily - this.getViewPanel().suspendEvents(); - this.getMsgPanel().remove(cmp); - this.getViewPanel().resumeEvents(); - }, this); - } - } - }); - - // add the warning message which will adjust the view panel height, so suspend events temporarily - this.getViewPanel().suspendEvents(); - this.getMsgPanel().add(warningCmp); - this.getViewPanel().resumeEvents(); - }, - - updateSaveChartThumbnail : function(chartDiv, chartConfig) - { - if (chartDiv.getEl()) { - var size = chartDiv.getEl().getSize(); - size.height = this.getPerChartHeight(chartConfig); - this.chartSVG = LABKEY.vis.SVGConverter.svgToStr(chartDiv.getEl().child('svg').dom); - this.getSavePanel().updateCurrentChartThumbnail(this.chartSVG, size); - } - }, - - isChartConfigValid : function(chartType, chartConfig, aes, scales) - { - var selectedMeasureNames = Object.keys(this.measures), - hasXMeasure = selectedMeasureNames.indexOf('x') > -1 && Ext4.isDefined(aes.x), - hasXSubMeasure = selectedMeasureNames.indexOf('xSub') > -1 && Ext4.isDefined(aes.xSub), - hasYMeasure = selectedMeasureNames.indexOf('y') > -1, - requiredMeasureNames = this.getChartTypePanel().getRequiredFieldNames(); - - // validate that all selected measures still exist by name in the query/dataset - if (!this.validateMeasuresExist(selectedMeasureNames, requiredMeasureNames)) - return false; - - // validate that the x axis measure exists and data is valid - if (hasXMeasure && !this.validateAxisMeasure(chartType, chartConfig, 'x', aes, scales, this.getMeasureStoreRecords())) - return false; - - // validate that the x subcategory axis measure exists and data is valid - if (hasXSubMeasure && !this.validateAxisMeasure(chartType, chartConfig, 'xSub', aes, scales, this.getMeasureStoreRecords())) - return false; - - // validate that the y axis measure exists and data is valid, handle case for single or multiple y-measures selected - if (hasYMeasure) { - var yMeasures = LABKEY.vis.GenericChartHelper.ensureMeasuresAsArray(this.measures['y']); - for (var i = 0; i < yMeasures.length; i++) { - var yMeasure = yMeasures[i]; - var yAes = {y: LABKEY.vis.GenericChartHelper.getYMeasureAes(yMeasure)}; - if (!this.validateAxisMeasure(chartType, yMeasure, 'y', yAes, scales, this.getMeasureStoreRecords(), yMeasure.converted)) { - return false; - } - } - } - - return true; - }, - - getPlotConfigs : function(newChartDiv, chartType, chartConfig, aes, scales, customRenderType, trendlineData) - { - var plotConfigArr = [], geom, labels, data = this.getMeasureStoreRecords(), me = this; - - geom = LABKEY.vis.GenericChartHelper.generateGeom(chartType, chartConfig.geomOptions); - if (chartType === 'line_plot' && data.length > this.dataPointLimit){ - chartConfig.geomOptions.hideDataPoints = true; - } - - labels = LABKEY.vis.GenericChartHelper.generateLabels(chartConfig.labels); - - 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) { - if (d.hasOwnProperty('value') && (!Ext4.isDefined(d.value) || isNaN(d.value))) { - d.value = 0; - } - }); - } - - if (customRenderType && Ext4.isFunction(customRenderType.generatePlotConfig)) { - var plotConfig = customRenderType.generatePlotConfig( - this, chartConfig, newChartDiv.id, - chartConfig.width, chartConfig.height, - data, aes, scales, labels - ); - - plotConfig.rendererType = 'd3'; - plotConfigArr.push(plotConfig); - } - else { - plotConfigArr = LABKEY.vis.GenericChartHelper.generatePlotConfigs(newChartDiv.id, chartConfig, labels, aes, scales, geom, data, trendlineData); - - if (this.renderType === 'pie_chart') - { - if (this.checkForNegativeData(data)) - { - // adding warning text without shrinking height cuts off the footer text - Ext4.each(plotConfigArr, function(plotConfig) { - plotConfig.height = Math.floor(plotConfig.height * 0.95); - }, this); - } - - // because of the load delay, need to reset the thumbnail svg for pie charts - Ext4.each(plotConfigArr, function(plotConfig) { - plotConfig.callbacks = { - onload: function(){ - me.updateSaveChartThumbnail(newChartDiv); - } - }; - }, this); - } - // if client has specified a line type (only applicable for scatter plot), apply that as another layer - else if (this.curveFit && this.measures.x && this.isScatterPlot(this.renderType, this.getXAxisType(this.measures.x))) - { - var factory = this.lineRenderers[this.curveFit.type]; - if (factory) - { - Ext4.each(plotConfigArr, function(plotConfig) { - plotConfig.layers.push( - new LABKEY.vis.Layer({ - geom: new LABKEY.vis.Geom.Path({}), - aes: {x: 'x', y: 'y'}, - data: LABKEY.vis.Stat.fn(factory.createRenderer(this.curveFit.params), this.curveFit.points, this.curveFit.min, this.curveFit.max) - }) - ); - }, this); - } - } - } - - return plotConfigArr; - }, - - checkForNegativeData : function(data) { - var negativesFound = []; - Ext4.each(data, function(entry) { - if (entry.value < 0) { - negativesFound.push(entry.label) - } - }); - - if (negativesFound.length > 0) - { - this.addWarningText('There are negative values in the data that the Pie Chart cannot display. ' - + 'Omitted: ' + negativesFound.join(', ')); - } - - return negativesFound.length > 0; - }, - - initMeasures : function() - { - // Initialize the x and y measures on first chart load. Returns false if we're missing the x or y measure. - var measure, fk, - queryColumnStore = this.getChartTypePanel().getQueryColumnsStore(), - requiredFieldNames = this.getChartTypePanel().getRequiredFieldNames(), - requiresX = requiredFieldNames.indexOf('x') > -1, - requiresY = requiredFieldNames.indexOf('y') > -1; - - if (!this.measures.y) - { - if (this.autoColumnYName || (requiresY && this.autoColumnName)) - { - fk = this.autoColumnYName || this.autoColumnName; - measure = this.getMeasureFromFieldKey(fk); - if (measure) - this.setYAxisMeasure(measure); - } - - if (requiresY && !this.measures.y) - { - this.getEl().unmask(); - this.showChartTypeWindow(); - return false; - } - } - - if (!this.measures.x) - { - if (this.renderType !== "box_plot" && this.renderType !== "auto_plot") - { - if (this.autoColumnXName || (requiresX && this.autoColumnName)) - { - fk = this.autoColumnXName || this.autoColumnName; - measure = this.getMeasureFromFieldKey(fk); - if (measure) - this.setXAxisMeasure(measure); - } - - if (requiresX && !this.measures.x) - { - this.getEl().unmask(); - this.showChartTypeWindow(); - return false; - } - } - else if (this.autoColumnYName != null) - { - measure = queryColumnStore.findRecord('label', 'Study: Cohort', 0, false, true, true); - if (measure) - this.setXAxisMeasure(measure); - - this.autoColumnYName = null; - } - } - - return true; - }, - - getMeasureFromFieldKey : function(fk) - { - var queryColumnStore = this.getChartTypePanel().getQueryColumnsStore(); - - // first search by fk.toString(), for example Analyte.Name -> Analyte$PName - var measure = queryColumnStore.findRecord('fieldKey', fk.toString(), 0, false, true, true); - if (measure != null) { - return measure; - } - - // second look by fk.getName() - return queryColumnStore.findRecord('fieldKey', fk.getName(), 0, false, true, true); - }, - - setYAxisMeasure : function(measure) - { - if (measure) - { - this.measures.y = measure.data ? measure.data : measure; - this.getChartTypePanel().setFieldSelection('y', this.measures.y); - this.getChartLayoutPanel().onMeasuresChange(this.measures, this.renderType); - } - }, - - setXAxisMeasure : function(measure) - { - if (measure) - { - this.measures.x = measure.data ? measure.data : measure; - this.getChartTypePanel().setFieldSelection('x', this.measures.x); - this.getChartLayoutPanel().onMeasuresChange(this.measures, this.renderType); - } - }, - - validateMeasuresExist: function(measureNames, requiredMeasureNames) - { - var store = this.getChartTypePanel().getQueryColumnsStore(), - valid = true, - message = null, - sep = ''; - - // Checks to make sure the measures are still available, if not we show an error. - Ext4.each(measureNames, function(propName) { - if (this.measures[propName] && propName !== 'trendline') { - var propMeasures = this.measures[propName]; - - // some properties allowMultiple so treat all as arrays - propMeasures = LABKEY.vis.GenericChartHelper.ensureMeasuresAsArray(propMeasures); - - Ext4.each(propMeasures, function(propMeasure) { - var indexByFieldKey = store.find('fieldKey', propMeasure.fieldKey, 0, false, false, true), - indexByName = store.find('fieldKey', propMeasure.name, 0, false, false, true); - - if (indexByFieldKey === -1 && indexByName === -1) { - if (message == null) - message = ''; - - message += sep + 'The saved ' + propName + ' measure, ' + propMeasure.name + ', is not available. It may have been renamed or removed.'; - sep = ' '; - - this.removeMeasureFromSelection(propName, propMeasure); - this.getChartTypePanel().setToForceApplyChanges(); - - if (requiredMeasureNames.indexOf(propName) > -1) - valid = false; - } - }, this); - } - }, this); - - this.handleValidation({success: valid, message: Ext4.util.Format.htmlEncode(message)}); - - return valid; - }, - - removeMeasureFromSelection : function(propName, measure) { - if (this.measures[propName]) { - if (!Ext4.isArray(this.measures[propName])) { - delete this.measures[propName]; - } - else { - Ext4.Array.remove(this.measures[propName], measure); - } - } - }, - - validateAxisMeasure : function(chartType, chartConfig, measureName, aes, scales, data, dataConversionHappened) - { - var validation = LABKEY.vis.GenericChartHelper.validateAxisMeasure(chartType, chartConfig, measureName, aes, scales, data, dataConversionHappened); - if (!validation.success) { - this.removeMeasureFromSelection(measureName, chartConfig); - } - - this.handleValidation(validation); - return validation.success; - }, - - handleValidation : function(validation) - { - if (validation.success === true) - { - if (validation.message != null) - this.addWarningText(validation.message); - } - else - { - this.getEl().unmask(); - this.setRenderRequested(false); - - if (this.editMode) - { - this.getChartTypePanel().setToForceApplyChanges(); - - Ext4.Msg.show({ - title: 'Error', - msg: Ext4.util.Format.htmlEncode(validation.message), - buttons: Ext4.MessageBox.OK, - icon: Ext4.MessageBox.ERROR, - fn: this.showChartTypeWindow, - scope: this - }); - } - else - { - this.clearChartPanel(true); - var errorDiv = Ext4.create('Ext.container.Container', { - border: 1, - autoEl: {tag: 'div'}, - padding: 10, - html: '

Error rendering chart:

' + validation.message, - autoScroll: true - }); - this.getViewPanel().add(errorDiv); - } - } - }, - - isScatterPlot : function(renderType, xAxisType) - { - if (renderType === 'scatter_plot') - return true; - - return (renderType === 'auto_plot' && LABKEY.vis.GenericChartHelper.isNumericType(xAxisType)); - }, - - isBoxPlot: function(renderType, xAxisType) - { - if (renderType === 'box_plot') - return true; - - return (renderType == 'auto_plot' && !LABKEY.vis.GenericChartHelper.isNumericType(xAxisType)); - }, - - getSelectedChartType : function() - { - if (Ext4.isString(this.renderType) && this.renderType !== 'auto_plot') - return this.renderType; - else if (this.measures.x && this.isBoxPlot(this.renderType, this.getXAxisType(this.measures.x))) - return 'box_plot'; - else if (this.measures.x && this.isScatterPlot(this.renderType, this.getXAxisType(this.measures.x))) - return 'scatter_plot'; - - return 'bar_plot'; - }, - - getXAxisType : function(xMeasure) - { - return xMeasure ? (xMeasure.normalizedType || xMeasure.type) : null; - }, - - clearChartPanel : function(clearMessages) - { - this.clearWarningText(); - this.getViewPanel().removeAll(); - if (clearMessages) { - this.clearMessagePanel(); - } - }, - - clearMessagePanel : function() { - this.getViewPanel().suspendEvents(); - this.getMsgPanel().removeAll(); - this.getViewPanel().resumeEvents(); - }, - - clearWarningText : function() - { - this.warningText = null; - }, - - addWarningText : function(warning) - { - if (!this.warningText) - this.warningText = Ext4.util.Format.htmlEncode(warning); - else - this.warningText = this.warningText + '  ' + Ext4.util.Format.htmlEncode(warning); - }, - - attachExportIcons : function(chartDiv, chartType, chartConfig) - { - if (this.supportedBrowser) - { - var index = 0; - Ext4.each(chartDiv.getEl().select('svg').elements, function(svgEl) { - chartDiv.add(this.createExportIcon(chartType, chartConfig, 'fa-file-pdf-o', 'Export to PDF', index, 0, function(){ - this.exportChartToImage(svgEl, LABKEY.vis.SVGConverter.FORMAT_PDF); - })); - - chartDiv.add(this.createExportIcon(chartType, chartConfig, 'fa-file-image-o', 'Export to PNG', index, 1, function(){ - this.exportChartToImage(svgEl, LABKEY.vis.SVGConverter.FORMAT_PNG); - })); - - index++; - }, this); - } - if (this.isDeveloper) - { - chartDiv.add(this.createExportIcon(chartType, chartConfig, 'fa-file-code-o', 'Export as Script', 0, this.supportedBrowser ? 2 : 0, function(){ - this.exportChartToScript(); - })); - } - }, - - createExportIcon : function(chartType, chartConfig, iconCls, tooltip, chartIndex, iconIndexFromLeft, callbackFn) - { - var chartWidth = this.getPerChartWidth(chartType, chartConfig), - viewPortWidth = this.getViewPanel().getWidth(), - chartsPerRow = chartWidth > viewPortWidth ? 1 : Math.floor(viewPortWidth / chartWidth), - topPx = Math.floor(chartIndex / chartsPerRow) * this.getPerChartHeight(chartConfig), - leftPx = ((chartIndex % chartsPerRow) * chartWidth) + (iconIndexFromLeft * 30) + 20; - - return Ext4.create('Ext.Component', { - cls: 'export-icon', - style: 'top: ' + topPx + 'px; left: ' + leftPx + 'px;', - html: '', - listeners: { - scope: this, - render: function(cmp) - { - Ext4.create('Ext.tip.ToolTip', { - target: cmp.getEl(), - constrainTo: this.getEl(), - width: 110, - html: tooltip - }); - - cmp.getEl().on('click', callbackFn, this); - } - } - }); - }, - - exportChartToImage : function(svgEl, type) - { - if (svgEl) { - var fileName = this.getChartConfig().labels.main, - exportType = type || LABKEY.vis.SVGConverter.FORMAT_PDF; - - LABKEY.vis.SVGConverter.convert(svgEl, exportType, fileName); - } - }, - - exportChartToScript : function() - { - var chartConfig = LABKEY.vis.GenericChartHelper.removeNumericConversionConfig(this.getChartConfig()); - var queryConfig = this.getQueryConfig(true); - - // Only push the required columns. - queryConfig.columns = []; - - Ext4.each(['x', 'y', 'color', 'shape', 'series'], function(name) { - if (Ext4.isDefined(chartConfig.measures[name])) { - var measuresArr = LABKEY.vis.GenericChartHelper.ensureMeasuresAsArray(chartConfig.measures[name]); - Ext4.each(measuresArr, function(measure) { - queryConfig.columns.push(measure.name); - }, this); - } - }, this); - - var templateConfig = { - chartConfig: chartConfig, - queryConfig: queryConfig - }; - - this.getExportScriptPanel().setScriptValue(templateConfig); - this.getExportScriptWindow().show(); - }, - - createFilterString : function(filters) - { - var filterParams = []; - for (var i = 0; i < filters.length; i++) - { - filterParams.push(this.filterToString(filters[i])); - } - - filterParams.sort(); - return filterParams.join('&'); - }, - - hasChartData : function() - { - return Ext4.isDefined(this.getMeasureStore()) && Ext4.isArray(this.getMeasureStoreRecords()); - }, - - onSelectRowsSuccess : function(measureStore) { - this.measureStore = measureStore; - - // when not in edit mode, we'll use the column metadata from the data query - if (!this.editMode) - this.getChartTypePanel().loadQueryColumns(this.getMeasureStoreMetadata().fields); - - this.queryTrendlineData(); - }, - - queryTrendlineData : async function() { - const chartConfig = this.getChartConfig(); - if (chartConfig.geomOptions.trendlineType && chartConfig.geomOptions.trendlineType !== '') { - this.setDataLoading(true); - - const data = this.getMeasureStoreRecords(); - this.trendlineData = await LABKEY.vis.GenericChartHelper.queryTrendlineData(chartConfig, data); - this.onQueryDataComplete(); - } else { - // trendlineType of '' means use Point-to-Point, i.e. no trendlineData - this.trendlineData = undefined; - this.onQueryDataComplete(); - } - }, - - onQueryDataComplete : function() { - this.setDataLoading(false); - - this.getMsgPanel().removeAll(); - - // If it's already been requested then we just need to request it again, since this time we have the data to render. - if (this.isRenderRequested()) - this.requestRender(); - }, - - getMeasureStore : function() - { - return this.measureStore; - }, - - getMeasureStoreRecords : function() - { - if (!this.getMeasureStore()) - console.error('No measureStore object defined.'); - - return this.getMeasureStore().records(); - }, - - getMeasureStoreMetadata : function() - { - if (!this.getMeasureStore()) - console.error('No measureStore object defined.'); - - return this.getMeasureStore().getResponseMetadata(); - }, - - getSchemaName : function() - { - if (this.getMeasureStoreMetadata() && this.getMeasureStoreMetadata().schemaName) - { - if (Ext4.isArray(this.getMeasureStoreMetadata().schemaName)) - return this.getMeasureStoreMetadata().schemaName[0]; - - return this.getMeasureStoreMetadata().schemaName; - } - - return null; - }, - - getQueryName : function() - { - if (this.getMeasureStoreMetadata()) - return this.getMeasureStoreMetadata().queryName; - - return null; - }, - - getDefaultTitle : function() - { - if (this.defaultTitleFn) - return this.defaultTitleFn(this.queryName, this.queryLabel, LABKEY.vis.GenericChartHelper.getDefaultMeasuresLabel(this.measures.y), this.measures.x ? this.measures.x.label : null); - - return this.queryLabel || this.queryName; - }, - - /** - * used to determine if the new chart options are different from the currently rendered options - */ - hasConfigurationChanged : function() - { - var queryCfg = this.getQueryConfig(); - - if (!queryCfg.schemaName || !queryCfg.queryName) - return false; - - // ugly race condition, haven't loaded a saved report yet - if (!this.reportLoaded) - return false; - - if (!this.hasChartData()) - return true; - - var filterStr = this.createFilterString(queryCfg.filterArray); - - if (this.currentFilterStr != filterStr) { - this.currentFilterStr = filterStr; - return true; - } - - var parameterStr = Ext4.JSON.encode(queryCfg.parameters); - if (this.currentParameterStr != parameterStr) { - this.currentParameterStr = parameterStr; - return true; - } - - return false; - }, - - setRenderRequested : function(requested) - { - this.renderRequested = requested; - }, - - isRenderRequested : function() - { - return this.renderRequested; - }, - - setDataLoading : function(loading) - { - this.dataLoading = loading; - }, - - isDataLoading : function() - { - return this.dataLoading; - }, - - requestData : function() - { - this.setDataLoading(true); - - var config = this.getQueryConfig(); - LABKEY.Query.MeasureStore.selectRows(config); - - this.requestRender(); - }, - - requestRender : function() - { - if (this.isDataLoading()) - this.setRenderRequested(true); - else - this.renderPlot(); - }, - - renderChart : function() - { - this.getEl().mask('Rendering Chart...'); - this.chartDefinitionChanged.delay(500); - }, - - resizeToViewport : function() { - console.warn('DEPRECATED: As of Release 17.3 ' + this.$className + '.resizeToViewport() is no longer supported.'); - }, - - onSaveBtnClicked : function(isSaveAs) - { - this.getSavePanel().setNoneThumbnail(this.getChartTypePanel().getImgUrl()); - this.getSavePanel().setSaveAs(isSaveAs); - this.getSavePanel().setMainTitle(isSaveAs ? "Save as" : "Save"); - this.getSaveWindow().show(); - } -}); +/* + * Copyright (c) 2016-2019 LabKey Corporation + * + * Licensed under the Apache License, Version 2.0: http://www.apache.org/licenses/LICENSE-2.0 + */ +Ext4.define('LABKEY.ext4.GenericChartPanel', { + extend : 'Ext.panel.Panel', + + cls : 'generic-chart-panel', + layout : 'fit', + editable : false, + minWidth : 900, + + initialSelection : null, + savedReportInfo : null, + hideViewData : false, + reportLoaded : true, + hideSave: false, + dataPointLimit: 10000, + + constructor : function(config) + { + Ext4.QuickTips.init(); + this.callParent([config]); + }, + + queryGenericChartColumns : function() + { + LABKEY.vis.GenericChartHelper.getQueryColumns(this, function(columnMetadata) + { + this.getChartTypePanel().loadQueryColumns(columnMetadata); + this.requestData(); + }, this); + }, + + initComponent : function() + { + this.measures = {}; + this.options = {}; + this.userFilters = []; + + // boolean to check if we should allow things like export to PDF + this.supportedBrowser = !(Ext4.isIE6 || Ext4.isIE7 || Ext4.isIE8); + + var params = LABKEY.ActionURL.getParameters(); + this.editMode = params.edit == "true" || !this.savedReportInfo; + this.parameters = LABKEY.Filter.getQueryParamsFromUrl(params['filterUrl'], this.dataRegionName); + + // Issue 19163 + Ext4.each(['autoColumnXName', 'autoColumnYName', 'autoColumnName'], function(autoColPropName) + { + if (this[autoColPropName]) + this[autoColPropName] = LABKEY.FieldKey.fromString(this[autoColPropName]); + }, this); + + // for backwards compatibility, map auto_plot to box_plot + if (this.renderType === 'auto_plot') + this.setRenderType('box_plot'); + + this.chartDefinitionChanged = new Ext4.util.DelayedTask(function(){ + this.markDirty(true); + this.requestRender(); + }, this); + + // delayed task to redraw the chart + this.updateChartTask = new Ext4.util.DelayedTask(function() + { + if (this.hasConfigurationChanged()) + { + this.getEl().mask('Loading Data...'); + + if (this.editMode && this.getChartTypePanel().getQueryColumnNames().length == 0) + this.queryGenericChartColumns(); + else + this.requestData(); + } + + }, this); + + // only linear for now but could expand in the future + this.lineRenderers = { + linear : { + createRenderer : function(params){ + if (params && params.length >= 2) { + return function(x){return x * params[0] + params[1];} + } + return function(x) {return x;} + } + } + }; + + this.items = [this.getCenterPanel()]; + + this.callParent(); + + if (this.savedReportInfo) + this.loadSavedConfig(); + else + this.loadInitialSelection(); + + window.onbeforeunload = LABKEY.beforeunload(this.beforeUnload, this); + }, + + getViewPanel : function() + { + if (!this.viewPanel) + { + this.viewPanel = Ext4.create('Ext.panel.Panel', { + autoScroll : true, + ui : 'custom', + listeners : { + scope: this, + activate: function() + { + this.updateChartTask.delay(500); + }, + resize: function(p) + { + // only re-render after the initial chart rendering + if (this.hasChartData()) { + this.clearMessagePanel(); + this.requestRender(); + } + } + } + }); + } + + return this.viewPanel; + }, + + getDataPanel : function() + { + if (!this.dataPanel) + { + this.dataPanel = Ext4.create('Ext.panel.Panel', { + flex : 1, + layout : 'fit', + border : false, + items : [ + Ext4.create('Ext.Component', { + autoScroll : true, + listeners : { + scope : this, + render : function(cmp){ + this.renderDataGrid(cmp.getId()); + } + } + }) + ] + }); + } + + return this.dataPanel; + }, + + getCenterPanel : function() + { + if (!this.centerPanel) + { + this.centerPanel = Ext4.create('Ext.panel.Panel', { + border: false, + layout: { + type: 'card', + deferredRender: true + }, + activeItem: 0, + items: [this.getViewPanel(), this.getDataPanel()], + dockedItems: [this.getTopButtonBar(), this.getMsgPanel()] + }); + } + + return this.centerPanel; + }, + + getChartTypeBtn : function() + { + if (!this.chartTypeBtn) + { + this.chartTypeBtn = Ext4.create('Ext.button.Button', { + text: 'Chart Type', + handler: this.showChartTypeWindow, + scope: this + }); + } + + return this.chartTypeBtn; + }, + + getChartLayoutBtn : function() + { + if (!this.chartLayoutBtn) + { + this.chartLayoutBtn = Ext4.create('Ext.button.Button', { + text: 'Chart Layout', + disabled: true, + handler: this.showChartLayoutWindow, + scope: this + }); + } + + return this.chartLayoutBtn; + }, + + getHelpBtn : function() + { + if (!this.helpBtn) + { + this.helpBtn = Ext4.create('Ext.button.Button', { + text: 'Help', + scope: this, + menu: { + showSeparator: false, + items: [{ + text: 'Reports and Visualizations', + iconCls: 'fa fa-table', + hrefTarget: '_blank', + href: LABKEY.Utils.getHelpTopicHref('reportsAndViews') + },{ + text: 'Bar Plots', + iconCls: 'fa fa-bar-chart', + hrefTarget: '_blank', + href: LABKEY.Utils.getHelpTopicHref('barchart') + },{ + text: 'Box Plots', + iconCls: 'fa fa-sliders fa-rotate-90', + hrefTarget: '_blank', + href: LABKEY.Utils.getHelpTopicHref('boxplot') + },{ + text: 'Line Plots', + iconCls: 'fa fa-line-chart', + hrefTarget: '_blank', + href: LABKEY.Utils.getHelpTopicHref('lineplot') + },{ + text: 'Pie Charts', + iconCls: 'fa fa-pie-chart', + hrefTarget: '_blank', + href: LABKEY.Utils.getHelpTopicHref('piechart') + },{ + text: 'Scatter Plots', + iconCls: 'fa fa-area-chart', + hrefTarget: '_blank', + href: LABKEY.Utils.getHelpTopicHref('scatterplot') + }] + } + }); + } + + return this.helpBtn; + }, + + getSaveBtn : function() + { + if (!this.saveBtn) + { + this.saveBtn = Ext4.create('Ext.button.Button', { + text: "Save", + hidden: LABKEY.user.isGuest || this.hideSave, + disabled: true, + handler: function(){ + this.onSaveBtnClicked(false) + }, + scope: this + }); + } + + return this.saveBtn; + }, + + getSaveAsBtn : function() + { + if (!this.saveAsBtn) + { + this.saveAsBtn = Ext4.create('Ext.button.Button', { + text: "Save As", + hidden : this.isNew() || LABKEY.user.isGuest || this.hideSave, + disabled: true, + handler: function(){ + this.onSaveBtnClicked(true); + }, + scope: this + }); + } + + return this.saveAsBtn; + }, + + getToggleViewBtn : function() + { + if (!this.toggleViewBtn) + { + this.toggleViewBtn = Ext4.create('Ext.button.Button', { + text:'View Data', + hidden: this.hideViewData, + scope: this, + handler: function() + { + if (this.getViewPanel().isHidden()) + { + this.getCenterPanel().getLayout().setActiveItem(0); + this.toggleViewBtn.setText('View Data'); + + this.getChartTypeBtn().show(); + this.getChartLayoutBtn().show(); + + if (Ext4.isArray(this.customButtons)) + { + for (var i = 0; i < this.customButtons.length; i++) + this.customButtons[i].show(); + } + } + else + { + this.getCenterPanel().getLayout().setActiveItem(1); + this.toggleViewBtn.setText('View Chart'); + + this.getMsgPanel().removeAll(); + this.getChartTypeBtn().hide(); + this.getChartLayoutBtn().hide(); + + if (Ext4.isArray(this.customButtons)) + { + for (var i = 0; i < this.customButtons.length; i++) + this.customButtons[i].hide(); + } + } + } + }); + } + + return this.toggleViewBtn; + }, + + getEditBtn : function() + { + if (!this.editBtn) + { + this.editBtn = Ext4.create('Ext.button.Button', { + xtype: 'button', + text: 'Edit', + scope: this, + handler: function() { + window.location = this.editModeURL; + } + }); + } + + return this.editBtn; + }, + + getTopButtonBar : function() + { + if (!this.topButtonBar) + { + this.topButtonBar = Ext4.create('Ext.toolbar.Toolbar', { + dock: 'top', + items: this.initTbarItems() + }); + } + + return this.topButtonBar; + }, + + initTbarItems : function() + { + var tbarItems = []; + tbarItems.push(this.getToggleViewBtn()); + tbarItems.push(this.getHelpBtn()); + tbarItems.push('->'); // rest of buttons will be right aligned + + if (this.editMode) + { + tbarItems.push(this.getChartTypeBtn()); + tbarItems.push(this.getChartLayoutBtn()); + + if (Ext4.isArray(this.customButtons)) + { + tbarItems.push(''); // horizontal spacer + for (var i = 0; i < this.customButtons.length; i++) + { + var btn = this.customButtons[i]; + btn.scope = this; + tbarItems.push(btn); + } + } + + if (!LABKEY.user.isGuest && !this.hideSave) + tbarItems.push(''); // horizontal spacer + if (this.canEdit) + tbarItems.push(this.getSaveBtn()); + tbarItems.push(this.getSaveAsBtn()); + } + else if (this.allowEditMode && this.editModeURL != null) + { + // add an "edit" button if the user is allowed to toggle to edit mode for this report + tbarItems.push(this.getEditBtn()); + } + + return tbarItems; + }, + + getMsgPanel : function() { + if (!this.msgPanel) { + this.msgPanel = Ext4.create('Ext.panel.Panel', { + hidden: true, + bodyStyle: 'border-width: 1px 0 0 0', + listeners: { + add: function(panel) { + panel.show(); + }, + remove: function(panel) { + if (panel.items.items.length == 0) { + panel.hide(); + } + } + } + }); + } + + return this.msgPanel; + }, + + showChartTypeWindow : function() + { + // make sure the chartTypePanel is shown in the window + if (this.getChartTypeWindow().items.items.length == 0) + this.getChartTypeWindow().add(this.getChartTypePanel()); + + this.getChartTypeWindow().show(); + }, + + showChartLayoutWindow : function() + { + this.getChartLayoutWindow().show(); + }, + + isNew : function() + { + return !this.savedReportInfo; + }, + + getChartTypeWindow : function() + { + if (!this.chartTypeWindow) + { + var panel = this.getChartTypePanel(); + + this.chartTypeWindow = Ext4.create('LABKEY.vis.ChartWizardWindow', { + panelToMask: this, + onEsc: function() { + panel.cancelHandler.call(panel); + }, + items: [panel] + }); + + // propagate the show event to the panel so it can stash the initial values + this.chartTypeWindow.on('show', function(window) + { + this.getChartTypePanel().fireEvent('show', this.getChartTypePanel()); + }, this); + } + + return this.chartTypeWindow; + }, + + getChartTypePanel : function() + { + if (!this.chartTypePanel) + { + this.chartTypePanel = Ext4.create('LABKEY.vis.ChartTypePanel', { + chartTypesToHide: ['time_chart'], + selectedType: this.getSelectedChartType(), + selectedFields: Ext4.apply(this.measures, { trendline: this.trendline }), + restrictColumnsEnabled: this.restrictColumnsEnabled, + customRenderTypes: this.customRenderTypes, + baseQueryKey: this.schemaName + '.' + this.queryName, + studyQueryName: this.schemaName == 'study' ? this.queryName : null + }); + } + + if (!this.hasAttachedChartTypeListeners) + { + this.chartTypePanel.on('cancel', this.closeChartTypeWindow, this); + this.chartTypePanel.on('apply', this.applyChartTypeSelection, this); + this.hasAttachedChartTypeListeners = true; + } + + return this.chartTypePanel; + }, + + closeChartTypeWindow : function(panel) + { + if (this.getChartTypeWindow().isVisible()) + this.getChartTypeWindow().hide(); + }, + + applyChartTypeSelection : function(panel, values, skipRender) + { + // close the window and clear any previous charts + this.closeChartTypeWindow(); + this.clearChartPanel(true); + + // only apply the values for the applicable chart type + if (Ext4.isObject(values) && values.type == 'time_chart') + return; + + this.setRenderType(values.type); + this.measures = values.fields; + if (values.fields.xSub) { + this.measures.color = this.measures.xSub; + } + + if (values.altValues.trendline) { + this.trendline = values.altValues.trendline; + // if the chart data has already been loaded then we only need to query the trendlineData + if (this.measureStore) this.queryTrendlineData(); + } + + this.getChartLayoutPanel().onMeasuresChange(this.measures, this.renderType); + this.getChartLayoutPanel().updateVisibleLayoutOptions(this.getSelectedChartTypeData(), this.measures); + this.ensureChartLayoutOptions(); + + if (!skipRender) + this.renderChart(); + }, + + getSelectedChartTypeData : function() + { + var selectedChartType = this.getChartTypePanel().getSelectedType(); + return selectedChartType ? selectedChartType.data : null; + }, + + getChartLayoutWindow : function() + { + if (!this.chartLayoutWindow) + { + var panel = this.getChartLayoutPanel(); + + this.chartLayoutWindow = Ext4.create('LABKEY.vis.ChartWizardWindow', { + panelToMask: this, + onEsc: function() { + panel.cancelHandler.call(panel); + }, + items: [panel] + }); + + // propagate the show event to the panel so it can stash the initial values + this.chartLayoutWindow.on('show', function(window) + { + this.getChartLayoutPanel().fireEvent('show', this.getChartLayoutPanel(), this.getSelectedChartTypeData(), this.measures); + }, this); + } + + return this.chartLayoutWindow; + }, + + getChartLayoutPanel : function() + { + if (!this.chartLayoutPanel) + { + this.chartLayoutPanel = Ext4.create('LABKEY.vis.ChartLayoutPanel', { + options: this.options, + isDeveloper: this.isDeveloper, + renderType: this.renderType, + initMeasures: this.measures, + multipleCharts: this.options && this.options.general && this.options.general.chartLayout !== 'single', + defaultChartLabel: this.getDefaultTitle(), + defaultOpacity: this.renderType == 'bar_chart' || this.renderType == 'line_plot' ? 100 : undefined, + defaultLineWidth: this.renderType == 'line_plot' ? 3 : undefined, + isSavedReport: !this.isNew(), + listeners: { + scope: this, + cancel: function(panel) + { + this.getChartLayoutWindow().hide(); + }, + apply: function(panel, values) + { + // special case for trendlineData: if there was a change to x-axis scale type or range, + // we need to reload the trendlineData + if (this.trendlineData) this.queryTrendlineData(); + + // note: this event will only fire if a change was made in the Chart Layout panel + this.ensureChartLayoutOptions(); + this.clearChartPanel(true); + this.renderChart(); + this.getChartLayoutWindow().hide(); + } + } + }); + } + + return this.chartLayoutPanel; + }, + + getExportScriptWindow : function() + { + if (!this.exportScriptWindow) + { + this.exportScriptWindow = Ext4.create('LABKEY.vis.ChartWizardWindow', { + panelToMask: this, + items: [this.getExportScriptPanel()] + }); + } + + return this.exportScriptWindow; + }, + + getExportScriptPanel : function() + { + if (!this.exportScriptPanel) + { + this.exportScriptPanel = Ext4.create('LABKEY.vis.GenericChartScriptPanel', { + width: Math.max(this.getViewPanel().getWidth() - 100, 800), + listeners: { + scope: this, + closeOptionsWindow: function(){ + this.getExportScriptWindow().hide(); + } + } + }); + } + + return this.exportScriptPanel; + }, + + getSaveWindow : function() + { + if (!this.saveWindow) + { + this.saveWindow = Ext4.create('LABKEY.vis.ChartWizardWindow', { + panelToMask: this, + items: [this.getSavePanel()] + }); + } + + return this.saveWindow; + }, + + getSavePanel : function() + { + if (!this.savePanel) + { + this.savePanel = Ext4.create('LABKEY.vis.SaveOptionsPanel', { + allowInherit: this.allowInherit, + canEdit: this.canEdit, + canShare: this.canShare, + listeners: { + scope: this, + closeOptionsWindow: function() + { + this.getSaveWindow().close() + }, + saveChart: this.saveReport + } + }); + } + + return this.savePanel; + }, + + ensureChartLayoutOptions : function() + { + // Make sure that we have the latest chart layout panel values. + // This will get the initial default values if the user has not yet opened the chart layout dialog. + // This will also preserve the developer pointClickFn if the user is not a developer. + Ext4.apply(this.options, this.getChartLayoutPanel().getValues()); + }, + + setRenderType : function(newRenderType) + { + if (this.renderType != newRenderType) + this.renderType = newRenderType; + }, + + renderDataGrid : function(renderTo) + { + var filters = []; + var removableFilters = this.userFilters; + + if (this.isDataRegionPresent()) + { + // If the region exists, then apply it's user filters as immutable filters to the QWP. + // They can be mutated on the data region directly. + filters = this.getDataRegionFilters(); + } + else + { + // If the region does not exist, then apply it's filters from the URL + // and allow them to be mutated on the QWP. + removableFilters = removableFilters.concat(this.getURLFilters()); + } + + var allFilters = this.getUniqueFilters(filters.concat(removableFilters)); + var userSort = LABKEY.Filter.getSortFromUrl(this.getFilterURL(), this.dataRegionName); + + this.currentFilterStr = this.createFilterString(allFilters); + this.currentParameterStr = Ext4.JSON.encode(this.parameters); + + var wpConfig = { + schemaName : this.schemaName, + queryName : this.queryName, + viewName : this.viewName, + columns : this.savedColumns, + parameters : this.parameters, + frame : 'none', + filters : filters, + disableAnalytics : true, + removeableFilters : removableFilters, + removeableSort : userSort, + showSurroundingBorder : false, + allowHeaderLock : false, + buttonBar : { + includeStandardButton: false, + items: [LABKEY.QueryWebPart.standardButtons.exportRows] + } + }; + + if (this.dataRegionName) { + wpConfig.dataRegionName = this.dataRegionName + '-chartdata'; + } + + var wp = new LABKEY.QueryWebPart(wpConfig); + + // save the dataregion + this.panelDataRegionName = wp.dataRegionName; + + // Issue 21418: support for parameterized queries + wp.on('render', function(){ + if (wp.parameters) + Ext4.apply(this.parameters, wp.parameters); + }, this); + + wp.render(renderTo); + }, + + /** + * Returns the filters applied to the associated Data Region. This region's presence is optional. + */ + getDataRegionFilters : function() + { + if (this.isDataRegionPresent()) + return LABKEY.DataRegions[this.dataRegionName].getUserFilterArray(); + + return []; + }, + + getFilterURL : function() + { + return LABKEY.ActionURL.getParameters(this.baseUrl)['filterUrl']; + }, + + /** + * Returns the filters applied to the associated QueryWebPart (QWP). The QWP's presence is optional. + * If this QWP is available, then this method will return any user modifiable filters from the QWP. + */ + getQWPFilters : function() + { + // The associated QWP may not exist yet if the user hasn't viewed it + if (this.isQWPPresent()) + return LABKEY.DataRegions[this.panelDataRegionName].getUserFilterArray(); + + return []; + }, + + getURLFilters : function() + { + return LABKEY.Filter.getFiltersFromUrl(this.getFilterURL(), this.dataRegionName); + }, + + isDataRegionPresent : function() + { + return LABKEY.DataRegions[this.dataRegionName] !== undefined; + }, + + isQWPPresent : function() + { + return LABKEY.DataRegions[this.panelDataRegionName] !== undefined; + }, + + // Returns a configuration based on the baseUrl plus any filters applied on the dataregion panel + // the configuration can be used to make a selectRows request + getQueryConfig : function(serialize) + { + var config = { + schemaName : this.schemaName, + queryName : this.queryName, + viewName : this.viewName, + dataRegionName: this.dataRegionName, + queryLabel : this.queryLabel, + parameters : this.parameters, + requiredVersion : 17.1, // Issue 49753 + maxRows: -1, + sort: LABKEY.vis.GenericChartHelper.getQueryConfigSortKey(this.measures), + method: 'POST' + }; + + config.columns = this.getQueryConfigColumns(); + + if (!serialize) + { + config.success = this.onSelectRowsSuccess; + config.failure = function(response, opts){ + var error, errorDiv; + + this.getEl().unmask(); + + if (response.exception) + { + error = '

' + response.exception + '

'; + if (response.exceptionClass == 'org.labkey.api.view.NotFoundException') + error = error + '

The source dataset, list, or query may have been deleted.

' + } + + errorDiv = Ext4.create('Ext.container.Container', { + border: 1, + autoEl: {tag: 'div'}, + padding: 10, + html: '

An unexpected error occurred while retrieving data.

' + error, + autoScroll: true + }); + + // Issue 18157 + this.getChartTypeBtn().disable(); + this.getChartLayoutBtn().disable(); + this.getToggleViewBtn().disable(); + this.getSaveBtn().disable(); + this.getSaveAsBtn().disable(); + + this.getViewPanel().add(errorDiv); + }; + config.scope = this; + } + + // Filter scenarios (Issue 37153, Issue 40384) + // 1. Filters defined explicitly on chart configuration (this.userFilters) + // 2. Filters defined on associated QWP (panelDataRegionName) + // 3. Filters defined on associated Data Region (dataRegionName) + // 4. Filters defined on URL for associated Data Region (overlap with #3) + var filters = this.getDataRegionFilters(); + + // If the QWP is present, then it is expected to have the "userFilters" and URL filters already applied. + // Additionally, they are removable from the QWP so respect the current filters on the QWP. + if (this.isQWPPresent()) + filters = filters.concat(this.getQWPFilters()); + else + filters = filters.concat(this.userFilters, this.getURLFilters()); + + filters = this.getUniqueFilters(filters); + + if (serialize) + { + var newFilters = []; + + for (var i=0; i < filters.length; i++) + { + var f = filters[i]; + newFilters.push({name : f.getColumnName(), value : f.getValue(), type : f.getFilterType().getURLSuffix()}); + } + + filters = newFilters; + } + + config.filterArray = filters; + + return config; + }, + + filterToString : function(filter) + { + return filter.getURLParameterName() + '=' + filter.getURLParameterValue(); + }, + + getUniqueFilters : function(filters) + { + var filterKeys = {}; + var filterSet = []; + + for (var x=0; x < filters.length; x++) + { + var ff = filters[x]; + var key = this.filterToString(ff); + + if (!filterKeys[key]) + { + filterKeys[key] = true; + filterSet.push(ff); + } + } + + return filterSet; + }, + + getQueryConfigColumns : function() + { + var columns = null; + + if (!this.editMode) + { + // If we're not in edit mode or if this is the first load we need to only load the minimum amount of data. + columns = []; + var measures = this.getChartConfig().measures; + + if (measures.x) + { + this.addMeasureForColumnQuery(columns, measures.x); + } + else if (this.autoColumnXName) + { + columns.push(this.autoColumnXName.toString()); + } + else + { + // Check if we have cohorts available + var queryColumnNames = this.getChartTypePanel().getQueryColumnNames(); + for (var i = 0; i < queryColumnNames.length; i++) + { + if (queryColumnNames[i].indexOf('Cohort') > -1) + columns.push(queryColumnNames[i]); + } + } + + if (measures.y) { + this.addMeasureForColumnQuery(columns, measures.y); + } + else if (this.autoColumnYName) { + columns.push(this.autoColumnYName.toString()); + } + + if (this.autoColumnName) { + columns.push(this.autoColumnName.toString()); + } + + Ext4.each(['ySub', 'xSub', 'color', 'shape', 'series'], function(name) { + if (measures[name]) { + this.addMeasureForColumnQuery(columns, measures[name]); + } + }, this); + } + else + { + // If we're in edit mode then we can load all of the columns. + columns = this.getChartTypePanel().getQueryColumnFieldKeys(); + } + + return columns; + }, + + addMeasureForColumnQuery : function(columns, initMeasure) + { + // account for the measure being a single object or an array of objects + var measures = Ext4.isArray(initMeasure) ? initMeasure : [initMeasure]; + Ext4.each(measures, function(measure) { + if (Ext4.isObject(measure)) + { + columns.push(measure.name); + + // Issue 27814: names with slashes need to be queried by encoded name + var encodedName = LABKEY.QueryKey.encodePart(measure.name); + if (measure.name !== encodedName) + columns.push(encodedName); + } + }); + }, + + getChartConfig : function() + { + var config = {}; + + config.renderType = this.renderType; + config.measures = Ext4.apply({}, this.measures); + config.scales = {}; + config.labels = {}; + + this.ensureChartLayoutOptions(); + if (this.options.general) + { + config.width = this.options.general.width; + config.height = this.options.general.height; + config.pointType = this.options.general.pointType; + config.labels.main = this.options.general.label; + config.labels.subtitle = this.options.general.subtitle; + config.labels.footer = this.options.general.footer; + + config.geomOptions = Ext4.apply({}, this.options.general); + config.geomOptions.showOutliers = config.pointType ? config.pointType == 'outliers' : true; + config.geomOptions.pieInnerRadius = this.options.general.pieInnerRadius; + config.geomOptions.pieOuterRadius = this.options.general.pieOuterRadius; + config.geomOptions.showPiePercentages = this.options.general.showPiePercentages; + config.geomOptions.piePercentagesColor = this.options.general.piePercentagesColor; + config.geomOptions.pieHideWhenLessThanPercentage = this.options.general.pieHideWhenLessThanPercentage; + config.geomOptions.gradientPercentage = this.options.general.gradientPercentage; + config.geomOptions.gradientColor = this.options.general.gradientColor; + config.geomOptions.colorPaletteScale = this.options.general.colorPaletteScale; + config.geomOptions.binShape = this.options.general.binShapeGroup; + config.geomOptions.binThreshold = this.options.general.binThreshold; + config.geomOptions.colorRange = this.options.general.binColorGroup; + config.geomOptions.binSingleColor = this.options.general.binSingleColor; + config.geomOptions.chartLayout = this.options.general.chartLayout; + config.geomOptions.marginTop = this.options.general.marginTop; + config.geomOptions.marginRight = this.options.general.marginRight; + config.geomOptions.marginBottom = this.options.general.marginBottom; + config.geomOptions.marginLeft = this.options.general.marginLeft; + } + + if (this.options.x) + { + this.applyAxisOptionsToConfig(this.options, config, 'x'); + if (this.measures.xSub) { + config.labels.xSub = this.measures.xSub.label; + } + } + + this.applyAxisOptionsToConfig(this.options, config, 'y'); + this.applyAxisOptionsToConfig(this.options, config, 'yRight'); + + if (this.options.developer) + config.measures.pointClickFn = this.options.developer.pointClickFn; + + if (this.curveFit) { + config.curveFit = this.curveFit; + } else if (this.trendline) { + config.geomOptions.trendlineType = this.trendline.trendlineType; + config.geomOptions.trendlineAsymptoteMin = this.trendline.trendlineAsymptoteMin; + config.geomOptions.trendlineAsymptoteMax = this.trendline.trendlineAsymptoteMax; + } + + if (this.getCustomChartOptions) + config.customOptions = this.getCustomChartOptions(); + + return config; + }, + + applyAxisOptionsToConfig : function(options, config, axisName) { + if (options[axisName]) + { + if (!config.labels[axisName]) { + config.labels[axisName] = options[axisName].label; + config.scales[axisName] = { + type: options[axisName].scaleRangeType || 'automatic', + trans: options[axisName].trans || options[axisName].scaleTrans + }; + } + + if (config.scales[axisName].type === "manual" && options[axisName].scaleRange) { + config.scales[axisName].min = options[axisName].scaleRange.min; + config.scales[axisName].max = options[axisName].scaleRange.max; + } + + if (options[axisName].aggregate) { + config.measures[axisName].aggregate = options[axisName].aggregate; + } + if (options[axisName].errorBars) { + config.measures[axisName].errorBars = options[axisName].errorBars; + } + } + }, + + markDirty : function(dirty) + { + this.dirty = dirty; + LABKEY.Utils.signalWebDriverTest("genericChartDirty", dirty); + }, + + isDirty : function() + { + return !LABKEY.user.isGuest && !this.hideSave && this.canEdit && this.dirty; + }, + + beforeUnload : function() + { + if (this.isDirty()) { + return 'please save your changes'; + } + }, + + getCurrentReportConfig : function() + { + var reportConfig = { + reportId : this.savedReportInfo ? this.savedReportInfo.reportId : undefined, + schemaName : this.schemaName, + queryName : this.queryName, + viewName : this.viewName, + dataRegionName: this.dataRegionName, + renderType : this.renderType, + jsonData : { + queryConfig : this.getQueryConfig(true), + chartConfig : this.getChartConfig() + } + }; + + var chartConfig = reportConfig.jsonData.chartConfig; + LABKEY.vis.GenericChartHelper.removeNumericConversionConfig(chartConfig); + + return reportConfig; + }, + + saveReport : function(data) + { + var reportConfig = this.getCurrentReportConfig(); + reportConfig.name = data.reportName; + reportConfig.description = data.reportDescription; + + reportConfig["public"] = data.shared; + reportConfig.inheritable = data.inheritable; + reportConfig.thumbnailType = data.thumbnailType; + reportConfig.svg = this.chartSVG; + + if (data.isSaveAs) + reportConfig.reportId = null; + + LABKEY.Ajax.request({ + url : LABKEY.ActionURL.buildURL('visualization', 'saveGenericReport.api'), + method : 'POST', + headers : { + 'Content-Type' : 'application/json' + }, + jsonData: reportConfig, + success : function(resp) + { + this.getSaveWindow().close(); + this.markDirty(false); + + // show success message and then fade the window out + var msgbox = Ext4.create('Ext.window.Window', { + html : 'Report saved successfully.', + cls : 'chart-wizard-dialog', + bodyStyle : 'background: transparent;', + header : false, + border : false, + padding : 20, + resizable: false, + draggable: false + }); + + msgbox.show(); + msgbox.getEl().fadeOut({ + delay : 1500, + duration: 1000, + callback : function() + { + msgbox.hide(); + } + }); + + // if a new report was created, we need to refresh the page with the correct report id on the URL + if (this.isNew() || data.isSaveAs) + { + var o = Ext4.decode(resp.responseText); + window.location = LABKEY.ActionURL.buildURL('reports', 'runReport', null, {reportId: o.reportId}); + } + }, + failure : this.onFailure, + scope : this + }); + }, + + onFailure : function(resp) + { + var error = Ext4.isString(resp.responseText) ? Ext4.decode(resp.responseText).exception : resp.exception; + Ext4.Msg.show({ + title: 'Error', + msg: error || 'An unknown error has occurred.', + buttons: Ext4.MessageBox.OK, + icon: Ext4.MessageBox.ERROR, + scope: this + }); + }, + + loadReportFromId : function(reportId) + { + this.reportLoaded = false; + + LABKEY.Query.Visualization.get({ + reportId: reportId, + scope: this, + success: function(result) + { + this.savedReportInfo = result; + this.loadSavedConfig(); + } + }); + }, + + loadSavedConfig : function() + { + var config = this.savedReportInfo, + queryConfig = {}, + chartConfig = {}; + + if (config.type == LABKEY.Query.Visualization.Type.GenericChart) + { + queryConfig = config.visualizationConfig.queryConfig; + chartConfig = config.visualizationConfig.chartConfig; + } + + this.schemaName = queryConfig.schemaName; + this.queryName = queryConfig.queryName; + this.viewName = queryConfig.viewName; + this.dataRegionName = queryConfig.dataRegionName; + + if (this.reportName) + this.reportName.setValue(config.name); + + if (this.reportDescription && config.description != null) + this.reportDescription.setValue(config.description); + + // TODO is this needed/used anymore? + if (this.reportPermission) + this.reportPermission.setValue({"public" : config.shared}); + + this.getSavePanel().setReportInfo({ + name: config.name, + description: config.description, + shared: config.shared, + inheritable: config.inheritable, + reportProps: config.reportProps, + thumbnailURL: config.thumbnailURL + }); + + this.loadQueryInfoFromConfig(queryConfig); + this.loadMeasuresFromConfig(chartConfig); + this.loadOptionsFromConfig(chartConfig); + + // if the renderType was not saved with the report info, get it based off of the x-axis measure type + this.renderType = chartConfig.renderType || this.getRenderType(chartConfig); + + this.markDirty(false); + this.reportLoaded = true; + this.updateChartTask.delay(500); + }, + + loadQueryInfoFromConfig : function(queryConfig) + { + if (Ext4.isObject(queryConfig)) + { + if (Ext4.isArray(queryConfig.filterArray)) + { + var filters = []; + for (var i=0; i < queryConfig.filterArray.length; i++) + { + var f = queryConfig.filterArray[i]; + var type = LABKEY.Filter.getFilterTypeForURLSuffix(f.type); + if (type !== undefined) { + var value = type.isMultiValued() ? f.value : (Ext4.isArray(f.value) ? f.value[0]: f.value); + filters.push(LABKEY.Filter.create(f.name, value, type)); + } + } + this.userFilters = filters; + } + + if (queryConfig.columns) + this.savedColumns = queryConfig.columns; + + if (queryConfig.queryLabel) + this.queryLabel = queryConfig.queryLabel; + + if (queryConfig.parameters) + this.parameters = queryConfig.parameters; + } + }, + + loadMeasuresFromConfig : function(chartConfig) + { + this.measures = {}; + + if (Ext4.isObject(chartConfig)) + { + if (Ext4.isObject(chartConfig.measures)) + { + Ext4.each(['x', 'y', 'xSub', 'color', 'shape', 'series'], function(name) { + if (chartConfig.measures[name]) { + this.measures[name] = chartConfig.measures[name]; + } + }, this); + } + } + }, + + loadOptionsFromConfig : function(chartConfig) + { + this.options = {}; + + if (Ext4.isObject(chartConfig)) + { + this.options.general = {}; + if (chartConfig.height) + this.options.general.height = chartConfig.height; + if (chartConfig.width) + this.options.general.width = chartConfig.width; + if (chartConfig.pointType) + this.options.general.pointType = chartConfig.pointType; + if (chartConfig.geomOptions) + Ext4.apply(this.options.general, chartConfig.geomOptions); + + if (chartConfig.labels && LABKEY.Utils.isString(chartConfig.labels.main)) + this.options.general.label = chartConfig.labels.main; + else + this.options.general.label = this.getDefaultTitle(); + + if (chartConfig.labels && chartConfig.labels.subtitle) + this.options.general.subtitle = chartConfig.labels.subtitle; + if (chartConfig.labels && chartConfig.labels.footer) + this.options.general.footer = chartConfig.labels.footer; + + this.loadAxisOptionsFromConfig(chartConfig, 'x'); + this.loadAxisOptionsFromConfig(chartConfig, 'y'); + this.loadAxisOptionsFromConfig(chartConfig, 'yRight'); + + this.options.developer = {}; + if (chartConfig.measures && chartConfig.measures.pointClickFn) + this.options.developer.pointClickFn = chartConfig.measures.pointClickFn; + + if (chartConfig.curveFit) { + this.curveFit = chartConfig.curveFit; + } else if (chartConfig.geomOptions.trendlineType) { + this.trendline = { + trendlineType: chartConfig.geomOptions.trendlineType, + trendlineAsymptoteMin: chartConfig.geomOptions.trendlineAsymptoteMin, + trendlineAsymptoteMax: chartConfig.geomOptions.trendlineAsymptoteMax, + } + } + } + }, + + loadAxisOptionsFromConfig : function(chartConfig, axisName) { + this.options[axisName] = {}; + if (chartConfig.labels && chartConfig.labels[axisName]) { + this.options[axisName].label = chartConfig.labels[axisName]; + } + if (chartConfig.scales && chartConfig.scales[axisName]) { + Ext4.apply(this.options[axisName], chartConfig.scales[axisName]); + } + if (chartConfig.measures && chartConfig.measures[axisName]) { + if (chartConfig.measures[axisName].aggregate) + this.options[axisName].aggregate = chartConfig.measures[axisName].aggregate.value ?? chartConfig.measures[axisName].aggregate; + if (chartConfig.measures[axisName].errorBars) + this.options[axisName].errorBars = chartConfig.measures[axisName].errorBars; + } + }, + + loadInitialSelection : function() + { + if (Ext4.isObject(this.initialSelection)) + { + this.applyChartTypeSelection(this.getChartTypePanel(), this.initialSelection, true); + // clear the initial selection object so it isn't loaded again + this.initialSelection = undefined; + + this.markDirty(false); + this.reportLoaded = true; + this.updateChartTask.delay(500); + } + }, + + handleNoData : function(errorMsg) + { + // Issue 18339 + this.setRenderRequested(false); + var errorDiv = Ext4.create('Ext.container.Container', { + border: 1, + autoEl: {tag: 'div'}, + html: '

An unexpected error occurred while retrieving data.

' + errorMsg, + autoScroll: true + }); + + this.getChartTypeBtn().disable(); + this.getChartLayoutBtn().disable(); + this.getSaveBtn().disable(); + this.getSaveAsBtn().disable(); + + // Keep the toggle button enabled so the user can remove filters + this.getToggleViewBtn().enable(); + + this.clearChartPanel(true); + this.getViewPanel().add(errorDiv); + this.getEl().unmask(); + }, + + renderPlot : function() + { + // Don't attempt to render if the view panel isn't visible or the chart type window is visible. + if (!this.isVisible() || this.getViewPanel().isHidden() || this.getChartTypeWindow().isVisible()) + return; + + // initMeasures returns false and opens the Chart Type panel if a required measure is not chosen by the user. + if (!this.initMeasures()) + return; + + this.clearChartPanel(false); + + var chartConfig = this.getChartConfig(); + var renderType = this.getRenderType(chartConfig); + + this.renderGenericChart(renderType, chartConfig); + + // We just rendered the plot, we don't need to request another render. + this.setRenderRequested(false); + }, + + getRenderType : function(chartConfig) + { + return LABKEY.vis.GenericChartHelper.getChartType(chartConfig); + }, + + renderGenericChart : function(chartType, chartConfig) + { + var aes, scales, customRenderType, hasNoDataMsg, newChartDiv, valueConversionResponse; + + hasNoDataMsg = LABKEY.vis.GenericChartHelper.validateResponseHasData(this.getMeasureStore(), true); + if (hasNoDataMsg != null) + this.addWarningText(hasNoDataMsg); + + this.getEl().mask('Rendering Chart...'); + + aes = LABKEY.vis.GenericChartHelper.generateAes(chartType, chartConfig.measures, this.getSchemaName(), this.getQueryName()); + + valueConversionResponse = LABKEY.vis.GenericChartHelper.doValueConversion(chartConfig, aes, this.renderType, this.getMeasureStoreRecords()); + if (!Ext4.Object.isEmpty(valueConversionResponse.processed)) + { + Ext4.Object.merge(chartConfig.measures, valueConversionResponse.processed); + //re-generate aes based on new converted values + aes = LABKEY.vis.GenericChartHelper.generateAes(chartType, chartConfig.measures, this.getSchemaName(), this.getQueryName()); + if (valueConversionResponse.warningMessage) { + this.addWarningText(valueConversionResponse.warningMessage); + } + } + + customRenderType = this.customRenderTypes ? this.customRenderTypes[this.renderType] : undefined; + if (customRenderType && customRenderType.generateAes) + aes = customRenderType.generateAes(this, chartConfig, aes); + + scales = LABKEY.vis.GenericChartHelper.generateScales(chartType, chartConfig.measures, chartConfig.scales, aes, this.getMeasureStore(), this.defaultNumberFormat); + if (customRenderType && customRenderType.generateScales) + scales = customRenderType.generateScales(this, chartConfig, scales); + + if (!this.isChartConfigValid(chartType, chartConfig, aes, scales)) + return; + + if (chartType == 'scatter_plot' && this.getMeasureStoreRecords().length > chartConfig.geomOptions.binThreshold) { + chartConfig.geomOptions.binned = true; + this.addWarningText("The number of individual points exceeds " + + Ext4.util.Format.number(chartConfig.geomOptions.binThreshold, '0,000') + + ". The data is now grouped by density, which overrides some layout options."); + } + else if (chartType == 'line_plot' && this.getMeasureStoreRecords().length > this.dataPointLimit) { + this.addWarningText("The number of individual points exceeds " + + Ext4.util.Format.number(this.dataPointLimit, '0,000') + + ". Data points will not be shown on this line plot."); + } + + this.beforeRenderPlotComplete(); + + chartConfig.width = this.getPerChartWidth(chartType, chartConfig); + chartConfig.height = this.getPerChartHeight(chartConfig); + + newChartDiv = this.getNewChartDisplayDiv(); + this.getViewPanel().add(newChartDiv); + + var plotConfigArr = this.getPlotConfigs(newChartDiv, chartType, chartConfig, aes, scales, customRenderType, this.trendlineData); + + Ext4.each(plotConfigArr, function(plotConfig) { + if (this.renderType === 'pie_chart') { + new LABKEY.vis.PieChart(plotConfig); + } + else { + var plot = new LABKEY.vis.Plot(plotConfig); + plot.render(); + } + }, this); + + this.afterRenderPlotComplete(newChartDiv, chartType, chartConfig); + }, + + getPerChartWidth : function(chartType, chartConfig) { + if (Ext4.isDefined(chartConfig.width) && chartConfig.width != null) { + return chartConfig.width; + } + else { + // default width based on the view panel width + return LABKEY.vis.GenericChartHelper.getChartTypeBasedWidth(chartType, chartConfig.measures, this.getMeasureStore(), this.getViewPanel().getWidth()) + } + }, + + getPerChartHeight : function(chartConfig) { + if (Ext4.isDefined(chartConfig.height) && chartConfig.height != null) { + return chartConfig.height; + } + else { + // default height based on the view panel height + var height = this.getViewPanel().getHeight() - 25; + if (chartConfig.geomOptions.chartLayout === 'per_measure') { + height = height / 1.25; + } + return height; + } + }, + + getNewChartDisplayDiv : function() + { + return Ext4.create('Ext.container.Container', { + cls: 'chart-render-div', + autoEl: {tag: 'div'} + }); + }, + + beforeRenderPlotComplete : function() + { + // add the warning msg before the plot so the plot has the proper height + if (this.warningText !== null) + this.addWarningMsg(this.warningText, true); + }, + + afterRenderPlotComplete : function(chartDiv, chartType, chartConfig) + { + this.getTopButtonBar().enable(); + this.getChartTypeBtn().enable(); + this.getChartLayoutBtn().enable(); + this.getSaveBtn().enable(); + this.getSaveAsBtn().enable(); + this.attachExportIcons(chartDiv, chartType, chartConfig); + this.getEl().unmask(); + + if (this.editMode && this.supportedBrowser) + this.updateSaveChartThumbnail(chartDiv, chartConfig); + }, + + addWarningMsg : function(warningText, allowDismiss) + { + var warningDivId = Ext4.id(); + var dismissLink = allowDismiss ? 'dismiss' : ''; + + var warningCmp = Ext4.create('Ext.container.Container', { + padding: 10, + cls: 'chart-warning', + html: warningText + ' ' + dismissLink, + listeners: { + scope: this, + render: function(cmp) { + Ext4.get('dismiss-link-' + warningDivId).on('click', function() { + // removing the warning message which will adjust the view panel height, so suspend events temporarily + this.getViewPanel().suspendEvents(); + this.getMsgPanel().remove(cmp); + this.getViewPanel().resumeEvents(); + }, this); + } + } + }); + + // add the warning message which will adjust the view panel height, so suspend events temporarily + this.getViewPanel().suspendEvents(); + this.getMsgPanel().add(warningCmp); + this.getViewPanel().resumeEvents(); + }, + + updateSaveChartThumbnail : function(chartDiv, chartConfig) + { + if (chartDiv.getEl()) { + var size = chartDiv.getEl().getSize(); + size.height = this.getPerChartHeight(chartConfig); + this.chartSVG = LABKEY.vis.SVGConverter.svgToStr(chartDiv.getEl().child('svg').dom); + this.getSavePanel().updateCurrentChartThumbnail(this.chartSVG, size); + } + }, + + isChartConfigValid : function(chartType, chartConfig, aes, scales) + { + var selectedMeasureNames = Object.keys(this.measures), + hasXMeasure = selectedMeasureNames.indexOf('x') > -1 && Ext4.isDefined(aes.x), + hasXSubMeasure = selectedMeasureNames.indexOf('xSub') > -1 && Ext4.isDefined(aes.xSub), + hasYMeasure = selectedMeasureNames.indexOf('y') > -1, + requiredMeasureNames = this.getChartTypePanel().getRequiredFieldNames(); + + // validate that all selected measures still exist by name in the query/dataset + if (!this.validateMeasuresExist(selectedMeasureNames, requiredMeasureNames)) + return false; + + // validate that the x axis measure exists and data is valid + if (hasXMeasure && !this.validateAxisMeasure(chartType, chartConfig, 'x', aes, scales, this.getMeasureStoreRecords())) + return false; + + // validate that the x subcategory axis measure exists and data is valid + if (hasXSubMeasure && !this.validateAxisMeasure(chartType, chartConfig, 'xSub', aes, scales, this.getMeasureStoreRecords())) + return false; + + // validate that the y axis measure exists and data is valid, handle case for single or multiple y-measures selected + if (hasYMeasure) { + var yMeasures = LABKEY.vis.GenericChartHelper.ensureMeasuresAsArray(this.measures['y']); + for (var i = 0; i < yMeasures.length; i++) { + var yMeasure = yMeasures[i]; + var yAes = {y: LABKEY.vis.GenericChartHelper.getYMeasureAes(yMeasure)}; + if (!this.validateAxisMeasure(chartType, yMeasure, 'y', yAes, scales, this.getMeasureStoreRecords(), yMeasure.converted)) { + return false; + } + } + } + + return true; + }, + + getPlotConfigs : function(newChartDiv, chartType, chartConfig, aes, scales, customRenderType, trendlineData) + { + var plotConfigArr = [], geom, labels, data = this.getMeasureStoreRecords(), me = this; + + geom = LABKEY.vis.GenericChartHelper.generateGeom(chartType, chartConfig.geomOptions); + if (chartType === 'line_plot' && data.length > this.dataPointLimit){ + chartConfig.geomOptions.hideDataPoints = true; + } + + labels = LABKEY.vis.GenericChartHelper.generateLabels(chartConfig.labels); + + 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) { + if (d.hasOwnProperty('value') && (!Ext4.isDefined(d.value) || isNaN(d.value))) { + d.value = 0; + } + }); + } + + if (customRenderType && Ext4.isFunction(customRenderType.generatePlotConfig)) { + var plotConfig = customRenderType.generatePlotConfig( + this, chartConfig, newChartDiv.id, + chartConfig.width, chartConfig.height, + data, aes, scales, labels + ); + + plotConfig.rendererType = 'd3'; + plotConfigArr.push(plotConfig); + } + else { + plotConfigArr = LABKEY.vis.GenericChartHelper.generatePlotConfigs(newChartDiv.id, chartConfig, labels, aes, scales, geom, data, trendlineData); + + if (this.renderType === 'pie_chart') + { + if (this.checkForNegativeData(data)) + { + // adding warning text without shrinking height cuts off the footer text + Ext4.each(plotConfigArr, function(plotConfig) { + plotConfig.height = Math.floor(plotConfig.height * 0.95); + }, this); + } + + // because of the load delay, need to reset the thumbnail svg for pie charts + Ext4.each(plotConfigArr, function(plotConfig) { + plotConfig.callbacks = { + onload: function(){ + me.updateSaveChartThumbnail(newChartDiv); + } + }; + }, this); + } + // if client has specified a line type (only applicable for scatter plot), apply that as another layer + else if (this.curveFit && this.measures.x && this.isScatterPlot(this.renderType, this.getXAxisType(this.measures.x))) + { + var factory = this.lineRenderers[this.curveFit.type]; + if (factory) + { + Ext4.each(plotConfigArr, function(plotConfig) { + plotConfig.layers.push( + new LABKEY.vis.Layer({ + geom: new LABKEY.vis.Geom.Path({}), + aes: {x: 'x', y: 'y'}, + data: LABKEY.vis.Stat.fn(factory.createRenderer(this.curveFit.params), this.curveFit.points, this.curveFit.min, this.curveFit.max) + }) + ); + }, this); + } + } + } + + return plotConfigArr; + }, + + checkForNegativeData : function(data) { + var negativesFound = []; + Ext4.each(data, function(entry) { + if (entry.value < 0) { + negativesFound.push(entry.label) + } + }); + + if (negativesFound.length > 0) + { + this.addWarningText('There are negative values in the data that the Pie Chart cannot display. ' + + 'Omitted: ' + negativesFound.join(', ')); + } + + return negativesFound.length > 0; + }, + + initMeasures : function() + { + // Initialize the x and y measures on first chart load. Returns false if we're missing the x or y measure. + var measure, fk, + queryColumnStore = this.getChartTypePanel().getQueryColumnsStore(), + requiredFieldNames = this.getChartTypePanel().getRequiredFieldNames(), + requiresX = requiredFieldNames.indexOf('x') > -1, + requiresY = requiredFieldNames.indexOf('y') > -1; + + if (!this.measures.y) + { + if (this.autoColumnYName || (requiresY && this.autoColumnName)) + { + fk = this.autoColumnYName || this.autoColumnName; + measure = this.getMeasureFromFieldKey(fk); + if (measure) + this.setYAxisMeasure(measure); + } + + if (requiresY && !this.measures.y) + { + this.getEl().unmask(); + this.showChartTypeWindow(); + return false; + } + } + + if (!this.measures.x) + { + if (this.renderType !== "box_plot" && this.renderType !== "auto_plot") + { + if (this.autoColumnXName || (requiresX && this.autoColumnName)) + { + fk = this.autoColumnXName || this.autoColumnName; + measure = this.getMeasureFromFieldKey(fk); + if (measure) + this.setXAxisMeasure(measure); + } + + if (requiresX && !this.measures.x) + { + this.getEl().unmask(); + this.showChartTypeWindow(); + return false; + } + } + else if (this.autoColumnYName != null) + { + measure = queryColumnStore.findRecord('label', 'Study: Cohort', 0, false, true, true); + if (measure) + this.setXAxisMeasure(measure); + + this.autoColumnYName = null; + } + } + + return true; + }, + + getMeasureFromFieldKey : function(fk) + { + var queryColumnStore = this.getChartTypePanel().getQueryColumnsStore(); + + // first search by fk.toString(), for example Analyte.Name -> Analyte$PName + var measure = queryColumnStore.findRecord('fieldKey', fk.toString(), 0, false, true, true); + if (measure != null) { + return measure; + } + + // second look by fk.getName() + return queryColumnStore.findRecord('fieldKey', fk.getName(), 0, false, true, true); + }, + + setYAxisMeasure : function(measure) + { + if (measure) + { + this.measures.y = measure.data ? measure.data : measure; + this.getChartTypePanel().setFieldSelection('y', this.measures.y); + this.getChartLayoutPanel().onMeasuresChange(this.measures, this.renderType); + } + }, + + setXAxisMeasure : function(measure) + { + if (measure) + { + this.measures.x = measure.data ? measure.data : measure; + this.getChartTypePanel().setFieldSelection('x', this.measures.x); + this.getChartLayoutPanel().onMeasuresChange(this.measures, this.renderType); + } + }, + + validateMeasuresExist: function(measureNames, requiredMeasureNames) + { + var store = this.getChartTypePanel().getQueryColumnsStore(), + valid = true, + message = null, + sep = ''; + + // Checks to make sure the measures are still available, if not we show an error. + Ext4.each(measureNames, function(propName) { + if (this.measures[propName] && propName !== 'trendline') { + var propMeasures = this.measures[propName]; + + // some properties allowMultiple so treat all as arrays + propMeasures = LABKEY.vis.GenericChartHelper.ensureMeasuresAsArray(propMeasures); + + Ext4.each(propMeasures, function(propMeasure) { + var indexByFieldKey = store.find('fieldKey', propMeasure.fieldKey, 0, false, false, true), + indexByName = store.find('fieldKey', propMeasure.name, 0, false, false, true); + + if (indexByFieldKey === -1 && indexByName === -1) { + if (message == null) + message = ''; + + message += sep + 'The saved ' + propName + ' measure, ' + propMeasure.name + ', is not available. It may have been renamed or removed.'; + sep = ' '; + + this.removeMeasureFromSelection(propName, propMeasure); + this.getChartTypePanel().setToForceApplyChanges(); + + if (requiredMeasureNames.indexOf(propName) > -1) + valid = false; + } + }, this); + } + }, this); + + this.handleValidation({success: valid, message: Ext4.util.Format.htmlEncode(message)}); + + return valid; + }, + + removeMeasureFromSelection : function(propName, measure) { + if (this.measures[propName]) { + if (!Ext4.isArray(this.measures[propName])) { + delete this.measures[propName]; + } + else { + Ext4.Array.remove(this.measures[propName], measure); + } + } + }, + + validateAxisMeasure : function(chartType, chartConfig, measureName, aes, scales, data, dataConversionHappened) + { + var validation = LABKEY.vis.GenericChartHelper.validateAxisMeasure(chartType, chartConfig, measureName, aes, scales, data, dataConversionHappened); + if (!validation.success) { + this.removeMeasureFromSelection(measureName, chartConfig); + } + + this.handleValidation(validation); + return validation.success; + }, + + handleValidation : function(validation) + { + if (validation.success === true) + { + if (validation.message != null) + this.addWarningText(validation.message); + } + else + { + this.getEl().unmask(); + this.setRenderRequested(false); + + if (this.editMode) + { + this.getChartTypePanel().setToForceApplyChanges(); + + Ext4.Msg.show({ + title: 'Error', + msg: Ext4.util.Format.htmlEncode(validation.message), + buttons: Ext4.MessageBox.OK, + icon: Ext4.MessageBox.ERROR, + fn: this.showChartTypeWindow, + scope: this + }); + } + else + { + this.clearChartPanel(true); + var errorDiv = Ext4.create('Ext.container.Container', { + border: 1, + autoEl: {tag: 'div'}, + padding: 10, + html: '

Error rendering chart:

' + validation.message, + autoScroll: true + }); + this.getViewPanel().add(errorDiv); + } + } + }, + + isScatterPlot : function(renderType, xAxisType) + { + if (renderType === 'scatter_plot') + return true; + + return (renderType === 'auto_plot' && LABKEY.vis.GenericChartHelper.isNumericType(xAxisType)); + }, + + isBoxPlot: function(renderType, xAxisType) + { + if (renderType === 'box_plot') + return true; + + return (renderType == 'auto_plot' && !LABKEY.vis.GenericChartHelper.isNumericType(xAxisType)); + }, + + getSelectedChartType : function() + { + if (Ext4.isString(this.renderType) && this.renderType !== 'auto_plot') + return this.renderType; + else if (this.measures.x && this.isBoxPlot(this.renderType, this.getXAxisType(this.measures.x))) + return 'box_plot'; + else if (this.measures.x && this.isScatterPlot(this.renderType, this.getXAxisType(this.measures.x))) + return 'scatter_plot'; + + return 'bar_plot'; + }, + + getXAxisType : function(xMeasure) + { + return xMeasure ? (xMeasure.normalizedType || xMeasure.type) : null; + }, + + clearChartPanel : function(clearMessages) + { + this.clearWarningText(); + this.getViewPanel().removeAll(); + if (clearMessages) { + this.clearMessagePanel(); + } + }, + + clearMessagePanel : function() { + this.getViewPanel().suspendEvents(); + this.getMsgPanel().removeAll(); + this.getViewPanel().resumeEvents(); + }, + + clearWarningText : function() + { + this.warningText = null; + }, + + addWarningText : function(warning) + { + if (!this.warningText) + this.warningText = Ext4.util.Format.htmlEncode(warning); + else + this.warningText = this.warningText + '  ' + Ext4.util.Format.htmlEncode(warning); + }, + + attachExportIcons : function(chartDiv, chartType, chartConfig) + { + if (this.supportedBrowser) + { + var index = 0; + Ext4.each(chartDiv.getEl().select('svg').elements, function(svgEl) { + chartDiv.add(this.createExportIcon(chartType, chartConfig, 'fa-file-pdf-o', 'Export to PDF', index, 0, function(){ + this.exportChartToImage(svgEl, LABKEY.vis.SVGConverter.FORMAT_PDF); + })); + + chartDiv.add(this.createExportIcon(chartType, chartConfig, 'fa-file-image-o', 'Export to PNG', index, 1, function(){ + this.exportChartToImage(svgEl, LABKEY.vis.SVGConverter.FORMAT_PNG); + })); + + index++; + }, this); + } + if (this.isDeveloper) + { + chartDiv.add(this.createExportIcon(chartType, chartConfig, 'fa-file-code-o', 'Export as Script', 0, this.supportedBrowser ? 2 : 0, function(){ + this.exportChartToScript(); + })); + } + }, + + createExportIcon : function(chartType, chartConfig, iconCls, tooltip, chartIndex, iconIndexFromLeft, callbackFn) + { + var chartWidth = this.getPerChartWidth(chartType, chartConfig), + viewPortWidth = this.getViewPanel().getWidth(), + chartsPerRow = chartWidth > viewPortWidth ? 1 : Math.floor(viewPortWidth / chartWidth), + topPx = Math.floor(chartIndex / chartsPerRow) * this.getPerChartHeight(chartConfig), + leftPx = ((chartIndex % chartsPerRow) * chartWidth) + (iconIndexFromLeft * 30) + 20; + + return Ext4.create('Ext.Component', { + cls: 'export-icon', + style: 'top: ' + topPx + 'px; left: ' + leftPx + 'px;', + html: '', + listeners: { + scope: this, + render: function(cmp) + { + Ext4.create('Ext.tip.ToolTip', { + target: cmp.getEl(), + constrainTo: this.getEl(), + width: 110, + html: tooltip + }); + + cmp.getEl().on('click', callbackFn, this); + } + } + }); + }, + + exportChartToImage : function(svgEl, type) + { + if (svgEl) { + var fileName = this.getChartConfig().labels.main, + exportType = type || LABKEY.vis.SVGConverter.FORMAT_PDF; + + LABKEY.vis.SVGConverter.convert(svgEl, exportType, fileName); + } + }, + + exportChartToScript : function() + { + var chartConfig = LABKEY.vis.GenericChartHelper.removeNumericConversionConfig(this.getChartConfig()); + var queryConfig = this.getQueryConfig(true); + + // Only push the required columns. + queryConfig.columns = []; + + Ext4.each(['x', 'y', 'color', 'shape', 'series'], function(name) { + if (Ext4.isDefined(chartConfig.measures[name])) { + var measuresArr = LABKEY.vis.GenericChartHelper.ensureMeasuresAsArray(chartConfig.measures[name]); + Ext4.each(measuresArr, function(measure) { + queryConfig.columns.push(measure.name); + }, this); + } + }, this); + + var templateConfig = { + chartConfig: chartConfig, + queryConfig: queryConfig + }; + + this.getExportScriptPanel().setScriptValue(templateConfig); + this.getExportScriptWindow().show(); + }, + + createFilterString : function(filters) + { + var filterParams = []; + for (var i = 0; i < filters.length; i++) + { + filterParams.push(this.filterToString(filters[i])); + } + + filterParams.sort(); + return filterParams.join('&'); + }, + + hasChartData : function() + { + return Ext4.isDefined(this.getMeasureStore()) && Ext4.isArray(this.getMeasureStoreRecords()); + }, + + onSelectRowsSuccess : function(measureStore) { + this.measureStore = measureStore; + + // when not in edit mode, we'll use the column metadata from the data query + if (!this.editMode) + this.getChartTypePanel().loadQueryColumns(this.getMeasureStoreMetadata().fields); + + this.queryTrendlineData(); + }, + + queryTrendlineData : async function() { + const chartConfig = this.getChartConfig(); + if (chartConfig.geomOptions.trendlineType && chartConfig.geomOptions.trendlineType !== '') { + this.setDataLoading(true); + + const data = this.getMeasureStoreRecords(); + this.trendlineData = await LABKEY.vis.GenericChartHelper.queryTrendlineData(chartConfig, data); + this.onQueryDataComplete(); + } else { + // trendlineType of '' means use Point-to-Point, i.e. no trendlineData + this.trendlineData = undefined; + this.onQueryDataComplete(); + } + }, + + onQueryDataComplete : function() { + this.setDataLoading(false); + + this.getMsgPanel().removeAll(); + + // If it's already been requested then we just need to request it again, since this time we have the data to render. + if (this.isRenderRequested()) + this.requestRender(); + }, + + getMeasureStore : function() + { + return this.measureStore; + }, + + getMeasureStoreRecords : function() + { + if (!this.getMeasureStore()) + console.error('No measureStore object defined.'); + + return this.getMeasureStore().records(); + }, + + getMeasureStoreMetadata : function() + { + if (!this.getMeasureStore()) + console.error('No measureStore object defined.'); + + return this.getMeasureStore().getResponseMetadata(); + }, + + getSchemaName : function() + { + if (this.getMeasureStoreMetadata() && this.getMeasureStoreMetadata().schemaName) + { + if (Ext4.isArray(this.getMeasureStoreMetadata().schemaName)) + return this.getMeasureStoreMetadata().schemaName[0]; + + return this.getMeasureStoreMetadata().schemaName; + } + + return null; + }, + + getQueryName : function() + { + if (this.getMeasureStoreMetadata()) + return this.getMeasureStoreMetadata().queryName; + + return null; + }, + + getDefaultTitle : function() + { + if (this.defaultTitleFn) + return this.defaultTitleFn(this.queryName, this.queryLabel, LABKEY.vis.GenericChartHelper.getDefaultMeasuresLabel(this.measures.y), this.measures.x ? this.measures.x.label : null); + + return this.queryLabel || this.queryName; + }, + + /** + * used to determine if the new chart options are different from the currently rendered options + */ + hasConfigurationChanged : function() + { + var queryCfg = this.getQueryConfig(); + + if (!queryCfg.schemaName || !queryCfg.queryName) + return false; + + // ugly race condition, haven't loaded a saved report yet + if (!this.reportLoaded) + return false; + + if (!this.hasChartData()) + return true; + + var filterStr = this.createFilterString(queryCfg.filterArray); + + if (this.currentFilterStr != filterStr) { + this.currentFilterStr = filterStr; + return true; + } + + var parameterStr = Ext4.JSON.encode(queryCfg.parameters); + if (this.currentParameterStr != parameterStr) { + this.currentParameterStr = parameterStr; + return true; + } + + return false; + }, + + setRenderRequested : function(requested) + { + this.renderRequested = requested; + }, + + isRenderRequested : function() + { + return this.renderRequested; + }, + + setDataLoading : function(loading) + { + this.dataLoading = loading; + }, + + isDataLoading : function() + { + return this.dataLoading; + }, + + requestData : function() + { + this.setDataLoading(true); + + var config = this.getQueryConfig(); + LABKEY.Query.MeasureStore.selectRows(config); + + this.requestRender(); + }, + + requestRender : function() + { + if (this.isDataLoading()) + this.setRenderRequested(true); + else + this.renderPlot(); + }, + + renderChart : function() + { + this.getEl().mask('Rendering Chart...'); + this.chartDefinitionChanged.delay(500); + }, + + resizeToViewport : function() { + console.warn('DEPRECATED: As of Release 17.3 ' + this.$className + '.resizeToViewport() is no longer supported.'); + }, + + onSaveBtnClicked : function(isSaveAs) + { + this.getSavePanel().setNoneThumbnail(this.getChartTypePanel().getImgUrl()); + this.getSavePanel().setSaveAs(isSaveAs); + this.getSavePanel().setMainTitle(isSaveAs ? "Save as" : "Save"); + this.getSaveWindow().show(); + } +}); diff --git a/visualization/resources/web/vis/genericChart/genericChartHelper.js b/visualization/resources/web/vis/genericChart/genericChartHelper.js index a8ff504d0bd..386db42e06a 100644 --- a/visualization/resources/web/vis/genericChart/genericChartHelper.js +++ b/visualization/resources/web/vis/genericChart/genericChartHelper.js @@ -1,2025 +1,2025 @@ -/* - * Copyright (c) 2013-2019 LabKey Corporation - * - * Licensed under the Apache License, Version 2.0: http://www.apache.org/licenses/LICENSE-2.0 - */ -if(!LABKEY.vis) { - LABKEY.vis = {}; -} - -/** - * @namespace Namespace used to encapsulate functions related to creating Generic Charts (Box, Scatter, etc.). Used in the - * Generic Chart Wizard and when exporting Generic Charts as Scripts. - */ -LABKEY.vis.GenericChartHelper = new function(){ - - var DEFAULT_TICK_LABEL_MAX = 25; - var $ = jQuery; - - var getRenderTypes = function() { - return [ - { - name: 'bar_chart', - title: 'Bar', - imgUrl: LABKEY.contextPath + '/visualization/images/barchart.png', - fields: [ - {name: 'x', label: 'X Axis', required: true, nonNumericOnly: true}, - {name: 'xSub', label: 'Group By', required: false, nonNumericOnly: true}, - {name: 'y', label: 'Y Axis', numericOnly: true} - ], - layoutOptions: {line: true, opacity: true, axisBased: true} - }, - { - name: 'box_plot', - title: 'Box', - imgUrl: LABKEY.contextPath + '/visualization/images/boxplot.png', - fields: [ - {name: 'x', label: 'X Axis'}, - {name: 'y', label: 'Y Axis', required: true, numericOnly: true}, - {name: 'color', label: 'Color', nonNumericOnly: true}, - {name: 'shape', label: 'Shape', nonNumericOnly: true} - ], - layoutOptions: {point: true, box: true, line: true, opacity: true, axisBased: true} - }, - { - name: 'line_plot', - title: 'Line', - imgUrl: LABKEY.contextPath + '/visualization/images/timechart.png', - fields: [ - {name: 'x', label: 'X Axis', required: true, numericOrDateOnly: true}, - {name: 'y', label: 'Y Axis', required: true, numericOnly: true, allowMultiple: true}, - {name: 'series', label: 'Series', nonNumericOnly: true}, - {name: 'trendline', label: 'Trendline', required: false, altSelectionOnly: true, altFieldType: 'LABKEY.vis.TrendlineField'}, - ], - layoutOptions: {opacity: true, axisBased: true, series: true, chartLayout: true} - }, - { - name: 'pie_chart', - title: 'Pie', - imgUrl: LABKEY.contextPath + '/visualization/images/piechart.png', - fields: [ - {name: 'x', label: 'Categories', required: true, nonNumericOnly: true}, - // Issue #29046 'Remove "measure" option from pie chart' - // {name: 'y', label: 'Measure', numericOnly: true} - ], - layoutOptions: {pie: true} - }, - { - name: 'scatter_plot', - title: 'Scatter', - imgUrl: LABKEY.contextPath + '/visualization/images/scatterplot.png', - fields: [ - {name: 'x', label: 'X Axis', required: true}, - {name: 'y', label: 'Y Axis', required: true, numericOnly: true, allowMultiple: true}, - {name: 'color', label: 'Color', nonNumericOnly: true}, - {name: 'shape', label: 'Shape', nonNumericOnly: true} - ], - layoutOptions: {point: true, opacity: true, axisBased: true, binnable: true, chartLayout: true} - }, - { - name: 'time_chart', - title: 'Time', - hidden: _getStudyTimepointType() == null, - imgUrl: LABKEY.contextPath + '/visualization/images/timechart.png', - fields: [ - {name: 'x', label: 'X Axis', required: true, altSelectionOnly: true, altFieldType: 'LABKEY.vis.TimeChartXAxisField'}, - {name: 'y', label: 'Y Axis', required: true, numericOnly: true, allowMultiple: true} - ], - layoutOptions: {time: true, axisBased: true, chartLayout: true} - } - ]; - }; - - /** - * Gets the chart type (i.e. box or scatter) based on the chartConfig object. - */ - const getChartType = function(chartConfig) - { - const renderType = chartConfig.renderType - const xAxisType = chartConfig.measures.x ? (chartConfig.measures.x.normalizedType || chartConfig.measures.x.type) : null; - - if (renderType === 'time_chart' || renderType === "bar_chart" || renderType === "pie_chart" - || renderType === "box_plot" || renderType === "scatter_plot" || renderType === "line_plot") - { - return renderType; - } - - if (!xAxisType) - { - // On some charts (non-study box plots) we don't require an x-axis, instead we generate one box plot for - // all of the data of your y-axis. If there is no xAxisType, then we have a box plot. Scatter plots require - // an x-axis measure. - return 'box_plot'; - } - - return (xAxisType === 'string' || xAxisType === 'boolean') ? 'box_plot' : 'scatter_plot'; - }; - - /** - * Generate a default label for the selected measure for the given renderType. - * @param renderType - * @param measureName - the chart type's measure name - * @param properties - properties for the selected column, note that this can be an array of properties - */ - var getSelectedMeasureLabel = function(renderType, measureName, properties) - { - var label = getDefaultMeasuresLabel(properties); - - if (label !== '' && measureName === 'y' && (renderType === 'bar_chart' || renderType === 'pie_chart')) { - var aggregateProps = LABKEY.Utils.isArray(properties) && properties.length === 1 - ? properties[0].aggregate : properties.aggregate; - - if (LABKEY.Utils.isDefined(aggregateProps)) { - var aggLabel = LABKEY.Utils.isObject(aggregateProps) ? (aggregateProps.name ?? aggregateProps.label) : LABKEY.Utils.capitalize(aggregateProps.toLowerCase()); - label = aggLabel + ' of ' + label; - } - else { - label = 'Sum of ' + label; - } - } - - return label; - }; - - /** - * Generate a plot title based on the selected measures array or object. - * @param renderType - * @param measures - * @returns {string} - */ - var getTitleFromMeasures = function(renderType, measures) - { - var queryLabels = []; - - if (LABKEY.Utils.isObject(measures)) - { - if (LABKEY.Utils.isArray(measures.y)) - { - $.each(measures.y, function(idx, m) - { - var measureQueryLabel = m.queryLabel || m.queryName; - if (queryLabels.indexOf(measureQueryLabel) === -1) - queryLabels.push(measureQueryLabel); - }); - } - else - { - var m = measures.x || measures.y; - queryLabels.push(m.queryLabel || m.queryName); - } - } - - return queryLabels.join(', '); - }; - - /** - * Get the sorted set of column metadata for the given schema/query/view. - * @param queryConfig - * @param successCallback - * @param callbackScope - */ - var getQueryColumns = function(queryConfig, successCallback, callbackScope) - { - LABKEY.Ajax.request({ - url: LABKEY.ActionURL.buildURL('visualization', 'getGenericReportColumns.api'), - method: 'GET', - params: { - schemaName: queryConfig.schemaName, - queryName: queryConfig.queryName, - viewName: queryConfig.viewName, - dataRegionName: queryConfig.dataRegionName, - includeCohort: true, - includeParticipantCategory : true - }, - success : function(response){ - var columnList = LABKEY.Utils.decode(response.responseText); - _queryColumnMetadata(queryConfig, columnList, successCallback, callbackScope) - }, - scope : this - }); - }; - - var _queryColumnMetadata = function(queryConfig, columnList, successCallback, callbackScope) - { - var columns = columnList.columns.all; - if (queryConfig.savedColumns) { - // make sure all savedColumns from the chart are included as options, they may not be in the view anymore - columns = columns.concat(queryConfig.savedColumns); - } - - LABKEY.Query.selectRows({ - maxRows: 0, // use maxRows 0 so that we just get the query metadata - schemaName: queryConfig.schemaName, - queryName: queryConfig.queryName, - viewName: queryConfig.viewName, - parameters: queryConfig.parameters, - requiredVersion: 9.1, - columns: columns, - method: 'POST', // Issue 31744: use POST as the columns list can be very long and cause a 400 error - success: function(response){ - var columnMetadata = _updateAndSortQueryFields(queryConfig, columnList, response.metaData.fields); - successCallback.call(callbackScope, columnMetadata); - }, - failure : function(response) { - // this likely means that the query no longer exists - successCallback.call(callbackScope, columnList, []); - }, - scope : this - }); - }; - - var _updateAndSortQueryFields = function(queryConfig, columnList, columnMetadata) - { - var queryFields = [], - queryFieldKeys = [], - columnTypes = LABKEY.Utils.isDefined(columnList.columns) ? columnList.columns : {}; - - $.each(columnMetadata, function(idx, column) - { - var f = $.extend(true, {}, column); - f.schemaName = queryConfig.schemaName; - f.queryName = queryConfig.queryName; - f.isCohortColumn = false; - f.isSubjectGroupColumn = false; - - // issue 23224: distinguish cohort and subject group fields in the list of query columns - if (columnTypes['cohort'] && columnTypes['cohort'].indexOf(f.fieldKey) > -1) - { - f.shortCaption = 'Study: ' + f.shortCaption; - f.isCohortColumn = true; - } - else if (columnTypes['subjectGroup'] && columnTypes['subjectGroup'].indexOf(f.fieldKey) > -1) - { - f.shortCaption = columnList.subject.nounSingular + ' Group: ' + f.shortCaption; - f.isSubjectGroupColumn = true; - } - - // Issue 31672: keep track of the distinct query field keys so we don't get duplicates - if (f.fieldKey.toLowerCase() != 'lsid' && queryFieldKeys.indexOf(f.fieldKey) == -1) { - queryFields.push(f); - queryFieldKeys.push(f.fieldKey); - } - }, this); - - // Sorts fields by their shortCaption, but put subject groups/categories/cohort at the end. - queryFields.sort(function(a, b) - { - if (a.isSubjectGroupColumn != b.isSubjectGroupColumn) - return a.isSubjectGroupColumn ? 1 : -1; - else if (a.isCohortColumn != b.isCohortColumn) - return a.isCohortColumn ? 1 : -1; - else if (a.shortCaption != b.shortCaption) - return a.shortCaption < b.shortCaption ? -1 : 1; - - return 0; - }); - - return queryFields; - }; - - /** - * Determine a reasonable width for the chart based on the chart type and selected measures / data. - * @param chartType - * @param measures - * @param measureStore - * @param defaultWidth - * @returns {int} - */ - 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); - - 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); - } - - return width; - }; - - /** - * Return the distinct set of y-axis sides for the given measures object. - * @param measures - */ - var getDistinctYAxisSides = function(measures) - { - var distinctSides = []; - $.each(ensureMeasuresAsArray(measures.y), function (idx, measure) { - if (LABKEY.Utils.isObject(measure)) { - var side = measure.yAxis || 'left'; - if (distinctSides.indexOf(side) === -1) { - distinctSides.push(side); - } - } - }, this); - return distinctSides; - }; - - /** - * Generate a default label for an array of measures by concatenating each meaures label together. - * @param measures - * @returns string concatenation of all measure labels - */ - var getDefaultMeasuresLabel = function(measures) - { - if (LABKEY.Utils.isDefined(measures)) { - if (!LABKEY.Utils.isArray(measures)) { - return measures.label || measures.queryName || ''; - } - - var label = '', sep = ''; - $.each(measures, function(idx, m) { - label += sep + (m.label || m.queryName); - sep = ', '; - }); - return label; - } - - return ''; - }; - - /** - * Given the saved labels object we convert it to include all label types (main, x, and y). Each label type defaults - * to empty string (''). - * @param {Object} labels The saved labels object. - * @returns {Object} - */ - var generateLabels = function(labels) { - return { - main: { value: labels.main || '' }, - subtitle: { value: labels.subtitle || '' }, - footer: { value: labels.footer || '' }, - x: { value: labels.x || '' }, - y: { value: labels.y || '' }, - yRight: { value: labels.yRight || '' } - }; - }; - - /** - * Generates an object containing {@link LABKEY.vis.Scale} objects used for the chart. - * @param {String} chartType The chartType from getChartType. - * @param {Object} measures The measures from generateMeasures. - * @param {Object} savedScales The scales object from the saved chart config. - * @param {Object} aes The aesthetic map object from genereateAes. - * @param {Object} measureStore The MeasureStore data using a selectRows API call. - * @param {Function} defaultFormatFn used to format values for tick marks. - * @returns {Object} - */ - var generateScales = function(chartType, measures, savedScales, aes, measureStore, defaultFormatFn) { - var scales = {}; - var data = LABKEY.Utils.isArray(measureStore.rows) ? measureStore.rows : measureStore.records(); - var fields = LABKEY.Utils.isObject(measureStore.metaData) ? measureStore.metaData.fields : measureStore.getResponseMetadata().fields; - var subjectColumn = getStudySubjectInfo().columnName; - var visitTableName = getStudySubjectInfo().tableName + 'Visit'; - var visitColName = visitTableName + '/Visit'; - var valExponentialDigits = 6; - - // Issue 38105: For plots with study visit labels on the x-axis, don't sort alphabetically - var sortFnX = measures.x && measures.x.fieldKey === visitColName ? undefined : LABKEY.vis.discreteSortFn; - - if (chartType === "box_plot") - { - scales.x = { - scaleType: 'discrete', // Force discrete x-axis scale for box plots. - sortFn: sortFnX, - tickLabelMax: DEFAULT_TICK_LABEL_MAX - }; - - var yMin = d3.min(data, aes.y); - var yMax = d3.max(data, aes.y); - var yPadding = ((yMax - yMin) * .1); - if (savedScales.y && savedScales.y.trans == "log") - { - // When subtracting padding we have to make sure we still produce valid values for a log scale. - // log([value less than 0]) = NaN. - // log(0) = -Infinity. - if (yMin - yPadding > 0) - { - yMin = yMin - yPadding; - } - } - else - { - yMin = yMin - yPadding; - } - - scales.y = { - min: yMin, - max: yMax + yPadding, - scaleType: 'continuous', - trans: savedScales.y ? savedScales.y.trans : 'linear' - }; - } - else - { - var xMeasureType = getMeasureType(measures.x); - - // Force discrete x-axis scale for bar plots. - var useContinuousScale = chartType != 'bar_chart' && isNumericType(xMeasureType); - - if (useContinuousScale) - { - scales.x = { - scaleType: 'continuous', - trans: savedScales.x ? savedScales.x.trans : 'linear' - }; - } - else - { - scales.x = { - scaleType: 'discrete', - sortFn: sortFnX, - tickLabelMax: DEFAULT_TICK_LABEL_MAX - }; - - //bar chart x-axis subcategories support - if (LABKEY.Utils.isDefined(measures.xSub)) { - scales.xSub = { - scaleType: 'discrete', - sortFn: LABKEY.vis.discreteSortFn, - tickLabelMax: DEFAULT_TICK_LABEL_MAX - }; - } - } - - // add both y (i.e. yLeft) and yRight, in case multiple y-axis measures are being plotted - scales.y = { - scaleType: 'continuous', - trans: savedScales.y ? savedScales.y.trans : 'linear' - }; - scales.yRight = { - scaleType: 'continuous', - trans: savedScales.yRight ? savedScales.yRight.trans : 'linear' - }; - } - - // if we have no data, show a default y-axis domain - if (scales.x && data.length == 0 && scales.x.scaleType == 'continuous') - scales.x.domain = [0,1]; - if (scales.y && data.length == 0) - scales.y.domain = [0,1]; - - // apply the field formatFn to the tick marks on the scales object - for (var i = 0; i < fields.length; i++) { - var type = fields[i].displayFieldJsonType ? fields[i].displayFieldJsonType : fields[i].type; - - var isMeasureXMatch = measures.x && _isFieldKeyMatch(measures.x, fields[i].fieldKey); - if (isMeasureXMatch && measures.x.name === subjectColumn && LABKEY.demoMode) { - scales.x.tickFormat = function(){return '******'}; - } - else if (isMeasureXMatch && isNumericType(type)) { - scales.x.tickFormat = _getNumberFormatFn(fields[i], defaultFormatFn); - } - - var yMeasures = ensureMeasuresAsArray(measures.y); - $.each(yMeasures, function(idx, yMeasure) { - var isMeasureYMatch = yMeasure && _isFieldKeyMatch(yMeasure, fields[i].fieldKey); - var isConvertedYMeasure = isMeasureYMatch && yMeasure.converted; - if (isMeasureYMatch && (isNumericType(type) || isConvertedYMeasure)) { - var tickFormatFn = _getNumberFormatFn(fields[i], defaultFormatFn); - - var ySide = yMeasure.yAxis === 'right' ? 'yRight' : 'y'; - scales[ySide].tickFormat = function(value) { - if (LABKEY.Utils.isNumber(value) && Math.abs(Math.round(value)).toString().length >= valExponentialDigits) { - return value.toExponential(); - } - else if (LABKEY.Utils.isFunction(tickFormatFn)) { - return tickFormatFn(value); - } - return value; - }; - } - }, this); - } - - _applySavedScaleDomain(scales, savedScales, 'x'); - if (LABKEY.Utils.isDefined(measures.xSub)) { - _applySavedScaleDomain(scales, savedScales, 'xSub'); - } - if (LABKEY.Utils.isDefined(measures.y)) { - _applySavedScaleDomain(scales, savedScales, 'y'); - _applySavedScaleDomain(scales, savedScales, 'yRight'); - } - - return scales; - }; - - // Issue 36227: if Ext4 is not available, try to generate our own number format function based on the "format" field metadata - var _getNumberFormatFn = function(field, defaultFormatFn) { - if (field.extFormatFn) { - if (window.Ext4) { - return eval(field.extFormatFn); - } - else if (field.format && LABKEY.Utils.isString(field.format) && field.format.indexOf('.') > -1) { - var precision = field.format.length - field.format.indexOf('.') - 1; - return function(v) { - return LABKEY.Utils.isNumber(v) ? v.toFixed(precision) : v; - } - } - } - - return defaultFormatFn; - }; - - var _isFieldKeyMatch = function(measure, fieldKey) { - if (LABKEY.Utils.isFunction(fieldKey.getName)) { - return fieldKey.getName() === measure.name || fieldKey.getName() === measure.fieldKey; - } else if (LABKEY.Utils.isArray(fieldKey)) { - fieldKey = fieldKey.join('/') - } - - return fieldKey === measure.name || fieldKey === measure.fieldKey; - }; - - var ensureMeasuresAsArray = function(measures) { - if (LABKEY.Utils.isDefined(measures)) { - return LABKEY.Utils.isArray(measures) ? $.extend(true, [], measures) : [$.extend(true, {}, measures)]; - } - return []; - }; - - var _applySavedScaleDomain = function(scales, savedScales, scaleName) { - if (savedScales[scaleName] && (savedScales[scaleName].min != null || savedScales[scaleName].max != null)) { - scales[scaleName].domain = [savedScales[scaleName].min, savedScales[scaleName].max]; - } - }; - - /** - * Generates the aesthetic map object needed by the visualization API to render the chart. See {@link LABKEY.vis.Plot} - * and {@link LABKEY.vis.Layer}. - * @param {String} chartType The chartType from getChartType. - * @param {Object} measures The measures from getMeasures. - * @param {String} schemaName The schemaName from the saved queryConfig. - * @param {String} queryName The queryName from the saved queryConfig. - * @returns {Object} - */ - 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); - - if (chartType === "box_plot") { - 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); - } - } else if (isNumericType(xMeasureType) || (chartType === 'scatter_plot' && measures.x.measure)) { - aes.x = generateContinuousAcc(xMeasureName); - } else { - aes.x = generateDiscreteAcc(xMeasureName, measures.x.label); - } - - // charts that have multiple y-measures selected will need to put the aes.y function on their specific layer - if (LABKEY.Utils.isDefined(measures.y) && !LABKEY.Utils.isArray(measures.y)) - { - var sideAesName = (measures.y.yAxis || 'left') === 'left' ? 'y' : 'yRight'; - var yMeasureName = measures.y.converted ? measures.y.convertedName : measures.y.name; - aes[sideAesName] = generateContinuousAcc(yMeasureName); - } - - if (chartType === "scatter_plot" || chartType === "line_plot") - { - aes.hoverText = generatePointHover(measures); - } - - if (chartType === "box_plot") - { - if (measures.color) { - aes.outlierColor = generateGroupingAcc(measures.color.name); - } - - if (measures.shape) { - aes.outlierShape = generateGroupingAcc(measures.shape.name); - } - - aes.hoverText = generateBoxplotHover(); - aes.outlierHoverText = generatePointHover(measures); - } - else if (chartType === 'bar_chart') - { - var xSubMeasureType = measures.xSub ? getMeasureType(measures.xSub) : null; - if (xSubMeasureType) - { - if (isNumericType(xSubMeasureType)) - aes.xSub = generateContinuousAcc(measures.xSub.name); - else - aes.xSub = generateDiscreteAcc(measures.xSub.name, measures.xSub.label); - } - } - - // color/shape aes are not dependent on chart type. If we have a box plot with all points enabled, then we - // create a second layer for points. So we'll need this no matter what. - if (measures.color) { - aes.color = generateGroupingAcc(measures.color.name); - } - - if (measures.shape) { - aes.shape = generateGroupingAcc(measures.shape.name); - } - - // also add the color and shape for the line plot series. - if (measures.series) { - aes.color = generateGroupingAcc(measures.series.name); - aes.shape = generateGroupingAcc(measures.series.name); - } - - if (measures.pointClickFn) { - aes.pointClickFn = generatePointClickFn( - measures, - schemaName, - queryName, - measures.pointClickFn - ); - } - - return aes; - }; - - var getYMeasureAes = function(measure) { - var yMeasureName = measure.converted ? measure.convertedName : measure.name; - return generateContinuousAcc(yMeasureName); - }; - - /** - * Generates a function that returns the text used for point hovers. - * @param {Object} measures The measures object from the saved chart config. - * @returns {Function} - */ - var generatePointHover = function(measures) - { - return function(row) { - var hover = '', sep = '', distinctNames = []; - - $.each(measures, function(key, measureObj) { - var measureArr = ensureMeasuresAsArray(measureObj); - $.each(measureArr, function(idx, measure) { - if (LABKEY.Utils.isObject(measure) && !LABKEY.Utils.isEmptyObj(measure) && distinctNames.indexOf(measure.name) == -1) { - 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); - }); - - return hover; - }; - }; - - /** - * Backwards compatibility for function that has been moved to LABKEY.vis.getAggregateData. - */ - var generateAggregateData = function(data, dimensionName, measureName, aggregate, nullDisplayValue) { - return LABKEY.vis.getAggregateData(data, dimensionName, null, measureName, aggregate, nullDisplayValue, false); - }; - - var _getRowValue = function(row, propName, valueName) - { - if (row.hasOwnProperty(propName)) { - // backwards compatibility for response row that is not a LABKEY.Query.Row - if (!(row instanceof LABKEY.Query.Row)) { - return row[propName].formattedValue || row[propName].displayValue || row[propName].value; - } - - var propValue = row.get(propName); - if (valueName != undefined && propValue.hasOwnProperty(valueName)) { - return propValue[valueName]; - } - else if (propValue.hasOwnProperty('formattedValue')) { - return propValue['formattedValue']; - } - else if (propValue.hasOwnProperty('displayValue')) { - return propValue['displayValue']; - } - return row.getValue(propName); - } - - return undefined; - }; - - /** - * Returns a function used to generate the hover text for box plots. - * @returns {Function} - */ - var generateBoxplotHover = function() { - return function(xValue, stats) { - return xValue + ':\nMin: ' + stats.min + '\nMax: ' + stats.max + '\nQ1: ' + stats.Q1 + '\nQ2: ' + stats.Q2 + - '\nQ3: ' + stats.Q3; - }; - }; - - /** - * Generates an accessor function that returns a discrete value from a row of data for a given measure and label. - * Used when an axis has a discrete measure (i.e. string). - * @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 - * @returns {Function} - */ - var generateDiscreteAcc = function(measureName, measureLabel, nullValueLabel) - { - return function(row) - { - var value = _getRowValue(row, measureName); - if (value === null) - value = nullValueLabel !== undefined ? nullValueLabel : "Not in " + measureLabel; - - return value; - }; - }; - - /** - * Generates an accessor function that returns a value from a row of data for a given measure. - * @param {String} measureName The name of the measure. - * @returns {Function} - */ - var generateContinuousAcc = function(measureName) - { - return function(row) - { - var value = _getRowValue(row, measureName, 'value'); - - if (value !== undefined) - { - if (Math.abs(value) === Infinity) - value = null; - - if (value === false || value === true) - value = value.toString(); - - return value; - } - - return undefined; - } - }; - - /** - * Generates an accesssor function for shape and color measures. - * @param {String} measureName The name of the measure. - * @returns {Function} - */ - var generateGroupingAcc = function(measureName) - { - return function(row) - { - var value = null; - if (LABKEY.Utils.isArray(row) && row.length > 0) { - value = _getRowValue(row[0], measureName); - } - else { - value = _getRowValue(row, measureName); - } - - if (value === null || value === undefined) - value = "n/a"; - - return value; - }; - }; - - /** - * Generates an accessor for boxplots that do not have an x-axis measure. Generally the measureName passed in is the - * queryName. - * @param {String} measureName The name of the measure. In this case it is generally the query name. - * @returns {Function} - */ - var generateMeasurelessAcc = function(measureName) { - // Used for box plots that do not have an x-axis measure. Instead we just return the queryName for every row. - return function(row) { - return measureName; - } - }; - - /** - * Generates the function to be executed when a user clicks a point. - * @param {Object} measures The measures from the saved chart config. - * @param {String} schemaName The schema name from the saved query config. - * @param {String} queryName The query name from the saved query config. - * @param {String} fnString The string value of the user-provided function to be executed when a point is clicked. - * @returns {Function} - */ - var generatePointClickFn = function(measures, schemaName, queryName, fnString){ - var measureInfo = { - schemaName: schemaName, - queryName: queryName - }; - - _addPointClickMeasureInfo(measureInfo, measures, 'x', 'xAxis'); - _addPointClickMeasureInfo(measureInfo, measures, 'y', 'yAxis'); - $.each(['color', 'shape', 'series'], function(idx, name) { - _addPointClickMeasureInfo(measureInfo, measures, name, name + 'Name'); - }, this); - - // using new Function is quicker than eval(), even in IE. - var pointClickFn = new Function('return ' + fnString)(); - return function(clickEvent, data){ - pointClickFn(data, measureInfo, clickEvent); - }; - }; - - var _addPointClickMeasureInfo = function(measureInfo, measures, name, key) { - if (LABKEY.Utils.isDefined(measures[name])) { - var measuresArr = ensureMeasuresAsArray(measures[name]); - $.each(measuresArr, function(idx, measure) { - if (!LABKEY.Utils.isDefined(measureInfo[key])) { - measureInfo[key] = measure.name; - } - else if (!LABKEY.Utils.isDefined(measureInfo[measure.name])) { - measureInfo[measure.name] = measure.name; - } - }, this); - } - }; - - /** - * Generates the Point Geom used for scatter plots and box plots with all points visible. - * @param {Object} chartOptions The saved chartOptions object from the chart config. - * @returns {LABKEY.vis.Geom.Point} - */ - var generatePointGeom = function(chartOptions){ - return new LABKEY.vis.Geom.Point({ - opacity: chartOptions.opacity, - size: chartOptions.pointSize, - color: '#' + chartOptions.pointFillColor, - position: chartOptions.position - }); - }; - - /** - * Generates the Boxplot Geom used for box plots. - * @param {Object} chartOptions The saved chartOptions object from the chart config. - * @returns {LABKEY.vis.Geom.Boxplot} - */ - var generateBoxplotGeom = function(chartOptions){ - return new LABKEY.vis.Geom.Boxplot({ - lineWidth: chartOptions.lineWidth, - outlierOpacity: chartOptions.opacity, - outlierFill: '#' + chartOptions.pointFillColor, - outlierSize: chartOptions.pointSize, - color: '#' + chartOptions.lineColor, - fill: chartOptions.boxFillColor == 'none' ? chartOptions.boxFillColor : '#' + chartOptions.boxFillColor, - position: chartOptions.position, - showOutliers: chartOptions.showOutliers - }); - }; - - /** - * Generates the Barplot Geom used for bar charts. - * @param {Object} chartOptions The saved chartOptions object from the chart config. - * @returns {LABKEY.vis.Geom.BarPlot} - */ - var generateBarGeom = function(chartOptions){ - return new LABKEY.vis.Geom.BarPlot({ - opacity: chartOptions.opacity, - color: '#' + chartOptions.lineColor, - fill: '#' + chartOptions.boxFillColor, - lineWidth: chartOptions.lineWidth - }); - }; - - /** - * Generates the Bin Geom used to bin a set of points. - * @param {Object} chartOptions The saved chartOptions object from the chart config. - * @returns {LABKEY.vis.Geom.Bin} - */ - var generateBinGeom = function(chartOptions) { - var colorRange = ["#e6e6e6", "#085D90"]; //light-gray and labkey blue is default - if (chartOptions.binColorGroup == 'SingleColor') { - var color = '#' + chartOptions.binSingleColor; - colorRange = ["#FFFFFF", color]; - } - else if (chartOptions.binColorGroup == 'Heat') { - colorRange = ["#fff6bc", "#e23202"]; - } - - return new LABKEY.vis.Geom.Bin({ - shape: chartOptions.binShape, - colorRange: colorRange, - size: chartOptions.binShape == 'square' ? 10 : 5 - }) - }; - - /** - * Generates a Geom based on the chartType. - * @param {String} chartType The chart type from getChartType. - * @param {Object} chartOptions The chartOptions object from the saved chart config. - * @returns {LABKEY.vis.Geom} - */ - var generateGeom = function(chartType, chartOptions) { - if (chartType == "box_plot") - return generateBoxplotGeom(chartOptions); - else if (chartType == "scatter_plot" || chartType == "line_plot") - return chartOptions.binned ? generateBinGeom(chartOptions) : generatePointGeom(chartOptions); - else if (chartType == "bar_chart") - return generateBarGeom(chartOptions); - }; - - /** - * Generate an array of plot configs for the given chart renderType and config options. - * @param renderTo - * @param chartConfig - * @param labels - * @param aes - * @param scales - * @param geom - * @param data - * @param trendlineData - * @returns {Array} array of plot config objects - */ - var generatePlotConfigs = function(renderTo, chartConfig, labels, aes, scales, geom, data, trendlineData) - { - var plotConfigArr = []; - - // if we have multiple y-measures and the request is to plot them separately, call the generatePlotConfig function - // for each y-measure separately with its own copy of the chartConfig object - if (chartConfig.geomOptions.chartLayout === 'per_measure' && LABKEY.Utils.isArray(chartConfig.measures.y)) { - - // if 'automatic across charts' scales are requested, need to manually calculate the min and max - if (chartConfig.scales.y && chartConfig.scales.y.type === 'automatic') { - scales.y = $.extend(scales.y, _getScaleDomainValuesForAllMeasures(data, chartConfig.measures.y, 'left')); - } - if (chartConfig.scales.yRight && chartConfig.scales.yRight.type === 'automatic') { - scales.yRight = $.extend(scales.yRight, _getScaleDomainValuesForAllMeasures(data, chartConfig.measures.y, 'right')); - } - - $.each(chartConfig.measures.y, function(idx, yMeasure) { - // copy the config and reset the measures.y array with the single measure - var newChartConfig = $.extend(true, {}, chartConfig); - newChartConfig.measures.y = $.extend(true, {}, yMeasure); - - // copy the labels object so that we can set the subtitle based on the y-measure - var newLabels = $.extend(true, {}, labels); - newLabels.subtitle = {value: yMeasure.label || yMeasure.name}; - - // only copy over the scales that are needed for this measures - var side = yMeasure.yAxis || 'left'; - var newScales = {x: $.extend(true, {}, scales.x)}; - if (side === 'left') { - newScales.y = $.extend(true, {}, scales.y); - } - else { - newScales.yRight = $.extend(true, {}, scales.yRight); - } - - plotConfigArr.push(generatePlotConfig(renderTo, newChartConfig, newLabels, aes, newScales, geom, data, trendlineData)); - }, this); - } - else { - plotConfigArr.push(generatePlotConfig(renderTo, chartConfig, labels, aes, scales, geom, data, trendlineData)); - } - - return plotConfigArr; - }; - - var _getScaleDomainValuesForAllMeasures = function(data, measures, side) { - var min = null, max = null; - - $.each(measures, function(idx, measure) { - var measureSide = measure.yAxis || 'left'; - if (side === measureSide) { - var accFn = LABKEY.vis.GenericChartHelper.getYMeasureAes(measure); - var tempMin = d3.min(data, accFn); - var tempMax = d3.max(data, accFn); - - if (min == null || tempMin < min) { - min = tempMin; - } - if (max == null || tempMax > max) { - max = tempMax; - } - } - }, this); - - return {domain: [min, max]}; - }; - - /** - * Generate the plot config for the given chart renderType and config options. - * @param renderTo - * @param chartConfig - * @param labels - * @param aes - * @param scales - * @param geom - * @param data - * @param trendlineData - * @returns {Object} - */ - var generatePlotConfig = function(renderTo, chartConfig, labels, aes, scales, geom, data, trendlineData) - { - var renderType = chartConfig.renderType, - layers = [], clipRect, - emptyTextFn = function(){return '';}, - plotConfig = { - renderTo: renderTo, - rendererType: 'd3', - width: chartConfig.width, - height: chartConfig.height, - gridLinesVisible: chartConfig.gridLinesVisible, - }; - - if (renderType === 'pie_chart') { - return _generatePieChartConfig(plotConfig, chartConfig, labels, data); - } - - clipRect = (scales.x && LABKEY.Utils.isArray(scales.x.domain)) || (scales.y && LABKEY.Utils.isArray(scales.y.domain)); - - // account for line chart hiding points - if (chartConfig.geomOptions.hideDataPoints) { - geom = null; - } - - // account for one or many y-measures by ensuring that we have an array of y-measures - var yMeasures = ensureMeasuresAsArray(chartConfig.measures.y); - - if (renderType === 'bar_chart') { - aes = { x: 'label', y: 'value' }; - - if (LABKEY.Utils.isDefined(chartConfig.measures.xSub)) - { - aes.xSub = 'subLabel'; - aes.color = 'label'; - } - - if (!scales.y) { - scales.y = {}; - } - - if (!scales.y.domain) { - var values = $.map(data, function(d) {return d.value + (d.error ?? 0);}), - min = Math.min(0, Math.min.apply(Math, values)), - max = Math.max(0, Math.max.apply(Math, values)); - - scales.y.domain = [min, max]; - } - } - else if (renderType === 'box_plot' && chartConfig.pointType === 'all') - { - layers.push( - new LABKEY.vis.Layer({ - geom: LABKEY.vis.GenericChartHelper.generatePointGeom(chartConfig.geomOptions), - aes: {hoverText: LABKEY.vis.GenericChartHelper.generatePointHover(chartConfig.measures)} - }) - ); - } - else if (renderType === 'line_plot') { - var xName = chartConfig.measures.x.name, - isDate = isDateType(getMeasureType(chartConfig.measures.x)); - - $.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); - } - return aVal - bVal; - }, - hoverText: emptyTextFn(), - }; - - pathAes[yMeasure.yAxis === 'right' ? 'yRight' : 'yLeft'] = getYMeasureAes(yMeasure); - - // use the series measure's values for the distinct colors and grouping - const hasSeries = chartConfig.measures.series !== undefined; - if (hasSeries) { - pathAes.pathColor = generateGroupingAcc(chartConfig.measures.series.name); - pathAes.group = generateGroupingAcc(chartConfig.measures.series.name); - pathAes.hoverText = function (row) { return chartConfig.measures.series.label + ': ' + row.group }; - } - // if no series measures but we have multiple y-measures, force the color and grouping to be distinct for each measure - else if (yMeasures.length > 1) { - pathAes.pathColor = emptyTextFn; - pathAes.group = emptyTextFn; - } - - if (trendlineData) { - trendlineData.forEach(trendline => { - if (trendline.data) { - const layerAes = { x: 'x', y: 'y' }; - if (hasSeries) { - layerAes.pathColor = function () { return trendline.name }; - } - - layerAes.hoverText = generateTrendlinePathHover(trendline); - - layers.push( - new LABKEY.vis.Layer({ - geom: new LABKEY.vis.Geom.Path({ - color: '#' + chartConfig.geomOptions.pointFillColor, - size: chartConfig.geomOptions.lineWidth ? chartConfig.geomOptions.lineWidth : 3, - opacity:chartConfig.geomOptions.opacity, - }), - aes: layerAes, - data: trendline.data.generatedPoints, - }) - ); - } - }); - } else { - layers.push( - new LABKEY.vis.Layer({ - name: yMeasures.length > 1 ? yMeasure.label || yMeasure.name : undefined, - geom: new LABKEY.vis.Geom.Path({ - color: '#' + chartConfig.geomOptions.pointFillColor, - size: chartConfig.geomOptions.lineWidth?chartConfig.geomOptions.lineWidth:3, - opacity:chartConfig.geomOptions.opacity - }), - aes: pathAes - }) - ); - } - }, this); - } - - // Issue 34711: better guess at the max number of discrete x-axis tick mark labels to show based on the plot width - if (scales.x && scales.x.scaleType === 'discrete' && scales.x.tickLabelMax) { - // approx 30 px for a 45 degree rotated tick label - scales.x.tickLabelMax = Math.floor((plotConfig.width - 300) / 30); - } - - var margins = _getPlotMargins(renderType, scales, aes, data, plotConfig, chartConfig); - if (LABKEY.Utils.isObject(margins)) { - plotConfig.margins = margins; - } - - if (chartConfig.measures.color) - { - scales.color = { - colorType: chartConfig.geomOptions.colorPaletteScale, - scaleType: 'discrete' - } - } - - if ((renderType === 'line_plot' || renderType === 'scatter_plot') && yMeasures.length > 0) { - $.each(yMeasures, function (idx, yMeasure) { - var layerAes = {}; - layerAes[yMeasure.yAxis === 'right' ? 'yRight' : 'yLeft'] = getYMeasureAes(yMeasure); - - // if no series measures but we have multiple y-measures, force the color and shape to be distinct for each measure - if (!aes.color && yMeasures.length > 1) { - layerAes.color = emptyTextFn; - } - if (!aes.shape && yMeasures.length > 1) { - 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, - geom: geom, - aes: layerAes - }) - ); - }, this); - } - else { - layers.push( - new LABKEY.vis.Layer({ - data: data, - geom: geom - }) - ); - } - - plotConfig = $.extend(plotConfig, { - clipRect: clipRect, - data: data, - labels: labels, - aes: aes, - scales: scales, - layers: layers - }); - - return plotConfig; - }; - - const hasPremiumModule = function() { - return LABKEY.getModuleContext('api').moduleNames.indexOf('premium') > -1; - }; - - const TRENDLINE_OPTIONS = { - '': { label: 'Point-to-Point', value: '' }, - 'Linear': { label: 'Linear Regression', value: 'Linear', equation: 'y = x * slope + intercept' }, - 'Polynomial': { label: 'Polynomial', value: 'Polynomial', equation: 'y = a0 + a1 * x + a2 * x^2' }, - '3 Parameter': { label: 'Nonlinear 3PL', value: '3 Parameter', showMax: true, schemaPrefix: 'assay', equation: 'y = max * abs(x/inflection)^abs(slope) / [1 + abs(x/inflection)^abs(slope)]' }, - 'Three Parameter': { label: 'Nonlinear 3PL (Alternate)', value: 'Three Parameter', showMax: true, schemaPrefix: 'assay', equation: 'y = max / [1 + (inflection - x) * slope]' }, - '4 Parameter': { label: 'Nonlinear 4PL', value: '4 Parameter', schemaPrefix: 'assay', equation: 'y = max + (min - max) / [1 + (x/inflection)^slope]' }, - 'Four Parameter': { label: 'Nonlinear 4PL (Alternate)', value: 'Four Parameter', showMin: true, showMax: true, schemaPrefix: 'assay', equation: 'y = min + (max - min) / [1 + (inflection - x) * slope]' }, - 'Five Parameter': { label: 'Nonlinear 5PL', value: 'Five Parameter', showMin: true, showMax: true, schemaPrefix: 'assay', equation: 'y = min + (max - min) / [[1 + (inflection - x) * slope]^asymmetry]' }, - } - - const generateTrendlinePathHover = function(trendline) { - let hoverText = trendline.name + '\n'; - hoverText += '\n' + TRENDLINE_OPTIONS[trendline.data.curveFit.type].label + ':\n'; - Object.entries(trendline.data.curveFit).forEach(([key, value]) => { - if (key === 'coefficients') { - hoverText += key + ': '; - value.forEach((v, i) => { - hoverText += (i > 0 ? ', ' : '') + LABKEY.Utils.roundNumber(v, 4); - }); - hoverText += '\n'; - } - else if (key !== 'type') { - hoverText += key + ': ' + LABKEY.Utils.roundNumber(value, 4) + '\n'; - } - }); - hoverText += '\nStatistics:\n'; - Object.entries(trendline.data.stats).forEach(([key, value]) => { - const label = key === 'RSquared' ? 'R-Squared' : (key === 'adjustedRSquared' ? 'Adjusted R-Squared' : key); - hoverText += label + ': ' + LABKEY.Utils.roundNumber(value, 4) + '\n'; - }); - - return function () { return hoverText }; - }; - - // support for y-axis trendline data when a single y-axis measure is selected - const queryTrendlineData = async function(chartConfig, data) { - const chartType = getChartType(chartConfig); - const yMeasures = ensureMeasuresAsArray(chartConfig.measures.y); - if (chartType === 'line_plot' && chartConfig.geomOptions?.trendlineType && chartConfig.geomOptions.trendlineType !== '' && yMeasures.length === 1) { - const xName = chartConfig.measures.x.name; - const trendlineConfig = getTrendlineConfig(chartConfig, data); - try { - await _queryTrendlineData(trendlineConfig, xName, yMeasures[0].name); - return trendlineConfig.data; - } catch (reason) { - // skip this series and render without trendline - return trendlineConfig.data; - } - } - - return undefined; - }; - - const getTrendlineConfig = function(chartConfig, data) { - const config = { - type: chartConfig.geomOptions.trendlineType, - logXScale: chartConfig.scales.x && chartConfig.scales.x.trans === 'log', - asymptoteMin: chartConfig.geomOptions.trendlineAsymptoteMin, - asymptoteMax: chartConfig.geomOptions.trendlineAsymptoteMax, - data: chartConfig.measures.series - ? LABKEY.vis.groupCountData(data, generateGroupingAcc(chartConfig.measures.series.name)) - : [{name: 'All', rawData: data}], - }; - - // special case to only use logXScale for linear trendlines - if (config.type === 'Linear') { - config.logXScale = false; - } - - return config; - }; - - const _queryTrendlineData = async function(trendlineConfig, xName, yName) { - for (let series of trendlineConfig.data) { - try { - // we need at least 2 data points for curve fitting - if (series.rawData.length > 1) { - series.data = await _querySeriesTrendlineData(trendlineConfig, series, xName, yName); - } - } catch (e) { - console.error(e); - } - } - }; - - const _querySeriesTrendlineData = function(trendlineConfig, seriesData, xName, yName) { - return new Promise(function(resolve, reject) { - if (!hasPremiumModule()) { - reject('Premium module required for curve fitting.'); - return; - } - - const points = seriesData.rawData.map(function(row) { - return { - x: _getRowValue(row, xName, 'value'), - y: _getRowValue(row, yName, 'value'), - }; - }); - const xAcc = function(row) { return row.x }; - const xMin = d3.min(points, xAcc); - const xMax = d3.max(points, xAcc); - - LABKEY.Ajax.request({ - url: LABKEY.ActionURL.buildURL('premium', 'calculateCurveFit.api'), - method: 'POST', - jsonData: { - curveFitType: trendlineConfig.type, - points: points, - logXScale: trendlineConfig.logXScale, - asymptoteMin: trendlineConfig.asymptoteMin, - asymptoteMax: trendlineConfig.asymptoteMax, - xMin: xMin, - xMax: xMax, - numberOfPoints: 1000, - }, - success : LABKEY.Utils.getCallbackWrapper(function(response) { - resolve(response); - }), - failure : LABKEY.Utils.getCallbackWrapper(function(reason) { - reject(reason); - }, this, true), - }); - }); - }; - - 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; - // 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 1; - }; - - var _getPlotMargins = function(renderType, scales, aes, data, plotConfig, chartConfig) { - var margins = {}; - - // issue 29690: for bar and box plots, set default bottom margin based on the number of labels and the max label length - if (LABKEY.Utils.isArray(data)) { - var maxLen = 0; - $.each(data, function(idx, d) { - var val = LABKEY.Utils.isFunction(aes.x) ? aes.x(d) : d[aes.x]; - var subVal = LABKEY.Utils.isFunction(aes.xSub) ? aes.xSub(d) : d[aes.xSub]; - if (LABKEY.Utils.isString(subVal)) { - maxLen = Math.max(maxLen, subVal.length); - } else if (LABKEY.Utils.isString(val)) { - maxLen = Math.max(maxLen, val.length); - } - }); - - var wrapLines = _wrapXAxisTickTextLines(scales, plotConfig, maxLen, data); - margins.bottom = 60 + ((wrapLines - 1) * 25); - } - - // issue 31857: allow custom margins to be set in Chart Layout dialog - if (chartConfig && chartConfig.geomOptions) { - if (chartConfig.geomOptions.marginTop !== null) { - margins.top = chartConfig.geomOptions.marginTop; - } - if (chartConfig.geomOptions.marginRight !== null) { - margins.right = chartConfig.geomOptions.marginRight; - } - if (chartConfig.geomOptions.marginBottom !== null) { - margins.bottom = chartConfig.geomOptions.marginBottom; - } - if (chartConfig.geomOptions.marginLeft !== null) { - margins.left = chartConfig.geomOptions.marginLeft; - } - } - - return !LABKEY.Utils.isEmptyObj(margins) ? margins : null; - }; - - var _generatePieChartConfig = function(baseConfig, chartConfig, labels, data) - { - var hasData = data.length > 0; - - return $.extend(baseConfig, { - data: hasData ? data : [{label: '', value: 1}], - header: { - title: { text: labels.main.value }, - subtitle: { text: labels.subtitle.value }, - titleSubtitlePadding: 1 - }, - footer: { - text: hasData ? labels.footer.value : 'No data to display', - location: 'bottom-center' - }, - labels: { - mainLabel: { fontSize: 14 }, - percentage: { - fontSize: 14, - color: chartConfig.geomOptions.piePercentagesColor != null ? '#' + chartConfig.geomOptions.piePercentagesColor : undefined - }, - outer: { pieDistance: 20 }, - inner: { - format: hasData && chartConfig.geomOptions.showPiePercentages ? 'percentage' : 'none', - hideWhenLessThanPercentage: chartConfig.geomOptions.pieHideWhenLessThanPercentage - } - }, - size: { - pieInnerRadius: hasData ? chartConfig.geomOptions.pieInnerRadius + '%' : '100%', - pieOuterRadius: hasData ? chartConfig.geomOptions.pieOuterRadius + '%' : '90%' - }, - misc: { - gradient: { - enabled: chartConfig.geomOptions.gradientPercentage != 0, - percentage: chartConfig.geomOptions.gradientPercentage, - color: '#' + chartConfig.geomOptions.gradientColor - }, - colors: { - segments: hasData ? LABKEY.vis.Scale[chartConfig.geomOptions.colorPaletteScale]() : ['#333333'] - } - }, - effects: { highlightSegmentOnMouseover: false }, - tooltips: { enabled: true } - }); - }; - - /** - * Check if the MeasureStore selectRows API response has data. Return an error string if no data exists. - * @param measureStore - * @param includeFilterMsg true to include a message about removing filters - * @returns {String} - */ - var validateResponseHasData = function(measureStore, includeFilterMsg) - { - var dataArray = getMeasureStoreRecords(measureStore); - if (dataArray.length == 0) - { - return 'The response returned 0 rows of data. The query may be empty or the applied filters may be too strict.' - + (includeFilterMsg ? 'Try removing or adjusting any filters if possible.' : ''); - } - - return null; - }; - - var getMeasureStoreRecords = function(measureStore) { - return LABKEY.Utils.isDefined(measureStore) ? measureStore.rows || measureStore.records() : []; - } - - /** - * Verifies that the axis measure is actually present and has data. Also checks to make sure that data can be used in a log - * scale (if applicable). Returns an object with a success parameter (boolean) and a message parameter (string). If the - * success parameter is false there is a critical error and the chart cannot be rendered. If success is true the chart - * can be rendered. Message will contain an error or warning message if applicable. If message is not null and success - * is true, there is a warning. - * @param {String} chartType The chartType from getChartType. - * @param {Object} chartConfigOrMeasure The saved chartConfig object or a specific measure object. - * @param {String} measureName The name of the axis measure property. - * @param {Object} aes The aes object from generateAes. - * @param {Object} scales The scales object from generateScales. - * @param {Array} data The response data from selectRows. - * @param {Boolean} dataConversionHappened Whether we converted any values in the measure data - * @returns {Object} - */ - var validateAxisMeasure = function(chartType, chartConfigOrMeasure, measureName, aes, scales, data, dataConversionHappened) { - var measure = LABKEY.Utils.isObject(chartConfigOrMeasure) && chartConfigOrMeasure.measures ? chartConfigOrMeasure.measures[measureName] : chartConfigOrMeasure; - return _validateAxisMeasure(chartType, measure, measureName, aes, scales, data, dataConversionHappened); - }; - - var _validateAxisMeasure = function(chartType, measure, measureName, aes, scales, data, dataConversionHappened) { - var dataIsNull = true, measureUndefined = true, invalidLogValues = false, hasZeroes = false, message = null; - - // no need to check measures if we have no data - if (data.length === 0) { - return {success: true, message: message}; - } - - for (var i = 0; i < data.length; i ++) - { - var value = aes[measureName](data[i]); - - if (value !== undefined) - measureUndefined = false; - - if (value !== null) - dataIsNull = false; - - if (value && value < 0) - invalidLogValues = true; - - if (value === 0 ) - hasZeroes = true; - } - - if (measureUndefined) - { - message = 'The measure, ' + measure.name + ', was not found. It may have been renamed or removed.'; - return {success: false, message: message}; - } - - if ((chartType == 'scatter_plot' || chartType == 'line_plot' || measureName == 'y') && dataIsNull && !dataConversionHappened) - { - message = 'All data values for ' + measure.label + ' are null. Please choose a different measure or review/remove data filters.'; - return {success: true, message: message}; - } - - if (scales[measureName] && scales[measureName].trans == "log") - { - if (invalidLogValues) - { - message = "Unable to use a log scale on the " + measureName + "-axis. All " + measureName - + "-axis values must be >= 0. Reverting to linear scale on " + measureName + "-axis."; - scales[measureName].trans = 'linear'; - } - else if (hasZeroes) - { - message = "Some " + measureName + "-axis values are 0. Plotting all " + measureName + "-axis values as value+1."; - var accFn = aes[measureName]; - aes[measureName] = function(row){return accFn(row) + 1}; - } - } - - return {success: true, message: message}; - }; - - /** - * Deprecated - use validateAxisMeasure - */ - var validateXAxis = function(chartType, chartConfig, aes, scales, data){ - return this.validateAxisMeasure(chartType, chartConfig, 'x', aes, scales, data); - }; - /** - * Deprecated - use validateAxisMeasure - */ - var validateYAxis = function(chartType, chartConfig, aes, scales, data){ - return this.validateAxisMeasure(chartType, chartConfig, 'y', aes, scales, data); - }; - - var getMeasureType = function(measure) { - return LABKEY.Utils.isObject(measure) ? (measure.normalizedType || measure.type) : null; - }; - - var isNumericType = function(type) - { - var t = LABKEY.Utils.isString(type) ? type.toLowerCase() : null; - return t == 'int' || t == 'integer' || t == 'float' || t == 'double'; - }; - - var isDateType = function(type) - { - var t = LABKEY.Utils.isString(type) ? type.toLowerCase() : null; - return t == 'date'; - }; - - var getAllowableTypes = function(field) { - var numericTypes = ['int', 'float', 'double', 'INTEGER', 'DOUBLE'], - nonNumericTypes = ['string', 'date', 'boolean', 'STRING', 'TEXT', 'DATE', 'BOOLEAN'], - numericAndDateTypes = numericTypes.concat(['date','DATE']); - - if (field.altSelectionOnly) - return []; - else if (field.numericOnly) - return numericTypes; - else if (field.nonNumericOnly) - return nonNumericTypes; - else if (field.numericOrDateOnly) - return numericAndDateTypes; - else - return numericTypes.concat(nonNumericTypes); - } - - var isMeasureDimensionMatch = function(chartType, field, isMeasure, isDimension) { - if ((chartType === 'box_plot' || chartType === 'bar_chart')) { - //x-axis does not support 'measure' column types for these plot types - if (field.name === 'x' || field.name === 'xSub') - return isDimension; - else - return isMeasure; - } - - return (field.numericOnly && isMeasure) || (field.nonNumericOnly && isDimension); - } - - var getQueryConfigSortKey = function(measures) { - var sortKey = 'lsid'; // needed to keep expected ordering for legend data - - // Issue 38105: For plots with study visit labels on the x-axis, sort by visit display order and then sequenceNum - var visitTableName = LABKEY.vis.GenericChartHelper.getStudySubjectInfo().tableName + 'Visit'; - if (measures.x && measures.x.fieldKey === visitTableName + '/Visit') { - var displayOrderColName = visitTableName + '/Visit/DisplayOrder'; - var seqNumColName = visitTableName + '/SequenceNum'; - sortKey = displayOrderColName + ', ' + seqNumColName; - } - - return sortKey; - } - - var getStudySubjectInfo = function() - { - var studyCtx = LABKEY.getModuleContext("study") || {}; - return LABKEY.Utils.isObject(studyCtx.subject) ? studyCtx.subject : { - tableName: 'Participant', - columnName: 'ParticipantId', - nounPlural: 'Participants', - nounSingular: 'Participant' - }; - }; - - var _getStudyTimepointType = function() - { - var studyCtx = LABKEY.getModuleContext("study") || {}; - return LABKEY.Utils.isDefined(studyCtx.timepointType) ? studyCtx.timepointType : null; - }; - - var _getMeasureRestrictions = function (chartType, measure) - { - var measureRestrictions = {}; - $.each(getRenderTypes(), function (idx, renderType) - { - if (renderType.name === chartType) - { - $.each(renderType.fields, function (idx2, field) - { - if (field.name === measure) - { - measureRestrictions.numericOnly = field.numericOnly; - measureRestrictions.nonNumericOnly = field.nonNumericOnly; - return false; - } - }); - return false; - } - }); - - return measureRestrictions; - }; - - /** - * Converts data values passed in to the appropriate type based on measure/dimension information. - * @param chartConfig Chart configuration object - * @param aes Aesthetic mapping functions for each measure/axis - * @param renderType The type of plot or chart (e.g. scatter_plot, bar_chart) - * @param data The response data from SelectRows - * @returns {{processed: {}, warningMessage: *}} - */ - var doValueConversion = function(chartConfig, aes, renderType, data) - { - var measuresForProcessing = {}, measureRestrictions = {}, configMeasure; - for (var measureName in chartConfig.measures) { - if (chartConfig.measures.hasOwnProperty(measureName) && LABKEY.Utils.isObject(chartConfig.measures[measureName])) { - configMeasure = chartConfig.measures[measureName]; - $.extend(measureRestrictions, _getMeasureRestrictions(renderType, measureName)); - - var isGroupingMeasure = measureName === 'color' || measureName === 'shape' || measureName === 'series'; - var isXAxis = measureName === 'x' || measureName === 'xSub'; - var isScatterOrLine = renderType === 'scatter_plot' || renderType === 'line_plot'; - var isBarYCount = renderType === 'bar_chart' && configMeasure.aggregate && (configMeasure.aggregate === 'COUNT' || configMeasure.aggregate.value === 'COUNT'); - - if (configMeasure.measure && !isGroupingMeasure && !isBarYCount - && ((!isXAxis && measureRestrictions.numericOnly ) || isScatterOrLine) && !isNumericType(configMeasure.type)) { - measuresForProcessing[measureName] = {}; - measuresForProcessing[measureName].name = configMeasure.name; - measuresForProcessing[measureName].convertedName = configMeasure.name + "_converted"; - measuresForProcessing[measureName].label = configMeasure.label; - configMeasure.normalizedType = 'float'; - configMeasure.type = 'float'; - } - } - } - - var response = {processed: {}}; - if (!LABKEY.Utils.isEmptyObj(measuresForProcessing)) { - response = _processMeasureData(data, aes, measuresForProcessing); - } - - //generate error message for dropped values - var warningMessage = ''; - for (var measure in response.droppedValues) { - if (response.droppedValues.hasOwnProperty(measure) && response.droppedValues[measure].numDropped) { - warningMessage += " The " - + measure + "-axis measure '" - + response.droppedValues[measure].label + "' had " - + response.droppedValues[measure].numDropped + - " value(s) that could not be converted to a number and are not included in the plot."; - } - } - - return {processed: response.processed, warningMessage: warningMessage}; - }; - - /** - * Does the explicit type conversion for each measure deemed suitable to convert. Currently we only - * attempt to convert strings to numbers for measures. - * @param rows Data from SelectRows - * @param aes Aesthetic mapping function for the measure/dimensions - * @param measuresForProcessing The measures to be converted, if any - * @returns {{droppedValues: {}, processed: {}}} - */ - var _processMeasureData = function(rows, aes, measuresForProcessing) { - var droppedValues = {}, processedMeasures = {}, dataIsNull; - rows.forEach(function(row) { - //convert measures if applicable - if (!LABKEY.Utils.isEmptyObj(measuresForProcessing)) { - for (var measure in measuresForProcessing) { - if (measuresForProcessing.hasOwnProperty(measure)) { - dataIsNull = true; - if (!droppedValues[measure]) { - droppedValues[measure] = {}; - droppedValues[measure].label = measuresForProcessing[measure].label; - droppedValues[measure].numDropped = 0; - } - - if (aes.hasOwnProperty(measure)) { - var value = aes[measure](row); - if (value !== null) { - dataIsNull = false; - } - row[measuresForProcessing[measure].convertedName] = {value: null}; - if (typeof value !== 'number' && value !== null) { - - //only try to convert strings to numbers - if (typeof value === 'string') { - value = value.trim(); - } - else { - //dates, objects, booleans etc. to be assigned value: NULL - value = ''; - } - - var n = Number(value); - // empty strings convert to 0, which we must explicitly deny - if (value === '' || isNaN(n)) { - droppedValues[measure].numDropped++; - } - else { - row[measuresForProcessing[measure].convertedName].value = n; - } - } - } - - if (!processedMeasures[measure]) { - processedMeasures[measure] = { - converted: false, - convertedName: measuresForProcessing[measure].convertedName, - type: 'float', - normalizedType: 'float' - } - } - - processedMeasures[measure].converted = processedMeasures[measure].converted || !dataIsNull; - } - } - } - }); - - return {droppedValues: droppedValues, processed: processedMeasures}; - }; - - /** - * removes all traces of String -> Numeric Conversion from the given chart config - * @param chartConfig - * @returns {updated ChartConfig} - */ - var removeNumericConversionConfig = function(chartConfig) { - if (chartConfig && chartConfig.measures) { - for (var measureName in chartConfig.measures) { - if (chartConfig.measures.hasOwnProperty(measureName)) { - var measure = chartConfig.measures[measureName]; - if (measure && measure.converted && measure.convertedName) { - measure.converted = null; - measure.convertedName = null; - if (LABKEY.vis.GenericChartHelper.isNumericType(measure.type)) { - measure.type = 'string'; - measure.normalizedType = 'string'; - } - } - } - } - } - - return chartConfig; - }; - - var renderChartSVG = function(renderTo, queryConfig, chartConfig) { - queryChartData(renderTo, queryConfig, chartConfig, function(measureStore, trendlineData) { - generateChartSVG(renderTo, chartConfig, measureStore, trendlineData); - }); - }; - - var queryChartData = function(renderTo, queryConfig, chartConfig, callback) { - queryConfig.containerPath = LABKEY.container.path; - - if (queryConfig.filterArray && queryConfig.filterArray.length > 0) { - var filters = []; - - for (var i = 0; i < queryConfig.filterArray.length; i++) { - var f = queryConfig.filterArray[i]; - // Issue 37191: Check to see if 'f' is already a filter instance (either labkey-api-js/src/filter/Filter.ts or clientapi/core/Query.js) - if (f.hasOwnProperty('getValue') || f.getValue instanceof Function) { - filters.push(f); - } - else { - filters.push(LABKEY.Filter.create(f.name, f.value, LABKEY.Filter.getFilterTypeForURLSuffix(f.type))); - } - } - - queryConfig.filterArray = filters; - } - - queryConfig.success = async function(measureStore) { - const trendlineData = await queryTrendlineData(chartConfig, measureStore.records()); - callback.call(this, measureStore, trendlineData); - }; - - LABKEY.Query.MeasureStore.selectRows(queryConfig); - }; - - var generateDataForChartType = function(chartConfig, chartType, geom, data) { - var dimName = null; - var subDimName = null; - var measureName = null; - var aggType = chartType === 'bar_chart' || chartType === 'pie_chart' ? 'COUNT' : null; - var aggErrorType = null; - - 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; - } else if (chartConfig.measures.series) { - subDimName = chartConfig.measures.series.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.value || chartConfig.measures.y.aggregate; - aggType = LABKEY.Utils.isObject(aggType) ? aggType.value : aggType; - aggErrorType = aggType === 'MEAN' ? chartConfig.measures.y.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, '[Blank]', false, aggErrorType, chartType === 'line_plot'); - if (aggErrorType) { - geom.errorAes = { getValue: function(d){ return d.error } }; - } - } - - return data; - } - - var generateChartSVG = function(renderTo, chartConfig, measureStore, trendlineData) { - var responseMetaData = measureStore.getResponseMetadata(); - - // explicitly set the chart width/height if not set in the config - if (!chartConfig.hasOwnProperty('width') || chartConfig.width == null) chartConfig.width = 1000; - if (!chartConfig.hasOwnProperty('height') || chartConfig.height == null) chartConfig.height = 600; - - var chartType = getChartType(chartConfig); - var aes = generateAes(chartType, chartConfig.measures, responseMetaData.schemaName, responseMetaData.queryName); - var valueConversionResponse = doValueConversion(chartConfig, aes, chartType, measureStore.records()); - if (!LABKEY.Utils.isEmptyObj(valueConversionResponse.processed)) { - $.extend(true, chartConfig.measures, valueConversionResponse.processed); - aes = generateAes(chartType, chartConfig.measures, responseMetaData.schemaName, responseMetaData.queryName); - } - var data = measureStore.records(); - if (chartType === 'scatter_plot' && data.length > chartConfig.geomOptions.binThreshold) { - chartConfig.geomOptions.binned = true; - } - var scales = generateScales(chartType, chartConfig.measures, chartConfig.scales, aes, measureStore); - var geom = generateGeom(chartType, chartConfig.geomOptions); - var labels = generateLabels(chartConfig.labels); - - if (chartType === 'bar_chart' || chartType === 'pie_chart' || chartType === 'line_plot') { - data = generateDataForChartType(chartConfig, chartType, geom, data); - } - - var validation = _validateChartConfig(chartConfig, aes, scales, measureStore); - _renderMessages(renderTo, validation.messages); - if (!validation.success) - return; - - var plotConfigArr = generatePlotConfigs(renderTo, chartConfig, labels, aes, scales, geom, data, trendlineData); - $.each(plotConfigArr, function(idx, plotConfig) { - if (chartType === 'pie_chart') { - new LABKEY.vis.PieChart(plotConfig); - } - else { - new LABKEY.vis.Plot(plotConfig).render(); - } - }, this); - } - - var _renderMessages = function(divId, messages) { - if (messages && messages.length > 0) { - var errorDiv = document.createElement('div'); - errorDiv.innerHTML = '

Error rendering chart:

' + messages.join('
') + '
'; - document.getElementById(divId).appendChild(errorDiv); - } - }; - - var _validateChartConfig = function(chartConfig, aes, scales, measureStore) { - var hasNoDataMsg = validateResponseHasData(measureStore, false); - if (hasNoDataMsg != null) - return {success: false, messages: [hasNoDataMsg]}; - - var messages = [], firstRecord = measureStore.records()[0], measureNames = Object.keys(chartConfig.measures); - for (var i = 0; i < measureNames.length; i++) { - var measuresArr = ensureMeasuresAsArray(chartConfig.measures[measureNames[i]]); - for (var j = 0; j < measuresArr.length; j++) { - var measure = measuresArr[j]; - if (LABKEY.Utils.isObject(measure)) { - if (measure.name && !LABKEY.Utils.isDefined(firstRecord[measure.name])) { - return {success: false, messages: ['The measure, ' + measure.name + ', is not available. It may have been renamed or removed.']}; - } - - var validation; - if (measureNames[i] === 'y') { - var yAes = {y: getYMeasureAes(measure)}; - validation = validateAxisMeasure(chartConfig.renderType, measure, 'y', yAes, scales, measureStore.records()); - } - else if (measureNames[i] === 'x' || measureNames[i] === 'xSub') { - validation = validateAxisMeasure(chartConfig.renderType, measure, measureNames[i], aes, scales, measureStore.records()); - } - - if (LABKEY.Utils.isObject(validation)) { - if (validation.message != null) - messages.push(validation.message); - if (!validation.success) - return {success: false, messages: messages}; - } - } - } - } - - return {success: true, messages: messages}; - }; - - return { - // NOTE: the @function below is needed or JSDoc will not include the documentation for loadVisDependencies. Don't - // ask me why, I do not know. - /** - * @function - */ - getRenderTypes: getRenderTypes, - getChartType: getChartType, - getSelectedMeasureLabel: getSelectedMeasureLabel, - getTitleFromMeasures: getTitleFromMeasures, - getMeasureType: getMeasureType, - getAllowableTypes: getAllowableTypes, - getQueryColumns : getQueryColumns, - getChartTypeBasedWidth : getChartTypeBasedWidth, - getDistinctYAxisSides : getDistinctYAxisSides, - getYMeasureAes : getYMeasureAes, - getDefaultMeasuresLabel: getDefaultMeasuresLabel, - getStudySubjectInfo: getStudySubjectInfo, - getQueryConfigSortKey: getQueryConfigSortKey, - ensureMeasuresAsArray: ensureMeasuresAsArray, - isNumericType: isNumericType, - isMeasureDimensionMatch: isMeasureDimensionMatch, - generateLabels: generateLabels, - generateScales: generateScales, - generateAes: generateAes, - doValueConversion: doValueConversion, - removeNumericConversionConfig: removeNumericConversionConfig, - generateAggregateData: generateAggregateData, - generatePointHover: generatePointHover, - generateBoxplotHover: generateBoxplotHover, - generateDataForChartType: generateDataForChartType, - generateDiscreteAcc: generateDiscreteAcc, - generateContinuousAcc: generateContinuousAcc, - generateGroupingAcc: generateGroupingAcc, - generatePointClickFn: generatePointClickFn, - generateGeom: generateGeom, - generateBoxplotGeom: generateBoxplotGeom, - generatePointGeom: generatePointGeom, - generatePlotConfigs: generatePlotConfigs, - generatePlotConfig: generatePlotConfig, - validateResponseHasData: validateResponseHasData, - validateAxisMeasure: validateAxisMeasure, - validateXAxis: validateXAxis, - validateYAxis: validateYAxis, - renderChartSVG: renderChartSVG, - queryChartData: queryChartData, - generateChartSVG: generateChartSVG, - getMeasureStoreRecords: getMeasureStoreRecords, - queryTrendlineData: queryTrendlineData, - TRENDLINE_OPTIONS: TRENDLINE_OPTIONS, - /** - * Loads all of the required dependencies for a Generic Chart. - * @param {Function} callback The callback to be executed when all of the visualization dependencies have been loaded. - * @param {Object} scope The scope to be used when executing the callback. - */ - loadVisDependencies: LABKEY.requiresVisualization - }; +/* + * Copyright (c) 2013-2019 LabKey Corporation + * + * Licensed under the Apache License, Version 2.0: http://www.apache.org/licenses/LICENSE-2.0 + */ +if(!LABKEY.vis) { + LABKEY.vis = {}; +} + +/** + * @namespace Namespace used to encapsulate functions related to creating Generic Charts (Box, Scatter, etc.). Used in the + * Generic Chart Wizard and when exporting Generic Charts as Scripts. + */ +LABKEY.vis.GenericChartHelper = new function(){ + + var DEFAULT_TICK_LABEL_MAX = 25; + var $ = jQuery; + + var getRenderTypes = function() { + return [ + { + name: 'bar_chart', + title: 'Bar', + imgUrl: LABKEY.contextPath + '/visualization/images/barchart.png', + fields: [ + {name: 'x', label: 'X Axis', required: true, nonNumericOnly: true}, + {name: 'xSub', label: 'Group By', required: false, nonNumericOnly: true}, + {name: 'y', label: 'Y Axis', numericOnly: true} + ], + layoutOptions: {line: true, opacity: true, axisBased: true} + }, + { + name: 'box_plot', + title: 'Box', + imgUrl: LABKEY.contextPath + '/visualization/images/boxplot.png', + fields: [ + {name: 'x', label: 'X Axis'}, + {name: 'y', label: 'Y Axis', required: true, numericOnly: true}, + {name: 'color', label: 'Color', nonNumericOnly: true}, + {name: 'shape', label: 'Shape', nonNumericOnly: true} + ], + layoutOptions: {point: true, box: true, line: true, opacity: true, axisBased: true} + }, + { + name: 'line_plot', + title: 'Line', + imgUrl: LABKEY.contextPath + '/visualization/images/timechart.png', + fields: [ + {name: 'x', label: 'X Axis', required: true, numericOrDateOnly: true}, + {name: 'y', label: 'Y Axis', required: true, numericOnly: true, allowMultiple: true}, + {name: 'series', label: 'Series', nonNumericOnly: true}, + {name: 'trendline', label: 'Trendline', required: false, altSelectionOnly: true, altFieldType: 'LABKEY.vis.TrendlineField'}, + ], + layoutOptions: {opacity: true, axisBased: true, series: true, chartLayout: true} + }, + { + name: 'pie_chart', + title: 'Pie', + imgUrl: LABKEY.contextPath + '/visualization/images/piechart.png', + fields: [ + {name: 'x', label: 'Categories', required: true, nonNumericOnly: true}, + // Issue #29046 'Remove "measure" option from pie chart' + // {name: 'y', label: 'Measure', numericOnly: true} + ], + layoutOptions: {pie: true} + }, + { + name: 'scatter_plot', + title: 'Scatter', + imgUrl: LABKEY.contextPath + '/visualization/images/scatterplot.png', + fields: [ + {name: 'x', label: 'X Axis', required: true}, + {name: 'y', label: 'Y Axis', required: true, numericOnly: true, allowMultiple: true}, + {name: 'color', label: 'Color', nonNumericOnly: true}, + {name: 'shape', label: 'Shape', nonNumericOnly: true} + ], + layoutOptions: {point: true, opacity: true, axisBased: true, binnable: true, chartLayout: true} + }, + { + name: 'time_chart', + title: 'Time', + hidden: _getStudyTimepointType() == null, + imgUrl: LABKEY.contextPath + '/visualization/images/timechart.png', + fields: [ + {name: 'x', label: 'X Axis', required: true, altSelectionOnly: true, altFieldType: 'LABKEY.vis.TimeChartXAxisField'}, + {name: 'y', label: 'Y Axis', required: true, numericOnly: true, allowMultiple: true} + ], + layoutOptions: {time: true, axisBased: true, chartLayout: true} + } + ]; + }; + + /** + * Gets the chart type (i.e. box or scatter) based on the chartConfig object. + */ + const getChartType = function(chartConfig) + { + const renderType = chartConfig.renderType + const xAxisType = chartConfig.measures.x ? (chartConfig.measures.x.normalizedType || chartConfig.measures.x.type) : null; + + if (renderType === 'time_chart' || renderType === "bar_chart" || renderType === "pie_chart" + || renderType === "box_plot" || renderType === "scatter_plot" || renderType === "line_plot") + { + return renderType; + } + + if (!xAxisType) + { + // On some charts (non-study box plots) we don't require an x-axis, instead we generate one box plot for + // all of the data of your y-axis. If there is no xAxisType, then we have a box plot. Scatter plots require + // an x-axis measure. + return 'box_plot'; + } + + return (xAxisType === 'string' || xAxisType === 'boolean') ? 'box_plot' : 'scatter_plot'; + }; + + /** + * Generate a default label for the selected measure for the given renderType. + * @param renderType + * @param measureName - the chart type's measure name + * @param properties - properties for the selected column, note that this can be an array of properties + */ + var getSelectedMeasureLabel = function(renderType, measureName, properties) + { + var label = getDefaultMeasuresLabel(properties); + + if (label !== '' && measureName === 'y' && (renderType === 'bar_chart' || renderType === 'pie_chart')) { + var aggregateProps = LABKEY.Utils.isArray(properties) && properties.length === 1 + ? properties[0].aggregate : properties.aggregate; + + if (LABKEY.Utils.isDefined(aggregateProps)) { + var aggLabel = LABKEY.Utils.isObject(aggregateProps) ? (aggregateProps.name ?? aggregateProps.label) : LABKEY.Utils.capitalize(aggregateProps.toLowerCase()); + label = aggLabel + ' of ' + label; + } + else { + label = 'Sum of ' + label; + } + } + + return label; + }; + + /** + * Generate a plot title based on the selected measures array or object. + * @param renderType + * @param measures + * @returns {string} + */ + var getTitleFromMeasures = function(renderType, measures) + { + var queryLabels = []; + + if (LABKEY.Utils.isObject(measures)) + { + if (LABKEY.Utils.isArray(measures.y)) + { + $.each(measures.y, function(idx, m) + { + var measureQueryLabel = m.queryLabel || m.queryName; + if (queryLabels.indexOf(measureQueryLabel) === -1) + queryLabels.push(measureQueryLabel); + }); + } + else + { + var m = measures.x || measures.y; + queryLabels.push(m.queryLabel || m.queryName); + } + } + + return queryLabels.join(', '); + }; + + /** + * Get the sorted set of column metadata for the given schema/query/view. + * @param queryConfig + * @param successCallback + * @param callbackScope + */ + var getQueryColumns = function(queryConfig, successCallback, callbackScope) + { + LABKEY.Ajax.request({ + url: LABKEY.ActionURL.buildURL('visualization', 'getGenericReportColumns.api'), + method: 'GET', + params: { + schemaName: queryConfig.schemaName, + queryName: queryConfig.queryName, + viewName: queryConfig.viewName, + dataRegionName: queryConfig.dataRegionName, + includeCohort: true, + includeParticipantCategory : true + }, + success : function(response){ + var columnList = LABKEY.Utils.decode(response.responseText); + _queryColumnMetadata(queryConfig, columnList, successCallback, callbackScope) + }, + scope : this + }); + }; + + var _queryColumnMetadata = function(queryConfig, columnList, successCallback, callbackScope) + { + var columns = columnList.columns.all; + if (queryConfig.savedColumns) { + // make sure all savedColumns from the chart are included as options, they may not be in the view anymore + columns = columns.concat(queryConfig.savedColumns); + } + + LABKEY.Query.selectRows({ + maxRows: 0, // use maxRows 0 so that we just get the query metadata + schemaName: queryConfig.schemaName, + queryName: queryConfig.queryName, + viewName: queryConfig.viewName, + parameters: queryConfig.parameters, + requiredVersion: 9.1, + columns: columns, + method: 'POST', // Issue 31744: use POST as the columns list can be very long and cause a 400 error + success: function(response){ + var columnMetadata = _updateAndSortQueryFields(queryConfig, columnList, response.metaData.fields); + successCallback.call(callbackScope, columnMetadata); + }, + failure : function(response) { + // this likely means that the query no longer exists + successCallback.call(callbackScope, columnList, []); + }, + scope : this + }); + }; + + var _updateAndSortQueryFields = function(queryConfig, columnList, columnMetadata) + { + var queryFields = [], + queryFieldKeys = [], + columnTypes = LABKEY.Utils.isDefined(columnList.columns) ? columnList.columns : {}; + + $.each(columnMetadata, function(idx, column) + { + var f = $.extend(true, {}, column); + f.schemaName = queryConfig.schemaName; + f.queryName = queryConfig.queryName; + f.isCohortColumn = false; + f.isSubjectGroupColumn = false; + + // issue 23224: distinguish cohort and subject group fields in the list of query columns + if (columnTypes['cohort'] && columnTypes['cohort'].indexOf(f.fieldKey) > -1) + { + f.shortCaption = 'Study: ' + f.shortCaption; + f.isCohortColumn = true; + } + else if (columnTypes['subjectGroup'] && columnTypes['subjectGroup'].indexOf(f.fieldKey) > -1) + { + f.shortCaption = columnList.subject.nounSingular + ' Group: ' + f.shortCaption; + f.isSubjectGroupColumn = true; + } + + // Issue 31672: keep track of the distinct query field keys so we don't get duplicates + if (f.fieldKey.toLowerCase() != 'lsid' && queryFieldKeys.indexOf(f.fieldKey) == -1) { + queryFields.push(f); + queryFieldKeys.push(f.fieldKey); + } + }, this); + + // Sorts fields by their shortCaption, but put subject groups/categories/cohort at the end. + queryFields.sort(function(a, b) + { + if (a.isSubjectGroupColumn != b.isSubjectGroupColumn) + return a.isSubjectGroupColumn ? 1 : -1; + else if (a.isCohortColumn != b.isCohortColumn) + return a.isCohortColumn ? 1 : -1; + else if (a.shortCaption != b.shortCaption) + return a.shortCaption < b.shortCaption ? -1 : 1; + + return 0; + }); + + return queryFields; + }; + + /** + * Determine a reasonable width for the chart based on the chart type and selected measures / data. + * @param chartType + * @param measures + * @param measureStore + * @param defaultWidth + * @returns {int} + */ + 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); + + 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); + } + + return width; + }; + + /** + * Return the distinct set of y-axis sides for the given measures object. + * @param measures + */ + var getDistinctYAxisSides = function(measures) + { + var distinctSides = []; + $.each(ensureMeasuresAsArray(measures.y), function (idx, measure) { + if (LABKEY.Utils.isObject(measure)) { + var side = measure.yAxis || 'left'; + if (distinctSides.indexOf(side) === -1) { + distinctSides.push(side); + } + } + }, this); + return distinctSides; + }; + + /** + * Generate a default label for an array of measures by concatenating each meaures label together. + * @param measures + * @returns string concatenation of all measure labels + */ + var getDefaultMeasuresLabel = function(measures) + { + if (LABKEY.Utils.isDefined(measures)) { + if (!LABKEY.Utils.isArray(measures)) { + return measures.label || measures.queryName || ''; + } + + var label = '', sep = ''; + $.each(measures, function(idx, m) { + label += sep + (m.label || m.queryName); + sep = ', '; + }); + return label; + } + + return ''; + }; + + /** + * Given the saved labels object we convert it to include all label types (main, x, and y). Each label type defaults + * to empty string (''). + * @param {Object} labels The saved labels object. + * @returns {Object} + */ + var generateLabels = function(labels) { + return { + main: { value: labels.main || '' }, + subtitle: { value: labels.subtitle || '' }, + footer: { value: labels.footer || '' }, + x: { value: labels.x || '' }, + y: { value: labels.y || '' }, + yRight: { value: labels.yRight || '' } + }; + }; + + /** + * Generates an object containing {@link LABKEY.vis.Scale} objects used for the chart. + * @param {String} chartType The chartType from getChartType. + * @param {Object} measures The measures from generateMeasures. + * @param {Object} savedScales The scales object from the saved chart config. + * @param {Object} aes The aesthetic map object from genereateAes. + * @param {Object} measureStore The MeasureStore data using a selectRows API call. + * @param {Function} defaultFormatFn used to format values for tick marks. + * @returns {Object} + */ + var generateScales = function(chartType, measures, savedScales, aes, measureStore, defaultFormatFn) { + var scales = {}; + var data = LABKEY.Utils.isArray(measureStore.rows) ? measureStore.rows : measureStore.records(); + var fields = LABKEY.Utils.isObject(measureStore.metaData) ? measureStore.metaData.fields : measureStore.getResponseMetadata().fields; + var subjectColumn = getStudySubjectInfo().columnName; + var visitTableName = getStudySubjectInfo().tableName + 'Visit'; + var visitColName = visitTableName + '/Visit'; + var valExponentialDigits = 6; + + // Issue 38105: For plots with study visit labels on the x-axis, don't sort alphabetically + var sortFnX = measures.x && measures.x.fieldKey === visitColName ? undefined : LABKEY.vis.discreteSortFn; + + if (chartType === "box_plot") + { + scales.x = { + scaleType: 'discrete', // Force discrete x-axis scale for box plots. + sortFn: sortFnX, + tickLabelMax: DEFAULT_TICK_LABEL_MAX + }; + + var yMin = d3.min(data, aes.y); + var yMax = d3.max(data, aes.y); + var yPadding = ((yMax - yMin) * .1); + if (savedScales.y && savedScales.y.trans == "log") + { + // When subtracting padding we have to make sure we still produce valid values for a log scale. + // log([value less than 0]) = NaN. + // log(0) = -Infinity. + if (yMin - yPadding > 0) + { + yMin = yMin - yPadding; + } + } + else + { + yMin = yMin - yPadding; + } + + scales.y = { + min: yMin, + max: yMax + yPadding, + scaleType: 'continuous', + trans: savedScales.y ? savedScales.y.trans : 'linear' + }; + } + else + { + var xMeasureType = getMeasureType(measures.x); + + // Force discrete x-axis scale for bar plots. + var useContinuousScale = chartType != 'bar_chart' && isNumericType(xMeasureType); + + if (useContinuousScale) + { + scales.x = { + scaleType: 'continuous', + trans: savedScales.x ? savedScales.x.trans : 'linear' + }; + } + else + { + scales.x = { + scaleType: 'discrete', + sortFn: sortFnX, + tickLabelMax: DEFAULT_TICK_LABEL_MAX + }; + + //bar chart x-axis subcategories support + if (LABKEY.Utils.isDefined(measures.xSub)) { + scales.xSub = { + scaleType: 'discrete', + sortFn: LABKEY.vis.discreteSortFn, + tickLabelMax: DEFAULT_TICK_LABEL_MAX + }; + } + } + + // add both y (i.e. yLeft) and yRight, in case multiple y-axis measures are being plotted + scales.y = { + scaleType: 'continuous', + trans: savedScales.y ? savedScales.y.trans : 'linear' + }; + scales.yRight = { + scaleType: 'continuous', + trans: savedScales.yRight ? savedScales.yRight.trans : 'linear' + }; + } + + // if we have no data, show a default y-axis domain + if (scales.x && data.length == 0 && scales.x.scaleType == 'continuous') + scales.x.domain = [0,1]; + if (scales.y && data.length == 0) + scales.y.domain = [0,1]; + + // apply the field formatFn to the tick marks on the scales object + for (var i = 0; i < fields.length; i++) { + var type = fields[i].displayFieldJsonType ? fields[i].displayFieldJsonType : fields[i].type; + + var isMeasureXMatch = measures.x && _isFieldKeyMatch(measures.x, fields[i].fieldKey); + if (isMeasureXMatch && measures.x.name === subjectColumn && LABKEY.demoMode) { + scales.x.tickFormat = function(){return '******'}; + } + else if (isMeasureXMatch && isNumericType(type)) { + scales.x.tickFormat = _getNumberFormatFn(fields[i], defaultFormatFn); + } + + var yMeasures = ensureMeasuresAsArray(measures.y); + $.each(yMeasures, function(idx, yMeasure) { + var isMeasureYMatch = yMeasure && _isFieldKeyMatch(yMeasure, fields[i].fieldKey); + var isConvertedYMeasure = isMeasureYMatch && yMeasure.converted; + if (isMeasureYMatch && (isNumericType(type) || isConvertedYMeasure)) { + var tickFormatFn = _getNumberFormatFn(fields[i], defaultFormatFn); + + var ySide = yMeasure.yAxis === 'right' ? 'yRight' : 'y'; + scales[ySide].tickFormat = function(value) { + if (LABKEY.Utils.isNumber(value) && Math.abs(Math.round(value)).toString().length >= valExponentialDigits) { + return value.toExponential(); + } + else if (LABKEY.Utils.isFunction(tickFormatFn)) { + return tickFormatFn(value); + } + return value; + }; + } + }, this); + } + + _applySavedScaleDomain(scales, savedScales, 'x'); + if (LABKEY.Utils.isDefined(measures.xSub)) { + _applySavedScaleDomain(scales, savedScales, 'xSub'); + } + if (LABKEY.Utils.isDefined(measures.y)) { + _applySavedScaleDomain(scales, savedScales, 'y'); + _applySavedScaleDomain(scales, savedScales, 'yRight'); + } + + return scales; + }; + + // Issue 36227: if Ext4 is not available, try to generate our own number format function based on the "format" field metadata + var _getNumberFormatFn = function(field, defaultFormatFn) { + if (field.extFormatFn) { + if (window.Ext4) { + return eval(field.extFormatFn); + } + else if (field.format && LABKEY.Utils.isString(field.format) && field.format.indexOf('.') > -1) { + var precision = field.format.length - field.format.indexOf('.') - 1; + return function(v) { + return LABKEY.Utils.isNumber(v) ? v.toFixed(precision) : v; + } + } + } + + return defaultFormatFn; + }; + + var _isFieldKeyMatch = function(measure, fieldKey) { + if (LABKEY.Utils.isFunction(fieldKey.getName)) { + return fieldKey.getName() === measure.name || fieldKey.getName() === measure.fieldKey; + } else if (LABKEY.Utils.isArray(fieldKey)) { + fieldKey = fieldKey.join('/') + } + + return fieldKey === measure.name || fieldKey === measure.fieldKey; + }; + + var ensureMeasuresAsArray = function(measures) { + if (LABKEY.Utils.isDefined(measures)) { + return LABKEY.Utils.isArray(measures) ? $.extend(true, [], measures) : [$.extend(true, {}, measures)]; + } + return []; + }; + + var _applySavedScaleDomain = function(scales, savedScales, scaleName) { + if (savedScales[scaleName] && (savedScales[scaleName].min != null || savedScales[scaleName].max != null)) { + scales[scaleName].domain = [savedScales[scaleName].min, savedScales[scaleName].max]; + } + }; + + /** + * Generates the aesthetic map object needed by the visualization API to render the chart. See {@link LABKEY.vis.Plot} + * and {@link LABKEY.vis.Layer}. + * @param {String} chartType The chartType from getChartType. + * @param {Object} measures The measures from getMeasures. + * @param {String} schemaName The schemaName from the saved queryConfig. + * @param {String} queryName The queryName from the saved queryConfig. + * @returns {Object} + */ + 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); + + if (chartType === "box_plot") { + 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); + } + } else if (isNumericType(xMeasureType) || (chartType === 'scatter_plot' && measures.x.measure)) { + aes.x = generateContinuousAcc(xMeasureName); + } else { + aes.x = generateDiscreteAcc(xMeasureName, measures.x.label); + } + + // charts that have multiple y-measures selected will need to put the aes.y function on their specific layer + if (LABKEY.Utils.isDefined(measures.y) && !LABKEY.Utils.isArray(measures.y)) + { + var sideAesName = (measures.y.yAxis || 'left') === 'left' ? 'y' : 'yRight'; + var yMeasureName = measures.y.converted ? measures.y.convertedName : measures.y.name; + aes[sideAesName] = generateContinuousAcc(yMeasureName); + } + + if (chartType === "scatter_plot" || chartType === "line_plot") + { + aes.hoverText = generatePointHover(measures); + } + + if (chartType === "box_plot") + { + if (measures.color) { + aes.outlierColor = generateGroupingAcc(measures.color.name); + } + + if (measures.shape) { + aes.outlierShape = generateGroupingAcc(measures.shape.name); + } + + aes.hoverText = generateBoxplotHover(); + aes.outlierHoverText = generatePointHover(measures); + } + else if (chartType === 'bar_chart') + { + var xSubMeasureType = measures.xSub ? getMeasureType(measures.xSub) : null; + if (xSubMeasureType) + { + if (isNumericType(xSubMeasureType)) + aes.xSub = generateContinuousAcc(measures.xSub.name); + else + aes.xSub = generateDiscreteAcc(measures.xSub.name, measures.xSub.label); + } + } + + // color/shape aes are not dependent on chart type. If we have a box plot with all points enabled, then we + // create a second layer for points. So we'll need this no matter what. + if (measures.color) { + aes.color = generateGroupingAcc(measures.color.name); + } + + if (measures.shape) { + aes.shape = generateGroupingAcc(measures.shape.name); + } + + // also add the color and shape for the line plot series. + if (measures.series) { + aes.color = generateGroupingAcc(measures.series.name); + aes.shape = generateGroupingAcc(measures.series.name); + } + + if (measures.pointClickFn) { + aes.pointClickFn = generatePointClickFn( + measures, + schemaName, + queryName, + measures.pointClickFn + ); + } + + return aes; + }; + + var getYMeasureAes = function(measure) { + var yMeasureName = measure.converted ? measure.convertedName : measure.name; + return generateContinuousAcc(yMeasureName); + }; + + /** + * Generates a function that returns the text used for point hovers. + * @param {Object} measures The measures object from the saved chart config. + * @returns {Function} + */ + var generatePointHover = function(measures) + { + return function(row) { + var hover = '', sep = '', distinctNames = []; + + $.each(measures, function(key, measureObj) { + var measureArr = ensureMeasuresAsArray(measureObj); + $.each(measureArr, function(idx, measure) { + if (LABKEY.Utils.isObject(measure) && !LABKEY.Utils.isEmptyObj(measure) && distinctNames.indexOf(measure.name) == -1) { + 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); + }); + + return hover; + }; + }; + + /** + * Backwards compatibility for function that has been moved to LABKEY.vis.getAggregateData. + */ + var generateAggregateData = function(data, dimensionName, measureName, aggregate, nullDisplayValue) { + return LABKEY.vis.getAggregateData(data, dimensionName, null, measureName, aggregate, nullDisplayValue, false); + }; + + var _getRowValue = function(row, propName, valueName) + { + if (row.hasOwnProperty(propName)) { + // backwards compatibility for response row that is not a LABKEY.Query.Row + if (!(row instanceof LABKEY.Query.Row)) { + return row[propName].formattedValue || row[propName].displayValue || row[propName].value; + } + + var propValue = row.get(propName); + if (valueName != undefined && propValue.hasOwnProperty(valueName)) { + return propValue[valueName]; + } + else if (propValue.hasOwnProperty('formattedValue')) { + return propValue['formattedValue']; + } + else if (propValue.hasOwnProperty('displayValue')) { + return propValue['displayValue']; + } + return row.getValue(propName); + } + + return undefined; + }; + + /** + * Returns a function used to generate the hover text for box plots. + * @returns {Function} + */ + var generateBoxplotHover = function() { + return function(xValue, stats) { + return xValue + ':\nMin: ' + stats.min + '\nMax: ' + stats.max + '\nQ1: ' + stats.Q1 + '\nQ2: ' + stats.Q2 + + '\nQ3: ' + stats.Q3; + }; + }; + + /** + * Generates an accessor function that returns a discrete value from a row of data for a given measure and label. + * Used when an axis has a discrete measure (i.e. string). + * @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 + * @returns {Function} + */ + var generateDiscreteAcc = function(measureName, measureLabel, nullValueLabel) + { + return function(row) + { + var value = _getRowValue(row, measureName); + if (value === null) + value = nullValueLabel !== undefined ? nullValueLabel : "Not in " + measureLabel; + + return value; + }; + }; + + /** + * Generates an accessor function that returns a value from a row of data for a given measure. + * @param {String} measureName The name of the measure. + * @returns {Function} + */ + var generateContinuousAcc = function(measureName) + { + return function(row) + { + var value = _getRowValue(row, measureName, 'value'); + + if (value !== undefined) + { + if (Math.abs(value) === Infinity) + value = null; + + if (value === false || value === true) + value = value.toString(); + + return value; + } + + return undefined; + } + }; + + /** + * Generates an accesssor function for shape and color measures. + * @param {String} measureName The name of the measure. + * @returns {Function} + */ + var generateGroupingAcc = function(measureName) + { + return function(row) + { + var value = null; + if (LABKEY.Utils.isArray(row) && row.length > 0) { + value = _getRowValue(row[0], measureName); + } + else { + value = _getRowValue(row, measureName); + } + + if (value === null || value === undefined) + value = "n/a"; + + return value; + }; + }; + + /** + * Generates an accessor for boxplots that do not have an x-axis measure. Generally the measureName passed in is the + * queryName. + * @param {String} measureName The name of the measure. In this case it is generally the query name. + * @returns {Function} + */ + var generateMeasurelessAcc = function(measureName) { + // Used for box plots that do not have an x-axis measure. Instead we just return the queryName for every row. + return function(row) { + return measureName; + } + }; + + /** + * Generates the function to be executed when a user clicks a point. + * @param {Object} measures The measures from the saved chart config. + * @param {String} schemaName The schema name from the saved query config. + * @param {String} queryName The query name from the saved query config. + * @param {String} fnString The string value of the user-provided function to be executed when a point is clicked. + * @returns {Function} + */ + var generatePointClickFn = function(measures, schemaName, queryName, fnString){ + var measureInfo = { + schemaName: schemaName, + queryName: queryName + }; + + _addPointClickMeasureInfo(measureInfo, measures, 'x', 'xAxis'); + _addPointClickMeasureInfo(measureInfo, measures, 'y', 'yAxis'); + $.each(['color', 'shape', 'series'], function(idx, name) { + _addPointClickMeasureInfo(measureInfo, measures, name, name + 'Name'); + }, this); + + // using new Function is quicker than eval(), even in IE. + var pointClickFn = new Function('return ' + fnString)(); + return function(clickEvent, data){ + pointClickFn(data, measureInfo, clickEvent); + }; + }; + + var _addPointClickMeasureInfo = function(measureInfo, measures, name, key) { + if (LABKEY.Utils.isDefined(measures[name])) { + var measuresArr = ensureMeasuresAsArray(measures[name]); + $.each(measuresArr, function(idx, measure) { + if (!LABKEY.Utils.isDefined(measureInfo[key])) { + measureInfo[key] = measure.name; + } + else if (!LABKEY.Utils.isDefined(measureInfo[measure.name])) { + measureInfo[measure.name] = measure.name; + } + }, this); + } + }; + + /** + * Generates the Point Geom used for scatter plots and box plots with all points visible. + * @param {Object} chartOptions The saved chartOptions object from the chart config. + * @returns {LABKEY.vis.Geom.Point} + */ + var generatePointGeom = function(chartOptions){ + return new LABKEY.vis.Geom.Point({ + opacity: chartOptions.opacity, + size: chartOptions.pointSize, + color: '#' + chartOptions.pointFillColor, + position: chartOptions.position + }); + }; + + /** + * Generates the Boxplot Geom used for box plots. + * @param {Object} chartOptions The saved chartOptions object from the chart config. + * @returns {LABKEY.vis.Geom.Boxplot} + */ + var generateBoxplotGeom = function(chartOptions){ + return new LABKEY.vis.Geom.Boxplot({ + lineWidth: chartOptions.lineWidth, + outlierOpacity: chartOptions.opacity, + outlierFill: '#' + chartOptions.pointFillColor, + outlierSize: chartOptions.pointSize, + color: '#' + chartOptions.lineColor, + fill: chartOptions.boxFillColor == 'none' ? chartOptions.boxFillColor : '#' + chartOptions.boxFillColor, + position: chartOptions.position, + showOutliers: chartOptions.showOutliers + }); + }; + + /** + * Generates the Barplot Geom used for bar charts. + * @param {Object} chartOptions The saved chartOptions object from the chart config. + * @returns {LABKEY.vis.Geom.BarPlot} + */ + var generateBarGeom = function(chartOptions){ + return new LABKEY.vis.Geom.BarPlot({ + opacity: chartOptions.opacity, + color: '#' + chartOptions.lineColor, + fill: '#' + chartOptions.boxFillColor, + lineWidth: chartOptions.lineWidth + }); + }; + + /** + * Generates the Bin Geom used to bin a set of points. + * @param {Object} chartOptions The saved chartOptions object from the chart config. + * @returns {LABKEY.vis.Geom.Bin} + */ + var generateBinGeom = function(chartOptions) { + var colorRange = ["#e6e6e6", "#085D90"]; //light-gray and labkey blue is default + if (chartOptions.binColorGroup == 'SingleColor') { + var color = '#' + chartOptions.binSingleColor; + colorRange = ["#FFFFFF", color]; + } + else if (chartOptions.binColorGroup == 'Heat') { + colorRange = ["#fff6bc", "#e23202"]; + } + + return new LABKEY.vis.Geom.Bin({ + shape: chartOptions.binShape, + colorRange: colorRange, + size: chartOptions.binShape == 'square' ? 10 : 5 + }) + }; + + /** + * Generates a Geom based on the chartType. + * @param {String} chartType The chart type from getChartType. + * @param {Object} chartOptions The chartOptions object from the saved chart config. + * @returns {LABKEY.vis.Geom} + */ + var generateGeom = function(chartType, chartOptions) { + if (chartType == "box_plot") + return generateBoxplotGeom(chartOptions); + else if (chartType == "scatter_plot" || chartType == "line_plot") + return chartOptions.binned ? generateBinGeom(chartOptions) : generatePointGeom(chartOptions); + else if (chartType == "bar_chart") + return generateBarGeom(chartOptions); + }; + + /** + * Generate an array of plot configs for the given chart renderType and config options. + * @param renderTo + * @param chartConfig + * @param labels + * @param aes + * @param scales + * @param geom + * @param data + * @param trendlineData + * @returns {Array} array of plot config objects + */ + var generatePlotConfigs = function(renderTo, chartConfig, labels, aes, scales, geom, data, trendlineData) + { + var plotConfigArr = []; + + // if we have multiple y-measures and the request is to plot them separately, call the generatePlotConfig function + // for each y-measure separately with its own copy of the chartConfig object + if (chartConfig.geomOptions.chartLayout === 'per_measure' && LABKEY.Utils.isArray(chartConfig.measures.y)) { + + // if 'automatic across charts' scales are requested, need to manually calculate the min and max + if (chartConfig.scales.y && chartConfig.scales.y.type === 'automatic') { + scales.y = $.extend(scales.y, _getScaleDomainValuesForAllMeasures(data, chartConfig.measures.y, 'left')); + } + if (chartConfig.scales.yRight && chartConfig.scales.yRight.type === 'automatic') { + scales.yRight = $.extend(scales.yRight, _getScaleDomainValuesForAllMeasures(data, chartConfig.measures.y, 'right')); + } + + $.each(chartConfig.measures.y, function(idx, yMeasure) { + // copy the config and reset the measures.y array with the single measure + var newChartConfig = $.extend(true, {}, chartConfig); + newChartConfig.measures.y = $.extend(true, {}, yMeasure); + + // copy the labels object so that we can set the subtitle based on the y-measure + var newLabels = $.extend(true, {}, labels); + newLabels.subtitle = {value: yMeasure.label || yMeasure.name}; + + // only copy over the scales that are needed for this measures + var side = yMeasure.yAxis || 'left'; + var newScales = {x: $.extend(true, {}, scales.x)}; + if (side === 'left') { + newScales.y = $.extend(true, {}, scales.y); + } + else { + newScales.yRight = $.extend(true, {}, scales.yRight); + } + + plotConfigArr.push(generatePlotConfig(renderTo, newChartConfig, newLabels, aes, newScales, geom, data, trendlineData)); + }, this); + } + else { + plotConfigArr.push(generatePlotConfig(renderTo, chartConfig, labels, aes, scales, geom, data, trendlineData)); + } + + return plotConfigArr; + }; + + var _getScaleDomainValuesForAllMeasures = function(data, measures, side) { + var min = null, max = null; + + $.each(measures, function(idx, measure) { + var measureSide = measure.yAxis || 'left'; + if (side === measureSide) { + var accFn = LABKEY.vis.GenericChartHelper.getYMeasureAes(measure); + var tempMin = d3.min(data, accFn); + var tempMax = d3.max(data, accFn); + + if (min == null || tempMin < min) { + min = tempMin; + } + if (max == null || tempMax > max) { + max = tempMax; + } + } + }, this); + + return {domain: [min, max]}; + }; + + /** + * Generate the plot config for the given chart renderType and config options. + * @param renderTo + * @param chartConfig + * @param labels + * @param aes + * @param scales + * @param geom + * @param data + * @param trendlineData + * @returns {Object} + */ + var generatePlotConfig = function(renderTo, chartConfig, labels, aes, scales, geom, data, trendlineData) + { + var renderType = chartConfig.renderType, + layers = [], clipRect, + emptyTextFn = function(){return '';}, + plotConfig = { + renderTo: renderTo, + rendererType: 'd3', + width: chartConfig.width, + height: chartConfig.height, + gridLinesVisible: chartConfig.gridLinesVisible, + }; + + if (renderType === 'pie_chart') { + return _generatePieChartConfig(plotConfig, chartConfig, labels, data); + } + + clipRect = (scales.x && LABKEY.Utils.isArray(scales.x.domain)) || (scales.y && LABKEY.Utils.isArray(scales.y.domain)); + + // account for line chart hiding points + if (chartConfig.geomOptions.hideDataPoints) { + geom = null; + } + + // account for one or many y-measures by ensuring that we have an array of y-measures + var yMeasures = ensureMeasuresAsArray(chartConfig.measures.y); + + if (renderType === 'bar_chart') { + aes = { x: 'label', y: 'value' }; + + if (LABKEY.Utils.isDefined(chartConfig.measures.xSub)) + { + aes.xSub = 'subLabel'; + aes.color = 'label'; + } + + if (!scales.y) { + scales.y = {}; + } + + if (!scales.y.domain) { + var values = $.map(data, function(d) {return d.value + (d.error ?? 0);}), + min = Math.min(0, Math.min.apply(Math, values)), + max = Math.max(0, Math.max.apply(Math, values)); + + scales.y.domain = [min, max]; + } + } + else if (renderType === 'box_plot' && chartConfig.pointType === 'all') + { + layers.push( + new LABKEY.vis.Layer({ + geom: LABKEY.vis.GenericChartHelper.generatePointGeom(chartConfig.geomOptions), + aes: {hoverText: LABKEY.vis.GenericChartHelper.generatePointHover(chartConfig.measures)} + }) + ); + } + else if (renderType === 'line_plot') { + var xName = chartConfig.measures.x.name, + isDate = isDateType(getMeasureType(chartConfig.measures.x)); + + $.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); + } + return aVal - bVal; + }, + hoverText: emptyTextFn(), + }; + + pathAes[yMeasure.yAxis === 'right' ? 'yRight' : 'yLeft'] = getYMeasureAes(yMeasure); + + // use the series measure's values for the distinct colors and grouping + const hasSeries = chartConfig.measures.series !== undefined; + if (hasSeries) { + pathAes.pathColor = generateGroupingAcc(chartConfig.measures.series.name); + pathAes.group = generateGroupingAcc(chartConfig.measures.series.name); + pathAes.hoverText = function (row) { return chartConfig.measures.series.label + ': ' + row.group }; + } + // if no series measures but we have multiple y-measures, force the color and grouping to be distinct for each measure + else if (yMeasures.length > 1) { + pathAes.pathColor = emptyTextFn; + pathAes.group = emptyTextFn; + } + + if (trendlineData) { + trendlineData.forEach(trendline => { + if (trendline.data) { + const layerAes = { x: 'x', y: 'y' }; + if (hasSeries) { + layerAes.pathColor = function () { return trendline.name }; + } + + layerAes.hoverText = generateTrendlinePathHover(trendline); + + layers.push( + new LABKEY.vis.Layer({ + geom: new LABKEY.vis.Geom.Path({ + color: '#' + chartConfig.geomOptions.pointFillColor, + size: chartConfig.geomOptions.lineWidth ? chartConfig.geomOptions.lineWidth : 3, + opacity:chartConfig.geomOptions.opacity, + }), + aes: layerAes, + data: trendline.data.generatedPoints, + }) + ); + } + }); + } else { + layers.push( + new LABKEY.vis.Layer({ + name: yMeasures.length > 1 ? yMeasure.label || yMeasure.name : undefined, + geom: new LABKEY.vis.Geom.Path({ + color: '#' + chartConfig.geomOptions.pointFillColor, + size: chartConfig.geomOptions.lineWidth?chartConfig.geomOptions.lineWidth:3, + opacity:chartConfig.geomOptions.opacity + }), + aes: pathAes + }) + ); + } + }, this); + } + + // Issue 34711: better guess at the max number of discrete x-axis tick mark labels to show based on the plot width + if (scales.x && scales.x.scaleType === 'discrete' && scales.x.tickLabelMax) { + // approx 30 px for a 45 degree rotated tick label + scales.x.tickLabelMax = Math.floor((plotConfig.width - 300) / 30); + } + + var margins = _getPlotMargins(renderType, scales, aes, data, plotConfig, chartConfig); + if (LABKEY.Utils.isObject(margins)) { + plotConfig.margins = margins; + } + + if (chartConfig.measures.color) + { + scales.color = { + colorType: chartConfig.geomOptions.colorPaletteScale, + scaleType: 'discrete' + } + } + + if ((renderType === 'line_plot' || renderType === 'scatter_plot') && yMeasures.length > 0) { + $.each(yMeasures, function (idx, yMeasure) { + var layerAes = {}; + layerAes[yMeasure.yAxis === 'right' ? 'yRight' : 'yLeft'] = getYMeasureAes(yMeasure); + + // if no series measures but we have multiple y-measures, force the color and shape to be distinct for each measure + if (!aes.color && yMeasures.length > 1) { + layerAes.color = emptyTextFn; + } + if (!aes.shape && yMeasures.length > 1) { + 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, + geom: geom, + aes: layerAes + }) + ); + }, this); + } + else { + layers.push( + new LABKEY.vis.Layer({ + data: data, + geom: geom + }) + ); + } + + plotConfig = $.extend(plotConfig, { + clipRect: clipRect, + data: data, + labels: labels, + aes: aes, + scales: scales, + layers: layers + }); + + return plotConfig; + }; + + const hasPremiumModule = function() { + return LABKEY.getModuleContext('api').moduleNames.indexOf('premium') > -1; + }; + + const TRENDLINE_OPTIONS = { + '': { label: 'Point-to-Point', value: '' }, + 'Linear': { label: 'Linear Regression', value: 'Linear', equation: 'y = x * slope + intercept' }, + 'Polynomial': { label: 'Polynomial', value: 'Polynomial', equation: 'y = a0 + a1 * x + a2 * x^2' }, + '3 Parameter': { label: 'Nonlinear 3PL', value: '3 Parameter', showMax: true, schemaPrefix: 'assay', equation: 'y = max * abs(x/inflection)^abs(slope) / [1 + abs(x/inflection)^abs(slope)]' }, + 'Three Parameter': { label: 'Nonlinear 3PL (Alternate)', value: 'Three Parameter', showMax: true, schemaPrefix: 'assay', equation: 'y = max / [1 + (inflection - x) * slope]' }, + '4 Parameter': { label: 'Nonlinear 4PL', value: '4 Parameter', schemaPrefix: 'assay', equation: 'y = max + (min - max) / [1 + (x/inflection)^slope]' }, + 'Four Parameter': { label: 'Nonlinear 4PL (Alternate)', value: 'Four Parameter', showMin: true, showMax: true, schemaPrefix: 'assay', equation: 'y = min + (max - min) / [1 + (inflection - x) * slope]' }, + 'Five Parameter': { label: 'Nonlinear 5PL', value: 'Five Parameter', showMin: true, showMax: true, schemaPrefix: 'assay', equation: 'y = min + (max - min) / [[1 + (inflection - x) * slope]^asymmetry]' }, + } + + const generateTrendlinePathHover = function(trendline) { + let hoverText = trendline.name + '\n'; + hoverText += '\n' + TRENDLINE_OPTIONS[trendline.data.curveFit.type].label + ':\n'; + Object.entries(trendline.data.curveFit).forEach(([key, value]) => { + if (key === 'coefficients') { + hoverText += key + ': '; + value.forEach((v, i) => { + hoverText += (i > 0 ? ', ' : '') + LABKEY.Utils.roundNumber(v, 4); + }); + hoverText += '\n'; + } + else if (key !== 'type') { + hoverText += key + ': ' + LABKEY.Utils.roundNumber(value, 4) + '\n'; + } + }); + hoverText += '\nStatistics:\n'; + Object.entries(trendline.data.stats).forEach(([key, value]) => { + const label = key === 'RSquared' ? 'R-Squared' : (key === 'adjustedRSquared' ? 'Adjusted R-Squared' : key); + hoverText += label + ': ' + LABKEY.Utils.roundNumber(value, 4) + '\n'; + }); + + return function () { return hoverText }; + }; + + // support for y-axis trendline data when a single y-axis measure is selected + const queryTrendlineData = async function(chartConfig, data) { + const chartType = getChartType(chartConfig); + const yMeasures = ensureMeasuresAsArray(chartConfig.measures.y); + if (chartType === 'line_plot' && chartConfig.geomOptions?.trendlineType && chartConfig.geomOptions.trendlineType !== '' && yMeasures.length === 1) { + const xName = chartConfig.measures.x.name; + const trendlineConfig = getTrendlineConfig(chartConfig, data); + try { + await _queryTrendlineData(trendlineConfig, xName, yMeasures[0].name); + return trendlineConfig.data; + } catch (reason) { + // skip this series and render without trendline + return trendlineConfig.data; + } + } + + return undefined; + }; + + const getTrendlineConfig = function(chartConfig, data) { + const config = { + type: chartConfig.geomOptions.trendlineType, + logXScale: chartConfig.scales.x && chartConfig.scales.x.trans === 'log', + asymptoteMin: chartConfig.geomOptions.trendlineAsymptoteMin, + asymptoteMax: chartConfig.geomOptions.trendlineAsymptoteMax, + data: chartConfig.measures.series + ? LABKEY.vis.groupCountData(data, generateGroupingAcc(chartConfig.measures.series.name)) + : [{name: 'All', rawData: data}], + }; + + // special case to only use logXScale for linear trendlines + if (config.type === 'Linear') { + config.logXScale = false; + } + + return config; + }; + + const _queryTrendlineData = async function(trendlineConfig, xName, yName) { + for (let series of trendlineConfig.data) { + try { + // we need at least 2 data points for curve fitting + if (series.rawData.length > 1) { + series.data = await _querySeriesTrendlineData(trendlineConfig, series, xName, yName); + } + } catch (e) { + console.error(e); + } + } + }; + + const _querySeriesTrendlineData = function(trendlineConfig, seriesData, xName, yName) { + return new Promise(function(resolve, reject) { + if (!hasPremiumModule()) { + reject('Premium module required for curve fitting.'); + return; + } + + const points = seriesData.rawData.map(function(row) { + return { + x: _getRowValue(row, xName, 'value'), + y: _getRowValue(row, yName, 'value'), + }; + }); + const xAcc = function(row) { return row.x }; + const xMin = d3.min(points, xAcc); + const xMax = d3.max(points, xAcc); + + LABKEY.Ajax.request({ + url: LABKEY.ActionURL.buildURL('premium', 'calculateCurveFit.api'), + method: 'POST', + jsonData: { + curveFitType: trendlineConfig.type, + points: points, + logXScale: trendlineConfig.logXScale, + asymptoteMin: trendlineConfig.asymptoteMin, + asymptoteMax: trendlineConfig.asymptoteMax, + xMin: xMin, + xMax: xMax, + numberOfPoints: 1000, + }, + success : LABKEY.Utils.getCallbackWrapper(function(response) { + resolve(response); + }), + failure : LABKEY.Utils.getCallbackWrapper(function(reason) { + reject(reason); + }, this, true), + }); + }); + }; + + 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; + // 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 1; + }; + + var _getPlotMargins = function(renderType, scales, aes, data, plotConfig, chartConfig) { + var margins = {}; + + // issue 29690: for bar and box plots, set default bottom margin based on the number of labels and the max label length + if (LABKEY.Utils.isArray(data)) { + var maxLen = 0; + $.each(data, function(idx, d) { + var val = LABKEY.Utils.isFunction(aes.x) ? aes.x(d) : d[aes.x]; + var subVal = LABKEY.Utils.isFunction(aes.xSub) ? aes.xSub(d) : d[aes.xSub]; + if (LABKEY.Utils.isString(subVal)) { + maxLen = Math.max(maxLen, subVal.length); + } else if (LABKEY.Utils.isString(val)) { + maxLen = Math.max(maxLen, val.length); + } + }); + + var wrapLines = _wrapXAxisTickTextLines(scales, plotConfig, maxLen, data); + margins.bottom = 60 + ((wrapLines - 1) * 25); + } + + // issue 31857: allow custom margins to be set in Chart Layout dialog + if (chartConfig && chartConfig.geomOptions) { + if (chartConfig.geomOptions.marginTop !== null) { + margins.top = chartConfig.geomOptions.marginTop; + } + if (chartConfig.geomOptions.marginRight !== null) { + margins.right = chartConfig.geomOptions.marginRight; + } + if (chartConfig.geomOptions.marginBottom !== null) { + margins.bottom = chartConfig.geomOptions.marginBottom; + } + if (chartConfig.geomOptions.marginLeft !== null) { + margins.left = chartConfig.geomOptions.marginLeft; + } + } + + return !LABKEY.Utils.isEmptyObj(margins) ? margins : null; + }; + + var _generatePieChartConfig = function(baseConfig, chartConfig, labels, data) + { + var hasData = data.length > 0; + + return $.extend(baseConfig, { + data: hasData ? data : [{label: '', value: 1}], + header: { + title: { text: labels.main.value }, + subtitle: { text: labels.subtitle.value }, + titleSubtitlePadding: 1 + }, + footer: { + text: hasData ? labels.footer.value : 'No data to display', + location: 'bottom-center' + }, + labels: { + mainLabel: { fontSize: 14 }, + percentage: { + fontSize: 14, + color: chartConfig.geomOptions.piePercentagesColor != null ? '#' + chartConfig.geomOptions.piePercentagesColor : undefined + }, + outer: { pieDistance: 20 }, + inner: { + format: hasData && chartConfig.geomOptions.showPiePercentages ? 'percentage' : 'none', + hideWhenLessThanPercentage: chartConfig.geomOptions.pieHideWhenLessThanPercentage + } + }, + size: { + pieInnerRadius: hasData ? chartConfig.geomOptions.pieInnerRadius + '%' : '100%', + pieOuterRadius: hasData ? chartConfig.geomOptions.pieOuterRadius + '%' : '90%' + }, + misc: { + gradient: { + enabled: chartConfig.geomOptions.gradientPercentage != 0, + percentage: chartConfig.geomOptions.gradientPercentage, + color: '#' + chartConfig.geomOptions.gradientColor + }, + colors: { + segments: hasData ? LABKEY.vis.Scale[chartConfig.geomOptions.colorPaletteScale]() : ['#333333'] + } + }, + effects: { highlightSegmentOnMouseover: false }, + tooltips: { enabled: true } + }); + }; + + /** + * Check if the MeasureStore selectRows API response has data. Return an error string if no data exists. + * @param measureStore + * @param includeFilterMsg true to include a message about removing filters + * @returns {String} + */ + var validateResponseHasData = function(measureStore, includeFilterMsg) + { + var dataArray = getMeasureStoreRecords(measureStore); + if (dataArray.length == 0) + { + return 'The response returned 0 rows of data. The query may be empty or the applied filters may be too strict.' + + (includeFilterMsg ? 'Try removing or adjusting any filters if possible.' : ''); + } + + return null; + }; + + var getMeasureStoreRecords = function(measureStore) { + return LABKEY.Utils.isDefined(measureStore) ? measureStore.rows || measureStore.records() : []; + } + + /** + * Verifies that the axis measure is actually present and has data. Also checks to make sure that data can be used in a log + * scale (if applicable). Returns an object with a success parameter (boolean) and a message parameter (string). If the + * success parameter is false there is a critical error and the chart cannot be rendered. If success is true the chart + * can be rendered. Message will contain an error or warning message if applicable. If message is not null and success + * is true, there is a warning. + * @param {String} chartType The chartType from getChartType. + * @param {Object} chartConfigOrMeasure The saved chartConfig object or a specific measure object. + * @param {String} measureName The name of the axis measure property. + * @param {Object} aes The aes object from generateAes. + * @param {Object} scales The scales object from generateScales. + * @param {Array} data The response data from selectRows. + * @param {Boolean} dataConversionHappened Whether we converted any values in the measure data + * @returns {Object} + */ + var validateAxisMeasure = function(chartType, chartConfigOrMeasure, measureName, aes, scales, data, dataConversionHappened) { + var measure = LABKEY.Utils.isObject(chartConfigOrMeasure) && chartConfigOrMeasure.measures ? chartConfigOrMeasure.measures[measureName] : chartConfigOrMeasure; + return _validateAxisMeasure(chartType, measure, measureName, aes, scales, data, dataConversionHappened); + }; + + var _validateAxisMeasure = function(chartType, measure, measureName, aes, scales, data, dataConversionHappened) { + var dataIsNull = true, measureUndefined = true, invalidLogValues = false, hasZeroes = false, message = null; + + // no need to check measures if we have no data + if (data.length === 0) { + return {success: true, message: message}; + } + + for (var i = 0; i < data.length; i ++) + { + var value = aes[measureName](data[i]); + + if (value !== undefined) + measureUndefined = false; + + if (value !== null) + dataIsNull = false; + + if (value && value < 0) + invalidLogValues = true; + + if (value === 0 ) + hasZeroes = true; + } + + if (measureUndefined) + { + message = 'The measure, ' + measure.name + ', was not found. It may have been renamed or removed.'; + return {success: false, message: message}; + } + + if ((chartType == 'scatter_plot' || chartType == 'line_plot' || measureName == 'y') && dataIsNull && !dataConversionHappened) + { + message = 'All data values for ' + measure.label + ' are null. Please choose a different measure or review/remove data filters.'; + return {success: true, message: message}; + } + + if (scales[measureName] && scales[measureName].trans == "log") + { + if (invalidLogValues) + { + message = "Unable to use a log scale on the " + measureName + "-axis. All " + measureName + + "-axis values must be >= 0. Reverting to linear scale on " + measureName + "-axis."; + scales[measureName].trans = 'linear'; + } + else if (hasZeroes) + { + message = "Some " + measureName + "-axis values are 0. Plotting all " + measureName + "-axis values as value+1."; + var accFn = aes[measureName]; + aes[measureName] = function(row){return accFn(row) + 1}; + } + } + + return {success: true, message: message}; + }; + + /** + * Deprecated - use validateAxisMeasure + */ + var validateXAxis = function(chartType, chartConfig, aes, scales, data){ + return this.validateAxisMeasure(chartType, chartConfig, 'x', aes, scales, data); + }; + /** + * Deprecated - use validateAxisMeasure + */ + var validateYAxis = function(chartType, chartConfig, aes, scales, data){ + return this.validateAxisMeasure(chartType, chartConfig, 'y', aes, scales, data); + }; + + var getMeasureType = function(measure) { + return LABKEY.Utils.isObject(measure) ? (measure.normalizedType || measure.type) : null; + }; + + var isNumericType = function(type) + { + var t = LABKEY.Utils.isString(type) ? type.toLowerCase() : null; + return t == 'int' || t == 'integer' || t == 'float' || t == 'double'; + }; + + var isDateType = function(type) + { + var t = LABKEY.Utils.isString(type) ? type.toLowerCase() : null; + return t == 'date'; + }; + + var getAllowableTypes = function(field) { + var numericTypes = ['int', 'float', 'double', 'INTEGER', 'DOUBLE'], + nonNumericTypes = ['string', 'date', 'boolean', 'STRING', 'TEXT', 'DATE', 'BOOLEAN'], + numericAndDateTypes = numericTypes.concat(['date','DATE']); + + if (field.altSelectionOnly) + return []; + else if (field.numericOnly) + return numericTypes; + else if (field.nonNumericOnly) + return nonNumericTypes; + else if (field.numericOrDateOnly) + return numericAndDateTypes; + else + return numericTypes.concat(nonNumericTypes); + } + + var isMeasureDimensionMatch = function(chartType, field, isMeasure, isDimension) { + if ((chartType === 'box_plot' || chartType === 'bar_chart')) { + //x-axis does not support 'measure' column types for these plot types + if (field.name === 'x' || field.name === 'xSub') + return isDimension; + else + return isMeasure; + } + + return (field.numericOnly && isMeasure) || (field.nonNumericOnly && isDimension); + } + + var getQueryConfigSortKey = function(measures) { + var sortKey = 'lsid'; // needed to keep expected ordering for legend data + + // Issue 38105: For plots with study visit labels on the x-axis, sort by visit display order and then sequenceNum + var visitTableName = LABKEY.vis.GenericChartHelper.getStudySubjectInfo().tableName + 'Visit'; + if (measures.x && measures.x.fieldKey === visitTableName + '/Visit') { + var displayOrderColName = visitTableName + '/Visit/DisplayOrder'; + var seqNumColName = visitTableName + '/SequenceNum'; + sortKey = displayOrderColName + ', ' + seqNumColName; + } + + return sortKey; + } + + var getStudySubjectInfo = function() + { + var studyCtx = LABKEY.getModuleContext("study") || {}; + return LABKEY.Utils.isObject(studyCtx.subject) ? studyCtx.subject : { + tableName: 'Participant', + columnName: 'ParticipantId', + nounPlural: 'Participants', + nounSingular: 'Participant' + }; + }; + + var _getStudyTimepointType = function() + { + var studyCtx = LABKEY.getModuleContext("study") || {}; + return LABKEY.Utils.isDefined(studyCtx.timepointType) ? studyCtx.timepointType : null; + }; + + var _getMeasureRestrictions = function (chartType, measure) + { + var measureRestrictions = {}; + $.each(getRenderTypes(), function (idx, renderType) + { + if (renderType.name === chartType) + { + $.each(renderType.fields, function (idx2, field) + { + if (field.name === measure) + { + measureRestrictions.numericOnly = field.numericOnly; + measureRestrictions.nonNumericOnly = field.nonNumericOnly; + return false; + } + }); + return false; + } + }); + + return measureRestrictions; + }; + + /** + * Converts data values passed in to the appropriate type based on measure/dimension information. + * @param chartConfig Chart configuration object + * @param aes Aesthetic mapping functions for each measure/axis + * @param renderType The type of plot or chart (e.g. scatter_plot, bar_chart) + * @param data The response data from SelectRows + * @returns {{processed: {}, warningMessage: *}} + */ + var doValueConversion = function(chartConfig, aes, renderType, data) + { + var measuresForProcessing = {}, measureRestrictions = {}, configMeasure; + for (var measureName in chartConfig.measures) { + if (chartConfig.measures.hasOwnProperty(measureName) && LABKEY.Utils.isObject(chartConfig.measures[measureName])) { + configMeasure = chartConfig.measures[measureName]; + $.extend(measureRestrictions, _getMeasureRestrictions(renderType, measureName)); + + var isGroupingMeasure = measureName === 'color' || measureName === 'shape' || measureName === 'series'; + var isXAxis = measureName === 'x' || measureName === 'xSub'; + var isScatterOrLine = renderType === 'scatter_plot' || renderType === 'line_plot'; + var isBarYCount = renderType === 'bar_chart' && configMeasure.aggregate && (configMeasure.aggregate === 'COUNT' || configMeasure.aggregate.value === 'COUNT'); + + if (configMeasure.measure && !isGroupingMeasure && !isBarYCount + && ((!isXAxis && measureRestrictions.numericOnly ) || isScatterOrLine) && !isNumericType(configMeasure.type)) { + measuresForProcessing[measureName] = {}; + measuresForProcessing[measureName].name = configMeasure.name; + measuresForProcessing[measureName].convertedName = configMeasure.name + "_converted"; + measuresForProcessing[measureName].label = configMeasure.label; + configMeasure.normalizedType = 'float'; + configMeasure.type = 'float'; + } + } + } + + var response = {processed: {}}; + if (!LABKEY.Utils.isEmptyObj(measuresForProcessing)) { + response = _processMeasureData(data, aes, measuresForProcessing); + } + + //generate error message for dropped values + var warningMessage = ''; + for (var measure in response.droppedValues) { + if (response.droppedValues.hasOwnProperty(measure) && response.droppedValues[measure].numDropped) { + warningMessage += " The " + + measure + "-axis measure '" + + response.droppedValues[measure].label + "' had " + + response.droppedValues[measure].numDropped + + " value(s) that could not be converted to a number and are not included in the plot."; + } + } + + return {processed: response.processed, warningMessage: warningMessage}; + }; + + /** + * Does the explicit type conversion for each measure deemed suitable to convert. Currently we only + * attempt to convert strings to numbers for measures. + * @param rows Data from SelectRows + * @param aes Aesthetic mapping function for the measure/dimensions + * @param measuresForProcessing The measures to be converted, if any + * @returns {{droppedValues: {}, processed: {}}} + */ + var _processMeasureData = function(rows, aes, measuresForProcessing) { + var droppedValues = {}, processedMeasures = {}, dataIsNull; + rows.forEach(function(row) { + //convert measures if applicable + if (!LABKEY.Utils.isEmptyObj(measuresForProcessing)) { + for (var measure in measuresForProcessing) { + if (measuresForProcessing.hasOwnProperty(measure)) { + dataIsNull = true; + if (!droppedValues[measure]) { + droppedValues[measure] = {}; + droppedValues[measure].label = measuresForProcessing[measure].label; + droppedValues[measure].numDropped = 0; + } + + if (aes.hasOwnProperty(measure)) { + var value = aes[measure](row); + if (value !== null) { + dataIsNull = false; + } + row[measuresForProcessing[measure].convertedName] = {value: null}; + if (typeof value !== 'number' && value !== null) { + + //only try to convert strings to numbers + if (typeof value === 'string') { + value = value.trim(); + } + else { + //dates, objects, booleans etc. to be assigned value: NULL + value = ''; + } + + var n = Number(value); + // empty strings convert to 0, which we must explicitly deny + if (value === '' || isNaN(n)) { + droppedValues[measure].numDropped++; + } + else { + row[measuresForProcessing[measure].convertedName].value = n; + } + } + } + + if (!processedMeasures[measure]) { + processedMeasures[measure] = { + converted: false, + convertedName: measuresForProcessing[measure].convertedName, + type: 'float', + normalizedType: 'float' + } + } + + processedMeasures[measure].converted = processedMeasures[measure].converted || !dataIsNull; + } + } + } + }); + + return {droppedValues: droppedValues, processed: processedMeasures}; + }; + + /** + * removes all traces of String -> Numeric Conversion from the given chart config + * @param chartConfig + * @returns {updated ChartConfig} + */ + var removeNumericConversionConfig = function(chartConfig) { + if (chartConfig && chartConfig.measures) { + for (var measureName in chartConfig.measures) { + if (chartConfig.measures.hasOwnProperty(measureName)) { + var measure = chartConfig.measures[measureName]; + if (measure && measure.converted && measure.convertedName) { + measure.converted = null; + measure.convertedName = null; + if (LABKEY.vis.GenericChartHelper.isNumericType(measure.type)) { + measure.type = 'string'; + measure.normalizedType = 'string'; + } + } + } + } + } + + return chartConfig; + }; + + var renderChartSVG = function(renderTo, queryConfig, chartConfig) { + queryChartData(renderTo, queryConfig, chartConfig, function(measureStore, trendlineData) { + generateChartSVG(renderTo, chartConfig, measureStore, trendlineData); + }); + }; + + var queryChartData = function(renderTo, queryConfig, chartConfig, callback) { + queryConfig.containerPath = LABKEY.container.path; + + if (queryConfig.filterArray && queryConfig.filterArray.length > 0) { + var filters = []; + + for (var i = 0; i < queryConfig.filterArray.length; i++) { + var f = queryConfig.filterArray[i]; + // Issue 37191: Check to see if 'f' is already a filter instance (either labkey-api-js/src/filter/Filter.ts or clientapi/core/Query.js) + if (f.hasOwnProperty('getValue') || f.getValue instanceof Function) { + filters.push(f); + } + else { + filters.push(LABKEY.Filter.create(f.name, f.value, LABKEY.Filter.getFilterTypeForURLSuffix(f.type))); + } + } + + queryConfig.filterArray = filters; + } + + queryConfig.success = async function(measureStore) { + const trendlineData = await queryTrendlineData(chartConfig, measureStore.records()); + callback.call(this, measureStore, trendlineData); + }; + + LABKEY.Query.MeasureStore.selectRows(queryConfig); + }; + + var generateDataForChartType = function(chartConfig, chartType, geom, data) { + var dimName = null; + var subDimName = null; + var measureName = null; + var aggType = chartType === 'bar_chart' || chartType === 'pie_chart' ? 'COUNT' : null; + var aggErrorType = null; + + 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; + } else if (chartConfig.measures.series) { + subDimName = chartConfig.measures.series.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.value || chartConfig.measures.y.aggregate; + aggType = LABKEY.Utils.isObject(aggType) ? aggType.value : aggType; + aggErrorType = aggType === 'MEAN' ? chartConfig.measures.y.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, '[Blank]', false, aggErrorType, chartType === 'line_plot'); + if (aggErrorType) { + geom.errorAes = { getValue: function(d){ return d.error } }; + } + } + + return data; + } + + var generateChartSVG = function(renderTo, chartConfig, measureStore, trendlineData) { + var responseMetaData = measureStore.getResponseMetadata(); + + // explicitly set the chart width/height if not set in the config + if (!chartConfig.hasOwnProperty('width') || chartConfig.width == null) chartConfig.width = 1000; + if (!chartConfig.hasOwnProperty('height') || chartConfig.height == null) chartConfig.height = 600; + + var chartType = getChartType(chartConfig); + var aes = generateAes(chartType, chartConfig.measures, responseMetaData.schemaName, responseMetaData.queryName); + var valueConversionResponse = doValueConversion(chartConfig, aes, chartType, measureStore.records()); + if (!LABKEY.Utils.isEmptyObj(valueConversionResponse.processed)) { + $.extend(true, chartConfig.measures, valueConversionResponse.processed); + aes = generateAes(chartType, chartConfig.measures, responseMetaData.schemaName, responseMetaData.queryName); + } + var data = measureStore.records(); + if (chartType === 'scatter_plot' && data.length > chartConfig.geomOptions.binThreshold) { + chartConfig.geomOptions.binned = true; + } + var scales = generateScales(chartType, chartConfig.measures, chartConfig.scales, aes, measureStore); + var geom = generateGeom(chartType, chartConfig.geomOptions); + var labels = generateLabels(chartConfig.labels); + + if (chartType === 'bar_chart' || chartType === 'pie_chart' || chartType === 'line_plot') { + data = generateDataForChartType(chartConfig, chartType, geom, data); + } + + var validation = _validateChartConfig(chartConfig, aes, scales, measureStore); + _renderMessages(renderTo, validation.messages); + if (!validation.success) + return; + + var plotConfigArr = generatePlotConfigs(renderTo, chartConfig, labels, aes, scales, geom, data, trendlineData); + $.each(plotConfigArr, function(idx, plotConfig) { + if (chartType === 'pie_chart') { + new LABKEY.vis.PieChart(plotConfig); + } + else { + new LABKEY.vis.Plot(plotConfig).render(); + } + }, this); + } + + var _renderMessages = function(divId, messages) { + if (messages && messages.length > 0) { + var errorDiv = document.createElement('div'); + errorDiv.innerHTML = '

Error rendering chart:

' + messages.join('
') + '
'; + document.getElementById(divId).appendChild(errorDiv); + } + }; + + var _validateChartConfig = function(chartConfig, aes, scales, measureStore) { + var hasNoDataMsg = validateResponseHasData(measureStore, false); + if (hasNoDataMsg != null) + return {success: false, messages: [hasNoDataMsg]}; + + var messages = [], firstRecord = measureStore.records()[0], measureNames = Object.keys(chartConfig.measures); + for (var i = 0; i < measureNames.length; i++) { + var measuresArr = ensureMeasuresAsArray(chartConfig.measures[measureNames[i]]); + for (var j = 0; j < measuresArr.length; j++) { + var measure = measuresArr[j]; + if (LABKEY.Utils.isObject(measure)) { + if (measure.name && !LABKEY.Utils.isDefined(firstRecord[measure.name])) { + return {success: false, messages: ['The measure, ' + measure.name + ', is not available. It may have been renamed or removed.']}; + } + + var validation; + if (measureNames[i] === 'y') { + var yAes = {y: getYMeasureAes(measure)}; + validation = validateAxisMeasure(chartConfig.renderType, measure, 'y', yAes, scales, measureStore.records()); + } + else if (measureNames[i] === 'x' || measureNames[i] === 'xSub') { + validation = validateAxisMeasure(chartConfig.renderType, measure, measureNames[i], aes, scales, measureStore.records()); + } + + if (LABKEY.Utils.isObject(validation)) { + if (validation.message != null) + messages.push(validation.message); + if (!validation.success) + return {success: false, messages: messages}; + } + } + } + } + + return {success: true, messages: messages}; + }; + + return { + // NOTE: the @function below is needed or JSDoc will not include the documentation for loadVisDependencies. Don't + // ask me why, I do not know. + /** + * @function + */ + getRenderTypes: getRenderTypes, + getChartType: getChartType, + getSelectedMeasureLabel: getSelectedMeasureLabel, + getTitleFromMeasures: getTitleFromMeasures, + getMeasureType: getMeasureType, + getAllowableTypes: getAllowableTypes, + getQueryColumns : getQueryColumns, + getChartTypeBasedWidth : getChartTypeBasedWidth, + getDistinctYAxisSides : getDistinctYAxisSides, + getYMeasureAes : getYMeasureAes, + getDefaultMeasuresLabel: getDefaultMeasuresLabel, + getStudySubjectInfo: getStudySubjectInfo, + getQueryConfigSortKey: getQueryConfigSortKey, + ensureMeasuresAsArray: ensureMeasuresAsArray, + isNumericType: isNumericType, + isMeasureDimensionMatch: isMeasureDimensionMatch, + generateLabels: generateLabels, + generateScales: generateScales, + generateAes: generateAes, + doValueConversion: doValueConversion, + removeNumericConversionConfig: removeNumericConversionConfig, + generateAggregateData: generateAggregateData, + generatePointHover: generatePointHover, + generateBoxplotHover: generateBoxplotHover, + generateDataForChartType: generateDataForChartType, + generateDiscreteAcc: generateDiscreteAcc, + generateContinuousAcc: generateContinuousAcc, + generateGroupingAcc: generateGroupingAcc, + generatePointClickFn: generatePointClickFn, + generateGeom: generateGeom, + generateBoxplotGeom: generateBoxplotGeom, + generatePointGeom: generatePointGeom, + generatePlotConfigs: generatePlotConfigs, + generatePlotConfig: generatePlotConfig, + validateResponseHasData: validateResponseHasData, + validateAxisMeasure: validateAxisMeasure, + validateXAxis: validateXAxis, + validateYAxis: validateYAxis, + renderChartSVG: renderChartSVG, + queryChartData: queryChartData, + generateChartSVG: generateChartSVG, + getMeasureStoreRecords: getMeasureStoreRecords, + queryTrendlineData: queryTrendlineData, + TRENDLINE_OPTIONS: TRENDLINE_OPTIONS, + /** + * Loads all of the required dependencies for a Generic Chart. + * @param {Function} callback The callback to be executed when all of the visualization dependencies have been loaded. + * @param {Object} scope The scope to be used when executing the callback. + */ + loadVisDependencies: LABKEY.requiresVisualization + }; }; \ No newline at end of file diff --git a/visualization/resources/web/vis/timeChart/timeChartHelper.js b/visualization/resources/web/vis/timeChart/timeChartHelper.js index 254aaff4da0..1ee62f8932e 100644 --- a/visualization/resources/web/vis/timeChart/timeChartHelper.js +++ b/visualization/resources/web/vis/timeChart/timeChartHelper.js @@ -1,1731 +1,1731 @@ -/* - * Copyright (c) 2013-2019 LabKey Corporation - * - * Licensed under the Apache License, Version 2.0: http://www.apache.org/licenses/LICENSE-2.0 - */ -if(!LABKEY.vis) { - LABKEY.vis = {}; -} - -/** - * @namespace Namespace used to encapsulate functions related to creating study Time Charts. - * Used in the Chart Wizard and when exporting Time Charts as scripts. - */ -LABKEY.vis.TimeChartHelper = new function() { - - var $ = jQuery; - var defaultVisitProperty = 'displayOrder'; - - /** - * Generate the main title and axis labels for the chart based on the specified x-axis and y-axis (left and right) labels. - * @param {String} mainTitle The label to be used as the main chart title. - * @param {String} subtitle The label to be used as the chart subtitle. - * @param {Array} axisArr An array of axis information including the x-axis and y-axis (left and right) labels. - * @returns {Object} - */ - var generateLabels = function(mainTitle, axisArr, subtitle) { - var xTitle = '', yLeftTitle = '', yRightTitle = ''; - for (var i = 0; i < axisArr.length; i++) - { - var axis = axisArr[i]; - if (axis.name == "y-axis") - { - if (axis.side == "left") - yLeftTitle = axis.label; - else - yRightTitle = axis.label; - } - else - { - xTitle = axis.label; - } - } - - return { - main : { value : mainTitle }, - subtitle : { value : subtitle, color: '#404040' }, - x : { value : xTitle }, - yLeft : { value : yLeftTitle }, - yRight : { value : yRightTitle } - }; - }; - - /** - * Generates an object containing {@link LABKEY.vis.Scale} objects used for the chart. - * @param {Object} config The chart configuration object that defines the selected measures, axis info, subjects/groups, etc. - * @param {Object} tickMap For visit based charts, the x-axis tick mark mapping, from generateTickMap. - * @param {Object} numberFormats The number format functions to use for the x-axis and y-axis (left and right) tick marks. - * @returns {Object} - */ - var generateScales = function(config, tickMap, numberFormats) { - if (config.measures.length == 0) - throw "There must be at least one specified measure in the chartInfo config!"; - - var xMin = null, xMax = null, xTrans = null, xTickFormat, xTickHoverText, - yLeftMin = null, yLeftMax = null, yLeftTrans = null, yLeftTickFormat, - yRightMin = null, yRightMax = null, yRightTrans = null, yRightTickFormat, - valExponentialDigits = 6; - - for (var i = 0; i < config.axis.length; i++) - { - var axis = config.axis[i]; - if (axis.name == "y-axis") - { - if (axis.side == "left") - { - yLeftMin = typeof axis.range.min == "number" ? axis.range.min : (config.hasNoData ? 0 : null); - yLeftMax = typeof axis.range.max == "number" ? axis.range.max : (config.hasNoData ? 10 : null); - yLeftTrans = axis.scale ? axis.scale : "linear"; - yLeftTickFormat = function(value) { - if (LABKEY.Utils.isNumber(value) && Math.abs(Math.round(value)).toString().length >= valExponentialDigits) { - return value.toExponential(); - } - else if (LABKEY.Utils.isFunction(numberFormats.left)) { - return numberFormats.left(value); - } - return value; - } - } - else - { - yRightMin = typeof axis.range.min == "number" ? axis.range.min : (config.hasNoData ? 0 : null); - yRightMax = typeof axis.range.max == "number" ? axis.range.max : (config.hasNoData ? 10 : null); - yRightTrans = axis.scale ? axis.scale : "linear"; - yRightTickFormat = function(value) { - if (LABKEY.Utils.isNumber(value) && Math.abs(Math.round(value)).toString().length >= valExponentialDigits) { - return value.toExponential(); - } - else if (LABKEY.Utils.isFunction(numberFormats.right)) { - return numberFormats.right(value); - } - return value; - } - } - } - else - { - xMin = typeof axis.range.min == "number" ? axis.range.min : null; - xMax = typeof axis.range.max == "number" ? axis.range.max : null; - xTrans = axis.scale ? axis.scale : "linear"; - } - } - - if (config.measures[0].time == "visit" && (config.measures[0].visitOptions === undefined || config.measures[0].visitOptions.visitDisplayProperty === defaultVisitProperty)) - { - xTickFormat = function(value) { - return tickMap[value] ? tickMap[value].label : ""; - }; - - xTickHoverText = function(value) { - return tickMap[value] ? tickMap[value].description : ""; - }; - } - // Issue 27309: Don't show decimal values on x-axis for date-based time charts with interval = "Days" - else if (config.measures[0].time == 'date' && config.measures[0].dateOptions.interval == 'Days') - { - xTickFormat = function(value) { - return LABKEY.Utils.isNumber(value) && value % 1 != 0 ? null : value; - }; - } - - return { - x: { - scaleType : 'continuous', - trans : xTrans, - domain : [xMin, xMax], - tickFormat : xTickFormat ? xTickFormat : null, - tickHoverText : xTickHoverText ? xTickHoverText : null - }, - yLeft: { - scaleType : 'continuous', - trans : yLeftTrans, - domain : [yLeftMin, yLeftMax], - tickFormat : yLeftTickFormat ? yLeftTickFormat : null - }, - yRight: { - scaleType : 'continuous', - trans : yRightTrans, - domain : [yRightMin, yRightMax], - tickFormat : yRightTickFormat ? yRightTickFormat : null - }, - shape: { - scaleType : 'discrete' - } - }; - }; - - /** - * Generate the x-axis interval column alias key. For date based charts, this will be a time interval (i.e. Days, Weeks, etc.) - * and for visit based charts, this will be the column alias for the visit field. - * @param {Object} config The chart configuration object that defines the selected measures, axis info, subjects/groups, etc. - * @param {Array} individualColumnAliases The array of column aliases for the individual subject data. - * @param {Array} aggregateColumnAliases The array of column aliases for the group/cohort aggregate data. - * @param {String} nounSingular The singular name of the study subject noun (i.e. Participant). - * @returns {String} - */ - var generateIntervalKey = function(config, individualColumnAliases, aggregateColumnAliases, nounSingular) - { - nounSingular = nounSingular || getStudySubjectInfo().nounSingular; - - if (config.measures.length == 0) - throw "There must be at least one specified measure in the chartInfo config!"; - if (!individualColumnAliases && !aggregateColumnAliases) - throw "We expect to either be displaying individual series lines or aggregate data!"; - - if (config.measures[0].time == "date") - { - return config.measures[0].dateOptions.interval; - } - else - { - return individualColumnAliases ? - LABKEY.vis.getColumnAlias(individualColumnAliases, nounSingular + "Visit/Visit") : - LABKEY.vis.getColumnAlias(aggregateColumnAliases, nounSingular + "Visit/Visit"); - } - }; - - /** - * Generate that x-axis tick mark mapping for a visit based chart. - * @param {Object} visitMap For visit based charts, the study visit information map. - * @returns {Object} - */ - var generateTickMap = function(visitMap) { - var tickMap = {}; - for (var rowId in visitMap) - { - if (visitMap.hasOwnProperty(rowId)) - { - tickMap[visitMap[rowId][defaultVisitProperty]] = { - label: visitMap[rowId].displayName, - description: visitMap[rowId].description || visitMap[rowId].displayName - }; - } - } - - return tickMap; - }; - - /** - * Generates the aesthetic map object needed by the visualization API to render the chart. See {@link LABKEY.vis.Plot} - * and {@link LABKEY.vis.Layer}. - * @param {Object} config The chart configuration object that defines the selected measures, axis info, subjects/groups, etc. - * @param {Object} visitMap For visit based charts, the study visit information map. - * @param {Array} individualColumnAliases The array of column aliases for the individual subject data. - * @param {String} intervalKey The x-axis interval column alias key (i.e. Days, Weeks, etc.), from generateIntervalKey. - * @param {String} nounColumnName The name of the study subject noun column (i.e. ParticipantId). - * @returns {Object} - */ - var generateAes = function(config, visitMap, individualColumnAliases, intervalKey, nounColumnName) - { - nounColumnName = nounColumnName || getStudySubjectInfo().columnName; - - if (config.measures.length == 0) - throw "There must be at least one specified measure in the chartInfo config!"; - - var xAes; - if (config.measures[0].time == "date") { - xAes = function(row) { - return _getRowValue(row, intervalKey); - }; - } - else { - xAes = function(row) { - var displayProp = config.measures[0].visitOptions ? config.measures[0].visitOptions.visitDisplayProperty : defaultVisitProperty; - return visitMap[_getRowValue(row, intervalKey, 'value')][displayProp]; - }; - } - - var individualSubjectColumn = individualColumnAliases ? LABKEY.vis.getColumnAlias(individualColumnAliases, nounColumnName) : null; - - return { - x: xAes, - color: function(row) { - return _getRowValue(row, individualSubjectColumn); - }, - group: function(row) { - return _getRowValue(row, individualSubjectColumn); - }, - shape: function(row) { - return _getRowValue(row, individualSubjectColumn); - }, - pathColor: function(rows) { - return LABKEY.Utils.isArray(rows) && rows.length > 0 ? _getRowValue(rows[0], individualSubjectColumn) : null; - } - }; - }; - - /** - * Generate an array of {@link LABKEY.vis.Layer} objects based on the selected chart series list. - * @param {Object} config The chart configuration object that defines the selected measures, axis info, subjects/groups, etc. - * @param {Object} visitMap For visit based charts, the study visit information map. - * @param {Array} individualColumnAliases The array of column aliases for the individual subject data. - * @param {Array} aggregateColumnAliases The array of column aliases for the group/cohort aggregate data. - * @param {Array} aggregateData The array of group/cohort aggregate data, from getChartData. - * @param {Array} seriesList The list of series that will be plotted for a given chart, from generateSeriesList. - * @param {String} intervalKey The x-axis interval column alias key (i.e. Days, Weeks, etc.), from generateIntervalKey. - * @param {String} nounColumnName The name of the study subject noun column (i.e. ParticipantId). - * @returns {Array} - */ - var generateLayers = function(config, visitMap, individualColumnAliases, aggregateColumnAliases, aggregateData, seriesList, intervalKey, nounColumnName) - { - nounColumnName = nounColumnName || getStudySubjectInfo().columnName; - - if (config.measures.length == 0) - throw "There must be at least one specified measure in the chartInfo config!"; - if (!individualColumnAliases && !aggregateColumnAliases) - throw "We expect to either be displaying individual series lines or aggregate data!"; - - var layers = []; - var isDateBased = config.measures[0].time == "date"; - var individualSubjectColumn = individualColumnAliases ? LABKEY.vis.getColumnAlias(individualColumnAliases, nounColumnName) : null; - var aggregateSubjectColumn = "UniqueId"; - - var generateLayerAes = function(name, yAxisSide, columnName){ - var yName = yAxisSide == "left" ? "yLeft" : "yRight"; - var aes = {}; - aes[yName] = function(row) { - // Have to parseFloat because for some reason ObsCon from Luminex was returning strings not floats/ints. - return row[columnName] ? parseFloat(_getRowValue(row, columnName)) : null; - }; - return aes; - }; - - var generateAggregateLayerAes = function(name, yAxisSide, columnName, intervalKey, subjectColumn, errorColumn){ - var yName = yAxisSide == "left" ? "yLeft" : "yRight"; - var aes = {}; - aes[yName] = function(row) { - // Have to parseFloat because for some reason ObsCon from Luminex was returning strings not floats/ints. - return row[columnName] ? parseFloat(_getRowValue(row, columnName)) : null; - }; - aes.group = aes.color = aes.shape = function(row) { - return _getRowValue(row, subjectColumn); - }; - aes.pathColor = function(rows) { - return LABKEY.Utils.isArray(rows) && rows.length > 0 ? _getRowValue(rows[0], subjectColumn) : null; - }; - aes.error = function(row) { - return row[errorColumn] ? _getRowValue(row, errorColumn) : null; - }; - return aes; - }; - - var hoverTextFn = function(subjectColumn, intervalKey, name, columnName, visitMap, errorColumn, errorType){ - if (visitMap) - { - if (errorColumn) - { - return function(row){ - var subject = _getRowValue(row, subjectColumn); - var errorVal = _getRowValue(row, errorColumn) || 'n/a'; - return ' ' + subject + ',\n '+ visitMap[_getRowValue(row, intervalKey, 'value')].displayName + - ',\n ' + name + ': ' + _getRowValue(row, columnName) + - ',\n ' + errorType + ': ' + errorVal; - } - } - else - { - return function(row){ - var subject = _getRowValue(row, subjectColumn); - return ' ' + subject + ',\n '+ visitMap[_getRowValue(row, intervalKey, 'value')].displayName + - ',\n ' + name + ': ' + _getRowValue(row, columnName); - }; - } - } - else - { - if (errorColumn) - { - return function(row){ - var subject = _getRowValue(row, subjectColumn); - var errorVal = _getRowValue(row, errorColumn) || 'n/a'; - return ' ' + subject + ',\n ' + intervalKey + ': ' + _getRowValue(row, intervalKey) + - ',\n ' + name + ': ' + _getRowValue(row, columnName) + - ',\n ' + errorType + ': ' + errorVal; - }; - } - else - { - return function(row){ - var subject = _getRowValue(row, subjectColumn); - return ' ' + subject + ',\n ' + intervalKey + ': ' + _getRowValue(row, intervalKey) + - ',\n ' + name + ': ' + _getRowValue(row, columnName); - }; - } - } - }; - - // Issue 15369: if two measures have the same name, use the alias for the subsequent series names (which will be unique) - // Issue 12369: if rendering two measures of the same pivoted value, use measure and pivot name for series names (which will be unique) - var useUniqueSeriesNames = false; - var uniqueChartSeriesNames = []; - for (var i = 0; i < seriesList.length; i++) - { - if (uniqueChartSeriesNames.indexOf(seriesList[i].name) > -1) - { - useUniqueSeriesNames = true; - break; - } - uniqueChartSeriesNames.push(seriesList[i].name); - } - - for (var i = seriesList.length -1; i >= 0; i--) - { - var chartSeries = seriesList[i]; - - var chartSeriesName = chartSeries.label; - if (useUniqueSeriesNames) - { - if (chartSeries.aliasLookupInfo.pivotValue) - chartSeriesName = chartSeries.aliasLookupInfo.measureName + " " + chartSeries.aliasLookupInfo.pivotValue; - else - chartSeriesName = chartSeries.aliasLookupInfo.alias; - } - - var columnName = individualColumnAliases ? LABKEY.vis.getColumnAlias(individualColumnAliases, chartSeries.aliasLookupInfo) : LABKEY.vis.getColumnAlias(aggregateColumnAliases, chartSeries.aliasLookupInfo); - if (individualColumnAliases) - { - if (!config.hideTrendLine) { - var pathLayerConfig = { - geom: new LABKEY.vis.Geom.Path({size: config.lineWidth}), - aes: generateLayerAes(chartSeriesName, chartSeries.yAxisSide, columnName) - }; - - if (seriesList.length > 1) - pathLayerConfig.name = chartSeriesName; - - layers.push(new LABKEY.vis.Layer(pathLayerConfig)); - } - - if (!config.hideDataPoints) - { - var pointLayerConfig = { - geom: new LABKEY.vis.Geom.Point(), - aes: generateLayerAes(chartSeriesName, chartSeries.yAxisSide, columnName) - }; - - if (seriesList.length > 1) - pointLayerConfig.name = chartSeriesName; - - if (isDateBased) - pointLayerConfig.aes.hoverText = hoverTextFn(individualSubjectColumn, intervalKey, chartSeriesName, columnName, null, null, null); - else - pointLayerConfig.aes.hoverText = hoverTextFn(individualSubjectColumn, intervalKey, chartSeriesName, columnName, visitMap, null, null); - - if (config.pointClickFn) - { - pointLayerConfig.aes.pointClickFn = generatePointClickFn( - config.pointClickFn, - {participant: individualSubjectColumn, interval: intervalKey, measure: columnName}, - {schemaName: chartSeries.schemaName, queryName: chartSeries.queryName, name: chartSeriesName} - ); - } - - layers.push(new LABKEY.vis.Layer(pointLayerConfig)); - } - } - - if (aggregateData && aggregateColumnAliases) - { - var errorBarType = null; - if (config.errorBars == 'SD') - errorBarType = '_STDDEV'; - else if (config.errorBars == 'SEM') - errorBarType = '_STDERR'; - - var errorColumnName = errorBarType ? LABKEY.vis.getColumnAlias(aggregateColumnAliases, chartSeries.aliasLookupInfo) + errorBarType : null; - - if (!config.hideTrendLine) { - var aggregatePathLayerConfig = { - data: aggregateData, - geom: new LABKEY.vis.Geom.Path({size: config.lineWidth}), - aes: generateAggregateLayerAes(chartSeriesName, chartSeries.yAxisSide, columnName, intervalKey, aggregateSubjectColumn, errorColumnName) - }; - - if (seriesList.length > 1) - aggregatePathLayerConfig.name = chartSeriesName; - - layers.push(new LABKEY.vis.Layer(aggregatePathLayerConfig)); - } - - if (errorColumnName) - { - var aggregateErrorLayerConfig = { - data: aggregateData, - geom: new LABKEY.vis.Geom.ErrorBar({ showVertical: true }), - aes: generateAggregateLayerAes(chartSeriesName, chartSeries.yAxisSide, columnName, intervalKey, aggregateSubjectColumn, errorColumnName) - }; - - if (seriesList.length > 1) - aggregateErrorLayerConfig.name = chartSeriesName; - - layers.push(new LABKEY.vis.Layer(aggregateErrorLayerConfig)); - } - - if (!config.hideDataPoints) - { - var aggregatePointLayerConfig = { - data: aggregateData, - geom: new LABKEY.vis.Geom.Point(), - aes: generateAggregateLayerAes(chartSeriesName, chartSeries.yAxisSide, columnName, intervalKey, aggregateSubjectColumn, errorColumnName) - }; - - if (seriesList.length > 1) - aggregatePointLayerConfig.name = chartSeriesName; - - if (isDateBased) - aggregatePointLayerConfig.aes.hoverText = hoverTextFn(aggregateSubjectColumn, intervalKey, chartSeriesName, columnName, null, errorColumnName, config.errorBars) - else - aggregatePointLayerConfig.aes.hoverText = hoverTextFn(aggregateSubjectColumn, intervalKey, chartSeriesName, columnName, visitMap, errorColumnName, config.errorBars); - - if (config.pointClickFn) - { - aggregatePointLayerConfig.aes.pointClickFn = generatePointClickFn( - config.pointClickFn, - {group: aggregateSubjectColumn, interval: intervalKey, measure: columnName}, - {schemaName: chartSeries.schemaName, queryName: chartSeries.queryName, name: chartSeriesName} - ); - } - - layers.push(new LABKEY.vis.Layer(aggregatePointLayerConfig)); - } - } - } - - return layers; - }; - - // private function - var generatePointClickFn = function(fnString, columnMap, measureInfo){ - // the developer is expected to return a function, so we encapalate it within the anonymous function - // (note: the function should have already be validated in a try/catch when applied via the developerOptionsPanel) - - // using new Function is quicker than eval(), even in IE. - var pointClickFn = new Function('return ' + fnString)(); - return function(clickEvent, data) { - pointClickFn(data, columnMap, measureInfo, clickEvent); - }; - }; - - /** - * Generate the list of series to be plotted in a given Time Chart. A series will be created for each measure and - * dimension that is selected in the chart. - * @param {Array} measures The array of selected measures from the chart config. - * @returns {Array} - */ - var generateSeriesList = function(measures) { - var arr = []; - for (var i = 0; i < measures.length; i++) - { - var md = measures[i]; - - if (md.dimension && md.dimension.values) - { - Ext4.each(md.dimension.values, function(val) { - arr.push({ - schemaName: md.dimension.schemaName, - queryName: md.dimension.queryName, - name: val, - label: val, - measureIndex: i, - yAxisSide: md.measure.yAxis, - aliasLookupInfo: {measureName: md.measure.name, pivotValue: val} - }); - }); - } - else - { - arr.push({ - schemaName: md.measure.schemaName, - queryName: md.measure.queryName, - name: md.measure.name, - label: md.measure.label, - measureIndex: i, - yAxisSide: md.measure.yAxis, - aliasLookupInfo: md.measure.alias ? {alias: md.measure.alias} : {measureName: md.measure.name} - }); - } - } - return arr; - }; - - // private function - var generateDataSortArray = function(subject, firstMeasure, isDateBased, nounSingular) - { - nounSingular = nounSingular || getStudySubjectInfo().nounSingular; - var hasDateCol = firstMeasure.dateOptions && firstMeasure.dateOptions.dateCol; - - return [ - subject, - { - schemaName : hasDateCol ? firstMeasure.dateOptions.dateCol.schemaName : firstMeasure.measure.schemaName, - queryName : hasDateCol ? firstMeasure.dateOptions.dateCol.queryName : firstMeasure.measure.queryName, - name : isDateBased && hasDateCol ? firstMeasure.dateOptions.dateCol.name : getSubjectVisitColName(nounSingular, 'DisplayOrder') - }, - { - schemaName : hasDateCol ? firstMeasure.dateOptions.dateCol.schemaName : firstMeasure.measure.schemaName, - queryName : hasDateCol ? firstMeasure.dateOptions.dateCol.queryName : firstMeasure.measure.queryName, - name : (isDateBased ? nounSingular + "Visit/Visit" : getSubjectVisitColName(nounSingular, 'SequenceNumMin')) - } - ]; - }; - - var getSubjectVisitColName = function(nounSingular, suffix) - { - var nounSingular = nounSingular || getStudySubjectInfo().nounSingular; - return nounSingular + 'Visit/Visit/' + suffix; - }; - - /** - * Determine whether or not the chart needs to clip the plotted lines and points based on manually set axis ranges. - * @param {Object} config The chart configuration object that defines the selected measures, axis info, subjects/groups, etc. - * @returns {boolean} - */ - var generateApplyClipRect = function(config) { - var xAxisIndex = getAxisIndex(config.axis, "x-axis"); - var leftAxisIndex = getAxisIndex(config.axis, "y-axis", "left"); - var rightAxisIndex = getAxisIndex(config.axis, "y-axis", "right"); - - return ( - xAxisIndex > -1 && (config.axis[xAxisIndex].range.min != null || config.axis[xAxisIndex].range.max != null) || - leftAxisIndex > -1 && (config.axis[leftAxisIndex].range.min != null || config.axis[leftAxisIndex].range.max != null) || - rightAxisIndex > -1 && (config.axis[rightAxisIndex].range.min != null || config.axis[rightAxisIndex].range.max != null) - ); - }; - - /** - * Generates axis range min and max values based on the full Time Chart data. This will be used when plotting multiple - * charts that are set to use the same axis ranges across all charts in the report. - * @param {Object} config The chart configuration object that defines the selected measures, axis info, subjects/groups, etc. - * @param {Object} data The data object, from getChartData. - * @param {Array} seriesList The list of series that will be plotted for a given chart, from generateSeriesList. - * @param {String} nounSingular The singular name of the study subject noun (i.e. Participant). - */ - var generateAcrossChartAxisRanges = function(config, data, seriesList, nounSingular) - { - nounSingular = nounSingular || getStudySubjectInfo().nounSingular; - - if (config.measures.length == 0) - throw "There must be at least one specified measure in the chartInfo config!"; - if (!data.individual && !data.aggregate) - throw "We expect to either be displaying individual series lines or aggregate data!"; - - var rows = []; - if (LABKEY.Utils.isDefined(data.individual)) { - rows = data.individual.measureStore.records(); - } - else if (LABKEY.Utils.isDefined(data.aggregate)) { - rows = data.aggregate.measureStore.records(); - } - - config.hasNoData = rows.length == 0; - - // In multi-chart case, we need to pre-compute the default axis ranges so that all charts share them - // (if 'automatic across charts' is selected for the given axis) - if (config.chartLayout != "single") - { - var leftMeasures = [], - rightMeasures = [], - xName, xFunc, - min, max, tempMin, tempMax, errorBarType, - leftAccessor, leftAccessorMax, leftAccessorMin, rightAccessorMax, rightAccessorMin, rightAccessor, - columnAliases = data.individual ? data.individual.columnAliases : (data.aggregate ? data.aggregate.columnAliases : null), - isDateBased = config.measures[0].time == "date", - xAxisIndex = getAxisIndex(config.axis, "x-axis"), - leftAxisIndex = getAxisIndex(config.axis, "y-axis", "left"), - rightAxisIndex = getAxisIndex(config.axis, "y-axis", "right"); - - for (var i = 0; i < seriesList.length; i++) - { - var columnName = LABKEY.vis.getColumnAlias(columnAliases, seriesList[i].aliasLookupInfo); - if (seriesList[i].yAxisSide == "left") - leftMeasures.push(columnName); - else if (seriesList[i].yAxisSide == "right") - rightMeasures.push(columnName); - } - - if (isDateBased) - { - xName = config.measures[0].dateOptions.interval; - xFunc = function(row){ - return _getRowValue(row, xName); - }; - } - else - { - var visitMap = data.individual ? data.individual.visitMap : data.aggregate.visitMap; - xName = LABKEY.vis.getColumnAlias(columnAliases, nounSingular + "Visit/Visit"); - xFunc = function(row){ - var displayProp = config.measures[0].visitOptions ? config.measures[0].visitOptions.visitDisplayProperty : defaultVisitProperty; - return visitMap[_getRowValue(row, xName, 'value')][displayProp]; - }; - } - - if (config.axis[xAxisIndex].range.type != 'automatic_per_chart') - { - if (config.axis[xAxisIndex].range.min == null) - config.axis[xAxisIndex].range.min = d3.min(rows, xFunc); - - if (config.axis[xAxisIndex].range.max == null) - config.axis[xAxisIndex].range.max = d3.max(rows, xFunc); - } - - if (config.errorBars !== 'None') - errorBarType = config.errorBars == 'SD' ? '_STDDEV' : '_STDERR'; - - if (leftAxisIndex > -1) - { - // If we have a left axis then we need to find the min/max - min = null; max = null; tempMin = null; tempMax = null; - leftAccessor = function(row) { - return _getRowValue(row, leftMeasures[i]); - }; - - if (errorBarType) - { - // If we have error bars we need to calculate min/max with the error values in mind. - leftAccessorMin = function(row) { - if (row.hasOwnProperty(leftMeasures[i] + errorBarType)) - { - var error = _getRowValue(row, leftMeasures[i] + errorBarType); - return _getRowValue(row, leftMeasures[i]) - error; - } - else - return null; - }; - - leftAccessorMax = function(row) { - if (row.hasOwnProperty(leftMeasures[i] + errorBarType)) - { - var error = _getRowValue(row, leftMeasures[i] + errorBarType); - return _getRowValue(row, leftMeasures[i]) + error; - } - else - return null; - }; - } - - if (config.axis[leftAxisIndex].range.type != 'automatic_per_chart') - { - if (config.axis[leftAxisIndex].range.min == null) - { - for (var i = 0; i < leftMeasures.length; i++) - { - tempMin = d3.min(rows, leftAccessorMin ? leftAccessorMin : leftAccessor); - min = min == null ? tempMin : tempMin < min ? tempMin : min; - } - config.axis[leftAxisIndex].range.min = min; - } - - if (config.axis[leftAxisIndex].range.max == null) - { - for (var i = 0; i < leftMeasures.length; i++) - { - tempMax = d3.max(rows, leftAccessorMax ? leftAccessorMax : leftAccessor); - max = max == null ? tempMax : tempMax > max ? tempMax : max; - } - config.axis[leftAxisIndex].range.max = max; - } - } - } - - if (rightAxisIndex > -1) - { - // If we have a right axis then we need to find the min/max - min = null; max = null; tempMin = null; tempMax = null; - rightAccessor = function(row){ - return _getRowValue(row, rightMeasures[i]); - }; - - if (errorBarType) - { - rightAccessorMin = function(row) { - if (row.hasOwnProperty(rightMeasures[i] + errorBarType)) - { - var error = _getRowValue(row, rightMeasures[i] + errorBarType); - return _getRowValue(row, rightMeasures[i]) - error; - } - else - return null; - }; - - rightAccessorMax = function(row) { - if (row.hasOwnProperty(rightMeasures[i] + errorBarType)) - { - var error = _getRowValue(row, rightMeasures[i] + errorBarType); - return _getRowValue(row, rightMeasures[i]) + error; - } - else - return null; - }; - } - - if (config.axis[rightAxisIndex].range.type != 'automatic_per_chart') - { - if (config.axis[rightAxisIndex].range.min == null) - { - for (var i = 0; i < rightMeasures.length; i++) - { - tempMin = d3.min(rows, rightAccessorMin ? rightAccessorMin : rightAccessor); - min = min == null ? tempMin : tempMin < min ? tempMin : min; - } - config.axis[rightAxisIndex].range.min = min; - } - - if (config.axis[rightAxisIndex].range.max == null) - { - for (var i = 0; i < rightMeasures.length; i++) - { - tempMax = d3.max(rows, rightAccessorMax ? rightAccessorMax : rightAccessor); - max = max == null ? tempMax : tempMax > max ? tempMax : max; - } - config.axis[rightAxisIndex].range.max = max; - } - } - } - } - }; - - /** - * Generates plot configs to be passed to the {@link LABKEY.vis.Plot} function for each chart in the report. - * @param {Object} config The chart configuration object that defines the selected measures, axis info, subjects/groups, etc. - * @param {Object} data The data object, from getChartData. - * @param {Array} seriesList The list of series that will be plotted for a given chart, from generateSeriesList. - * @param {boolean} applyClipRect A boolean indicating whether or not to clip the plotted data region, from generateApplyClipRect. - * @param {int} maxCharts The maximum number of charts to display in one report. - * @param {String} nounColumnName The name of the study subject noun column (i.e. ParticipantId). - * @returns {Array} - */ - var generatePlotConfigs = function(config, data, seriesList, applyClipRect, maxCharts, nounColumnName) { - var plotConfigInfoArr = [], - subjectColumnName = null; - - nounColumnName = nounColumnName || getStudySubjectInfo().columnName; - if (data.individual) - subjectColumnName = LABKEY.vis.getColumnAlias(data.individual.columnAliases, nounColumnName); - - var generateGroupSeries = function(rows, groups, subjectColumn) { - // subjectColumn is the aliasColumnName looked up from the getData response columnAliases array - // groups is config.subject.groups - var dataByGroup = {}; - - for (var i = 0; i < rows.length; i++) - { - var rowSubject = _getRowValue(rows[i], subjectColumn); - for (var j = 0; j < groups.length; j++) - { - if (groups[j].participantIds.indexOf(rowSubject) > -1) - { - if (!dataByGroup[groups[j].label]) - dataByGroup[groups[j].label] = []; - - dataByGroup[groups[j].label].push(rows[i]); - } - } - } - - return dataByGroup; - }; - - // four options: all series on one chart, one chart per subject, one chart per group, or one chart per measure/dimension - if (config.chartLayout == "per_subject") - { - var groupAccessor = function(row) { - return _getRowValue(row, subjectColumnName); - }; - - var dataPerParticipant = getDataWithSeriesCheck(data.individual.measureStore.records(), groupAccessor, seriesList, data.individual.columnAliases); - for (var participant in dataPerParticipant) - { - if (dataPerParticipant.hasOwnProperty(participant)) - { - // skip the group if there is no data for it - if (!dataPerParticipant[participant].hasSeriesData) - continue; - - plotConfigInfoArr.push({ - title: config.title ? config.title : participant, - subtitle: config.title ? participant : undefined, - series: seriesList, - individualData: dataPerParticipant[participant].data, - applyClipRect: applyClipRect - }); - - if (plotConfigInfoArr.length >= maxCharts) - break; - } - } - } - else if (config.chartLayout == "per_group") - { - var groupedIndividualData = null, groupedAggregateData = null; - - //Display individual lines - if (data.individual) { - groupedIndividualData = generateGroupSeries(data.individual.measureStore.records(), config.subject.groups, subjectColumnName); - } - - // Display aggregate lines - if (data.aggregate) { - var groupAccessor = function(row) { - return _getRowValue(row, 'UniqueId'); - }; - - groupedAggregateData = getDataWithSeriesCheck(data.aggregate.measureStore.records(), groupAccessor, seriesList, data.aggregate.columnAliases); - } - - for (var i = 0; i < (config.subject.groups.length > maxCharts ? maxCharts : config.subject.groups.length); i++) - { - var group = config.subject.groups[i]; - - // skip the group if there is no data for it - if ((groupedIndividualData != null && !groupedIndividualData[group.label]) - || (groupedAggregateData != null && (!groupedAggregateData[group.label] || !groupedAggregateData[group.label].hasSeriesData))) - { - continue; - } - - plotConfigInfoArr.push({ - title: config.title ? config.title : group.label, - subtitle: config.title ? group.label : undefined, - series: seriesList, - individualData: groupedIndividualData && groupedIndividualData[group.label] ? groupedIndividualData[group.label] : null, - aggregateData: groupedAggregateData && groupedAggregateData[group.label] ? groupedAggregateData[group.label].data : null, - applyClipRect: applyClipRect - }); - - if (plotConfigInfoArr.length > maxCharts) - break; - } - } - else if (config.chartLayout == "per_dimension") - { - for (var i = 0; i < (seriesList.length > maxCharts ? maxCharts : seriesList.length); i++) - { - // skip the measure/dimension if there is no data for it - if ((data.aggregate && !data.aggregate.hasData[seriesList[i].name]) - || (data.individual && !data.individual.hasData[seriesList[i].name])) - { - continue; - } - - plotConfigInfoArr.push({ - title: config.title ? config.title : seriesList[i].label, - subtitle: config.title ? seriesList[i].label : undefined, - series: [seriesList[i]], - individualData: data.individual ? data.individual.measureStore.records() : null, - aggregateData: data.aggregate ? data.aggregate.measureStore.records() : null, - applyClipRect: applyClipRect - }); - - if (plotConfigInfoArr.length > maxCharts) - break; - } - } - else if (config.chartLayout == "single") - { - //Single Line Chart, with all participants or groups. - plotConfigInfoArr.push({ - title: config.title, - series: seriesList, - individualData: data.individual ? data.individual.measureStore.records() : null, - aggregateData: data.aggregate ? data.aggregate.measureStore.records() : null, - height: 610, - style: null, - applyClipRect: applyClipRect - }); - } - - return plotConfigInfoArr; - }; - - // private function - var getDataWithSeriesCheck = function(data, groupAccessor, seriesList, columnAliases) { - /* - Groups data by the groupAccessor passed in. Also, checks for the existance of any series data for that groupAccessor. - Returns an object where each attribute will be a groupAccessor with an array of data rows and a boolean for hasSeriesData - */ - var groupedData = {}; - for (var i = 0; i < data.length; i++) - { - var value = groupAccessor(data[i]); - if (!groupedData[value]) - { - groupedData[value] = {data: [], hasSeriesData: false}; - } - groupedData[value].data.push(data[i]); - - for (var j = 0; j < seriesList.length; j++) - { - var seriesAlias = LABKEY.vis.getColumnAlias(columnAliases, seriesList[j].aliasLookupInfo); - if (seriesAlias && _getRowValue(data[i], seriesAlias) != null) - { - groupedData[value].hasSeriesData = true; - break; - } - } - } - return groupedData; - }; - - /** - * Get the index in the axes array for a given axis (ie left y-axis). - * @param {Array} axes The array of specified axis information for this chart. - * @param {String} axisName The chart axis (i.e. x-axis or y-axis). - * @param {String} [side] The y-axis side (i.e. left or right). - * @returns {number} - */ - var getAxisIndex = function(axes, axisName, side) { - var index = -1; - for (var i = 0; i < axes.length; i++) - { - if (!side && axes[i].name == axisName) - { - index = i; - break; - } - else if (axes[i].name == axisName && axes[i].side == side) - { - index = i; - break; - } - } - return index; - }; - - /** - * Get the data needed for the specified Time Chart based on the chart config. Makes calls to the - * {@link LABKEY.Query.Visualization.getData} to get the individual subject data and grouped aggregate data. - * Calls the success callback function in the config when it has received all of the requested data. - * @param {Object} config The chart configuration object that defines the selected measures, axis info, subjects/groups, etc. - */ - var getChartData = function(config) { - if (!config.success) - throw "You must specify a success callback function!"; - if (!config.failure) - throw "You must specify a failure callback function!"; - if (!config.chartInfo) - throw "You must specify a chartInfo config!"; - if (config.chartInfo.measures.length == 0) - throw "There must be at least one specified measure in the chartInfo config!"; - if (!config.chartInfo.displayIndividual && !config.chartInfo.displayAggregate) - throw "We expect to either be displaying individual series lines or aggregate data!"; - - // issue 22254: perf issues if we try to show individual lines for a group with a large number of subjects - var subjectLength = config.chartInfo.subject.values ? config.chartInfo.subject.values.length : 0; - if (config.chartInfo.displayIndividual && subjectLength > 10000) - { - config.chartInfo.displayIndividual = false; - config.chartInfo.subject.values = undefined; - } - - var chartData = {numberFormats: {}}; - var counter = config.chartInfo.displayIndividual && config.chartInfo.displayAggregate ? 2 : 1; - var isDateBased = config.chartInfo.measures[0].time == "date"; - var seriesList = generateSeriesList(config.chartInfo.measures); - - // get the visit map info for those visits in the response data - var trimVisitMapDomain = function(origVisitMap, visitsInDataArr) { - var trimmedVisits = []; - for (var v in origVisitMap) { - if (origVisitMap.hasOwnProperty(v)) { - if (visitsInDataArr.indexOf(parseInt(v)) != -1) { - trimmedVisits.push(Ext4.apply({id: v}, origVisitMap[v])); - } - } - } - // sort the trimmed visit list by displayOrder and then reset displayOrder starting at 1 - trimmedVisits.sort(function(a,b){return a.displayOrder - b.displayOrder}); - var newVisitMap = {}; - for (var i = 0; i < trimmedVisits.length; i++) - { - trimmedVisits[i].displayOrder = i + 1; - newVisitMap[trimmedVisits[i].id] = trimmedVisits[i]; - } - - return newVisitMap; - }; - - var successCallback = function(response, dataType) { - // check for success=false - if (LABKEY.Utils.isDefined(response.success) && LABKEY.Utils.isBoolean(response.success) && !response.success) - { - config.failure.call(config.scope, response); - return; - } - - // Issue 16156: for date based charts, give error message if there are no calculated interval values - if (isDateBased) { - var intervalAlias = config.chartInfo.measures[0].dateOptions.interval; - var uniqueNonNullValues = Ext4.Array.clean(response.measureStore.members(intervalAlias)); - chartData.hasIntervalData = uniqueNonNullValues.length > 0; - } - else { - chartData.hasIntervalData = true; - } - - // make sure each measure/dimension has at least some data, and get a list of which visits are in the data response - // also keep track of which measure/dimensions have negative values (for log scale) - response.hasData = {}; - response.hasNegativeValues = {}; - Ext4.each(seriesList, function(s) { - var alias = LABKEY.vis.getColumnAlias(response.columnAliases, s.aliasLookupInfo); - var uniqueNonNullValues = Ext4.Array.clean(response.measureStore.members(alias)); - - response.hasData[s.name] = uniqueNonNullValues.length > 0; - response.hasNegativeValues[s.name] = Ext4.Array.min(uniqueNonNullValues) < 0; - }); - - // trim the visit map domain to just those visits in the response data - if (!isDateBased) { - var nounSingular = config.nounSingular || getStudySubjectInfo().nounSingular; - var visitMappedName = LABKEY.vis.getColumnAlias(response.columnAliases, nounSingular + "Visit/Visit"); - var visitsInData = response.measureStore.members(visitMappedName); - response.visitMap = trimVisitMapDomain(response.visitMap, visitsInData); - } - else { - response.visitMap = {}; - } - - chartData[dataType] = response; - - generateNumberFormats(config.chartInfo, chartData, config.defaultNumberFormat); - - // if we have all request data back, return the result - counter--; - if (counter == 0) - config.success.call(config.scope, chartData); - }; - - var getSelectRowsSort = function(response, dataType) - { - var nounSingular = config.nounSingular || getStudySubjectInfo().nounSingular, - sort = dataType == 'aggregate' ? 'GroupingOrder,UniqueId' : response.measureToColumn[config.chartInfo.subject.name]; - - if (isDateBased) - { - sort += ',' + config.chartInfo.measures[0].dateOptions.interval; - } - else - { - // Issue 28529: if we have a SubjectVisit/sequencenum column, use that instead of SubjectVisit/Visit/SequenceNumMin - var sequenceNumCol = response.measureToColumn[nounSingular + 'Visit/sequencenum']; - if (!LABKEY.Utils.isDefined(sequenceNumCol)) - sequenceNumCol = response.measureToColumn[getSubjectVisitColName(nounSingular, 'SequenceNumMin')]; - - sort += ',' + response.measureToColumn[getSubjectVisitColName(nounSingular, 'DisplayOrder')] + ',' + sequenceNumCol; - } - - return sort; - }; - - var queryTempResultsForRows = function(response, dataType) - { - // Issue 28529: re-query for the actual data off of the temp query results - LABKEY.Query.MeasureStore.selectRows({ - containerPath: config.containerPath, - schemaName: response.schemaName, - queryName: response.queryName, - requiredVersion : 13.2, - maxRows: -1, - sort: getSelectRowsSort(response, dataType), - success: function(measureStore) { - response.measureStore = measureStore; - successCallback(response, dataType); - } - }); - }; - - if (config.chartInfo.displayIndividual) - { - //Get data for individual lines. - LABKEY.Query.Visualization.getData({ - metaDataOnly: true, - containerPath: config.containerPath, - success: function(response) { - queryTempResultsForRows(response, "individual"); - }, - failure : function(info, response, options) { - config.failure.call(config.scope, info, Ext4.JSON.decode(response.responseText)); - }, - measures: config.chartInfo.measures, - sorts: generateDataSortArray(config.chartInfo.subject, config.chartInfo.measures[0], isDateBased, config.nounSingular), - limit : config.dataLimit || 10000, - parameters : config.chartInfo.parameters, - filterUrl: config.chartInfo.filterUrl, - filterQuery: config.chartInfo.filterQuery - }); - } - - if (config.chartInfo.displayAggregate) - { - //Get data for Aggregates lines. - var groups = []; - for (var i = 0; i < config.chartInfo.subject.groups.length; i++) - { - var group = config.chartInfo.subject.groups[i]; - // encode the group id & type, so we can distinguish between cohort and participant group in the union table - groups.push(group.id + '-' + group.type); - } - - LABKEY.Query.Visualization.getData({ - metaDataOnly: true, - containerPath: config.containerPath, - success: function(response) { - queryTempResultsForRows(response, "aggregate"); - }, - failure : function(info) { - config.failure.call(config.scope, info); - }, - measures: config.chartInfo.measures, - groupBys: [ - // Issue 18747: if grouping by cohorts and ptid groups, order it so the cohorts are first - {schemaName: 'study', queryName: 'ParticipantGroupCohortUnion', name: 'GroupingOrder', values: [0,1]}, - {schemaName: 'study', queryName: 'ParticipantGroupCohortUnion', name: 'UniqueId', values: groups} - ], - sorts: generateDataSortArray(config.chartInfo.subject, config.chartInfo.measures[0], isDateBased, config.nounSingular), - limit : config.dataLimit || 10000, - parameters : config.chartInfo.parameters, - filterUrl: config.chartInfo.filterUrl, - filterQuery: config.chartInfo.filterQuery - }); - } - }; - - /** - * Get the set of measures from the tables/queries in the study schema. - * @param successCallback - * @param callbackScope - */ - var getStudyMeasures = function(successCallback, callbackScope) - { - if (getStudyTimepointType() != null) - { - LABKEY.Query.Visualization.getMeasures({ - filters: ['study|~'], - dateMeasures: false, - success: function (measures, response) - { - var o = Ext4.JSON.decode(response.responseText); - successCallback.call(callbackScope, o.measures); - }, - failure: this.onFailure, - scope: this - }); - } - else - { - successCallback.call(callbackScope, []); - } - }; - - /** - * If this is a container with a configured study, get the timepoint type from the study module context. - * @returns {String|null} - */ - var getStudyTimepointType = function() - { - var studyCtx = LABKEY.getModuleContext("study") || {}; - return LABKEY.Utils.isDefined(studyCtx.timepointType) ? studyCtx.timepointType : null; - }; - - /** - * Generate the number format functions for the left and right y-axis and attach them to the chart data object - * @param {Object} config The chart configuration object that defines the selected measures, axis info, subjects/groups, etc. - * @param {Object} data The data object, from getChartData. - * @param {Object} defaultNumberFormat - */ - var generateNumberFormats = function(config, data, defaultNumberFormat) { - var fields = data.individual ? data.individual.metaData.fields : data.aggregate.metaData.fields; - - for (var i = 0; i < config.axis.length; i++) - { - var axis = config.axis[i]; - if (axis.side) - { - // Find the first measure with the matching side that has a numberFormat. - for (var j = 0; j < config.measures.length; j++) - { - var measure = config.measures[j].measure; - - if (data.numberFormats[axis.side]) - break; - - if (measure.yAxis == axis.side) - { - var metaDataName = measure.alias; - for (var k = 0; k < fields.length; k++) - { - var field = fields[k]; - if (field.name == metaDataName) - { - if (field.extFormatFn) - { - data.numberFormats[axis.side] = eval(field.extFormatFn); - break; - } - } - } - } - } - - if (!data.numberFormats[axis.side]) - { - // If after all the searching we still don't have a numberformat use the default number format. - data.numberFormats[axis.side] = defaultNumberFormat; - } - } - } - }; - - /** - * Verifies the information in the chart config to make sure it has proper measures, axis info, subjects/groups, etc. - * Returns an object with a success parameter (boolean) and a message parameter (string). If the success pararameter - * is false there is a critical error and the chart cannot be rendered. If success is true the chart can be rendered. - * Message will contain an error or warning message if applicable. If message is not null and success is true, there is a warning. - * @param {Object} config The chart configuration object that defines the selected measures, axis info, subjects/groups, etc. - * @returns {Object} - */ - var validateChartConfig = function(config) { - var message = ""; - - if (!config.measures || config.measures.length == 0) - { - message = "No measure selected. Please select at lease one measure."; - return {success: false, message: message}; - } - - if (!config.axis || getAxisIndex(config.axis, "x-axis") == -1) - { - message = "Could not find x-axis in chart measure information."; - return {success: false, message: message}; - } - - if (config.chartSubjectSelection == "subjects" && config.subject.values.length == 0) - { - var nounSingular = getStudySubjectInfo().nounSingular; - message = "No " + nounSingular.toLowerCase() + " selected. " + - "Please select at least one " + nounSingular.toLowerCase() + "."; - return {success: false, message: message}; - } - - if (config.chartSubjectSelection == "groups" && config.subject.groups.length < 1) - { - message = "No group selected. Please select at least one group."; - return {success: false, message: message}; - } - - if (generateSeriesList(config.measures).length == 0) - { - message = "No series or dimension selected. Please select at least one series/dimension value."; - return {success: false, message: message}; - } - - if (!(config.displayIndividual || config.displayAggregate)) - { - message = "Please select either \"Show Individual Lines\" or \"Show Mean\"."; - return {success: false, message: message}; - } - - // issue 22254: perf issues if we try to show individual lines for a group with a large number of subjects - var subjectLength = config.subject.values ? config.subject.values.length : 0; - if (config.displayIndividual && subjectLength > 10000) - { - var nounPlural = getStudySubjectInfo().nounPlural; - message = "Unable to display individual series lines for greater than 10,000 total " + nounPlural.toLowerCase() + "."; - return {success: false, message: message}; - } - - return {success: true, message: message}; - }; - - /** - * Verifies that the chart data contains the expected interval values and measure/dimension data. Also checks to make - * sure that data can be used in a log scale (if applicable). Returns an object with a success parameter (boolean) - * and a message parameter (string). If the success pararameter is false there is a critical error and the chart - * cannot be rendered. If success is true the chart can be rendered. Message will contain an error or warning - * message if applicable. If message is not null and success is true, there is a warning. - * @param {Object} data The data object, from getChartData. - * @param {Object} config The chart configuration object that defines the selected measures, axis info, subjects/groups, etc. - * @param {Array} seriesList The list of series that will be plotted for a given chart, from generateSeriesList. - * @param {int} limit The data limit for a single report. - * @returns {Object} - */ - var validateChartData = function(data, config, seriesList, limit) { - var message = "", - sep = "", - msg = "", - commaSep = "", - noDataCounter = 0; - - // warn the user if the data limit has been reached - var individualDataCount = LABKEY.Utils.isDefined(data.individual) ? data.individual.measureStore.records().length : null; - var aggregateDataCount = LABKEY.Utils.isDefined(data.aggregate) ? data.aggregate.measureStore.records().length : null; - if (individualDataCount >= limit || aggregateDataCount >= limit) { - message += sep + "The data limit for plotting has been reached. Consider filtering your data."; - sep = "
"; - } - - // for date based charts, give error message if there are no calculated interval values - if (!data.hasIntervalData) - { - message += sep + "No calculated interval values (i.e. Days, Months, etc.) for the selected 'Measure Date' and 'Interval Start Date'."; - sep = "
"; - } - - // check to see if any of the measures don't have data - Ext4.iterate(data.aggregate ? data.aggregate.hasData : data.individual.hasData, function(key, value) { - if (!value) - { - noDataCounter++; - msg += commaSep + key; - commaSep = ", "; - } - }, this); - if (msg.length > 0) - { - msg = "No data found for the following measures/dimensions: " + msg; - - // if there is no data for any series, add to explanation - if (noDataCounter == seriesList.length) - { - var isDateBased = config && config.measures[0].time == "date"; - if (isDateBased) - msg += ". This may be the result of a missing start date value for the selected subject(s)."; - } - - message += sep + msg; - sep = "
"; - } - - // check to make sure that data can be used in a log scale (if applicable) - if (config) - { - var leftAxisIndex = getAxisIndex(config.axis, "y-axis", "left"); - var rightAxisIndex = getAxisIndex(config.axis, "y-axis", "right"); - - Ext4.each(config.measures, function(md){ - var m = md.measure; - - // check the left y-axis - if (m.yAxis == "left" && leftAxisIndex > -1 && config.axis[leftAxisIndex].scale == "log" - && ((data.individual && data.individual.hasNegativeValues && data.individual.hasNegativeValues[m.name]) - || (data.aggregate && data.aggregate.hasNegativeValues && data.aggregate.hasNegativeValues[m.name]))) - { - config.axis[leftAxisIndex].scale = "linear"; - message += sep + "Unable to use a log scale on the left y-axis. All y-axis values must be >= 0. Reverting to linear scale on left y-axis."; - sep = "
"; - } - - // check the right y-axis - if (m.yAxis == "right" && rightAxisIndex > -1 && config.axis[rightAxisIndex].scale == "log" - && ((data.individual && data.individual.hasNegativeValues[m.name]) - || (data.aggregate && data.aggregate.hasNegativeValues[m.name]))) - { - config.axis[rightAxisIndex].scale = "linear"; - message += sep + "Unable to use a log scale on the right y-axis. All y-axis values must be >= 0. Reverting to linear scale on right y-axis."; - sep = "
"; - } - - }); - } - - return {success: true, message: message}; - }; - - /** - * Support backwards compatibility for charts saved prior to chartInfo reconfiguration (2011-08-31). - * Support backwards compatibility for save thumbnail options (2012-06-19). - * @param chartInfo - * @param savedReportInfo - */ - var convertSavedReportConfig = function(chartInfo, savedReportInfo) - { - if (LABKEY.Utils.isDefined(chartInfo)) - { - Ext4.applyIf(chartInfo, { - axis: [], - //This is for charts saved prior to 2011-10-07 - chartSubjectSelection: chartInfo.chartLayout == 'per_group' ? 'groups' : 'subjects', - displayIndividual: true, - displayAggregate: false - }); - for (var i = 0; i < chartInfo.measures.length; i++) - { - var md = chartInfo.measures[i]; - - Ext4.applyIf(md.measure, {yAxis: "left"}); - - // if the axis info is in md, move it to the axis array - if (md.axis) - { - // default the y-axis to the left side if not specified - if (md.axis.name == "y-axis") - Ext4.applyIf(md.axis, {side: "left"}); - - // move the axis info to the axis array - if (getAxisIndex(chartInfo.axis, md.axis.name, md.axis.side) == -1) - chartInfo.axis.push(Ext4.apply({}, md.axis)); - - // if the chartInfo has an x-axis measure, move the date info it to the related y-axis measures - if (md.axis.name == "x-axis") - { - for (var j = 0; j < chartInfo.measures.length; j++) - { - var schema = md.measure.schemaName; - var query = md.measure.queryName; - if (chartInfo.measures[j].axis && chartInfo.measures[j].axis.name == "y-axis" - && chartInfo.measures[j].measure.schemaName == schema - && chartInfo.measures[j].measure.queryName == query) - { - chartInfo.measures[j].dateOptions = { - dateCol: Ext4.apply({}, md.measure), - zeroDateCol: Ext4.apply({}, md.dateOptions.zeroDateCol), - interval: md.dateOptions.interval - }; - } - } - - // remove the x-axis date measure from the measures array - chartInfo.measures.splice(i, 1); - i--; - } - else - { - // remove the axis property from the measure - delete md.axis; - } - } - } - } - - if (LABKEY.Utils.isObject(chartInfo) && LABKEY.Utils.isObject(savedReportInfo)) - { - if (chartInfo.saveThumbnail != undefined) - { - if (savedReportInfo.reportProps == null) - savedReportInfo.reportProps = {}; - - Ext4.applyIf(savedReportInfo.reportProps, { - thumbnailType: !chartInfo.saveThumbnail ? 'NONE' : 'AUTO' - }); - } - } - }; - - var getStudySubjectInfo = function() - { - var studyCtx = LABKEY.getModuleContext("study") || {}; - return LABKEY.Utils.isObject(studyCtx.subject) ? studyCtx.subject : { - tableName: 'Participant', - columnName: 'ParticipantId', - nounPlural: 'Participants', - nounSingular: 'Participant' - }; - }; - - var getMeasureAlias = function(measure) - { - if (LABKEY.Utils.isString(measure.alias)) - return measure.alias; - else - return measure.schemaName + '_' + measure.queryName + '_' + measure.name; - }; - - var getMeasuresLabelBySide = function(measures, side) - { - var labels = []; - Ext4.each(measures, function(measure) - { - if (measure.yAxis == side && labels.indexOf(measure.label) == -1) - labels.push(measure.label); - }); - - return labels.join(', '); - }; - - var getDistinctYAxisSides = function(measures) - { - return Ext4.Array.unique(Ext4.Array.pluck(measures, 'yAxis')); - }; - - var _getRowValue = function(row, propName, valueName) - { - if (row.hasOwnProperty(propName)) { - // backwards compatibility for response row that is not a LABKEY.Query.Row - if (!(row instanceof LABKEY.Query.Row)) { - return row[propName].displayValue || row[propName].value; - } - - var propValue = row.get(propName); - if (valueName != undefined && propValue.hasOwnProperty(valueName)) { - return propValue[valueName]; - } - else if (propValue.hasOwnProperty('displayValue')) { - return propValue['displayValue']; - } - return row.getValue(propName); - } - - return undefined; - }; - - var renderChartSVG = function(renderTo, queryConfig, chartConfig) { - // Before we load the data, validate some information about the chart config - var messages = []; - var validation = validateChartConfig(chartConfig); - if (validation.message != null) - { - messages.push(validation.message); - } - if (!validation.success) - { - _renderMessages(renderTo, messages); - return; - } - - var nounSingular = 'Participant'; - var subjectColumnName = 'ParticipantId'; - if (LABKEY.moduleContext.study && LABKEY.moduleContext.study.subject) - { - nounSingular = LABKEY.moduleContext.study.subject.nounSingular; - subjectColumnName = LABKEY.moduleContext.study.subject.columnName; - } - - // When all the dependencies are loaded, we load the data using time chart helper getChartData - Ext4.applyIf(queryConfig, { - chartInfo: chartConfig, - containerPath: LABKEY.container.path, - nounSingular: nounSingular, - subjectColumnName: subjectColumnName, - dataLimit: 10000, - maxCharts: 20, - defaultMultiChartHeight: 380, - defaultSingleChartHeight: 600, - defaultWidth: 1075, - defaultNumberFormat: function(v) { return v.toFixed(1); } - }); - - queryConfig.success = function(response) { - _getChartDataCallback(renderTo, queryConfig, chartConfig, response); - }; - queryConfig.failure = function(info) { - _renderMessages(renderTo, ['Error: ' + info.exception]); - }; - - LABKEY.vis.TimeChartHelper.getChartData(queryConfig); - }; - - var _getChartDataCallback = function(renderTo, queryConfig, chartConfig, responseData) { - var individualColumnAliases = responseData.individual ? responseData.individual.columnAliases : null; - var aggregateColumnAliases = responseData.aggregate ? responseData.aggregate.columnAliases : null; - var visitMap = responseData.individual ? responseData.individual.visitMap : responseData.aggregate.visitMap; - var intervalKey = generateIntervalKey(chartConfig, individualColumnAliases, aggregateColumnAliases, queryConfig.nounSingular); - var aes = generateAes(chartConfig, visitMap, individualColumnAliases, intervalKey, queryConfig.subjectColumnName); - var tickMap = generateTickMap(visitMap); - var seriesList = generateSeriesList(chartConfig.measures); - var applyClipRect = generateApplyClipRect(chartConfig); - - // Once we have the data, we can set all of the axis min/max range values - generateAcrossChartAxisRanges(chartConfig, responseData, seriesList, queryConfig.nounSingular); - var scales = generateScales(chartConfig, tickMap, responseData.numberFormats); - - // Validate that the chart data has expected values and give warnings if certain elements are not present - var messages = []; - var validation = validateChartData(responseData, chartConfig, seriesList, queryConfig.dataLimit, false); - if (validation.message != null) - { - messages.push(validation.message); - } - if (!validation.success) - { - _renderMessages(renderTo, messages); - return; - } - - // For time charts, we allow multiple plots to be displayed by participant, group, or measure/dimension - var plotConfigsArr = generatePlotConfigs(chartConfig, responseData, seriesList, applyClipRect, queryConfig.maxCharts, queryConfig.subjectColumnName); - for (var configIndex = 0; configIndex < plotConfigsArr.length; configIndex++) - { - var clipRect = plotConfigsArr[configIndex].applyClipRect; - var series = plotConfigsArr[configIndex].series; - var height = chartConfig.height || (plotConfigsArr.length > 1 ? queryConfig.defaultMultiChartHeight : queryConfig.defaultSingleChartHeight); - var width = chartConfig.width || queryConfig.defaultWidth; - var labels = generateLabels(plotConfigsArr[configIndex].title, chartConfig.axis, plotConfigsArr[configIndex].subtitle); - var layers = generateLayers(chartConfig, visitMap, individualColumnAliases, aggregateColumnAliases, plotConfigsArr[configIndex].aggregateData, series, intervalKey, queryConfig.subjectColumnName); - var data = plotConfigsArr[configIndex].individualData ? plotConfigsArr[configIndex].individualData : plotConfigsArr[configIndex].aggregateData; - - var plotConfig = { - renderTo: renderTo, - rendererType: 'd3', - clipRect: clipRect, - width: width, - height: height, - labels: labels, - aes: aes, - scales: scales, - layers: layers, - data: data - }; - - var plot = new LABKEY.vis.Plot(plotConfig); - plot.render(); - } - - // Give a warning if the max number of charts has been exceeded - if (plotConfigsArr.length >= queryConfig.maxCharts) - messages.push('Only showing the first ' + queryConfig.maxCharts + ' charts.'); - - _renderMessages(renderTo, messages); - }; - - var _renderMessages = function(id, messages) { - var messageDiv; - var el = document.getElementById(id); - var child; - if (el && el.children.length > 0) - child = el.children[0]; - - for (var i = 0; i < messages.length; i++) - { - messageDiv = document.createElement('div'); - messageDiv.setAttribute('style', 'font-style:italic'); - messageDiv.innerHTML = messages[i]; - if (child) - el.insertBefore(messageDiv, child); - else - el.appendChild(messageDiv); - } - }; - - return { - /** - * Loads all of the required dependencies for a Time Chart. - * @param {Function} callback The callback to be executed when all of the visualization dependencies have been loaded. - * @param {Object} scope The scope to be used when executing the callback. - */ - loadVisDependencies: LABKEY.requiresVisualization, - generateAcrossChartAxisRanges : generateAcrossChartAxisRanges, - generateAes : generateAes, - generateApplyClipRect : generateApplyClipRect, - generateIntervalKey : generateIntervalKey, - generateLabels : generateLabels, - generateLayers : generateLayers, - generatePlotConfigs : generatePlotConfigs, - generateScales : generateScales, - generateSeriesList : generateSeriesList, - generateTickMap : generateTickMap, - generateNumberFormats : generateNumberFormats, - getAxisIndex : getAxisIndex, - getMeasureAlias : getMeasureAlias, - getMeasuresLabelBySide : getMeasuresLabelBySide, - getDistinctYAxisSides : getDistinctYAxisSides, - getStudyTimepointType : getStudyTimepointType, - getStudySubjectInfo : getStudySubjectInfo, - getStudyMeasures : getStudyMeasures, - getChartData : getChartData, - validateChartConfig : validateChartConfig, - validateChartData : validateChartData, - convertSavedReportConfig : convertSavedReportConfig, - renderChartSVG: renderChartSVG - }; -}; +/* + * Copyright (c) 2013-2019 LabKey Corporation + * + * Licensed under the Apache License, Version 2.0: http://www.apache.org/licenses/LICENSE-2.0 + */ +if(!LABKEY.vis) { + LABKEY.vis = {}; +} + +/** + * @namespace Namespace used to encapsulate functions related to creating study Time Charts. + * Used in the Chart Wizard and when exporting Time Charts as scripts. + */ +LABKEY.vis.TimeChartHelper = new function() { + + var $ = jQuery; + var defaultVisitProperty = 'displayOrder'; + + /** + * Generate the main title and axis labels for the chart based on the specified x-axis and y-axis (left and right) labels. + * @param {String} mainTitle The label to be used as the main chart title. + * @param {String} subtitle The label to be used as the chart subtitle. + * @param {Array} axisArr An array of axis information including the x-axis and y-axis (left and right) labels. + * @returns {Object} + */ + var generateLabels = function(mainTitle, axisArr, subtitle) { + var xTitle = '', yLeftTitle = '', yRightTitle = ''; + for (var i = 0; i < axisArr.length; i++) + { + var axis = axisArr[i]; + if (axis.name == "y-axis") + { + if (axis.side == "left") + yLeftTitle = axis.label; + else + yRightTitle = axis.label; + } + else + { + xTitle = axis.label; + } + } + + return { + main : { value : mainTitle }, + subtitle : { value : subtitle, color: '#404040' }, + x : { value : xTitle }, + yLeft : { value : yLeftTitle }, + yRight : { value : yRightTitle } + }; + }; + + /** + * Generates an object containing {@link LABKEY.vis.Scale} objects used for the chart. + * @param {Object} config The chart configuration object that defines the selected measures, axis info, subjects/groups, etc. + * @param {Object} tickMap For visit based charts, the x-axis tick mark mapping, from generateTickMap. + * @param {Object} numberFormats The number format functions to use for the x-axis and y-axis (left and right) tick marks. + * @returns {Object} + */ + var generateScales = function(config, tickMap, numberFormats) { + if (config.measures.length == 0) + throw "There must be at least one specified measure in the chartInfo config!"; + + var xMin = null, xMax = null, xTrans = null, xTickFormat, xTickHoverText, + yLeftMin = null, yLeftMax = null, yLeftTrans = null, yLeftTickFormat, + yRightMin = null, yRightMax = null, yRightTrans = null, yRightTickFormat, + valExponentialDigits = 6; + + for (var i = 0; i < config.axis.length; i++) + { + var axis = config.axis[i]; + if (axis.name == "y-axis") + { + if (axis.side == "left") + { + yLeftMin = typeof axis.range.min == "number" ? axis.range.min : (config.hasNoData ? 0 : null); + yLeftMax = typeof axis.range.max == "number" ? axis.range.max : (config.hasNoData ? 10 : null); + yLeftTrans = axis.scale ? axis.scale : "linear"; + yLeftTickFormat = function(value) { + if (LABKEY.Utils.isNumber(value) && Math.abs(Math.round(value)).toString().length >= valExponentialDigits) { + return value.toExponential(); + } + else if (LABKEY.Utils.isFunction(numberFormats.left)) { + return numberFormats.left(value); + } + return value; + } + } + else + { + yRightMin = typeof axis.range.min == "number" ? axis.range.min : (config.hasNoData ? 0 : null); + yRightMax = typeof axis.range.max == "number" ? axis.range.max : (config.hasNoData ? 10 : null); + yRightTrans = axis.scale ? axis.scale : "linear"; + yRightTickFormat = function(value) { + if (LABKEY.Utils.isNumber(value) && Math.abs(Math.round(value)).toString().length >= valExponentialDigits) { + return value.toExponential(); + } + else if (LABKEY.Utils.isFunction(numberFormats.right)) { + return numberFormats.right(value); + } + return value; + } + } + } + else + { + xMin = typeof axis.range.min == "number" ? axis.range.min : null; + xMax = typeof axis.range.max == "number" ? axis.range.max : null; + xTrans = axis.scale ? axis.scale : "linear"; + } + } + + if (config.measures[0].time == "visit" && (config.measures[0].visitOptions === undefined || config.measures[0].visitOptions.visitDisplayProperty === defaultVisitProperty)) + { + xTickFormat = function(value) { + return tickMap[value] ? tickMap[value].label : ""; + }; + + xTickHoverText = function(value) { + return tickMap[value] ? tickMap[value].description : ""; + }; + } + // Issue 27309: Don't show decimal values on x-axis for date-based time charts with interval = "Days" + else if (config.measures[0].time == 'date' && config.measures[0].dateOptions.interval == 'Days') + { + xTickFormat = function(value) { + return LABKEY.Utils.isNumber(value) && value % 1 != 0 ? null : value; + }; + } + + return { + x: { + scaleType : 'continuous', + trans : xTrans, + domain : [xMin, xMax], + tickFormat : xTickFormat ? xTickFormat : null, + tickHoverText : xTickHoverText ? xTickHoverText : null + }, + yLeft: { + scaleType : 'continuous', + trans : yLeftTrans, + domain : [yLeftMin, yLeftMax], + tickFormat : yLeftTickFormat ? yLeftTickFormat : null + }, + yRight: { + scaleType : 'continuous', + trans : yRightTrans, + domain : [yRightMin, yRightMax], + tickFormat : yRightTickFormat ? yRightTickFormat : null + }, + shape: { + scaleType : 'discrete' + } + }; + }; + + /** + * Generate the x-axis interval column alias key. For date based charts, this will be a time interval (i.e. Days, Weeks, etc.) + * and for visit based charts, this will be the column alias for the visit field. + * @param {Object} config The chart configuration object that defines the selected measures, axis info, subjects/groups, etc. + * @param {Array} individualColumnAliases The array of column aliases for the individual subject data. + * @param {Array} aggregateColumnAliases The array of column aliases for the group/cohort aggregate data. + * @param {String} nounSingular The singular name of the study subject noun (i.e. Participant). + * @returns {String} + */ + var generateIntervalKey = function(config, individualColumnAliases, aggregateColumnAliases, nounSingular) + { + nounSingular = nounSingular || getStudySubjectInfo().nounSingular; + + if (config.measures.length == 0) + throw "There must be at least one specified measure in the chartInfo config!"; + if (!individualColumnAliases && !aggregateColumnAliases) + throw "We expect to either be displaying individual series lines or aggregate data!"; + + if (config.measures[0].time == "date") + { + return config.measures[0].dateOptions.interval; + } + else + { + return individualColumnAliases ? + LABKEY.vis.getColumnAlias(individualColumnAliases, nounSingular + "Visit/Visit") : + LABKEY.vis.getColumnAlias(aggregateColumnAliases, nounSingular + "Visit/Visit"); + } + }; + + /** + * Generate that x-axis tick mark mapping for a visit based chart. + * @param {Object} visitMap For visit based charts, the study visit information map. + * @returns {Object} + */ + var generateTickMap = function(visitMap) { + var tickMap = {}; + for (var rowId in visitMap) + { + if (visitMap.hasOwnProperty(rowId)) + { + tickMap[visitMap[rowId][defaultVisitProperty]] = { + label: visitMap[rowId].displayName, + description: visitMap[rowId].description || visitMap[rowId].displayName + }; + } + } + + return tickMap; + }; + + /** + * Generates the aesthetic map object needed by the visualization API to render the chart. See {@link LABKEY.vis.Plot} + * and {@link LABKEY.vis.Layer}. + * @param {Object} config The chart configuration object that defines the selected measures, axis info, subjects/groups, etc. + * @param {Object} visitMap For visit based charts, the study visit information map. + * @param {Array} individualColumnAliases The array of column aliases for the individual subject data. + * @param {String} intervalKey The x-axis interval column alias key (i.e. Days, Weeks, etc.), from generateIntervalKey. + * @param {String} nounColumnName The name of the study subject noun column (i.e. ParticipantId). + * @returns {Object} + */ + var generateAes = function(config, visitMap, individualColumnAliases, intervalKey, nounColumnName) + { + nounColumnName = nounColumnName || getStudySubjectInfo().columnName; + + if (config.measures.length == 0) + throw "There must be at least one specified measure in the chartInfo config!"; + + var xAes; + if (config.measures[0].time == "date") { + xAes = function(row) { + return _getRowValue(row, intervalKey); + }; + } + else { + xAes = function(row) { + var displayProp = config.measures[0].visitOptions ? config.measures[0].visitOptions.visitDisplayProperty : defaultVisitProperty; + return visitMap[_getRowValue(row, intervalKey, 'value')][displayProp]; + }; + } + + var individualSubjectColumn = individualColumnAliases ? LABKEY.vis.getColumnAlias(individualColumnAliases, nounColumnName) : null; + + return { + x: xAes, + color: function(row) { + return _getRowValue(row, individualSubjectColumn); + }, + group: function(row) { + return _getRowValue(row, individualSubjectColumn); + }, + shape: function(row) { + return _getRowValue(row, individualSubjectColumn); + }, + pathColor: function(rows) { + return LABKEY.Utils.isArray(rows) && rows.length > 0 ? _getRowValue(rows[0], individualSubjectColumn) : null; + } + }; + }; + + /** + * Generate an array of {@link LABKEY.vis.Layer} objects based on the selected chart series list. + * @param {Object} config The chart configuration object that defines the selected measures, axis info, subjects/groups, etc. + * @param {Object} visitMap For visit based charts, the study visit information map. + * @param {Array} individualColumnAliases The array of column aliases for the individual subject data. + * @param {Array} aggregateColumnAliases The array of column aliases for the group/cohort aggregate data. + * @param {Array} aggregateData The array of group/cohort aggregate data, from getChartData. + * @param {Array} seriesList The list of series that will be plotted for a given chart, from generateSeriesList. + * @param {String} intervalKey The x-axis interval column alias key (i.e. Days, Weeks, etc.), from generateIntervalKey. + * @param {String} nounColumnName The name of the study subject noun column (i.e. ParticipantId). + * @returns {Array} + */ + var generateLayers = function(config, visitMap, individualColumnAliases, aggregateColumnAliases, aggregateData, seriesList, intervalKey, nounColumnName) + { + nounColumnName = nounColumnName || getStudySubjectInfo().columnName; + + if (config.measures.length == 0) + throw "There must be at least one specified measure in the chartInfo config!"; + if (!individualColumnAliases && !aggregateColumnAliases) + throw "We expect to either be displaying individual series lines or aggregate data!"; + + var layers = []; + var isDateBased = config.measures[0].time == "date"; + var individualSubjectColumn = individualColumnAliases ? LABKEY.vis.getColumnAlias(individualColumnAliases, nounColumnName) : null; + var aggregateSubjectColumn = "UniqueId"; + + var generateLayerAes = function(name, yAxisSide, columnName){ + var yName = yAxisSide == "left" ? "yLeft" : "yRight"; + var aes = {}; + aes[yName] = function(row) { + // Have to parseFloat because for some reason ObsCon from Luminex was returning strings not floats/ints. + return row[columnName] ? parseFloat(_getRowValue(row, columnName)) : null; + }; + return aes; + }; + + var generateAggregateLayerAes = function(name, yAxisSide, columnName, intervalKey, subjectColumn, errorColumn){ + var yName = yAxisSide == "left" ? "yLeft" : "yRight"; + var aes = {}; + aes[yName] = function(row) { + // Have to parseFloat because for some reason ObsCon from Luminex was returning strings not floats/ints. + return row[columnName] ? parseFloat(_getRowValue(row, columnName)) : null; + }; + aes.group = aes.color = aes.shape = function(row) { + return _getRowValue(row, subjectColumn); + }; + aes.pathColor = function(rows) { + return LABKEY.Utils.isArray(rows) && rows.length > 0 ? _getRowValue(rows[0], subjectColumn) : null; + }; + aes.error = function(row) { + return row[errorColumn] ? _getRowValue(row, errorColumn) : null; + }; + return aes; + }; + + var hoverTextFn = function(subjectColumn, intervalKey, name, columnName, visitMap, errorColumn, errorType){ + if (visitMap) + { + if (errorColumn) + { + return function(row){ + var subject = _getRowValue(row, subjectColumn); + var errorVal = _getRowValue(row, errorColumn) || 'n/a'; + return ' ' + subject + ',\n '+ visitMap[_getRowValue(row, intervalKey, 'value')].displayName + + ',\n ' + name + ': ' + _getRowValue(row, columnName) + + ',\n ' + errorType + ': ' + errorVal; + } + } + else + { + return function(row){ + var subject = _getRowValue(row, subjectColumn); + return ' ' + subject + ',\n '+ visitMap[_getRowValue(row, intervalKey, 'value')].displayName + + ',\n ' + name + ': ' + _getRowValue(row, columnName); + }; + } + } + else + { + if (errorColumn) + { + return function(row){ + var subject = _getRowValue(row, subjectColumn); + var errorVal = _getRowValue(row, errorColumn) || 'n/a'; + return ' ' + subject + ',\n ' + intervalKey + ': ' + _getRowValue(row, intervalKey) + + ',\n ' + name + ': ' + _getRowValue(row, columnName) + + ',\n ' + errorType + ': ' + errorVal; + }; + } + else + { + return function(row){ + var subject = _getRowValue(row, subjectColumn); + return ' ' + subject + ',\n ' + intervalKey + ': ' + _getRowValue(row, intervalKey) + + ',\n ' + name + ': ' + _getRowValue(row, columnName); + }; + } + } + }; + + // Issue 15369: if two measures have the same name, use the alias for the subsequent series names (which will be unique) + // Issue 12369: if rendering two measures of the same pivoted value, use measure and pivot name for series names (which will be unique) + var useUniqueSeriesNames = false; + var uniqueChartSeriesNames = []; + for (var i = 0; i < seriesList.length; i++) + { + if (uniqueChartSeriesNames.indexOf(seriesList[i].name) > -1) + { + useUniqueSeriesNames = true; + break; + } + uniqueChartSeriesNames.push(seriesList[i].name); + } + + for (var i = seriesList.length -1; i >= 0; i--) + { + var chartSeries = seriesList[i]; + + var chartSeriesName = chartSeries.label; + if (useUniqueSeriesNames) + { + if (chartSeries.aliasLookupInfo.pivotValue) + chartSeriesName = chartSeries.aliasLookupInfo.measureName + " " + chartSeries.aliasLookupInfo.pivotValue; + else + chartSeriesName = chartSeries.aliasLookupInfo.alias; + } + + var columnName = individualColumnAliases ? LABKEY.vis.getColumnAlias(individualColumnAliases, chartSeries.aliasLookupInfo) : LABKEY.vis.getColumnAlias(aggregateColumnAliases, chartSeries.aliasLookupInfo); + if (individualColumnAliases) + { + if (!config.hideTrendLine) { + var pathLayerConfig = { + geom: new LABKEY.vis.Geom.Path({size: config.lineWidth}), + aes: generateLayerAes(chartSeriesName, chartSeries.yAxisSide, columnName) + }; + + if (seriesList.length > 1) + pathLayerConfig.name = chartSeriesName; + + layers.push(new LABKEY.vis.Layer(pathLayerConfig)); + } + + if (!config.hideDataPoints) + { + var pointLayerConfig = { + geom: new LABKEY.vis.Geom.Point(), + aes: generateLayerAes(chartSeriesName, chartSeries.yAxisSide, columnName) + }; + + if (seriesList.length > 1) + pointLayerConfig.name = chartSeriesName; + + if (isDateBased) + pointLayerConfig.aes.hoverText = hoverTextFn(individualSubjectColumn, intervalKey, chartSeriesName, columnName, null, null, null); + else + pointLayerConfig.aes.hoverText = hoverTextFn(individualSubjectColumn, intervalKey, chartSeriesName, columnName, visitMap, null, null); + + if (config.pointClickFn) + { + pointLayerConfig.aes.pointClickFn = generatePointClickFn( + config.pointClickFn, + {participant: individualSubjectColumn, interval: intervalKey, measure: columnName}, + {schemaName: chartSeries.schemaName, queryName: chartSeries.queryName, name: chartSeriesName} + ); + } + + layers.push(new LABKEY.vis.Layer(pointLayerConfig)); + } + } + + if (aggregateData && aggregateColumnAliases) + { + var errorBarType = null; + if (config.errorBars == 'SD') + errorBarType = '_STDDEV'; + else if (config.errorBars == 'SEM') + errorBarType = '_STDERR'; + + var errorColumnName = errorBarType ? LABKEY.vis.getColumnAlias(aggregateColumnAliases, chartSeries.aliasLookupInfo) + errorBarType : null; + + if (!config.hideTrendLine) { + var aggregatePathLayerConfig = { + data: aggregateData, + geom: new LABKEY.vis.Geom.Path({size: config.lineWidth}), + aes: generateAggregateLayerAes(chartSeriesName, chartSeries.yAxisSide, columnName, intervalKey, aggregateSubjectColumn, errorColumnName) + }; + + if (seriesList.length > 1) + aggregatePathLayerConfig.name = chartSeriesName; + + layers.push(new LABKEY.vis.Layer(aggregatePathLayerConfig)); + } + + if (errorColumnName) + { + var aggregateErrorLayerConfig = { + data: aggregateData, + geom: new LABKEY.vis.Geom.ErrorBar({ showVertical: true }), + aes: generateAggregateLayerAes(chartSeriesName, chartSeries.yAxisSide, columnName, intervalKey, aggregateSubjectColumn, errorColumnName) + }; + + if (seriesList.length > 1) + aggregateErrorLayerConfig.name = chartSeriesName; + + layers.push(new LABKEY.vis.Layer(aggregateErrorLayerConfig)); + } + + if (!config.hideDataPoints) + { + var aggregatePointLayerConfig = { + data: aggregateData, + geom: new LABKEY.vis.Geom.Point(), + aes: generateAggregateLayerAes(chartSeriesName, chartSeries.yAxisSide, columnName, intervalKey, aggregateSubjectColumn, errorColumnName) + }; + + if (seriesList.length > 1) + aggregatePointLayerConfig.name = chartSeriesName; + + if (isDateBased) + aggregatePointLayerConfig.aes.hoverText = hoverTextFn(aggregateSubjectColumn, intervalKey, chartSeriesName, columnName, null, errorColumnName, config.errorBars) + else + aggregatePointLayerConfig.aes.hoverText = hoverTextFn(aggregateSubjectColumn, intervalKey, chartSeriesName, columnName, visitMap, errorColumnName, config.errorBars); + + if (config.pointClickFn) + { + aggregatePointLayerConfig.aes.pointClickFn = generatePointClickFn( + config.pointClickFn, + {group: aggregateSubjectColumn, interval: intervalKey, measure: columnName}, + {schemaName: chartSeries.schemaName, queryName: chartSeries.queryName, name: chartSeriesName} + ); + } + + layers.push(new LABKEY.vis.Layer(aggregatePointLayerConfig)); + } + } + } + + return layers; + }; + + // private function + var generatePointClickFn = function(fnString, columnMap, measureInfo){ + // the developer is expected to return a function, so we encapalate it within the anonymous function + // (note: the function should have already be validated in a try/catch when applied via the developerOptionsPanel) + + // using new Function is quicker than eval(), even in IE. + var pointClickFn = new Function('return ' + fnString)(); + return function(clickEvent, data) { + pointClickFn(data, columnMap, measureInfo, clickEvent); + }; + }; + + /** + * Generate the list of series to be plotted in a given Time Chart. A series will be created for each measure and + * dimension that is selected in the chart. + * @param {Array} measures The array of selected measures from the chart config. + * @returns {Array} + */ + var generateSeriesList = function(measures) { + var arr = []; + for (var i = 0; i < measures.length; i++) + { + var md = measures[i]; + + if (md.dimension && md.dimension.values) + { + Ext4.each(md.dimension.values, function(val) { + arr.push({ + schemaName: md.dimension.schemaName, + queryName: md.dimension.queryName, + name: val, + label: val, + measureIndex: i, + yAxisSide: md.measure.yAxis, + aliasLookupInfo: {measureName: md.measure.name, pivotValue: val} + }); + }); + } + else + { + arr.push({ + schemaName: md.measure.schemaName, + queryName: md.measure.queryName, + name: md.measure.name, + label: md.measure.label, + measureIndex: i, + yAxisSide: md.measure.yAxis, + aliasLookupInfo: md.measure.alias ? {alias: md.measure.alias} : {measureName: md.measure.name} + }); + } + } + return arr; + }; + + // private function + var generateDataSortArray = function(subject, firstMeasure, isDateBased, nounSingular) + { + nounSingular = nounSingular || getStudySubjectInfo().nounSingular; + var hasDateCol = firstMeasure.dateOptions && firstMeasure.dateOptions.dateCol; + + return [ + subject, + { + schemaName : hasDateCol ? firstMeasure.dateOptions.dateCol.schemaName : firstMeasure.measure.schemaName, + queryName : hasDateCol ? firstMeasure.dateOptions.dateCol.queryName : firstMeasure.measure.queryName, + name : isDateBased && hasDateCol ? firstMeasure.dateOptions.dateCol.name : getSubjectVisitColName(nounSingular, 'DisplayOrder') + }, + { + schemaName : hasDateCol ? firstMeasure.dateOptions.dateCol.schemaName : firstMeasure.measure.schemaName, + queryName : hasDateCol ? firstMeasure.dateOptions.dateCol.queryName : firstMeasure.measure.queryName, + name : (isDateBased ? nounSingular + "Visit/Visit" : getSubjectVisitColName(nounSingular, 'SequenceNumMin')) + } + ]; + }; + + var getSubjectVisitColName = function(nounSingular, suffix) + { + var nounSingular = nounSingular || getStudySubjectInfo().nounSingular; + return nounSingular + 'Visit/Visit/' + suffix; + }; + + /** + * Determine whether or not the chart needs to clip the plotted lines and points based on manually set axis ranges. + * @param {Object} config The chart configuration object that defines the selected measures, axis info, subjects/groups, etc. + * @returns {boolean} + */ + var generateApplyClipRect = function(config) { + var xAxisIndex = getAxisIndex(config.axis, "x-axis"); + var leftAxisIndex = getAxisIndex(config.axis, "y-axis", "left"); + var rightAxisIndex = getAxisIndex(config.axis, "y-axis", "right"); + + return ( + xAxisIndex > -1 && (config.axis[xAxisIndex].range.min != null || config.axis[xAxisIndex].range.max != null) || + leftAxisIndex > -1 && (config.axis[leftAxisIndex].range.min != null || config.axis[leftAxisIndex].range.max != null) || + rightAxisIndex > -1 && (config.axis[rightAxisIndex].range.min != null || config.axis[rightAxisIndex].range.max != null) + ); + }; + + /** + * Generates axis range min and max values based on the full Time Chart data. This will be used when plotting multiple + * charts that are set to use the same axis ranges across all charts in the report. + * @param {Object} config The chart configuration object that defines the selected measures, axis info, subjects/groups, etc. + * @param {Object} data The data object, from getChartData. + * @param {Array} seriesList The list of series that will be plotted for a given chart, from generateSeriesList. + * @param {String} nounSingular The singular name of the study subject noun (i.e. Participant). + */ + var generateAcrossChartAxisRanges = function(config, data, seriesList, nounSingular) + { + nounSingular = nounSingular || getStudySubjectInfo().nounSingular; + + if (config.measures.length == 0) + throw "There must be at least one specified measure in the chartInfo config!"; + if (!data.individual && !data.aggregate) + throw "We expect to either be displaying individual series lines or aggregate data!"; + + var rows = []; + if (LABKEY.Utils.isDefined(data.individual)) { + rows = data.individual.measureStore.records(); + } + else if (LABKEY.Utils.isDefined(data.aggregate)) { + rows = data.aggregate.measureStore.records(); + } + + config.hasNoData = rows.length == 0; + + // In multi-chart case, we need to pre-compute the default axis ranges so that all charts share them + // (if 'automatic across charts' is selected for the given axis) + if (config.chartLayout != "single") + { + var leftMeasures = [], + rightMeasures = [], + xName, xFunc, + min, max, tempMin, tempMax, errorBarType, + leftAccessor, leftAccessorMax, leftAccessorMin, rightAccessorMax, rightAccessorMin, rightAccessor, + columnAliases = data.individual ? data.individual.columnAliases : (data.aggregate ? data.aggregate.columnAliases : null), + isDateBased = config.measures[0].time == "date", + xAxisIndex = getAxisIndex(config.axis, "x-axis"), + leftAxisIndex = getAxisIndex(config.axis, "y-axis", "left"), + rightAxisIndex = getAxisIndex(config.axis, "y-axis", "right"); + + for (var i = 0; i < seriesList.length; i++) + { + var columnName = LABKEY.vis.getColumnAlias(columnAliases, seriesList[i].aliasLookupInfo); + if (seriesList[i].yAxisSide == "left") + leftMeasures.push(columnName); + else if (seriesList[i].yAxisSide == "right") + rightMeasures.push(columnName); + } + + if (isDateBased) + { + xName = config.measures[0].dateOptions.interval; + xFunc = function(row){ + return _getRowValue(row, xName); + }; + } + else + { + var visitMap = data.individual ? data.individual.visitMap : data.aggregate.visitMap; + xName = LABKEY.vis.getColumnAlias(columnAliases, nounSingular + "Visit/Visit"); + xFunc = function(row){ + var displayProp = config.measures[0].visitOptions ? config.measures[0].visitOptions.visitDisplayProperty : defaultVisitProperty; + return visitMap[_getRowValue(row, xName, 'value')][displayProp]; + }; + } + + if (config.axis[xAxisIndex].range.type != 'automatic_per_chart') + { + if (config.axis[xAxisIndex].range.min == null) + config.axis[xAxisIndex].range.min = d3.min(rows, xFunc); + + if (config.axis[xAxisIndex].range.max == null) + config.axis[xAxisIndex].range.max = d3.max(rows, xFunc); + } + + if (config.errorBars !== 'None') + errorBarType = config.errorBars == 'SD' ? '_STDDEV' : '_STDERR'; + + if (leftAxisIndex > -1) + { + // If we have a left axis then we need to find the min/max + min = null; max = null; tempMin = null; tempMax = null; + leftAccessor = function(row) { + return _getRowValue(row, leftMeasures[i]); + }; + + if (errorBarType) + { + // If we have error bars we need to calculate min/max with the error values in mind. + leftAccessorMin = function(row) { + if (row.hasOwnProperty(leftMeasures[i] + errorBarType)) + { + var error = _getRowValue(row, leftMeasures[i] + errorBarType); + return _getRowValue(row, leftMeasures[i]) - error; + } + else + return null; + }; + + leftAccessorMax = function(row) { + if (row.hasOwnProperty(leftMeasures[i] + errorBarType)) + { + var error = _getRowValue(row, leftMeasures[i] + errorBarType); + return _getRowValue(row, leftMeasures[i]) + error; + } + else + return null; + }; + } + + if (config.axis[leftAxisIndex].range.type != 'automatic_per_chart') + { + if (config.axis[leftAxisIndex].range.min == null) + { + for (var i = 0; i < leftMeasures.length; i++) + { + tempMin = d3.min(rows, leftAccessorMin ? leftAccessorMin : leftAccessor); + min = min == null ? tempMin : tempMin < min ? tempMin : min; + } + config.axis[leftAxisIndex].range.min = min; + } + + if (config.axis[leftAxisIndex].range.max == null) + { + for (var i = 0; i < leftMeasures.length; i++) + { + tempMax = d3.max(rows, leftAccessorMax ? leftAccessorMax : leftAccessor); + max = max == null ? tempMax : tempMax > max ? tempMax : max; + } + config.axis[leftAxisIndex].range.max = max; + } + } + } + + if (rightAxisIndex > -1) + { + // If we have a right axis then we need to find the min/max + min = null; max = null; tempMin = null; tempMax = null; + rightAccessor = function(row){ + return _getRowValue(row, rightMeasures[i]); + }; + + if (errorBarType) + { + rightAccessorMin = function(row) { + if (row.hasOwnProperty(rightMeasures[i] + errorBarType)) + { + var error = _getRowValue(row, rightMeasures[i] + errorBarType); + return _getRowValue(row, rightMeasures[i]) - error; + } + else + return null; + }; + + rightAccessorMax = function(row) { + if (row.hasOwnProperty(rightMeasures[i] + errorBarType)) + { + var error = _getRowValue(row, rightMeasures[i] + errorBarType); + return _getRowValue(row, rightMeasures[i]) + error; + } + else + return null; + }; + } + + if (config.axis[rightAxisIndex].range.type != 'automatic_per_chart') + { + if (config.axis[rightAxisIndex].range.min == null) + { + for (var i = 0; i < rightMeasures.length; i++) + { + tempMin = d3.min(rows, rightAccessorMin ? rightAccessorMin : rightAccessor); + min = min == null ? tempMin : tempMin < min ? tempMin : min; + } + config.axis[rightAxisIndex].range.min = min; + } + + if (config.axis[rightAxisIndex].range.max == null) + { + for (var i = 0; i < rightMeasures.length; i++) + { + tempMax = d3.max(rows, rightAccessorMax ? rightAccessorMax : rightAccessor); + max = max == null ? tempMax : tempMax > max ? tempMax : max; + } + config.axis[rightAxisIndex].range.max = max; + } + } + } + } + }; + + /** + * Generates plot configs to be passed to the {@link LABKEY.vis.Plot} function for each chart in the report. + * @param {Object} config The chart configuration object that defines the selected measures, axis info, subjects/groups, etc. + * @param {Object} data The data object, from getChartData. + * @param {Array} seriesList The list of series that will be plotted for a given chart, from generateSeriesList. + * @param {boolean} applyClipRect A boolean indicating whether or not to clip the plotted data region, from generateApplyClipRect. + * @param {int} maxCharts The maximum number of charts to display in one report. + * @param {String} nounColumnName The name of the study subject noun column (i.e. ParticipantId). + * @returns {Array} + */ + var generatePlotConfigs = function(config, data, seriesList, applyClipRect, maxCharts, nounColumnName) { + var plotConfigInfoArr = [], + subjectColumnName = null; + + nounColumnName = nounColumnName || getStudySubjectInfo().columnName; + if (data.individual) + subjectColumnName = LABKEY.vis.getColumnAlias(data.individual.columnAliases, nounColumnName); + + var generateGroupSeries = function(rows, groups, subjectColumn) { + // subjectColumn is the aliasColumnName looked up from the getData response columnAliases array + // groups is config.subject.groups + var dataByGroup = {}; + + for (var i = 0; i < rows.length; i++) + { + var rowSubject = _getRowValue(rows[i], subjectColumn); + for (var j = 0; j < groups.length; j++) + { + if (groups[j].participantIds.indexOf(rowSubject) > -1) + { + if (!dataByGroup[groups[j].label]) + dataByGroup[groups[j].label] = []; + + dataByGroup[groups[j].label].push(rows[i]); + } + } + } + + return dataByGroup; + }; + + // four options: all series on one chart, one chart per subject, one chart per group, or one chart per measure/dimension + if (config.chartLayout == "per_subject") + { + var groupAccessor = function(row) { + return _getRowValue(row, subjectColumnName); + }; + + var dataPerParticipant = getDataWithSeriesCheck(data.individual.measureStore.records(), groupAccessor, seriesList, data.individual.columnAliases); + for (var participant in dataPerParticipant) + { + if (dataPerParticipant.hasOwnProperty(participant)) + { + // skip the group if there is no data for it + if (!dataPerParticipant[participant].hasSeriesData) + continue; + + plotConfigInfoArr.push({ + title: config.title ? config.title : participant, + subtitle: config.title ? participant : undefined, + series: seriesList, + individualData: dataPerParticipant[participant].data, + applyClipRect: applyClipRect + }); + + if (plotConfigInfoArr.length >= maxCharts) + break; + } + } + } + else if (config.chartLayout == "per_group") + { + var groupedIndividualData = null, groupedAggregateData = null; + + //Display individual lines + if (data.individual) { + groupedIndividualData = generateGroupSeries(data.individual.measureStore.records(), config.subject.groups, subjectColumnName); + } + + // Display aggregate lines + if (data.aggregate) { + var groupAccessor = function(row) { + return _getRowValue(row, 'UniqueId'); + }; + + groupedAggregateData = getDataWithSeriesCheck(data.aggregate.measureStore.records(), groupAccessor, seriesList, data.aggregate.columnAliases); + } + + for (var i = 0; i < (config.subject.groups.length > maxCharts ? maxCharts : config.subject.groups.length); i++) + { + var group = config.subject.groups[i]; + + // skip the group if there is no data for it + if ((groupedIndividualData != null && !groupedIndividualData[group.label]) + || (groupedAggregateData != null && (!groupedAggregateData[group.label] || !groupedAggregateData[group.label].hasSeriesData))) + { + continue; + } + + plotConfigInfoArr.push({ + title: config.title ? config.title : group.label, + subtitle: config.title ? group.label : undefined, + series: seriesList, + individualData: groupedIndividualData && groupedIndividualData[group.label] ? groupedIndividualData[group.label] : null, + aggregateData: groupedAggregateData && groupedAggregateData[group.label] ? groupedAggregateData[group.label].data : null, + applyClipRect: applyClipRect + }); + + if (plotConfigInfoArr.length > maxCharts) + break; + } + } + else if (config.chartLayout == "per_dimension") + { + for (var i = 0; i < (seriesList.length > maxCharts ? maxCharts : seriesList.length); i++) + { + // skip the measure/dimension if there is no data for it + if ((data.aggregate && !data.aggregate.hasData[seriesList[i].name]) + || (data.individual && !data.individual.hasData[seriesList[i].name])) + { + continue; + } + + plotConfigInfoArr.push({ + title: config.title ? config.title : seriesList[i].label, + subtitle: config.title ? seriesList[i].label : undefined, + series: [seriesList[i]], + individualData: data.individual ? data.individual.measureStore.records() : null, + aggregateData: data.aggregate ? data.aggregate.measureStore.records() : null, + applyClipRect: applyClipRect + }); + + if (plotConfigInfoArr.length > maxCharts) + break; + } + } + else if (config.chartLayout == "single") + { + //Single Line Chart, with all participants or groups. + plotConfigInfoArr.push({ + title: config.title, + series: seriesList, + individualData: data.individual ? data.individual.measureStore.records() : null, + aggregateData: data.aggregate ? data.aggregate.measureStore.records() : null, + height: 610, + style: null, + applyClipRect: applyClipRect + }); + } + + return plotConfigInfoArr; + }; + + // private function + var getDataWithSeriesCheck = function(data, groupAccessor, seriesList, columnAliases) { + /* + Groups data by the groupAccessor passed in. Also, checks for the existance of any series data for that groupAccessor. + Returns an object where each attribute will be a groupAccessor with an array of data rows and a boolean for hasSeriesData + */ + var groupedData = {}; + for (var i = 0; i < data.length; i++) + { + var value = groupAccessor(data[i]); + if (!groupedData[value]) + { + groupedData[value] = {data: [], hasSeriesData: false}; + } + groupedData[value].data.push(data[i]); + + for (var j = 0; j < seriesList.length; j++) + { + var seriesAlias = LABKEY.vis.getColumnAlias(columnAliases, seriesList[j].aliasLookupInfo); + if (seriesAlias && _getRowValue(data[i], seriesAlias) != null) + { + groupedData[value].hasSeriesData = true; + break; + } + } + } + return groupedData; + }; + + /** + * Get the index in the axes array for a given axis (ie left y-axis). + * @param {Array} axes The array of specified axis information for this chart. + * @param {String} axisName The chart axis (i.e. x-axis or y-axis). + * @param {String} [side] The y-axis side (i.e. left or right). + * @returns {number} + */ + var getAxisIndex = function(axes, axisName, side) { + var index = -1; + for (var i = 0; i < axes.length; i++) + { + if (!side && axes[i].name == axisName) + { + index = i; + break; + } + else if (axes[i].name == axisName && axes[i].side == side) + { + index = i; + break; + } + } + return index; + }; + + /** + * Get the data needed for the specified Time Chart based on the chart config. Makes calls to the + * {@link LABKEY.Query.Visualization.getData} to get the individual subject data and grouped aggregate data. + * Calls the success callback function in the config when it has received all of the requested data. + * @param {Object} config The chart configuration object that defines the selected measures, axis info, subjects/groups, etc. + */ + var getChartData = function(config) { + if (!config.success) + throw "You must specify a success callback function!"; + if (!config.failure) + throw "You must specify a failure callback function!"; + if (!config.chartInfo) + throw "You must specify a chartInfo config!"; + if (config.chartInfo.measures.length == 0) + throw "There must be at least one specified measure in the chartInfo config!"; + if (!config.chartInfo.displayIndividual && !config.chartInfo.displayAggregate) + throw "We expect to either be displaying individual series lines or aggregate data!"; + + // issue 22254: perf issues if we try to show individual lines for a group with a large number of subjects + var subjectLength = config.chartInfo.subject.values ? config.chartInfo.subject.values.length : 0; + if (config.chartInfo.displayIndividual && subjectLength > 10000) + { + config.chartInfo.displayIndividual = false; + config.chartInfo.subject.values = undefined; + } + + var chartData = {numberFormats: {}}; + var counter = config.chartInfo.displayIndividual && config.chartInfo.displayAggregate ? 2 : 1; + var isDateBased = config.chartInfo.measures[0].time == "date"; + var seriesList = generateSeriesList(config.chartInfo.measures); + + // get the visit map info for those visits in the response data + var trimVisitMapDomain = function(origVisitMap, visitsInDataArr) { + var trimmedVisits = []; + for (var v in origVisitMap) { + if (origVisitMap.hasOwnProperty(v)) { + if (visitsInDataArr.indexOf(parseInt(v)) != -1) { + trimmedVisits.push(Ext4.apply({id: v}, origVisitMap[v])); + } + } + } + // sort the trimmed visit list by displayOrder and then reset displayOrder starting at 1 + trimmedVisits.sort(function(a,b){return a.displayOrder - b.displayOrder}); + var newVisitMap = {}; + for (var i = 0; i < trimmedVisits.length; i++) + { + trimmedVisits[i].displayOrder = i + 1; + newVisitMap[trimmedVisits[i].id] = trimmedVisits[i]; + } + + return newVisitMap; + }; + + var successCallback = function(response, dataType) { + // check for success=false + if (LABKEY.Utils.isDefined(response.success) && LABKEY.Utils.isBoolean(response.success) && !response.success) + { + config.failure.call(config.scope, response); + return; + } + + // Issue 16156: for date based charts, give error message if there are no calculated interval values + if (isDateBased) { + var intervalAlias = config.chartInfo.measures[0].dateOptions.interval; + var uniqueNonNullValues = Ext4.Array.clean(response.measureStore.members(intervalAlias)); + chartData.hasIntervalData = uniqueNonNullValues.length > 0; + } + else { + chartData.hasIntervalData = true; + } + + // make sure each measure/dimension has at least some data, and get a list of which visits are in the data response + // also keep track of which measure/dimensions have negative values (for log scale) + response.hasData = {}; + response.hasNegativeValues = {}; + Ext4.each(seriesList, function(s) { + var alias = LABKEY.vis.getColumnAlias(response.columnAliases, s.aliasLookupInfo); + var uniqueNonNullValues = Ext4.Array.clean(response.measureStore.members(alias)); + + response.hasData[s.name] = uniqueNonNullValues.length > 0; + response.hasNegativeValues[s.name] = Ext4.Array.min(uniqueNonNullValues) < 0; + }); + + // trim the visit map domain to just those visits in the response data + if (!isDateBased) { + var nounSingular = config.nounSingular || getStudySubjectInfo().nounSingular; + var visitMappedName = LABKEY.vis.getColumnAlias(response.columnAliases, nounSingular + "Visit/Visit"); + var visitsInData = response.measureStore.members(visitMappedName); + response.visitMap = trimVisitMapDomain(response.visitMap, visitsInData); + } + else { + response.visitMap = {}; + } + + chartData[dataType] = response; + + generateNumberFormats(config.chartInfo, chartData, config.defaultNumberFormat); + + // if we have all request data back, return the result + counter--; + if (counter == 0) + config.success.call(config.scope, chartData); + }; + + var getSelectRowsSort = function(response, dataType) + { + var nounSingular = config.nounSingular || getStudySubjectInfo().nounSingular, + sort = dataType == 'aggregate' ? 'GroupingOrder,UniqueId' : response.measureToColumn[config.chartInfo.subject.name]; + + if (isDateBased) + { + sort += ',' + config.chartInfo.measures[0].dateOptions.interval; + } + else + { + // Issue 28529: if we have a SubjectVisit/sequencenum column, use that instead of SubjectVisit/Visit/SequenceNumMin + var sequenceNumCol = response.measureToColumn[nounSingular + 'Visit/sequencenum']; + if (!LABKEY.Utils.isDefined(sequenceNumCol)) + sequenceNumCol = response.measureToColumn[getSubjectVisitColName(nounSingular, 'SequenceNumMin')]; + + sort += ',' + response.measureToColumn[getSubjectVisitColName(nounSingular, 'DisplayOrder')] + ',' + sequenceNumCol; + } + + return sort; + }; + + var queryTempResultsForRows = function(response, dataType) + { + // Issue 28529: re-query for the actual data off of the temp query results + LABKEY.Query.MeasureStore.selectRows({ + containerPath: config.containerPath, + schemaName: response.schemaName, + queryName: response.queryName, + requiredVersion : 13.2, + maxRows: -1, + sort: getSelectRowsSort(response, dataType), + success: function(measureStore) { + response.measureStore = measureStore; + successCallback(response, dataType); + } + }); + }; + + if (config.chartInfo.displayIndividual) + { + //Get data for individual lines. + LABKEY.Query.Visualization.getData({ + metaDataOnly: true, + containerPath: config.containerPath, + success: function(response) { + queryTempResultsForRows(response, "individual"); + }, + failure : function(info, response, options) { + config.failure.call(config.scope, info, Ext4.JSON.decode(response.responseText)); + }, + measures: config.chartInfo.measures, + sorts: generateDataSortArray(config.chartInfo.subject, config.chartInfo.measures[0], isDateBased, config.nounSingular), + limit : config.dataLimit || 10000, + parameters : config.chartInfo.parameters, + filterUrl: config.chartInfo.filterUrl, + filterQuery: config.chartInfo.filterQuery + }); + } + + if (config.chartInfo.displayAggregate) + { + //Get data for Aggregates lines. + var groups = []; + for (var i = 0; i < config.chartInfo.subject.groups.length; i++) + { + var group = config.chartInfo.subject.groups[i]; + // encode the group id & type, so we can distinguish between cohort and participant group in the union table + groups.push(group.id + '-' + group.type); + } + + LABKEY.Query.Visualization.getData({ + metaDataOnly: true, + containerPath: config.containerPath, + success: function(response) { + queryTempResultsForRows(response, "aggregate"); + }, + failure : function(info) { + config.failure.call(config.scope, info); + }, + measures: config.chartInfo.measures, + groupBys: [ + // Issue 18747: if grouping by cohorts and ptid groups, order it so the cohorts are first + {schemaName: 'study', queryName: 'ParticipantGroupCohortUnion', name: 'GroupingOrder', values: [0,1]}, + {schemaName: 'study', queryName: 'ParticipantGroupCohortUnion', name: 'UniqueId', values: groups} + ], + sorts: generateDataSortArray(config.chartInfo.subject, config.chartInfo.measures[0], isDateBased, config.nounSingular), + limit : config.dataLimit || 10000, + parameters : config.chartInfo.parameters, + filterUrl: config.chartInfo.filterUrl, + filterQuery: config.chartInfo.filterQuery + }); + } + }; + + /** + * Get the set of measures from the tables/queries in the study schema. + * @param successCallback + * @param callbackScope + */ + var getStudyMeasures = function(successCallback, callbackScope) + { + if (getStudyTimepointType() != null) + { + LABKEY.Query.Visualization.getMeasures({ + filters: ['study|~'], + dateMeasures: false, + success: function (measures, response) + { + var o = Ext4.JSON.decode(response.responseText); + successCallback.call(callbackScope, o.measures); + }, + failure: this.onFailure, + scope: this + }); + } + else + { + successCallback.call(callbackScope, []); + } + }; + + /** + * If this is a container with a configured study, get the timepoint type from the study module context. + * @returns {String|null} + */ + var getStudyTimepointType = function() + { + var studyCtx = LABKEY.getModuleContext("study") || {}; + return LABKEY.Utils.isDefined(studyCtx.timepointType) ? studyCtx.timepointType : null; + }; + + /** + * Generate the number format functions for the left and right y-axis and attach them to the chart data object + * @param {Object} config The chart configuration object that defines the selected measures, axis info, subjects/groups, etc. + * @param {Object} data The data object, from getChartData. + * @param {Object} defaultNumberFormat + */ + var generateNumberFormats = function(config, data, defaultNumberFormat) { + var fields = data.individual ? data.individual.metaData.fields : data.aggregate.metaData.fields; + + for (var i = 0; i < config.axis.length; i++) + { + var axis = config.axis[i]; + if (axis.side) + { + // Find the first measure with the matching side that has a numberFormat. + for (var j = 0; j < config.measures.length; j++) + { + var measure = config.measures[j].measure; + + if (data.numberFormats[axis.side]) + break; + + if (measure.yAxis == axis.side) + { + var metaDataName = measure.alias; + for (var k = 0; k < fields.length; k++) + { + var field = fields[k]; + if (field.name == metaDataName) + { + if (field.extFormatFn) + { + data.numberFormats[axis.side] = eval(field.extFormatFn); + break; + } + } + } + } + } + + if (!data.numberFormats[axis.side]) + { + // If after all the searching we still don't have a numberformat use the default number format. + data.numberFormats[axis.side] = defaultNumberFormat; + } + } + } + }; + + /** + * Verifies the information in the chart config to make sure it has proper measures, axis info, subjects/groups, etc. + * Returns an object with a success parameter (boolean) and a message parameter (string). If the success pararameter + * is false there is a critical error and the chart cannot be rendered. If success is true the chart can be rendered. + * Message will contain an error or warning message if applicable. If message is not null and success is true, there is a warning. + * @param {Object} config The chart configuration object that defines the selected measures, axis info, subjects/groups, etc. + * @returns {Object} + */ + var validateChartConfig = function(config) { + var message = ""; + + if (!config.measures || config.measures.length == 0) + { + message = "No measure selected. Please select at lease one measure."; + return {success: false, message: message}; + } + + if (!config.axis || getAxisIndex(config.axis, "x-axis") == -1) + { + message = "Could not find x-axis in chart measure information."; + return {success: false, message: message}; + } + + if (config.chartSubjectSelection == "subjects" && config.subject.values.length == 0) + { + var nounSingular = getStudySubjectInfo().nounSingular; + message = "No " + nounSingular.toLowerCase() + " selected. " + + "Please select at least one " + nounSingular.toLowerCase() + "."; + return {success: false, message: message}; + } + + if (config.chartSubjectSelection == "groups" && config.subject.groups.length < 1) + { + message = "No group selected. Please select at least one group."; + return {success: false, message: message}; + } + + if (generateSeriesList(config.measures).length == 0) + { + message = "No series or dimension selected. Please select at least one series/dimension value."; + return {success: false, message: message}; + } + + if (!(config.displayIndividual || config.displayAggregate)) + { + message = "Please select either \"Show Individual Lines\" or \"Show Mean\"."; + return {success: false, message: message}; + } + + // issue 22254: perf issues if we try to show individual lines for a group with a large number of subjects + var subjectLength = config.subject.values ? config.subject.values.length : 0; + if (config.displayIndividual && subjectLength > 10000) + { + var nounPlural = getStudySubjectInfo().nounPlural; + message = "Unable to display individual series lines for greater than 10,000 total " + nounPlural.toLowerCase() + "."; + return {success: false, message: message}; + } + + return {success: true, message: message}; + }; + + /** + * Verifies that the chart data contains the expected interval values and measure/dimension data. Also checks to make + * sure that data can be used in a log scale (if applicable). Returns an object with a success parameter (boolean) + * and a message parameter (string). If the success pararameter is false there is a critical error and the chart + * cannot be rendered. If success is true the chart can be rendered. Message will contain an error or warning + * message if applicable. If message is not null and success is true, there is a warning. + * @param {Object} data The data object, from getChartData. + * @param {Object} config The chart configuration object that defines the selected measures, axis info, subjects/groups, etc. + * @param {Array} seriesList The list of series that will be plotted for a given chart, from generateSeriesList. + * @param {int} limit The data limit for a single report. + * @returns {Object} + */ + var validateChartData = function(data, config, seriesList, limit) { + var message = "", + sep = "", + msg = "", + commaSep = "", + noDataCounter = 0; + + // warn the user if the data limit has been reached + var individualDataCount = LABKEY.Utils.isDefined(data.individual) ? data.individual.measureStore.records().length : null; + var aggregateDataCount = LABKEY.Utils.isDefined(data.aggregate) ? data.aggregate.measureStore.records().length : null; + if (individualDataCount >= limit || aggregateDataCount >= limit) { + message += sep + "The data limit for plotting has been reached. Consider filtering your data."; + sep = "
"; + } + + // for date based charts, give error message if there are no calculated interval values + if (!data.hasIntervalData) + { + message += sep + "No calculated interval values (i.e. Days, Months, etc.) for the selected 'Measure Date' and 'Interval Start Date'."; + sep = "
"; + } + + // check to see if any of the measures don't have data + Ext4.iterate(data.aggregate ? data.aggregate.hasData : data.individual.hasData, function(key, value) { + if (!value) + { + noDataCounter++; + msg += commaSep + key; + commaSep = ", "; + } + }, this); + if (msg.length > 0) + { + msg = "No data found for the following measures/dimensions: " + msg; + + // if there is no data for any series, add to explanation + if (noDataCounter == seriesList.length) + { + var isDateBased = config && config.measures[0].time == "date"; + if (isDateBased) + msg += ". This may be the result of a missing start date value for the selected subject(s)."; + } + + message += sep + msg; + sep = "
"; + } + + // check to make sure that data can be used in a log scale (if applicable) + if (config) + { + var leftAxisIndex = getAxisIndex(config.axis, "y-axis", "left"); + var rightAxisIndex = getAxisIndex(config.axis, "y-axis", "right"); + + Ext4.each(config.measures, function(md){ + var m = md.measure; + + // check the left y-axis + if (m.yAxis == "left" && leftAxisIndex > -1 && config.axis[leftAxisIndex].scale == "log" + && ((data.individual && data.individual.hasNegativeValues && data.individual.hasNegativeValues[m.name]) + || (data.aggregate && data.aggregate.hasNegativeValues && data.aggregate.hasNegativeValues[m.name]))) + { + config.axis[leftAxisIndex].scale = "linear"; + message += sep + "Unable to use a log scale on the left y-axis. All y-axis values must be >= 0. Reverting to linear scale on left y-axis."; + sep = "
"; + } + + // check the right y-axis + if (m.yAxis == "right" && rightAxisIndex > -1 && config.axis[rightAxisIndex].scale == "log" + && ((data.individual && data.individual.hasNegativeValues[m.name]) + || (data.aggregate && data.aggregate.hasNegativeValues[m.name]))) + { + config.axis[rightAxisIndex].scale = "linear"; + message += sep + "Unable to use a log scale on the right y-axis. All y-axis values must be >= 0. Reverting to linear scale on right y-axis."; + sep = "
"; + } + + }); + } + + return {success: true, message: message}; + }; + + /** + * Support backwards compatibility for charts saved prior to chartInfo reconfiguration (2011-08-31). + * Support backwards compatibility for save thumbnail options (2012-06-19). + * @param chartInfo + * @param savedReportInfo + */ + var convertSavedReportConfig = function(chartInfo, savedReportInfo) + { + if (LABKEY.Utils.isDefined(chartInfo)) + { + Ext4.applyIf(chartInfo, { + axis: [], + //This is for charts saved prior to 2011-10-07 + chartSubjectSelection: chartInfo.chartLayout == 'per_group' ? 'groups' : 'subjects', + displayIndividual: true, + displayAggregate: false + }); + for (var i = 0; i < chartInfo.measures.length; i++) + { + var md = chartInfo.measures[i]; + + Ext4.applyIf(md.measure, {yAxis: "left"}); + + // if the axis info is in md, move it to the axis array + if (md.axis) + { + // default the y-axis to the left side if not specified + if (md.axis.name == "y-axis") + Ext4.applyIf(md.axis, {side: "left"}); + + // move the axis info to the axis array + if (getAxisIndex(chartInfo.axis, md.axis.name, md.axis.side) == -1) + chartInfo.axis.push(Ext4.apply({}, md.axis)); + + // if the chartInfo has an x-axis measure, move the date info it to the related y-axis measures + if (md.axis.name == "x-axis") + { + for (var j = 0; j < chartInfo.measures.length; j++) + { + var schema = md.measure.schemaName; + var query = md.measure.queryName; + if (chartInfo.measures[j].axis && chartInfo.measures[j].axis.name == "y-axis" + && chartInfo.measures[j].measure.schemaName == schema + && chartInfo.measures[j].measure.queryName == query) + { + chartInfo.measures[j].dateOptions = { + dateCol: Ext4.apply({}, md.measure), + zeroDateCol: Ext4.apply({}, md.dateOptions.zeroDateCol), + interval: md.dateOptions.interval + }; + } + } + + // remove the x-axis date measure from the measures array + chartInfo.measures.splice(i, 1); + i--; + } + else + { + // remove the axis property from the measure + delete md.axis; + } + } + } + } + + if (LABKEY.Utils.isObject(chartInfo) && LABKEY.Utils.isObject(savedReportInfo)) + { + if (chartInfo.saveThumbnail != undefined) + { + if (savedReportInfo.reportProps == null) + savedReportInfo.reportProps = {}; + + Ext4.applyIf(savedReportInfo.reportProps, { + thumbnailType: !chartInfo.saveThumbnail ? 'NONE' : 'AUTO' + }); + } + } + }; + + var getStudySubjectInfo = function() + { + var studyCtx = LABKEY.getModuleContext("study") || {}; + return LABKEY.Utils.isObject(studyCtx.subject) ? studyCtx.subject : { + tableName: 'Participant', + columnName: 'ParticipantId', + nounPlural: 'Participants', + nounSingular: 'Participant' + }; + }; + + var getMeasureAlias = function(measure) + { + if (LABKEY.Utils.isString(measure.alias)) + return measure.alias; + else + return measure.schemaName + '_' + measure.queryName + '_' + measure.name; + }; + + var getMeasuresLabelBySide = function(measures, side) + { + var labels = []; + Ext4.each(measures, function(measure) + { + if (measure.yAxis == side && labels.indexOf(measure.label) == -1) + labels.push(measure.label); + }); + + return labels.join(', '); + }; + + var getDistinctYAxisSides = function(measures) + { + return Ext4.Array.unique(Ext4.Array.pluck(measures, 'yAxis')); + }; + + var _getRowValue = function(row, propName, valueName) + { + if (row.hasOwnProperty(propName)) { + // backwards compatibility for response row that is not a LABKEY.Query.Row + if (!(row instanceof LABKEY.Query.Row)) { + return row[propName].displayValue || row[propName].value; + } + + var propValue = row.get(propName); + if (valueName != undefined && propValue.hasOwnProperty(valueName)) { + return propValue[valueName]; + } + else if (propValue.hasOwnProperty('displayValue')) { + return propValue['displayValue']; + } + return row.getValue(propName); + } + + return undefined; + }; + + var renderChartSVG = function(renderTo, queryConfig, chartConfig) { + // Before we load the data, validate some information about the chart config + var messages = []; + var validation = validateChartConfig(chartConfig); + if (validation.message != null) + { + messages.push(validation.message); + } + if (!validation.success) + { + _renderMessages(renderTo, messages); + return; + } + + var nounSingular = 'Participant'; + var subjectColumnName = 'ParticipantId'; + if (LABKEY.moduleContext.study && LABKEY.moduleContext.study.subject) + { + nounSingular = LABKEY.moduleContext.study.subject.nounSingular; + subjectColumnName = LABKEY.moduleContext.study.subject.columnName; + } + + // When all the dependencies are loaded, we load the data using time chart helper getChartData + Ext4.applyIf(queryConfig, { + chartInfo: chartConfig, + containerPath: LABKEY.container.path, + nounSingular: nounSingular, + subjectColumnName: subjectColumnName, + dataLimit: 10000, + maxCharts: 20, + defaultMultiChartHeight: 380, + defaultSingleChartHeight: 600, + defaultWidth: 1075, + defaultNumberFormat: function(v) { return v.toFixed(1); } + }); + + queryConfig.success = function(response) { + _getChartDataCallback(renderTo, queryConfig, chartConfig, response); + }; + queryConfig.failure = function(info) { + _renderMessages(renderTo, ['Error: ' + info.exception]); + }; + + LABKEY.vis.TimeChartHelper.getChartData(queryConfig); + }; + + var _getChartDataCallback = function(renderTo, queryConfig, chartConfig, responseData) { + var individualColumnAliases = responseData.individual ? responseData.individual.columnAliases : null; + var aggregateColumnAliases = responseData.aggregate ? responseData.aggregate.columnAliases : null; + var visitMap = responseData.individual ? responseData.individual.visitMap : responseData.aggregate.visitMap; + var intervalKey = generateIntervalKey(chartConfig, individualColumnAliases, aggregateColumnAliases, queryConfig.nounSingular); + var aes = generateAes(chartConfig, visitMap, individualColumnAliases, intervalKey, queryConfig.subjectColumnName); + var tickMap = generateTickMap(visitMap); + var seriesList = generateSeriesList(chartConfig.measures); + var applyClipRect = generateApplyClipRect(chartConfig); + + // Once we have the data, we can set all of the axis min/max range values + generateAcrossChartAxisRanges(chartConfig, responseData, seriesList, queryConfig.nounSingular); + var scales = generateScales(chartConfig, tickMap, responseData.numberFormats); + + // Validate that the chart data has expected values and give warnings if certain elements are not present + var messages = []; + var validation = validateChartData(responseData, chartConfig, seriesList, queryConfig.dataLimit, false); + if (validation.message != null) + { + messages.push(validation.message); + } + if (!validation.success) + { + _renderMessages(renderTo, messages); + return; + } + + // For time charts, we allow multiple plots to be displayed by participant, group, or measure/dimension + var plotConfigsArr = generatePlotConfigs(chartConfig, responseData, seriesList, applyClipRect, queryConfig.maxCharts, queryConfig.subjectColumnName); + for (var configIndex = 0; configIndex < plotConfigsArr.length; configIndex++) + { + var clipRect = plotConfigsArr[configIndex].applyClipRect; + var series = plotConfigsArr[configIndex].series; + var height = chartConfig.height || (plotConfigsArr.length > 1 ? queryConfig.defaultMultiChartHeight : queryConfig.defaultSingleChartHeight); + var width = chartConfig.width || queryConfig.defaultWidth; + var labels = generateLabels(plotConfigsArr[configIndex].title, chartConfig.axis, plotConfigsArr[configIndex].subtitle); + var layers = generateLayers(chartConfig, visitMap, individualColumnAliases, aggregateColumnAliases, plotConfigsArr[configIndex].aggregateData, series, intervalKey, queryConfig.subjectColumnName); + var data = plotConfigsArr[configIndex].individualData ? plotConfigsArr[configIndex].individualData : plotConfigsArr[configIndex].aggregateData; + + var plotConfig = { + renderTo: renderTo, + rendererType: 'd3', + clipRect: clipRect, + width: width, + height: height, + labels: labels, + aes: aes, + scales: scales, + layers: layers, + data: data + }; + + var plot = new LABKEY.vis.Plot(plotConfig); + plot.render(); + } + + // Give a warning if the max number of charts has been exceeded + if (plotConfigsArr.length >= queryConfig.maxCharts) + messages.push('Only showing the first ' + queryConfig.maxCharts + ' charts.'); + + _renderMessages(renderTo, messages); + }; + + var _renderMessages = function(id, messages) { + var messageDiv; + var el = document.getElementById(id); + var child; + if (el && el.children.length > 0) + child = el.children[0]; + + for (var i = 0; i < messages.length; i++) + { + messageDiv = document.createElement('div'); + messageDiv.setAttribute('style', 'font-style:italic'); + messageDiv.innerHTML = messages[i]; + if (child) + el.insertBefore(messageDiv, child); + else + el.appendChild(messageDiv); + } + }; + + return { + /** + * Loads all of the required dependencies for a Time Chart. + * @param {Function} callback The callback to be executed when all of the visualization dependencies have been loaded. + * @param {Object} scope The scope to be used when executing the callback. + */ + loadVisDependencies: LABKEY.requiresVisualization, + generateAcrossChartAxisRanges : generateAcrossChartAxisRanges, + generateAes : generateAes, + generateApplyClipRect : generateApplyClipRect, + generateIntervalKey : generateIntervalKey, + generateLabels : generateLabels, + generateLayers : generateLayers, + generatePlotConfigs : generatePlotConfigs, + generateScales : generateScales, + generateSeriesList : generateSeriesList, + generateTickMap : generateTickMap, + generateNumberFormats : generateNumberFormats, + getAxisIndex : getAxisIndex, + getMeasureAlias : getMeasureAlias, + getMeasuresLabelBySide : getMeasuresLabelBySide, + getDistinctYAxisSides : getDistinctYAxisSides, + getStudyTimepointType : getStudyTimepointType, + getStudySubjectInfo : getStudySubjectInfo, + getStudyMeasures : getStudyMeasures, + getChartData : getChartData, + validateChartConfig : validateChartConfig, + validateChartData : validateChartData, + convertSavedReportConfig : convertSavedReportConfig, + renderChartSVG: renderChartSVG + }; +}; From e31dd4b76ac07549791a826d856b0b95565ff39c Mon Sep 17 00:00:00 2001 From: cnathe Date: Mon, 13 Oct 2025 15:50:29 -0500 Subject: [PATCH 21/40] github CR feedback --- core/webapp/vis/src/internal/D3Renderer.js | 4 ++-- core/webapp/vis/src/statistics.js | 6 ++++-- core/webapp/vis/src/utils.js | 5 ++++- .../resources/web/vis/genericChart/genericChartHelper.js | 6 ++++-- 4 files changed, 14 insertions(+), 7 deletions(-) diff --git a/core/webapp/vis/src/internal/D3Renderer.js b/core/webapp/vis/src/internal/D3Renderer.js index 2177c8dc7fa..177c5171e98 100644 --- a/core/webapp/vis/src/internal/D3Renderer.js +++ b/core/webapp/vis/src/internal/D3Renderer.js @@ -494,7 +494,7 @@ LABKEY.vis.internal.Axis = function() { if (!width) width = scale(v) - grid.leftEdge; - let text = d3.select(this), + var text = d3.select(this), words = text.text().split(/[\s]+/).reverse(), word, line = [], @@ -2222,7 +2222,7 @@ LABKEY.vis.internal.D3Renderer = function(plot) { var renderErrorBar = function(layer, plot, geom, data, xAcc) { var colorAcc, topFn, bottomFn, verticalFn, selection, newBars; - var errorLineWidth = geom.errorWidth ?? geom.width; + var errorLineWidth = geom.errorWidth != null ? geom.errorWidth : geom.width; var 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; diff --git a/core/webapp/vis/src/statistics.js b/core/webapp/vis/src/statistics.js index e00fed4607f..7a38d75ed94 100644 --- a/core/webapp/vis/src/statistics.js +++ b/core/webapp/vis/src/statistics.js @@ -202,8 +202,9 @@ LABKEY.vis.Stat.MEAN = LABKEY.vis.Stat.getMean; * @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, sample = false) +LABKEY.vis.Stat.getStdDev = function(values, sample) { + if (sample === undefined || sample === null) sample = false; if (values == null) throw "invalid input"; if (values.length === 0) @@ -230,7 +231,8 @@ LABKEY.vis.Stat.SD = LABKEY.vis.Stat.getStdDev; * @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) { +LABKEY.vis.Stat.getStdErr = function(values, sample) { + if (sample === undefined || sample === null) sample = false; if (values == null) throw "invalid input"; if (values.length === 0) diff --git a/core/webapp/vis/src/utils.js b/core/webapp/vis/src/utils.js index 56524ff45ab..5b7509a5ab0 100644 --- a/core/webapp/vis/src/utils.js +++ b/core/webapp/vis/src/utils.js @@ -222,7 +222,7 @@ LABKEY.vis.groupCountData = function(data, groupAccessor, subgroupAccessor, prop * @param {Boolean} keepNames True to use the dimension names in the results data. Defaults to false. * @returns {Array} An array of results for each group/subgroup/aggregate */ -LABKEY.vis.getAggregateData = function(data, dimensionName, subDimensionName, measureName, aggregate, nullDisplayValue, includeTotal, errorBarType, keepNames = false) +LABKEY.vis.getAggregateData = function(data, dimensionName, subDimensionName, measureName, aggregate, nullDisplayValue, includeTotal, errorBarType, keepNames) { var results = [], subgroupAccessor, groupAccessor = typeof dimensionName === 'function' ? dimensionName : function(row){ return LABKEY.vis.getValue(row[dimensionName]);}, @@ -287,6 +287,9 @@ LABKEY.vis.getAggregateData = function(data, dimensionName, subDimensionName, me throw 'Aggregate ' + aggregate + ' is not yet supported.'; } + if (keepNames === undefined || keepNames === null) { + keepNames = false; + } 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']; diff --git a/visualization/resources/web/vis/genericChart/genericChartHelper.js b/visualization/resources/web/vis/genericChart/genericChartHelper.js index 386db42e06a..a259be1b801 100644 --- a/visualization/resources/web/vis/genericChart/genericChartHelper.js +++ b/visualization/resources/web/vis/genericChart/genericChartHelper.js @@ -130,7 +130,9 @@ LABKEY.vis.GenericChartHelper = new function(){ ? properties[0].aggregate : properties.aggregate; if (LABKEY.Utils.isDefined(aggregateProps)) { - var aggLabel = LABKEY.Utils.isObject(aggregateProps) ? (aggregateProps.name ?? aggregateProps.label) : LABKEY.Utils.capitalize(aggregateProps.toLowerCase()); + var aggLabel = LABKEY.Utils.isObject(aggregateProps) + ? (aggregateProps.name != null ? aggregateProps.name : aggregateProps.label) + : LABKEY.Utils.capitalize(aggregateProps.toLowerCase()); label = aggLabel + ' of ' + label; } else { @@ -1065,7 +1067,7 @@ LABKEY.vis.GenericChartHelper = new function(){ } if (!scales.y.domain) { - var values = $.map(data, function(d) {return d.value + (d.error ?? 0);}), + var values = $.map(data, function(d) {return d.value + (d.error != null ? d.error : 0);}), min = Math.min(0, Math.min.apply(Math, values)), max = Math.max(0, Math.max.apply(Math, values)); From df97e290bd1e5cb47a7740473b491d5696c91587 Mon Sep 17 00:00:00 2001 From: cnathe Date: Tue, 14 Oct 2025 11:05:14 -0500 Subject: [PATCH 22/40] add metric for genericChartWithErrorBarsCount --- query/src/org/labkey/query/reports/ReportServiceImpl.java | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) 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 ); }); } From 78a2de4fbb9fe5b405fae3fb3742cd8e47714d26 Mon Sep 17 00:00:00 2001 From: cnathe Date: Wed, 15 Oct 2025 17:05:01 -0500 Subject: [PATCH 23/40] CR feedback - var to const/let, misc linting, use ?? and arrow functions, default value in param, replace square bracket accessor --- core/webapp/vis/src/geom.js | 4 +- core/webapp/vis/src/internal/D3Renderer.js | 137 ++++++++---------- core/webapp/vis/src/statistics.js | 6 +- core/webapp/vis/src/utils.js | 35 ++--- .../vis/genericChart/genericChartHelper.js | 19 +-- 5 files changed, 91 insertions(+), 110 deletions(-) diff --git a/core/webapp/vis/src/geom.js b/core/webapp/vis/src/geom.js index 2719c12d07e..39ab71d86e8 100644 --- a/core/webapp/vis/src/geom.js +++ b/core/webapp/vis/src/geom.js @@ -340,8 +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.errorShowVertical = ('showVertical' in config && config.showVertical != null && config.showVertical != undefined) ? config.showVertical : 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 177c5171e98..7ca328a9545 100644 --- a/core/webapp/vis/src/internal/D3Renderer.js +++ b/core/webapp/vis/src/internal/D3Renderer.js @@ -454,85 +454,71 @@ LABKEY.vis.internal.Axis = function() { } } - var hasTickAction = tickHover || tickClick || tickMouseOver || tickMouseOut; + const hasTickAction = tickHover || tickClick || tickMouseOver || tickMouseOut; if (hasTickAction) { addTickAreaRects(textAnchors, !hasOverlap); addHighlightRects(textAnchors); } - if (orientation == 'bottom') { - if (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; - } + 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)})`; - textEls.attr('transform', function(v) {return 'rotate(' + tickOverlapRotation + ',' + textXFn(v) + ',' + textYFn(v) + ')';}) - .attr('text-anchor', 'start'); + 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) + ')'; - }); - - addHighlightRects(textAnchors); - textAnchors.selectAll('rect.highlight') - .attr('transform', function (v) - { - return 'rotate(' + tickOverlapRotation + ',' + textXFn(v) + ',' + textYFn(v) + ')'; - }); - } - } else { - function wrapAxisTickLabel(text) { - var width; - text.each(function(v) { - if (!width) - width = scale(v) - grid.leftEdge; - - var text = d3.select(this), - words = text.text().split(/[\s]+/).reverse(), - word, - line = [], - lineNumber = 0, - lineHeight = 1.1, // ems - x = this.getAttribute("x"), - y = this.getAttribute("y"), - dy = 0, - tspan = text.text(null) - .append("tspan") - .attr("x", x) - .attr("y", y) - .attr("dy", dy + "em"); - - 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 = text.append("tspan") - .attr("x", x) - .attr("y", y) - .attr("dy", ++lineNumber * lineHeight + dy + "em") - .text(word); - } - } - }); - } + if (hasTickAction) { + addTickAreaRects(textAnchors); + textAnchors.selectAll("rect." + (tickRectCls ? tickRectCls : "tick-rect")) + .attr('transform', rotate); - textEls.attr('transform', '').call(wrapAxisTickLabel); - textAnchors.selectAll('rect.highlight').attr('transform', ''); + addHighlightRects(textAnchors); + 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 line = []; + let lineNumber = 0; + let tspan = textEl.text(null) + .append("tspan") + .attr("x", x) + .attr("y", y) + .attr("dy", "0em"); + + for (const word of words) { + 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) { @@ -2221,12 +2207,11 @@ LABKEY.vis.internal.D3Renderer = function(plot) { }; var renderErrorBar = function(layer, plot, geom, data, xAcc) { - var colorAcc, topFn, bottomFn, verticalFn, selection, newBars; - var errorLineWidth = geom.errorWidth != null ? geom.errorWidth : geom.width; - var xAcc_ = xAcc || function(row) {return geom.getX(row);}; + 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; - 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 = xAcc_(d); value = geom.yAes.getValue(d); @@ -2234,7 +2219,7 @@ LABKEY.vis.internal.D3Renderer = function(plot) { y = geom.yScale.scale(value + error); 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 = xAcc_(d); value = geom.yAes.getValue(d); @@ -2246,7 +2231,7 @@ LABKEY.vis.internal.D3Renderer = function(plot) { } return value == null || isNaN(x) || isNaN(y) ? null : LABKEY.vis.makeLine(x - errorLineWidth, y, x + errorLineWidth, y); }; - verticalFn = function(d) { + const verticalFn = function(d) { var x, y, y1, y2, value, error; x = xAcc_(d); value = geom.yAes.getValue(d); @@ -2269,10 +2254,10 @@ LABKEY.vis.internal.D3Renderer = function(plot) { 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'); diff --git a/core/webapp/vis/src/statistics.js b/core/webapp/vis/src/statistics.js index 7a38d75ed94..e00fed4607f 100644 --- a/core/webapp/vis/src/statistics.js +++ b/core/webapp/vis/src/statistics.js @@ -202,9 +202,8 @@ LABKEY.vis.Stat.MEAN = LABKEY.vis.Stat.getMean; * @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, sample) +LABKEY.vis.Stat.getStdDev = function(values, sample = false) { - if (sample === undefined || sample === null) sample = false; if (values == null) throw "invalid input"; if (values.length === 0) @@ -231,8 +230,7 @@ LABKEY.vis.Stat.SD = LABKEY.vis.Stat.getStdDev; * @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) { - if (sample === undefined || sample === null) sample = false; +LABKEY.vis.Stat.getStdErr = function(values, sample = false) { if (values == null) throw "invalid input"; if (values.length === 0) diff --git a/core/webapp/vis/src/utils.js b/core/webapp/vis/src/utils.js index 5b7509a5ab0..d8008835def 100644 --- a/core/webapp/vis/src/utils.js +++ b/core/webapp/vis/src/utils.js @@ -181,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; @@ -195,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,7 +222,7 @@ LABKEY.vis.groupCountData = function(data, groupAccessor, subgroupAccessor, prop * @param {Boolean} keepNames True to use the dimension names in the results data. Defaults to false. * @returns {Array} An array of results for each group/subgroup/aggregate */ -LABKEY.vis.getAggregateData = function(data, dimensionName, subDimensionName, measureName, aggregate, nullDisplayValue, includeTotal, errorBarType, keepNames) +LABKEY.vis.getAggregateData = function(data, dimensionName, subDimensionName, measureName, aggregate, nullDisplayValue, includeTotal, errorBarType, keepNames = false) { var results = [], subgroupAccessor, groupAccessor = typeof dimensionName === 'function' ? dimensionName : function(row){ return LABKEY.vis.getValue(row[dimensionName]);}, @@ -242,18 +242,18 @@ 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 @@ -262,7 +262,7 @@ LABKEY.vis.getAggregateData = function(data, dimensionName, subDimensionName, me 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') { @@ -287,24 +287,21 @@ LABKEY.vis.getAggregateData = function(data, dimensionName, subDimensionName, me throw 'Aggregate ' + aggregate + ' is not yet supported.'; } - if (keepNames === undefined || keepNames === null) { - keepNames = false; - } 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']; + var dimValue = row.label; row[dimensionName] = { value: !isNaN(Number(dimValue)) ? Number(dimValue) : dimValue }; - row[measureName] = { value: row['value'] }; + row[measureName] = { value: row.value }; row[measureName].aggType = aggregate; if (row.hasOwnProperty('subLabel')) { - row[subDimensionName] = { value: row['subLabel'] }; + row[subDimensionName] = { value: row.subLabel }; } if (row.hasOwnProperty('error')) { - row[measureName].error = row['error']; + row[measureName].error = row.error; } if (row.hasOwnProperty('errorType')) { - row[measureName].errorType = row['errorType']; + row[measureName].errorType = row.errorType; } } diff --git a/visualization/resources/web/vis/genericChart/genericChartHelper.js b/visualization/resources/web/vis/genericChart/genericChartHelper.js index a259be1b801..2aa879f4e3a 100644 --- a/visualization/resources/web/vis/genericChart/genericChartHelper.js +++ b/visualization/resources/web/vis/genericChart/genericChartHelper.js @@ -131,7 +131,7 @@ LABKEY.vis.GenericChartHelper = new function(){ if (LABKEY.Utils.isDefined(aggregateProps)) { var aggLabel = LABKEY.Utils.isObject(aggregateProps) - ? (aggregateProps.name != null ? aggregateProps.name : aggregateProps.label) + ? (aggregateProps.name ?? aggregateProps.label) : LABKEY.Utils.capitalize(aggregateProps.toLowerCase()); label = aggLabel + ' of ' + label; } @@ -1067,7 +1067,7 @@ LABKEY.vis.GenericChartHelper = new function(){ } if (!scales.y.domain) { - var values = $.map(data, function(d) {return d.value + (d.error != null ? d.error : 0);}), + var values = $.map(data, d => d.value + (d.error ?? 0)), min = Math.min(0, Math.min.apply(Math, values)), max = Math.max(0, Math.max.apply(Math, values)); @@ -1383,7 +1383,8 @@ LABKEY.vis.GenericChartHelper = new function(){ }); var wrapLines = _wrapXAxisTickTextLines(scales, plotConfig, maxLen, data); - margins.bottom = 60 + ((wrapLines - 1) * 25); + // 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 @@ -1842,11 +1843,11 @@ LABKEY.vis.GenericChartHelper = new function(){ }; var generateDataForChartType = function(chartConfig, chartType, geom, data) { - var dimName = null; - var subDimName = null; - var measureName = null; - var aggType = chartType === 'bar_chart' || chartType === 'pie_chart' ? 'COUNT' : null; - var aggErrorType = null; + let dimName = null; + 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; @@ -1873,7 +1874,7 @@ LABKEY.vis.GenericChartHelper = new function(){ if (aggType) { data = LABKEY.vis.getAggregateData(data, dimName, subDimName, measureName, aggType, '[Blank]', false, aggErrorType, chartType === 'line_plot'); if (aggErrorType) { - geom.errorAes = { getValue: function(d){ return d.error } }; + geom.errorAes = { getValue: d => d.error }; } } From bfcbdec7b058708d88315de4105ec1a6e633af47 Mon Sep 17 00:00:00 2001 From: cnathe Date: Wed, 15 Oct 2025 17:05:42 -0500 Subject: [PATCH 24/40] LKS Chart wizard update to remove duplicate aggregate method combo in bar chart modals --- .../genericChartAxisPanel.js | 45 ++++++++++++------- .../web/vis/chartWizard/genericChartPanel.js | 13 +++--- 2 files changed, 37 insertions(+), 21 deletions(-) diff --git a/visualization/resources/web/vis/chartWizard/chartLayoutDialog/genericChartAxisPanel.js b/visualization/resources/web/vis/chartWizard/chartLayoutDialog/genericChartAxisPanel.js index 720c55960bd..eaca3f483b1 100644 --- a/visualization/resources/web/vis/chartWizard/chartLayoutDialog/genericChartAxisPanel.js +++ b/visualization/resources/web/vis/chartWizard/chartLayoutDialog/genericChartAxisPanel.js @@ -137,17 +137,6 @@ Ext4.define('LABKEY.vis.GenericChartAxisPanel', { } }); - var aggregateMethodData = []; - if (this.renderType === 'bar_chart') - aggregateMethodData.push(['COUNT', 'Count (non-blank)']); - if (this.renderType === 'line_plot') - aggregateMethodData.push(['', 'None']); - aggregateMethodData.push(['SUM', 'Sum']); - aggregateMethodData.push(['MIN', 'Min']); - aggregateMethodData.push(['MAX', 'Max']); - aggregateMethodData.push(['MEAN', 'Mean']); - aggregateMethodData.push(['MEDIAN', 'Median']); - this.aggregateMethodCombobox = Ext4.create('Ext.form.field.ComboBox', { fieldLabel: 'Aggregate Method', name: 'aggregate', @@ -157,13 +146,26 @@ Ext4.define('LABKEY.vis.GenericChartAxisPanel', { width: 300, store: Ext4.create('Ext.data.ArrayStore', { fields: ['value', 'display'], - data: aggregateMethodData + data: [ + ['', 'None'], + ['SUM', 'Sum'], + ['MIN', 'Min'], + ['MAX', 'Max'], + ['MEAN', 'Mean'], + ['MEDIAN', 'Median'] + ] }), forceSelection: 'true', editable: false, valueField: 'value', displayField: 'display', - value: '' + value: '', + listeners: { + scope: this, + change: function(rg, newValue) { + this.onAggregateMethodChange(newValue); + } + } }); this.errorBarsRadioGroup = Ext4.create('Ext.form.RadioGroup', { @@ -344,13 +346,20 @@ 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); - var radioComp = this.errorBarsRadioGroup.down('radio[inputValue="' + value + '"]'); + const selector = value === '' ? 'radio': 'radio[inputValue="' + value + '"]'; + var radioComp = this.errorBarsRadioGroup.down(selector); if (radioComp) radioComp.setValue(true); }, @@ -378,6 +387,8 @@ 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 @@ -390,9 +401,11 @@ Ext4.define('LABKEY.vis.GenericChartAxisPanel', { this.setScaleTypeOptionVisible(false); } - // only show aggregate method and error bars option for y-axis on bar and line charts - if (!(this.axisName === 'y' && (isBar || isLine))) { + // 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); } }, diff --git a/visualization/resources/web/vis/chartWizard/genericChartPanel.js b/visualization/resources/web/vis/chartWizard/genericChartPanel.js index 64939720a54..8954ab3f8a4 100644 --- a/visualization/resources/web/vis/chartWizard/genericChartPanel.js +++ b/visualization/resources/web/vis/chartWizard/genericChartPanel.js @@ -1047,11 +1047,14 @@ Ext4.define('LABKEY.ext4.GenericChartPanel', { config.scales[axisName].max = options[axisName].scaleRange.max; } - if (options[axisName].aggregate) { - config.measures[axisName].aggregate = options[axisName].aggregate; - } - if (options[axisName].errorBars) { - config.measures[axisName].errorBars = options[axisName].errorBars; + if (config.measures[axisName]) { + if (options[axisName].aggregate !== undefined) { + config.measures[axisName].aggregate = options[axisName].aggregate; + config.measures[axisName].errorBars = undefined; // reset error bars if aggregate changes + } + if (options[axisName].errorBars !== undefined) { + config.measures[axisName].errorBars = options[axisName].errorBars; + } } } }, From 9951d147f5013e843d9d7dc40b5e04aea59e6654 Mon Sep 17 00:00:00 2001 From: cnathe Date: Thu, 16 Oct 2025 10:53:36 -0500 Subject: [PATCH 25/40] for bar chart, set min range to 0 when not set by user (even when user has set max manually) --- .../resources/web/vis/genericChart/genericChartHelper.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/visualization/resources/web/vis/genericChart/genericChartHelper.js b/visualization/resources/web/vis/genericChart/genericChartHelper.js index 2aa879f4e3a..43a7283af7f 100644 --- a/visualization/resources/web/vis/genericChart/genericChartHelper.js +++ b/visualization/resources/web/vis/genericChart/genericChartHelper.js @@ -1072,6 +1072,9 @@ LABKEY.vis.GenericChartHelper = new function(){ max = Math.max(0, Math.max.apply(Math, values)); scales.y.domain = [min, max]; + } else if (!scales.y.domain[0]) { + // if user has set a max but not a min, default to 0 for bar chart + scales.y.domain[0] = 0; } } else if (renderType === 'box_plot' && chartConfig.pointType === 'all') From c414d89545f443666e8c8076a7e69239ade9353e Mon Sep 17 00:00:00 2001 From: cnathe Date: Thu, 16 Oct 2025 14:03:40 -0500 Subject: [PATCH 26/40] better handling for LKS multiple y-axis measures scenario (only apply aggregate/error bars for one left y-axis measure) --- .../web/vis/chartWizard/chartLayoutPanel.js | 6 ++++++ .../web/vis/chartWizard/genericChartPanel.js | 10 +++++++--- .../web/vis/genericChart/genericChartHelper.js | 13 ++++++++----- 3 files changed, 21 insertions(+), 8 deletions(-) diff --git a/visualization/resources/web/vis/chartWizard/chartLayoutPanel.js b/visualization/resources/web/vis/chartWizard/chartLayoutPanel.js index ac8cd9f5728..8749a0611d9 100644 --- a/visualization/resources/web/vis/chartWizard/chartLayoutPanel.js +++ b/visualization/resources/web/vis/chartWizard/chartLayoutPanel.js @@ -345,6 +345,12 @@ 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 sides in use + if (inputField.fieldLabel === 'Aggregate Method' || inputField.fieldLabel === 'Error Bars') { + const sides = LABKEY.vis.GenericChartHelper.getDistinctYAxisSides(measures); + inputField.setVisible(sides.length < 2); + } }, this); } }, this); diff --git a/visualization/resources/web/vis/chartWizard/genericChartPanel.js b/visualization/resources/web/vis/chartWizard/genericChartPanel.js index 8954ab3f8a4..ba567b874a9 100644 --- a/visualization/resources/web/vis/chartWizard/genericChartPanel.js +++ b/visualization/resources/web/vis/chartWizard/genericChartPanel.js @@ -1048,12 +1048,16 @@ Ext4.define('LABKEY.ext4.GenericChartPanel', { } 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) { - config.measures[axisName].aggregate = options[axisName].aggregate; - config.measures[axisName].errorBars = undefined; // reset error bars if aggregate changes + measure.aggregate = options[axisName].aggregate; + measure.errorBars = undefined; // reset error bars if aggregate changes } if (options[axisName].errorBars !== undefined) { - config.measures[axisName].errorBars = options[axisName].errorBars; + measure.errorBars = options[axisName].errorBars; } } } diff --git a/visualization/resources/web/vis/genericChart/genericChartHelper.js b/visualization/resources/web/vis/genericChart/genericChartHelper.js index 43a7283af7f..3644f991d62 100644 --- a/visualization/resources/web/vis/genericChart/genericChartHelper.js +++ b/visualization/resources/web/vis/genericChart/genericChartHelper.js @@ -1860,13 +1860,16 @@ LABKEY.vis.GenericChartHelper = new function(){ } else if (chartConfig.measures.series) { subDimName = chartConfig.measures.series.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.value || chartConfig.measures.y.aggregate; + // 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' ? chartConfig.measures.y.errorBars : null; + aggErrorType = aggType === 'MEAN' ? yMeasure.errorBars : null; } else if (measureName != null && (chartType === 'bar_chart' || chartType === 'pie_chart')) { // default to SUM for bar and pie charts From b26e889bb65fff1a93b619a3b9d323b794adcb1f Mon Sep 17 00:00:00 2001 From: cnathe Date: Thu, 16 Oct 2025 14:26:56 -0500 Subject: [PATCH 27/40] better handling for LKS multiple y-axis measures scenario (only apply aggregate/error bars for one left y-axis measure) --- .../web/vis/chartWizard/genericChartPanel.js | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/visualization/resources/web/vis/chartWizard/genericChartPanel.js b/visualization/resources/web/vis/chartWizard/genericChartPanel.js index ba567b874a9..799812bdae0 100644 --- a/visualization/resources/web/vis/chartWizard/genericChartPanel.js +++ b/visualization/resources/web/vis/chartWizard/genericChartPanel.js @@ -1339,10 +1339,14 @@ Ext4.define('LABKEY.ext4.GenericChartPanel', { Ext4.apply(this.options[axisName], chartConfig.scales[axisName]); } if (chartConfig.measures && chartConfig.measures[axisName]) { - if (chartConfig.measures[axisName].aggregate) - this.options[axisName].aggregate = chartConfig.measures[axisName].aggregate.value ?? chartConfig.measures[axisName].aggregate; - if (chartConfig.measures[axisName].errorBars) - this.options[axisName].errorBars = chartConfig.measures[axisName].errorBars; + // 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; } }, From bbccc56d28b0cd424e0d594fb8f904e81c8ddc20 Mon Sep 17 00:00:00 2001 From: cnathe Date: Thu, 16 Oct 2025 15:50:05 -0500 Subject: [PATCH 28/40] add try/catch to getChartTypeBasedWidth to account for missing/renamed measure --- .../vis/genericChart/genericChartHelper.js | 4071 +++++++++-------- 1 file changed, 2038 insertions(+), 2033 deletions(-) diff --git a/visualization/resources/web/vis/genericChart/genericChartHelper.js b/visualization/resources/web/vis/genericChart/genericChartHelper.js index 3644f991d62..d33386f9bc9 100644 --- a/visualization/resources/web/vis/genericChart/genericChartHelper.js +++ b/visualization/resources/web/vis/genericChart/genericChartHelper.js @@ -1,2034 +1,2039 @@ -/* - * Copyright (c) 2013-2019 LabKey Corporation - * - * Licensed under the Apache License, Version 2.0: http://www.apache.org/licenses/LICENSE-2.0 - */ -if(!LABKEY.vis) { - LABKEY.vis = {}; -} - -/** - * @namespace Namespace used to encapsulate functions related to creating Generic Charts (Box, Scatter, etc.). Used in the - * Generic Chart Wizard and when exporting Generic Charts as Scripts. - */ -LABKEY.vis.GenericChartHelper = new function(){ - - var DEFAULT_TICK_LABEL_MAX = 25; - var $ = jQuery; - - var getRenderTypes = function() { - return [ - { - name: 'bar_chart', - title: 'Bar', - imgUrl: LABKEY.contextPath + '/visualization/images/barchart.png', - fields: [ - {name: 'x', label: 'X Axis', required: true, nonNumericOnly: true}, - {name: 'xSub', label: 'Group By', required: false, nonNumericOnly: true}, - {name: 'y', label: 'Y Axis', numericOnly: true} - ], - layoutOptions: {line: true, opacity: true, axisBased: true} - }, - { - name: 'box_plot', - title: 'Box', - imgUrl: LABKEY.contextPath + '/visualization/images/boxplot.png', - fields: [ - {name: 'x', label: 'X Axis'}, - {name: 'y', label: 'Y Axis', required: true, numericOnly: true}, - {name: 'color', label: 'Color', nonNumericOnly: true}, - {name: 'shape', label: 'Shape', nonNumericOnly: true} - ], - layoutOptions: {point: true, box: true, line: true, opacity: true, axisBased: true} - }, - { - name: 'line_plot', - title: 'Line', - imgUrl: LABKEY.contextPath + '/visualization/images/timechart.png', - fields: [ - {name: 'x', label: 'X Axis', required: true, numericOrDateOnly: true}, - {name: 'y', label: 'Y Axis', required: true, numericOnly: true, allowMultiple: true}, - {name: 'series', label: 'Series', nonNumericOnly: true}, - {name: 'trendline', label: 'Trendline', required: false, altSelectionOnly: true, altFieldType: 'LABKEY.vis.TrendlineField'}, - ], - layoutOptions: {opacity: true, axisBased: true, series: true, chartLayout: true} - }, - { - name: 'pie_chart', - title: 'Pie', - imgUrl: LABKEY.contextPath + '/visualization/images/piechart.png', - fields: [ - {name: 'x', label: 'Categories', required: true, nonNumericOnly: true}, - // Issue #29046 'Remove "measure" option from pie chart' - // {name: 'y', label: 'Measure', numericOnly: true} - ], - layoutOptions: {pie: true} - }, - { - name: 'scatter_plot', - title: 'Scatter', - imgUrl: LABKEY.contextPath + '/visualization/images/scatterplot.png', - fields: [ - {name: 'x', label: 'X Axis', required: true}, - {name: 'y', label: 'Y Axis', required: true, numericOnly: true, allowMultiple: true}, - {name: 'color', label: 'Color', nonNumericOnly: true}, - {name: 'shape', label: 'Shape', nonNumericOnly: true} - ], - layoutOptions: {point: true, opacity: true, axisBased: true, binnable: true, chartLayout: true} - }, - { - name: 'time_chart', - title: 'Time', - hidden: _getStudyTimepointType() == null, - imgUrl: LABKEY.contextPath + '/visualization/images/timechart.png', - fields: [ - {name: 'x', label: 'X Axis', required: true, altSelectionOnly: true, altFieldType: 'LABKEY.vis.TimeChartXAxisField'}, - {name: 'y', label: 'Y Axis', required: true, numericOnly: true, allowMultiple: true} - ], - layoutOptions: {time: true, axisBased: true, chartLayout: true} - } - ]; - }; - - /** - * Gets the chart type (i.e. box or scatter) based on the chartConfig object. - */ - const getChartType = function(chartConfig) - { - const renderType = chartConfig.renderType - const xAxisType = chartConfig.measures.x ? (chartConfig.measures.x.normalizedType || chartConfig.measures.x.type) : null; - - if (renderType === 'time_chart' || renderType === "bar_chart" || renderType === "pie_chart" - || renderType === "box_plot" || renderType === "scatter_plot" || renderType === "line_plot") - { - return renderType; - } - - if (!xAxisType) - { - // On some charts (non-study box plots) we don't require an x-axis, instead we generate one box plot for - // all of the data of your y-axis. If there is no xAxisType, then we have a box plot. Scatter plots require - // an x-axis measure. - return 'box_plot'; - } - - return (xAxisType === 'string' || xAxisType === 'boolean') ? 'box_plot' : 'scatter_plot'; - }; - - /** - * Generate a default label for the selected measure for the given renderType. - * @param renderType - * @param measureName - the chart type's measure name - * @param properties - properties for the selected column, note that this can be an array of properties - */ - var getSelectedMeasureLabel = function(renderType, measureName, properties) - { - var label = getDefaultMeasuresLabel(properties); - - if (label !== '' && measureName === 'y' && (renderType === 'bar_chart' || renderType === 'pie_chart')) { - var aggregateProps = LABKEY.Utils.isArray(properties) && properties.length === 1 - ? properties[0].aggregate : properties.aggregate; - - if (LABKEY.Utils.isDefined(aggregateProps)) { - var aggLabel = LABKEY.Utils.isObject(aggregateProps) - ? (aggregateProps.name ?? aggregateProps.label) - : LABKEY.Utils.capitalize(aggregateProps.toLowerCase()); - label = aggLabel + ' of ' + label; - } - else { - label = 'Sum of ' + label; - } - } - - return label; - }; - - /** - * Generate a plot title based on the selected measures array or object. - * @param renderType - * @param measures - * @returns {string} - */ - var getTitleFromMeasures = function(renderType, measures) - { - var queryLabels = []; - - if (LABKEY.Utils.isObject(measures)) - { - if (LABKEY.Utils.isArray(measures.y)) - { - $.each(measures.y, function(idx, m) - { - var measureQueryLabel = m.queryLabel || m.queryName; - if (queryLabels.indexOf(measureQueryLabel) === -1) - queryLabels.push(measureQueryLabel); - }); - } - else - { - var m = measures.x || measures.y; - queryLabels.push(m.queryLabel || m.queryName); - } - } - - return queryLabels.join(', '); - }; - - /** - * Get the sorted set of column metadata for the given schema/query/view. - * @param queryConfig - * @param successCallback - * @param callbackScope - */ - var getQueryColumns = function(queryConfig, successCallback, callbackScope) - { - LABKEY.Ajax.request({ - url: LABKEY.ActionURL.buildURL('visualization', 'getGenericReportColumns.api'), - method: 'GET', - params: { - schemaName: queryConfig.schemaName, - queryName: queryConfig.queryName, - viewName: queryConfig.viewName, - dataRegionName: queryConfig.dataRegionName, - includeCohort: true, - includeParticipantCategory : true - }, - success : function(response){ - var columnList = LABKEY.Utils.decode(response.responseText); - _queryColumnMetadata(queryConfig, columnList, successCallback, callbackScope) - }, - scope : this - }); - }; - - var _queryColumnMetadata = function(queryConfig, columnList, successCallback, callbackScope) - { - var columns = columnList.columns.all; - if (queryConfig.savedColumns) { - // make sure all savedColumns from the chart are included as options, they may not be in the view anymore - columns = columns.concat(queryConfig.savedColumns); - } - - LABKEY.Query.selectRows({ - maxRows: 0, // use maxRows 0 so that we just get the query metadata - schemaName: queryConfig.schemaName, - queryName: queryConfig.queryName, - viewName: queryConfig.viewName, - parameters: queryConfig.parameters, - requiredVersion: 9.1, - columns: columns, - method: 'POST', // Issue 31744: use POST as the columns list can be very long and cause a 400 error - success: function(response){ - var columnMetadata = _updateAndSortQueryFields(queryConfig, columnList, response.metaData.fields); - successCallback.call(callbackScope, columnMetadata); - }, - failure : function(response) { - // this likely means that the query no longer exists - successCallback.call(callbackScope, columnList, []); - }, - scope : this - }); - }; - - var _updateAndSortQueryFields = function(queryConfig, columnList, columnMetadata) - { - var queryFields = [], - queryFieldKeys = [], - columnTypes = LABKEY.Utils.isDefined(columnList.columns) ? columnList.columns : {}; - - $.each(columnMetadata, function(idx, column) - { - var f = $.extend(true, {}, column); - f.schemaName = queryConfig.schemaName; - f.queryName = queryConfig.queryName; - f.isCohortColumn = false; - f.isSubjectGroupColumn = false; - - // issue 23224: distinguish cohort and subject group fields in the list of query columns - if (columnTypes['cohort'] && columnTypes['cohort'].indexOf(f.fieldKey) > -1) - { - f.shortCaption = 'Study: ' + f.shortCaption; - f.isCohortColumn = true; - } - else if (columnTypes['subjectGroup'] && columnTypes['subjectGroup'].indexOf(f.fieldKey) > -1) - { - f.shortCaption = columnList.subject.nounSingular + ' Group: ' + f.shortCaption; - f.isSubjectGroupColumn = true; - } - - // Issue 31672: keep track of the distinct query field keys so we don't get duplicates - if (f.fieldKey.toLowerCase() != 'lsid' && queryFieldKeys.indexOf(f.fieldKey) == -1) { - queryFields.push(f); - queryFieldKeys.push(f.fieldKey); - } - }, this); - - // Sorts fields by their shortCaption, but put subject groups/categories/cohort at the end. - queryFields.sort(function(a, b) - { - if (a.isSubjectGroupColumn != b.isSubjectGroupColumn) - return a.isSubjectGroupColumn ? 1 : -1; - else if (a.isCohortColumn != b.isCohortColumn) - return a.isCohortColumn ? 1 : -1; - else if (a.shortCaption != b.shortCaption) - return a.shortCaption < b.shortCaption ? -1 : 1; - - return 0; - }); - - return queryFields; - }; - - /** - * Determine a reasonable width for the chart based on the chart type and selected measures / data. - * @param chartType - * @param measures - * @param measureStore - * @param defaultWidth - * @returns {int} - */ - 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); - - 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); - } - - return width; - }; - - /** - * Return the distinct set of y-axis sides for the given measures object. - * @param measures - */ - var getDistinctYAxisSides = function(measures) - { - var distinctSides = []; - $.each(ensureMeasuresAsArray(measures.y), function (idx, measure) { - if (LABKEY.Utils.isObject(measure)) { - var side = measure.yAxis || 'left'; - if (distinctSides.indexOf(side) === -1) { - distinctSides.push(side); - } - } - }, this); - return distinctSides; - }; - - /** - * Generate a default label for an array of measures by concatenating each meaures label together. - * @param measures - * @returns string concatenation of all measure labels - */ - var getDefaultMeasuresLabel = function(measures) - { - if (LABKEY.Utils.isDefined(measures)) { - if (!LABKEY.Utils.isArray(measures)) { - return measures.label || measures.queryName || ''; - } - - var label = '', sep = ''; - $.each(measures, function(idx, m) { - label += sep + (m.label || m.queryName); - sep = ', '; - }); - return label; - } - - return ''; - }; - - /** - * Given the saved labels object we convert it to include all label types (main, x, and y). Each label type defaults - * to empty string (''). - * @param {Object} labels The saved labels object. - * @returns {Object} - */ - var generateLabels = function(labels) { - return { - main: { value: labels.main || '' }, - subtitle: { value: labels.subtitle || '' }, - footer: { value: labels.footer || '' }, - x: { value: labels.x || '' }, - y: { value: labels.y || '' }, - yRight: { value: labels.yRight || '' } - }; - }; - - /** - * Generates an object containing {@link LABKEY.vis.Scale} objects used for the chart. - * @param {String} chartType The chartType from getChartType. - * @param {Object} measures The measures from generateMeasures. - * @param {Object} savedScales The scales object from the saved chart config. - * @param {Object} aes The aesthetic map object from genereateAes. - * @param {Object} measureStore The MeasureStore data using a selectRows API call. - * @param {Function} defaultFormatFn used to format values for tick marks. - * @returns {Object} - */ - var generateScales = function(chartType, measures, savedScales, aes, measureStore, defaultFormatFn) { - var scales = {}; - var data = LABKEY.Utils.isArray(measureStore.rows) ? measureStore.rows : measureStore.records(); - var fields = LABKEY.Utils.isObject(measureStore.metaData) ? measureStore.metaData.fields : measureStore.getResponseMetadata().fields; - var subjectColumn = getStudySubjectInfo().columnName; - var visitTableName = getStudySubjectInfo().tableName + 'Visit'; - var visitColName = visitTableName + '/Visit'; - var valExponentialDigits = 6; - - // Issue 38105: For plots with study visit labels on the x-axis, don't sort alphabetically - var sortFnX = measures.x && measures.x.fieldKey === visitColName ? undefined : LABKEY.vis.discreteSortFn; - - if (chartType === "box_plot") - { - scales.x = { - scaleType: 'discrete', // Force discrete x-axis scale for box plots. - sortFn: sortFnX, - tickLabelMax: DEFAULT_TICK_LABEL_MAX - }; - - var yMin = d3.min(data, aes.y); - var yMax = d3.max(data, aes.y); - var yPadding = ((yMax - yMin) * .1); - if (savedScales.y && savedScales.y.trans == "log") - { - // When subtracting padding we have to make sure we still produce valid values for a log scale. - // log([value less than 0]) = NaN. - // log(0) = -Infinity. - if (yMin - yPadding > 0) - { - yMin = yMin - yPadding; - } - } - else - { - yMin = yMin - yPadding; - } - - scales.y = { - min: yMin, - max: yMax + yPadding, - scaleType: 'continuous', - trans: savedScales.y ? savedScales.y.trans : 'linear' - }; - } - else - { - var xMeasureType = getMeasureType(measures.x); - - // Force discrete x-axis scale for bar plots. - var useContinuousScale = chartType != 'bar_chart' && isNumericType(xMeasureType); - - if (useContinuousScale) - { - scales.x = { - scaleType: 'continuous', - trans: savedScales.x ? savedScales.x.trans : 'linear' - }; - } - else - { - scales.x = { - scaleType: 'discrete', - sortFn: sortFnX, - tickLabelMax: DEFAULT_TICK_LABEL_MAX - }; - - //bar chart x-axis subcategories support - if (LABKEY.Utils.isDefined(measures.xSub)) { - scales.xSub = { - scaleType: 'discrete', - sortFn: LABKEY.vis.discreteSortFn, - tickLabelMax: DEFAULT_TICK_LABEL_MAX - }; - } - } - - // add both y (i.e. yLeft) and yRight, in case multiple y-axis measures are being plotted - scales.y = { - scaleType: 'continuous', - trans: savedScales.y ? savedScales.y.trans : 'linear' - }; - scales.yRight = { - scaleType: 'continuous', - trans: savedScales.yRight ? savedScales.yRight.trans : 'linear' - }; - } - - // if we have no data, show a default y-axis domain - if (scales.x && data.length == 0 && scales.x.scaleType == 'continuous') - scales.x.domain = [0,1]; - if (scales.y && data.length == 0) - scales.y.domain = [0,1]; - - // apply the field formatFn to the tick marks on the scales object - for (var i = 0; i < fields.length; i++) { - var type = fields[i].displayFieldJsonType ? fields[i].displayFieldJsonType : fields[i].type; - - var isMeasureXMatch = measures.x && _isFieldKeyMatch(measures.x, fields[i].fieldKey); - if (isMeasureXMatch && measures.x.name === subjectColumn && LABKEY.demoMode) { - scales.x.tickFormat = function(){return '******'}; - } - else if (isMeasureXMatch && isNumericType(type)) { - scales.x.tickFormat = _getNumberFormatFn(fields[i], defaultFormatFn); - } - - var yMeasures = ensureMeasuresAsArray(measures.y); - $.each(yMeasures, function(idx, yMeasure) { - var isMeasureYMatch = yMeasure && _isFieldKeyMatch(yMeasure, fields[i].fieldKey); - var isConvertedYMeasure = isMeasureYMatch && yMeasure.converted; - if (isMeasureYMatch && (isNumericType(type) || isConvertedYMeasure)) { - var tickFormatFn = _getNumberFormatFn(fields[i], defaultFormatFn); - - var ySide = yMeasure.yAxis === 'right' ? 'yRight' : 'y'; - scales[ySide].tickFormat = function(value) { - if (LABKEY.Utils.isNumber(value) && Math.abs(Math.round(value)).toString().length >= valExponentialDigits) { - return value.toExponential(); - } - else if (LABKEY.Utils.isFunction(tickFormatFn)) { - return tickFormatFn(value); - } - return value; - }; - } - }, this); - } - - _applySavedScaleDomain(scales, savedScales, 'x'); - if (LABKEY.Utils.isDefined(measures.xSub)) { - _applySavedScaleDomain(scales, savedScales, 'xSub'); - } - if (LABKEY.Utils.isDefined(measures.y)) { - _applySavedScaleDomain(scales, savedScales, 'y'); - _applySavedScaleDomain(scales, savedScales, 'yRight'); - } - - return scales; - }; - - // Issue 36227: if Ext4 is not available, try to generate our own number format function based on the "format" field metadata - var _getNumberFormatFn = function(field, defaultFormatFn) { - if (field.extFormatFn) { - if (window.Ext4) { - return eval(field.extFormatFn); - } - else if (field.format && LABKEY.Utils.isString(field.format) && field.format.indexOf('.') > -1) { - var precision = field.format.length - field.format.indexOf('.') - 1; - return function(v) { - return LABKEY.Utils.isNumber(v) ? v.toFixed(precision) : v; - } - } - } - - return defaultFormatFn; - }; - - var _isFieldKeyMatch = function(measure, fieldKey) { - if (LABKEY.Utils.isFunction(fieldKey.getName)) { - return fieldKey.getName() === measure.name || fieldKey.getName() === measure.fieldKey; - } else if (LABKEY.Utils.isArray(fieldKey)) { - fieldKey = fieldKey.join('/') - } - - return fieldKey === measure.name || fieldKey === measure.fieldKey; - }; - - var ensureMeasuresAsArray = function(measures) { - if (LABKEY.Utils.isDefined(measures)) { - return LABKEY.Utils.isArray(measures) ? $.extend(true, [], measures) : [$.extend(true, {}, measures)]; - } - return []; - }; - - var _applySavedScaleDomain = function(scales, savedScales, scaleName) { - if (savedScales[scaleName] && (savedScales[scaleName].min != null || savedScales[scaleName].max != null)) { - scales[scaleName].domain = [savedScales[scaleName].min, savedScales[scaleName].max]; - } - }; - - /** - * Generates the aesthetic map object needed by the visualization API to render the chart. See {@link LABKEY.vis.Plot} - * and {@link LABKEY.vis.Layer}. - * @param {String} chartType The chartType from getChartType. - * @param {Object} measures The measures from getMeasures. - * @param {String} schemaName The schemaName from the saved queryConfig. - * @param {String} queryName The queryName from the saved queryConfig. - * @returns {Object} - */ - 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); - - if (chartType === "box_plot") { - 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); - } - } else if (isNumericType(xMeasureType) || (chartType === 'scatter_plot' && measures.x.measure)) { - aes.x = generateContinuousAcc(xMeasureName); - } else { - aes.x = generateDiscreteAcc(xMeasureName, measures.x.label); - } - - // charts that have multiple y-measures selected will need to put the aes.y function on their specific layer - if (LABKEY.Utils.isDefined(measures.y) && !LABKEY.Utils.isArray(measures.y)) - { - var sideAesName = (measures.y.yAxis || 'left') === 'left' ? 'y' : 'yRight'; - var yMeasureName = measures.y.converted ? measures.y.convertedName : measures.y.name; - aes[sideAesName] = generateContinuousAcc(yMeasureName); - } - - if (chartType === "scatter_plot" || chartType === "line_plot") - { - aes.hoverText = generatePointHover(measures); - } - - if (chartType === "box_plot") - { - if (measures.color) { - aes.outlierColor = generateGroupingAcc(measures.color.name); - } - - if (measures.shape) { - aes.outlierShape = generateGroupingAcc(measures.shape.name); - } - - aes.hoverText = generateBoxplotHover(); - aes.outlierHoverText = generatePointHover(measures); - } - else if (chartType === 'bar_chart') - { - var xSubMeasureType = measures.xSub ? getMeasureType(measures.xSub) : null; - if (xSubMeasureType) - { - if (isNumericType(xSubMeasureType)) - aes.xSub = generateContinuousAcc(measures.xSub.name); - else - aes.xSub = generateDiscreteAcc(measures.xSub.name, measures.xSub.label); - } - } - - // color/shape aes are not dependent on chart type. If we have a box plot with all points enabled, then we - // create a second layer for points. So we'll need this no matter what. - if (measures.color) { - aes.color = generateGroupingAcc(measures.color.name); - } - - if (measures.shape) { - aes.shape = generateGroupingAcc(measures.shape.name); - } - - // also add the color and shape for the line plot series. - if (measures.series) { - aes.color = generateGroupingAcc(measures.series.name); - aes.shape = generateGroupingAcc(measures.series.name); - } - - if (measures.pointClickFn) { - aes.pointClickFn = generatePointClickFn( - measures, - schemaName, - queryName, - measures.pointClickFn - ); - } - - return aes; - }; - - var getYMeasureAes = function(measure) { - var yMeasureName = measure.converted ? measure.convertedName : measure.name; - return generateContinuousAcc(yMeasureName); - }; - - /** - * Generates a function that returns the text used for point hovers. - * @param {Object} measures The measures object from the saved chart config. - * @returns {Function} - */ - var generatePointHover = function(measures) - { - return function(row) { - var hover = '', sep = '', distinctNames = []; - - $.each(measures, function(key, measureObj) { - var measureArr = ensureMeasuresAsArray(measureObj); - $.each(measureArr, function(idx, measure) { - if (LABKEY.Utils.isObject(measure) && !LABKEY.Utils.isEmptyObj(measure) && distinctNames.indexOf(measure.name) == -1) { - 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); - }); - - return hover; - }; - }; - - /** - * Backwards compatibility for function that has been moved to LABKEY.vis.getAggregateData. - */ - var generateAggregateData = function(data, dimensionName, measureName, aggregate, nullDisplayValue) { - return LABKEY.vis.getAggregateData(data, dimensionName, null, measureName, aggregate, nullDisplayValue, false); - }; - - var _getRowValue = function(row, propName, valueName) - { - if (row.hasOwnProperty(propName)) { - // backwards compatibility for response row that is not a LABKEY.Query.Row - if (!(row instanceof LABKEY.Query.Row)) { - return row[propName].formattedValue || row[propName].displayValue || row[propName].value; - } - - var propValue = row.get(propName); - if (valueName != undefined && propValue.hasOwnProperty(valueName)) { - return propValue[valueName]; - } - else if (propValue.hasOwnProperty('formattedValue')) { - return propValue['formattedValue']; - } - else if (propValue.hasOwnProperty('displayValue')) { - return propValue['displayValue']; - } - return row.getValue(propName); - } - - return undefined; - }; - - /** - * Returns a function used to generate the hover text for box plots. - * @returns {Function} - */ - var generateBoxplotHover = function() { - return function(xValue, stats) { - return xValue + ':\nMin: ' + stats.min + '\nMax: ' + stats.max + '\nQ1: ' + stats.Q1 + '\nQ2: ' + stats.Q2 + - '\nQ3: ' + stats.Q3; - }; - }; - - /** - * Generates an accessor function that returns a discrete value from a row of data for a given measure and label. - * Used when an axis has a discrete measure (i.e. string). - * @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 - * @returns {Function} - */ - var generateDiscreteAcc = function(measureName, measureLabel, nullValueLabel) - { - return function(row) - { - var value = _getRowValue(row, measureName); - if (value === null) - value = nullValueLabel !== undefined ? nullValueLabel : "Not in " + measureLabel; - - return value; - }; - }; - - /** - * Generates an accessor function that returns a value from a row of data for a given measure. - * @param {String} measureName The name of the measure. - * @returns {Function} - */ - var generateContinuousAcc = function(measureName) - { - return function(row) - { - var value = _getRowValue(row, measureName, 'value'); - - if (value !== undefined) - { - if (Math.abs(value) === Infinity) - value = null; - - if (value === false || value === true) - value = value.toString(); - - return value; - } - - return undefined; - } - }; - - /** - * Generates an accesssor function for shape and color measures. - * @param {String} measureName The name of the measure. - * @returns {Function} - */ - var generateGroupingAcc = function(measureName) - { - return function(row) - { - var value = null; - if (LABKEY.Utils.isArray(row) && row.length > 0) { - value = _getRowValue(row[0], measureName); - } - else { - value = _getRowValue(row, measureName); - } - - if (value === null || value === undefined) - value = "n/a"; - - return value; - }; - }; - - /** - * Generates an accessor for boxplots that do not have an x-axis measure. Generally the measureName passed in is the - * queryName. - * @param {String} measureName The name of the measure. In this case it is generally the query name. - * @returns {Function} - */ - var generateMeasurelessAcc = function(measureName) { - // Used for box plots that do not have an x-axis measure. Instead we just return the queryName for every row. - return function(row) { - return measureName; - } - }; - - /** - * Generates the function to be executed when a user clicks a point. - * @param {Object} measures The measures from the saved chart config. - * @param {String} schemaName The schema name from the saved query config. - * @param {String} queryName The query name from the saved query config. - * @param {String} fnString The string value of the user-provided function to be executed when a point is clicked. - * @returns {Function} - */ - var generatePointClickFn = function(measures, schemaName, queryName, fnString){ - var measureInfo = { - schemaName: schemaName, - queryName: queryName - }; - - _addPointClickMeasureInfo(measureInfo, measures, 'x', 'xAxis'); - _addPointClickMeasureInfo(measureInfo, measures, 'y', 'yAxis'); - $.each(['color', 'shape', 'series'], function(idx, name) { - _addPointClickMeasureInfo(measureInfo, measures, name, name + 'Name'); - }, this); - - // using new Function is quicker than eval(), even in IE. - var pointClickFn = new Function('return ' + fnString)(); - return function(clickEvent, data){ - pointClickFn(data, measureInfo, clickEvent); - }; - }; - - var _addPointClickMeasureInfo = function(measureInfo, measures, name, key) { - if (LABKEY.Utils.isDefined(measures[name])) { - var measuresArr = ensureMeasuresAsArray(measures[name]); - $.each(measuresArr, function(idx, measure) { - if (!LABKEY.Utils.isDefined(measureInfo[key])) { - measureInfo[key] = measure.name; - } - else if (!LABKEY.Utils.isDefined(measureInfo[measure.name])) { - measureInfo[measure.name] = measure.name; - } - }, this); - } - }; - - /** - * Generates the Point Geom used for scatter plots and box plots with all points visible. - * @param {Object} chartOptions The saved chartOptions object from the chart config. - * @returns {LABKEY.vis.Geom.Point} - */ - var generatePointGeom = function(chartOptions){ - return new LABKEY.vis.Geom.Point({ - opacity: chartOptions.opacity, - size: chartOptions.pointSize, - color: '#' + chartOptions.pointFillColor, - position: chartOptions.position - }); - }; - - /** - * Generates the Boxplot Geom used for box plots. - * @param {Object} chartOptions The saved chartOptions object from the chart config. - * @returns {LABKEY.vis.Geom.Boxplot} - */ - var generateBoxplotGeom = function(chartOptions){ - return new LABKEY.vis.Geom.Boxplot({ - lineWidth: chartOptions.lineWidth, - outlierOpacity: chartOptions.opacity, - outlierFill: '#' + chartOptions.pointFillColor, - outlierSize: chartOptions.pointSize, - color: '#' + chartOptions.lineColor, - fill: chartOptions.boxFillColor == 'none' ? chartOptions.boxFillColor : '#' + chartOptions.boxFillColor, - position: chartOptions.position, - showOutliers: chartOptions.showOutliers - }); - }; - - /** - * Generates the Barplot Geom used for bar charts. - * @param {Object} chartOptions The saved chartOptions object from the chart config. - * @returns {LABKEY.vis.Geom.BarPlot} - */ - var generateBarGeom = function(chartOptions){ - return new LABKEY.vis.Geom.BarPlot({ - opacity: chartOptions.opacity, - color: '#' + chartOptions.lineColor, - fill: '#' + chartOptions.boxFillColor, - lineWidth: chartOptions.lineWidth - }); - }; - - /** - * Generates the Bin Geom used to bin a set of points. - * @param {Object} chartOptions The saved chartOptions object from the chart config. - * @returns {LABKEY.vis.Geom.Bin} - */ - var generateBinGeom = function(chartOptions) { - var colorRange = ["#e6e6e6", "#085D90"]; //light-gray and labkey blue is default - if (chartOptions.binColorGroup == 'SingleColor') { - var color = '#' + chartOptions.binSingleColor; - colorRange = ["#FFFFFF", color]; - } - else if (chartOptions.binColorGroup == 'Heat') { - colorRange = ["#fff6bc", "#e23202"]; - } - - return new LABKEY.vis.Geom.Bin({ - shape: chartOptions.binShape, - colorRange: colorRange, - size: chartOptions.binShape == 'square' ? 10 : 5 - }) - }; - - /** - * Generates a Geom based on the chartType. - * @param {String} chartType The chart type from getChartType. - * @param {Object} chartOptions The chartOptions object from the saved chart config. - * @returns {LABKEY.vis.Geom} - */ - var generateGeom = function(chartType, chartOptions) { - if (chartType == "box_plot") - return generateBoxplotGeom(chartOptions); - else if (chartType == "scatter_plot" || chartType == "line_plot") - return chartOptions.binned ? generateBinGeom(chartOptions) : generatePointGeom(chartOptions); - else if (chartType == "bar_chart") - return generateBarGeom(chartOptions); - }; - - /** - * Generate an array of plot configs for the given chart renderType and config options. - * @param renderTo - * @param chartConfig - * @param labels - * @param aes - * @param scales - * @param geom - * @param data - * @param trendlineData - * @returns {Array} array of plot config objects - */ - var generatePlotConfigs = function(renderTo, chartConfig, labels, aes, scales, geom, data, trendlineData) - { - var plotConfigArr = []; - - // if we have multiple y-measures and the request is to plot them separately, call the generatePlotConfig function - // for each y-measure separately with its own copy of the chartConfig object - if (chartConfig.geomOptions.chartLayout === 'per_measure' && LABKEY.Utils.isArray(chartConfig.measures.y)) { - - // if 'automatic across charts' scales are requested, need to manually calculate the min and max - if (chartConfig.scales.y && chartConfig.scales.y.type === 'automatic') { - scales.y = $.extend(scales.y, _getScaleDomainValuesForAllMeasures(data, chartConfig.measures.y, 'left')); - } - if (chartConfig.scales.yRight && chartConfig.scales.yRight.type === 'automatic') { - scales.yRight = $.extend(scales.yRight, _getScaleDomainValuesForAllMeasures(data, chartConfig.measures.y, 'right')); - } - - $.each(chartConfig.measures.y, function(idx, yMeasure) { - // copy the config and reset the measures.y array with the single measure - var newChartConfig = $.extend(true, {}, chartConfig); - newChartConfig.measures.y = $.extend(true, {}, yMeasure); - - // copy the labels object so that we can set the subtitle based on the y-measure - var newLabels = $.extend(true, {}, labels); - newLabels.subtitle = {value: yMeasure.label || yMeasure.name}; - - // only copy over the scales that are needed for this measures - var side = yMeasure.yAxis || 'left'; - var newScales = {x: $.extend(true, {}, scales.x)}; - if (side === 'left') { - newScales.y = $.extend(true, {}, scales.y); - } - else { - newScales.yRight = $.extend(true, {}, scales.yRight); - } - - plotConfigArr.push(generatePlotConfig(renderTo, newChartConfig, newLabels, aes, newScales, geom, data, trendlineData)); - }, this); - } - else { - plotConfigArr.push(generatePlotConfig(renderTo, chartConfig, labels, aes, scales, geom, data, trendlineData)); - } - - return plotConfigArr; - }; - - var _getScaleDomainValuesForAllMeasures = function(data, measures, side) { - var min = null, max = null; - - $.each(measures, function(idx, measure) { - var measureSide = measure.yAxis || 'left'; - if (side === measureSide) { - var accFn = LABKEY.vis.GenericChartHelper.getYMeasureAes(measure); - var tempMin = d3.min(data, accFn); - var tempMax = d3.max(data, accFn); - - if (min == null || tempMin < min) { - min = tempMin; - } - if (max == null || tempMax > max) { - max = tempMax; - } - } - }, this); - - return {domain: [min, max]}; - }; - - /** - * Generate the plot config for the given chart renderType and config options. - * @param renderTo - * @param chartConfig - * @param labels - * @param aes - * @param scales - * @param geom - * @param data - * @param trendlineData - * @returns {Object} - */ - var generatePlotConfig = function(renderTo, chartConfig, labels, aes, scales, geom, data, trendlineData) - { - var renderType = chartConfig.renderType, - layers = [], clipRect, - emptyTextFn = function(){return '';}, - plotConfig = { - renderTo: renderTo, - rendererType: 'd3', - width: chartConfig.width, - height: chartConfig.height, - gridLinesVisible: chartConfig.gridLinesVisible, - }; - - if (renderType === 'pie_chart') { - return _generatePieChartConfig(plotConfig, chartConfig, labels, data); - } - - clipRect = (scales.x && LABKEY.Utils.isArray(scales.x.domain)) || (scales.y && LABKEY.Utils.isArray(scales.y.domain)); - - // account for line chart hiding points - if (chartConfig.geomOptions.hideDataPoints) { - geom = null; - } - - // account for one or many y-measures by ensuring that we have an array of y-measures - var yMeasures = ensureMeasuresAsArray(chartConfig.measures.y); - - if (renderType === 'bar_chart') { - aes = { x: 'label', y: 'value' }; - - if (LABKEY.Utils.isDefined(chartConfig.measures.xSub)) - { - aes.xSub = 'subLabel'; - aes.color = 'label'; - } - - if (!scales.y) { - scales.y = {}; - } - - if (!scales.y.domain) { - var values = $.map(data, d => d.value + (d.error ?? 0)), - min = Math.min(0, Math.min.apply(Math, values)), - max = Math.max(0, Math.max.apply(Math, values)); - - scales.y.domain = [min, max]; - } else if (!scales.y.domain[0]) { - // if user has set a max but not a min, default to 0 for bar chart - scales.y.domain[0] = 0; - } - } - else if (renderType === 'box_plot' && chartConfig.pointType === 'all') - { - layers.push( - new LABKEY.vis.Layer({ - geom: LABKEY.vis.GenericChartHelper.generatePointGeom(chartConfig.geomOptions), - aes: {hoverText: LABKEY.vis.GenericChartHelper.generatePointHover(chartConfig.measures)} - }) - ); - } - else if (renderType === 'line_plot') { - var xName = chartConfig.measures.x.name, - isDate = isDateType(getMeasureType(chartConfig.measures.x)); - - $.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); - } - return aVal - bVal; - }, - hoverText: emptyTextFn(), - }; - - pathAes[yMeasure.yAxis === 'right' ? 'yRight' : 'yLeft'] = getYMeasureAes(yMeasure); - - // use the series measure's values for the distinct colors and grouping - const hasSeries = chartConfig.measures.series !== undefined; - if (hasSeries) { - pathAes.pathColor = generateGroupingAcc(chartConfig.measures.series.name); - pathAes.group = generateGroupingAcc(chartConfig.measures.series.name); - pathAes.hoverText = function (row) { return chartConfig.measures.series.label + ': ' + row.group }; - } - // if no series measures but we have multiple y-measures, force the color and grouping to be distinct for each measure - else if (yMeasures.length > 1) { - pathAes.pathColor = emptyTextFn; - pathAes.group = emptyTextFn; - } - - if (trendlineData) { - trendlineData.forEach(trendline => { - if (trendline.data) { - const layerAes = { x: 'x', y: 'y' }; - if (hasSeries) { - layerAes.pathColor = function () { return trendline.name }; - } - - layerAes.hoverText = generateTrendlinePathHover(trendline); - - layers.push( - new LABKEY.vis.Layer({ - geom: new LABKEY.vis.Geom.Path({ - color: '#' + chartConfig.geomOptions.pointFillColor, - size: chartConfig.geomOptions.lineWidth ? chartConfig.geomOptions.lineWidth : 3, - opacity:chartConfig.geomOptions.opacity, - }), - aes: layerAes, - data: trendline.data.generatedPoints, - }) - ); - } - }); - } else { - layers.push( - new LABKEY.vis.Layer({ - name: yMeasures.length > 1 ? yMeasure.label || yMeasure.name : undefined, - geom: new LABKEY.vis.Geom.Path({ - color: '#' + chartConfig.geomOptions.pointFillColor, - size: chartConfig.geomOptions.lineWidth?chartConfig.geomOptions.lineWidth:3, - opacity:chartConfig.geomOptions.opacity - }), - aes: pathAes - }) - ); - } - }, this); - } - - // Issue 34711: better guess at the max number of discrete x-axis tick mark labels to show based on the plot width - if (scales.x && scales.x.scaleType === 'discrete' && scales.x.tickLabelMax) { - // approx 30 px for a 45 degree rotated tick label - scales.x.tickLabelMax = Math.floor((plotConfig.width - 300) / 30); - } - - var margins = _getPlotMargins(renderType, scales, aes, data, plotConfig, chartConfig); - if (LABKEY.Utils.isObject(margins)) { - plotConfig.margins = margins; - } - - if (chartConfig.measures.color) - { - scales.color = { - colorType: chartConfig.geomOptions.colorPaletteScale, - scaleType: 'discrete' - } - } - - if ((renderType === 'line_plot' || renderType === 'scatter_plot') && yMeasures.length > 0) { - $.each(yMeasures, function (idx, yMeasure) { - var layerAes = {}; - layerAes[yMeasure.yAxis === 'right' ? 'yRight' : 'yLeft'] = getYMeasureAes(yMeasure); - - // if no series measures but we have multiple y-measures, force the color and shape to be distinct for each measure - if (!aes.color && yMeasures.length > 1) { - layerAes.color = emptyTextFn; - } - if (!aes.shape && yMeasures.length > 1) { - 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, - geom: geom, - aes: layerAes - }) - ); - }, this); - } - else { - layers.push( - new LABKEY.vis.Layer({ - data: data, - geom: geom - }) - ); - } - - plotConfig = $.extend(plotConfig, { - clipRect: clipRect, - data: data, - labels: labels, - aes: aes, - scales: scales, - layers: layers - }); - - return plotConfig; - }; - - const hasPremiumModule = function() { - return LABKEY.getModuleContext('api').moduleNames.indexOf('premium') > -1; - }; - - const TRENDLINE_OPTIONS = { - '': { label: 'Point-to-Point', value: '' }, - 'Linear': { label: 'Linear Regression', value: 'Linear', equation: 'y = x * slope + intercept' }, - 'Polynomial': { label: 'Polynomial', value: 'Polynomial', equation: 'y = a0 + a1 * x + a2 * x^2' }, - '3 Parameter': { label: 'Nonlinear 3PL', value: '3 Parameter', showMax: true, schemaPrefix: 'assay', equation: 'y = max * abs(x/inflection)^abs(slope) / [1 + abs(x/inflection)^abs(slope)]' }, - 'Three Parameter': { label: 'Nonlinear 3PL (Alternate)', value: 'Three Parameter', showMax: true, schemaPrefix: 'assay', equation: 'y = max / [1 + (inflection - x) * slope]' }, - '4 Parameter': { label: 'Nonlinear 4PL', value: '4 Parameter', schemaPrefix: 'assay', equation: 'y = max + (min - max) / [1 + (x/inflection)^slope]' }, - 'Four Parameter': { label: 'Nonlinear 4PL (Alternate)', value: 'Four Parameter', showMin: true, showMax: true, schemaPrefix: 'assay', equation: 'y = min + (max - min) / [1 + (inflection - x) * slope]' }, - 'Five Parameter': { label: 'Nonlinear 5PL', value: 'Five Parameter', showMin: true, showMax: true, schemaPrefix: 'assay', equation: 'y = min + (max - min) / [[1 + (inflection - x) * slope]^asymmetry]' }, - } - - const generateTrendlinePathHover = function(trendline) { - let hoverText = trendline.name + '\n'; - hoverText += '\n' + TRENDLINE_OPTIONS[trendline.data.curveFit.type].label + ':\n'; - Object.entries(trendline.data.curveFit).forEach(([key, value]) => { - if (key === 'coefficients') { - hoverText += key + ': '; - value.forEach((v, i) => { - hoverText += (i > 0 ? ', ' : '') + LABKEY.Utils.roundNumber(v, 4); - }); - hoverText += '\n'; - } - else if (key !== 'type') { - hoverText += key + ': ' + LABKEY.Utils.roundNumber(value, 4) + '\n'; - } - }); - hoverText += '\nStatistics:\n'; - Object.entries(trendline.data.stats).forEach(([key, value]) => { - const label = key === 'RSquared' ? 'R-Squared' : (key === 'adjustedRSquared' ? 'Adjusted R-Squared' : key); - hoverText += label + ': ' + LABKEY.Utils.roundNumber(value, 4) + '\n'; - }); - - return function () { return hoverText }; - }; - - // support for y-axis trendline data when a single y-axis measure is selected - const queryTrendlineData = async function(chartConfig, data) { - const chartType = getChartType(chartConfig); - const yMeasures = ensureMeasuresAsArray(chartConfig.measures.y); - if (chartType === 'line_plot' && chartConfig.geomOptions?.trendlineType && chartConfig.geomOptions.trendlineType !== '' && yMeasures.length === 1) { - const xName = chartConfig.measures.x.name; - const trendlineConfig = getTrendlineConfig(chartConfig, data); - try { - await _queryTrendlineData(trendlineConfig, xName, yMeasures[0].name); - return trendlineConfig.data; - } catch (reason) { - // skip this series and render without trendline - return trendlineConfig.data; - } - } - - return undefined; - }; - - const getTrendlineConfig = function(chartConfig, data) { - const config = { - type: chartConfig.geomOptions.trendlineType, - logXScale: chartConfig.scales.x && chartConfig.scales.x.trans === 'log', - asymptoteMin: chartConfig.geomOptions.trendlineAsymptoteMin, - asymptoteMax: chartConfig.geomOptions.trendlineAsymptoteMax, - data: chartConfig.measures.series - ? LABKEY.vis.groupCountData(data, generateGroupingAcc(chartConfig.measures.series.name)) - : [{name: 'All', rawData: data}], - }; - - // special case to only use logXScale for linear trendlines - if (config.type === 'Linear') { - config.logXScale = false; - } - - return config; - }; - - const _queryTrendlineData = async function(trendlineConfig, xName, yName) { - for (let series of trendlineConfig.data) { - try { - // we need at least 2 data points for curve fitting - if (series.rawData.length > 1) { - series.data = await _querySeriesTrendlineData(trendlineConfig, series, xName, yName); - } - } catch (e) { - console.error(e); - } - } - }; - - const _querySeriesTrendlineData = function(trendlineConfig, seriesData, xName, yName) { - return new Promise(function(resolve, reject) { - if (!hasPremiumModule()) { - reject('Premium module required for curve fitting.'); - return; - } - - const points = seriesData.rawData.map(function(row) { - return { - x: _getRowValue(row, xName, 'value'), - y: _getRowValue(row, yName, 'value'), - }; - }); - const xAcc = function(row) { return row.x }; - const xMin = d3.min(points, xAcc); - const xMax = d3.max(points, xAcc); - - LABKEY.Ajax.request({ - url: LABKEY.ActionURL.buildURL('premium', 'calculateCurveFit.api'), - method: 'POST', - jsonData: { - curveFitType: trendlineConfig.type, - points: points, - logXScale: trendlineConfig.logXScale, - asymptoteMin: trendlineConfig.asymptoteMin, - asymptoteMax: trendlineConfig.asymptoteMax, - xMin: xMin, - xMax: xMax, - numberOfPoints: 1000, - }, - success : LABKEY.Utils.getCallbackWrapper(function(response) { - resolve(response); - }), - failure : LABKEY.Utils.getCallbackWrapper(function(reason) { - reject(reason); - }, this, true), - }); - }); - }; - - 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; - // 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 1; - }; - - var _getPlotMargins = function(renderType, scales, aes, data, plotConfig, chartConfig) { - var margins = {}; - - // issue 29690: for bar and box plots, set default bottom margin based on the number of labels and the max label length - if (LABKEY.Utils.isArray(data)) { - var maxLen = 0; - $.each(data, function(idx, d) { - var val = LABKEY.Utils.isFunction(aes.x) ? aes.x(d) : d[aes.x]; - var subVal = LABKEY.Utils.isFunction(aes.xSub) ? aes.xSub(d) : d[aes.xSub]; - if (LABKEY.Utils.isString(subVal)) { - maxLen = Math.max(maxLen, subVal.length); - } else if (LABKEY.Utils.isString(val)) { - maxLen = Math.max(maxLen, val.length); - } - }); - - 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 - if (chartConfig && chartConfig.geomOptions) { - if (chartConfig.geomOptions.marginTop !== null) { - margins.top = chartConfig.geomOptions.marginTop; - } - if (chartConfig.geomOptions.marginRight !== null) { - margins.right = chartConfig.geomOptions.marginRight; - } - if (chartConfig.geomOptions.marginBottom !== null) { - margins.bottom = chartConfig.geomOptions.marginBottom; - } - if (chartConfig.geomOptions.marginLeft !== null) { - margins.left = chartConfig.geomOptions.marginLeft; - } - } - - return !LABKEY.Utils.isEmptyObj(margins) ? margins : null; - }; - - var _generatePieChartConfig = function(baseConfig, chartConfig, labels, data) - { - var hasData = data.length > 0; - - return $.extend(baseConfig, { - data: hasData ? data : [{label: '', value: 1}], - header: { - title: { text: labels.main.value }, - subtitle: { text: labels.subtitle.value }, - titleSubtitlePadding: 1 - }, - footer: { - text: hasData ? labels.footer.value : 'No data to display', - location: 'bottom-center' - }, - labels: { - mainLabel: { fontSize: 14 }, - percentage: { - fontSize: 14, - color: chartConfig.geomOptions.piePercentagesColor != null ? '#' + chartConfig.geomOptions.piePercentagesColor : undefined - }, - outer: { pieDistance: 20 }, - inner: { - format: hasData && chartConfig.geomOptions.showPiePercentages ? 'percentage' : 'none', - hideWhenLessThanPercentage: chartConfig.geomOptions.pieHideWhenLessThanPercentage - } - }, - size: { - pieInnerRadius: hasData ? chartConfig.geomOptions.pieInnerRadius + '%' : '100%', - pieOuterRadius: hasData ? chartConfig.geomOptions.pieOuterRadius + '%' : '90%' - }, - misc: { - gradient: { - enabled: chartConfig.geomOptions.gradientPercentage != 0, - percentage: chartConfig.geomOptions.gradientPercentage, - color: '#' + chartConfig.geomOptions.gradientColor - }, - colors: { - segments: hasData ? LABKEY.vis.Scale[chartConfig.geomOptions.colorPaletteScale]() : ['#333333'] - } - }, - effects: { highlightSegmentOnMouseover: false }, - tooltips: { enabled: true } - }); - }; - - /** - * Check if the MeasureStore selectRows API response has data. Return an error string if no data exists. - * @param measureStore - * @param includeFilterMsg true to include a message about removing filters - * @returns {String} - */ - var validateResponseHasData = function(measureStore, includeFilterMsg) - { - var dataArray = getMeasureStoreRecords(measureStore); - if (dataArray.length == 0) - { - return 'The response returned 0 rows of data. The query may be empty or the applied filters may be too strict.' - + (includeFilterMsg ? 'Try removing or adjusting any filters if possible.' : ''); - } - - return null; - }; - - var getMeasureStoreRecords = function(measureStore) { - return LABKEY.Utils.isDefined(measureStore) ? measureStore.rows || measureStore.records() : []; - } - - /** - * Verifies that the axis measure is actually present and has data. Also checks to make sure that data can be used in a log - * scale (if applicable). Returns an object with a success parameter (boolean) and a message parameter (string). If the - * success parameter is false there is a critical error and the chart cannot be rendered. If success is true the chart - * can be rendered. Message will contain an error or warning message if applicable. If message is not null and success - * is true, there is a warning. - * @param {String} chartType The chartType from getChartType. - * @param {Object} chartConfigOrMeasure The saved chartConfig object or a specific measure object. - * @param {String} measureName The name of the axis measure property. - * @param {Object} aes The aes object from generateAes. - * @param {Object} scales The scales object from generateScales. - * @param {Array} data The response data from selectRows. - * @param {Boolean} dataConversionHappened Whether we converted any values in the measure data - * @returns {Object} - */ - var validateAxisMeasure = function(chartType, chartConfigOrMeasure, measureName, aes, scales, data, dataConversionHappened) { - var measure = LABKEY.Utils.isObject(chartConfigOrMeasure) && chartConfigOrMeasure.measures ? chartConfigOrMeasure.measures[measureName] : chartConfigOrMeasure; - return _validateAxisMeasure(chartType, measure, measureName, aes, scales, data, dataConversionHappened); - }; - - var _validateAxisMeasure = function(chartType, measure, measureName, aes, scales, data, dataConversionHappened) { - var dataIsNull = true, measureUndefined = true, invalidLogValues = false, hasZeroes = false, message = null; - - // no need to check measures if we have no data - if (data.length === 0) { - return {success: true, message: message}; - } - - for (var i = 0; i < data.length; i ++) - { - var value = aes[measureName](data[i]); - - if (value !== undefined) - measureUndefined = false; - - if (value !== null) - dataIsNull = false; - - if (value && value < 0) - invalidLogValues = true; - - if (value === 0 ) - hasZeroes = true; - } - - if (measureUndefined) - { - message = 'The measure, ' + measure.name + ', was not found. It may have been renamed or removed.'; - return {success: false, message: message}; - } - - if ((chartType == 'scatter_plot' || chartType == 'line_plot' || measureName == 'y') && dataIsNull && !dataConversionHappened) - { - message = 'All data values for ' + measure.label + ' are null. Please choose a different measure or review/remove data filters.'; - return {success: true, message: message}; - } - - if (scales[measureName] && scales[measureName].trans == "log") - { - if (invalidLogValues) - { - message = "Unable to use a log scale on the " + measureName + "-axis. All " + measureName - + "-axis values must be >= 0. Reverting to linear scale on " + measureName + "-axis."; - scales[measureName].trans = 'linear'; - } - else if (hasZeroes) - { - message = "Some " + measureName + "-axis values are 0. Plotting all " + measureName + "-axis values as value+1."; - var accFn = aes[measureName]; - aes[measureName] = function(row){return accFn(row) + 1}; - } - } - - return {success: true, message: message}; - }; - - /** - * Deprecated - use validateAxisMeasure - */ - var validateXAxis = function(chartType, chartConfig, aes, scales, data){ - return this.validateAxisMeasure(chartType, chartConfig, 'x', aes, scales, data); - }; - /** - * Deprecated - use validateAxisMeasure - */ - var validateYAxis = function(chartType, chartConfig, aes, scales, data){ - return this.validateAxisMeasure(chartType, chartConfig, 'y', aes, scales, data); - }; - - var getMeasureType = function(measure) { - return LABKEY.Utils.isObject(measure) ? (measure.normalizedType || measure.type) : null; - }; - - var isNumericType = function(type) - { - var t = LABKEY.Utils.isString(type) ? type.toLowerCase() : null; - return t == 'int' || t == 'integer' || t == 'float' || t == 'double'; - }; - - var isDateType = function(type) - { - var t = LABKEY.Utils.isString(type) ? type.toLowerCase() : null; - return t == 'date'; - }; - - var getAllowableTypes = function(field) { - var numericTypes = ['int', 'float', 'double', 'INTEGER', 'DOUBLE'], - nonNumericTypes = ['string', 'date', 'boolean', 'STRING', 'TEXT', 'DATE', 'BOOLEAN'], - numericAndDateTypes = numericTypes.concat(['date','DATE']); - - if (field.altSelectionOnly) - return []; - else if (field.numericOnly) - return numericTypes; - else if (field.nonNumericOnly) - return nonNumericTypes; - else if (field.numericOrDateOnly) - return numericAndDateTypes; - else - return numericTypes.concat(nonNumericTypes); - } - - var isMeasureDimensionMatch = function(chartType, field, isMeasure, isDimension) { - if ((chartType === 'box_plot' || chartType === 'bar_chart')) { - //x-axis does not support 'measure' column types for these plot types - if (field.name === 'x' || field.name === 'xSub') - return isDimension; - else - return isMeasure; - } - - return (field.numericOnly && isMeasure) || (field.nonNumericOnly && isDimension); - } - - var getQueryConfigSortKey = function(measures) { - var sortKey = 'lsid'; // needed to keep expected ordering for legend data - - // Issue 38105: For plots with study visit labels on the x-axis, sort by visit display order and then sequenceNum - var visitTableName = LABKEY.vis.GenericChartHelper.getStudySubjectInfo().tableName + 'Visit'; - if (measures.x && measures.x.fieldKey === visitTableName + '/Visit') { - var displayOrderColName = visitTableName + '/Visit/DisplayOrder'; - var seqNumColName = visitTableName + '/SequenceNum'; - sortKey = displayOrderColName + ', ' + seqNumColName; - } - - return sortKey; - } - - var getStudySubjectInfo = function() - { - var studyCtx = LABKEY.getModuleContext("study") || {}; - return LABKEY.Utils.isObject(studyCtx.subject) ? studyCtx.subject : { - tableName: 'Participant', - columnName: 'ParticipantId', - nounPlural: 'Participants', - nounSingular: 'Participant' - }; - }; - - var _getStudyTimepointType = function() - { - var studyCtx = LABKEY.getModuleContext("study") || {}; - return LABKEY.Utils.isDefined(studyCtx.timepointType) ? studyCtx.timepointType : null; - }; - - var _getMeasureRestrictions = function (chartType, measure) - { - var measureRestrictions = {}; - $.each(getRenderTypes(), function (idx, renderType) - { - if (renderType.name === chartType) - { - $.each(renderType.fields, function (idx2, field) - { - if (field.name === measure) - { - measureRestrictions.numericOnly = field.numericOnly; - measureRestrictions.nonNumericOnly = field.nonNumericOnly; - return false; - } - }); - return false; - } - }); - - return measureRestrictions; - }; - - /** - * Converts data values passed in to the appropriate type based on measure/dimension information. - * @param chartConfig Chart configuration object - * @param aes Aesthetic mapping functions for each measure/axis - * @param renderType The type of plot or chart (e.g. scatter_plot, bar_chart) - * @param data The response data from SelectRows - * @returns {{processed: {}, warningMessage: *}} - */ - var doValueConversion = function(chartConfig, aes, renderType, data) - { - var measuresForProcessing = {}, measureRestrictions = {}, configMeasure; - for (var measureName in chartConfig.measures) { - if (chartConfig.measures.hasOwnProperty(measureName) && LABKEY.Utils.isObject(chartConfig.measures[measureName])) { - configMeasure = chartConfig.measures[measureName]; - $.extend(measureRestrictions, _getMeasureRestrictions(renderType, measureName)); - - var isGroupingMeasure = measureName === 'color' || measureName === 'shape' || measureName === 'series'; - var isXAxis = measureName === 'x' || measureName === 'xSub'; - var isScatterOrLine = renderType === 'scatter_plot' || renderType === 'line_plot'; - var isBarYCount = renderType === 'bar_chart' && configMeasure.aggregate && (configMeasure.aggregate === 'COUNT' || configMeasure.aggregate.value === 'COUNT'); - - if (configMeasure.measure && !isGroupingMeasure && !isBarYCount - && ((!isXAxis && measureRestrictions.numericOnly ) || isScatterOrLine) && !isNumericType(configMeasure.type)) { - measuresForProcessing[measureName] = {}; - measuresForProcessing[measureName].name = configMeasure.name; - measuresForProcessing[measureName].convertedName = configMeasure.name + "_converted"; - measuresForProcessing[measureName].label = configMeasure.label; - configMeasure.normalizedType = 'float'; - configMeasure.type = 'float'; - } - } - } - - var response = {processed: {}}; - if (!LABKEY.Utils.isEmptyObj(measuresForProcessing)) { - response = _processMeasureData(data, aes, measuresForProcessing); - } - - //generate error message for dropped values - var warningMessage = ''; - for (var measure in response.droppedValues) { - if (response.droppedValues.hasOwnProperty(measure) && response.droppedValues[measure].numDropped) { - warningMessage += " The " - + measure + "-axis measure '" - + response.droppedValues[measure].label + "' had " - + response.droppedValues[measure].numDropped + - " value(s) that could not be converted to a number and are not included in the plot."; - } - } - - return {processed: response.processed, warningMessage: warningMessage}; - }; - - /** - * Does the explicit type conversion for each measure deemed suitable to convert. Currently we only - * attempt to convert strings to numbers for measures. - * @param rows Data from SelectRows - * @param aes Aesthetic mapping function for the measure/dimensions - * @param measuresForProcessing The measures to be converted, if any - * @returns {{droppedValues: {}, processed: {}}} - */ - var _processMeasureData = function(rows, aes, measuresForProcessing) { - var droppedValues = {}, processedMeasures = {}, dataIsNull; - rows.forEach(function(row) { - //convert measures if applicable - if (!LABKEY.Utils.isEmptyObj(measuresForProcessing)) { - for (var measure in measuresForProcessing) { - if (measuresForProcessing.hasOwnProperty(measure)) { - dataIsNull = true; - if (!droppedValues[measure]) { - droppedValues[measure] = {}; - droppedValues[measure].label = measuresForProcessing[measure].label; - droppedValues[measure].numDropped = 0; - } - - if (aes.hasOwnProperty(measure)) { - var value = aes[measure](row); - if (value !== null) { - dataIsNull = false; - } - row[measuresForProcessing[measure].convertedName] = {value: null}; - if (typeof value !== 'number' && value !== null) { - - //only try to convert strings to numbers - if (typeof value === 'string') { - value = value.trim(); - } - else { - //dates, objects, booleans etc. to be assigned value: NULL - value = ''; - } - - var n = Number(value); - // empty strings convert to 0, which we must explicitly deny - if (value === '' || isNaN(n)) { - droppedValues[measure].numDropped++; - } - else { - row[measuresForProcessing[measure].convertedName].value = n; - } - } - } - - if (!processedMeasures[measure]) { - processedMeasures[measure] = { - converted: false, - convertedName: measuresForProcessing[measure].convertedName, - type: 'float', - normalizedType: 'float' - } - } - - processedMeasures[measure].converted = processedMeasures[measure].converted || !dataIsNull; - } - } - } - }); - - return {droppedValues: droppedValues, processed: processedMeasures}; - }; - - /** - * removes all traces of String -> Numeric Conversion from the given chart config - * @param chartConfig - * @returns {updated ChartConfig} - */ - var removeNumericConversionConfig = function(chartConfig) { - if (chartConfig && chartConfig.measures) { - for (var measureName in chartConfig.measures) { - if (chartConfig.measures.hasOwnProperty(measureName)) { - var measure = chartConfig.measures[measureName]; - if (measure && measure.converted && measure.convertedName) { - measure.converted = null; - measure.convertedName = null; - if (LABKEY.vis.GenericChartHelper.isNumericType(measure.type)) { - measure.type = 'string'; - measure.normalizedType = 'string'; - } - } - } - } - } - - return chartConfig; - }; - - var renderChartSVG = function(renderTo, queryConfig, chartConfig) { - queryChartData(renderTo, queryConfig, chartConfig, function(measureStore, trendlineData) { - generateChartSVG(renderTo, chartConfig, measureStore, trendlineData); - }); - }; - - var queryChartData = function(renderTo, queryConfig, chartConfig, callback) { - queryConfig.containerPath = LABKEY.container.path; - - if (queryConfig.filterArray && queryConfig.filterArray.length > 0) { - var filters = []; - - for (var i = 0; i < queryConfig.filterArray.length; i++) { - var f = queryConfig.filterArray[i]; - // Issue 37191: Check to see if 'f' is already a filter instance (either labkey-api-js/src/filter/Filter.ts or clientapi/core/Query.js) - if (f.hasOwnProperty('getValue') || f.getValue instanceof Function) { - filters.push(f); - } - else { - filters.push(LABKEY.Filter.create(f.name, f.value, LABKEY.Filter.getFilterTypeForURLSuffix(f.type))); - } - } - - queryConfig.filterArray = filters; - } - - queryConfig.success = async function(measureStore) { - const trendlineData = await queryTrendlineData(chartConfig, measureStore.records()); - callback.call(this, measureStore, trendlineData); - }; - - LABKEY.Query.MeasureStore.selectRows(queryConfig); - }; - - var generateDataForChartType = function(chartConfig, chartType, geom, data) { - let dimName = null; - 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; - } - 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, '[Blank]', false, aggErrorType, chartType === 'line_plot'); - if (aggErrorType) { - geom.errorAes = { getValue: d => d.error }; - } - } - - return data; - } - - var generateChartSVG = function(renderTo, chartConfig, measureStore, trendlineData) { - var responseMetaData = measureStore.getResponseMetadata(); - - // explicitly set the chart width/height if not set in the config - if (!chartConfig.hasOwnProperty('width') || chartConfig.width == null) chartConfig.width = 1000; - if (!chartConfig.hasOwnProperty('height') || chartConfig.height == null) chartConfig.height = 600; - - var chartType = getChartType(chartConfig); - var aes = generateAes(chartType, chartConfig.measures, responseMetaData.schemaName, responseMetaData.queryName); - var valueConversionResponse = doValueConversion(chartConfig, aes, chartType, measureStore.records()); - if (!LABKEY.Utils.isEmptyObj(valueConversionResponse.processed)) { - $.extend(true, chartConfig.measures, valueConversionResponse.processed); - aes = generateAes(chartType, chartConfig.measures, responseMetaData.schemaName, responseMetaData.queryName); - } - var data = measureStore.records(); - if (chartType === 'scatter_plot' && data.length > chartConfig.geomOptions.binThreshold) { - chartConfig.geomOptions.binned = true; - } - var scales = generateScales(chartType, chartConfig.measures, chartConfig.scales, aes, measureStore); - var geom = generateGeom(chartType, chartConfig.geomOptions); - var labels = generateLabels(chartConfig.labels); - - if (chartType === 'bar_chart' || chartType === 'pie_chart' || chartType === 'line_plot') { - data = generateDataForChartType(chartConfig, chartType, geom, data); - } - - var validation = _validateChartConfig(chartConfig, aes, scales, measureStore); - _renderMessages(renderTo, validation.messages); - if (!validation.success) - return; - - var plotConfigArr = generatePlotConfigs(renderTo, chartConfig, labels, aes, scales, geom, data, trendlineData); - $.each(plotConfigArr, function(idx, plotConfig) { - if (chartType === 'pie_chart') { - new LABKEY.vis.PieChart(plotConfig); - } - else { - new LABKEY.vis.Plot(plotConfig).render(); - } - }, this); - } - - var _renderMessages = function(divId, messages) { - if (messages && messages.length > 0) { - var errorDiv = document.createElement('div'); - errorDiv.innerHTML = '

Error rendering chart:

' + messages.join('
') + '
'; - document.getElementById(divId).appendChild(errorDiv); - } - }; - - var _validateChartConfig = function(chartConfig, aes, scales, measureStore) { - var hasNoDataMsg = validateResponseHasData(measureStore, false); - if (hasNoDataMsg != null) - return {success: false, messages: [hasNoDataMsg]}; - - var messages = [], firstRecord = measureStore.records()[0], measureNames = Object.keys(chartConfig.measures); - for (var i = 0; i < measureNames.length; i++) { - var measuresArr = ensureMeasuresAsArray(chartConfig.measures[measureNames[i]]); - for (var j = 0; j < measuresArr.length; j++) { - var measure = measuresArr[j]; - if (LABKEY.Utils.isObject(measure)) { - if (measure.name && !LABKEY.Utils.isDefined(firstRecord[measure.name])) { - return {success: false, messages: ['The measure, ' + measure.name + ', is not available. It may have been renamed or removed.']}; - } - - var validation; - if (measureNames[i] === 'y') { - var yAes = {y: getYMeasureAes(measure)}; - validation = validateAxisMeasure(chartConfig.renderType, measure, 'y', yAes, scales, measureStore.records()); - } - else if (measureNames[i] === 'x' || measureNames[i] === 'xSub') { - validation = validateAxisMeasure(chartConfig.renderType, measure, measureNames[i], aes, scales, measureStore.records()); - } - - if (LABKEY.Utils.isObject(validation)) { - if (validation.message != null) - messages.push(validation.message); - if (!validation.success) - return {success: false, messages: messages}; - } - } - } - } - - return {success: true, messages: messages}; - }; - - return { - // NOTE: the @function below is needed or JSDoc will not include the documentation for loadVisDependencies. Don't - // ask me why, I do not know. - /** - * @function - */ - getRenderTypes: getRenderTypes, - getChartType: getChartType, - getSelectedMeasureLabel: getSelectedMeasureLabel, - getTitleFromMeasures: getTitleFromMeasures, - getMeasureType: getMeasureType, - getAllowableTypes: getAllowableTypes, - getQueryColumns : getQueryColumns, - getChartTypeBasedWidth : getChartTypeBasedWidth, - getDistinctYAxisSides : getDistinctYAxisSides, - getYMeasureAes : getYMeasureAes, - getDefaultMeasuresLabel: getDefaultMeasuresLabel, - getStudySubjectInfo: getStudySubjectInfo, - getQueryConfigSortKey: getQueryConfigSortKey, - ensureMeasuresAsArray: ensureMeasuresAsArray, - isNumericType: isNumericType, - isMeasureDimensionMatch: isMeasureDimensionMatch, - generateLabels: generateLabels, - generateScales: generateScales, - generateAes: generateAes, - doValueConversion: doValueConversion, - removeNumericConversionConfig: removeNumericConversionConfig, - generateAggregateData: generateAggregateData, - generatePointHover: generatePointHover, - generateBoxplotHover: generateBoxplotHover, - generateDataForChartType: generateDataForChartType, - generateDiscreteAcc: generateDiscreteAcc, - generateContinuousAcc: generateContinuousAcc, - generateGroupingAcc: generateGroupingAcc, - generatePointClickFn: generatePointClickFn, - generateGeom: generateGeom, - generateBoxplotGeom: generateBoxplotGeom, - generatePointGeom: generatePointGeom, - generatePlotConfigs: generatePlotConfigs, - generatePlotConfig: generatePlotConfig, - validateResponseHasData: validateResponseHasData, - validateAxisMeasure: validateAxisMeasure, - validateXAxis: validateXAxis, - validateYAxis: validateYAxis, - renderChartSVG: renderChartSVG, - queryChartData: queryChartData, - generateChartSVG: generateChartSVG, - getMeasureStoreRecords: getMeasureStoreRecords, - queryTrendlineData: queryTrendlineData, - TRENDLINE_OPTIONS: TRENDLINE_OPTIONS, - /** - * Loads all of the required dependencies for a Generic Chart. - * @param {Function} callback The callback to be executed when all of the visualization dependencies have been loaded. - * @param {Object} scope The scope to be used when executing the callback. - */ - loadVisDependencies: LABKEY.requiresVisualization - }; +/* + * Copyright (c) 2013-2019 LabKey Corporation + * + * Licensed under the Apache License, Version 2.0: http://www.apache.org/licenses/LICENSE-2.0 + */ +if(!LABKEY.vis) { + LABKEY.vis = {}; +} + +/** + * @namespace Namespace used to encapsulate functions related to creating Generic Charts (Box, Scatter, etc.). Used in the + * Generic Chart Wizard and when exporting Generic Charts as Scripts. + */ +LABKEY.vis.GenericChartHelper = new function(){ + + var DEFAULT_TICK_LABEL_MAX = 25; + var $ = jQuery; + + var getRenderTypes = function() { + return [ + { + name: 'bar_chart', + title: 'Bar', + imgUrl: LABKEY.contextPath + '/visualization/images/barchart.png', + fields: [ + {name: 'x', label: 'X Axis', required: true, nonNumericOnly: true}, + {name: 'xSub', label: 'Group By', required: false, nonNumericOnly: true}, + {name: 'y', label: 'Y Axis', numericOnly: true} + ], + layoutOptions: {line: true, opacity: true, axisBased: true} + }, + { + name: 'box_plot', + title: 'Box', + imgUrl: LABKEY.contextPath + '/visualization/images/boxplot.png', + fields: [ + {name: 'x', label: 'X Axis'}, + {name: 'y', label: 'Y Axis', required: true, numericOnly: true}, + {name: 'color', label: 'Color', nonNumericOnly: true}, + {name: 'shape', label: 'Shape', nonNumericOnly: true} + ], + layoutOptions: {point: true, box: true, line: true, opacity: true, axisBased: true} + }, + { + name: 'line_plot', + title: 'Line', + imgUrl: LABKEY.contextPath + '/visualization/images/timechart.png', + fields: [ + {name: 'x', label: 'X Axis', required: true, numericOrDateOnly: true}, + {name: 'y', label: 'Y Axis', required: true, numericOnly: true, allowMultiple: true}, + {name: 'series', label: 'Series', nonNumericOnly: true}, + {name: 'trendline', label: 'Trendline', required: false, altSelectionOnly: true, altFieldType: 'LABKEY.vis.TrendlineField'}, + ], + layoutOptions: {opacity: true, axisBased: true, series: true, chartLayout: true} + }, + { + name: 'pie_chart', + title: 'Pie', + imgUrl: LABKEY.contextPath + '/visualization/images/piechart.png', + fields: [ + {name: 'x', label: 'Categories', required: true, nonNumericOnly: true}, + // Issue #29046 'Remove "measure" option from pie chart' + // {name: 'y', label: 'Measure', numericOnly: true} + ], + layoutOptions: {pie: true} + }, + { + name: 'scatter_plot', + title: 'Scatter', + imgUrl: LABKEY.contextPath + '/visualization/images/scatterplot.png', + fields: [ + {name: 'x', label: 'X Axis', required: true}, + {name: 'y', label: 'Y Axis', required: true, numericOnly: true, allowMultiple: true}, + {name: 'color', label: 'Color', nonNumericOnly: true}, + {name: 'shape', label: 'Shape', nonNumericOnly: true} + ], + layoutOptions: {point: true, opacity: true, axisBased: true, binnable: true, chartLayout: true} + }, + { + name: 'time_chart', + title: 'Time', + hidden: _getStudyTimepointType() == null, + imgUrl: LABKEY.contextPath + '/visualization/images/timechart.png', + fields: [ + {name: 'x', label: 'X Axis', required: true, altSelectionOnly: true, altFieldType: 'LABKEY.vis.TimeChartXAxisField'}, + {name: 'y', label: 'Y Axis', required: true, numericOnly: true, allowMultiple: true} + ], + layoutOptions: {time: true, axisBased: true, chartLayout: true} + } + ]; + }; + + /** + * Gets the chart type (i.e. box or scatter) based on the chartConfig object. + */ + const getChartType = function(chartConfig) + { + const renderType = chartConfig.renderType + const xAxisType = chartConfig.measures.x ? (chartConfig.measures.x.normalizedType || chartConfig.measures.x.type) : null; + + if (renderType === 'time_chart' || renderType === "bar_chart" || renderType === "pie_chart" + || renderType === "box_plot" || renderType === "scatter_plot" || renderType === "line_plot") + { + return renderType; + } + + if (!xAxisType) + { + // On some charts (non-study box plots) we don't require an x-axis, instead we generate one box plot for + // all of the data of your y-axis. If there is no xAxisType, then we have a box plot. Scatter plots require + // an x-axis measure. + return 'box_plot'; + } + + return (xAxisType === 'string' || xAxisType === 'boolean') ? 'box_plot' : 'scatter_plot'; + }; + + /** + * Generate a default label for the selected measure for the given renderType. + * @param renderType + * @param measureName - the chart type's measure name + * @param properties - properties for the selected column, note that this can be an array of properties + */ + var getSelectedMeasureLabel = function(renderType, measureName, properties) + { + var label = getDefaultMeasuresLabel(properties); + + if (label !== '' && measureName === 'y' && (renderType === 'bar_chart' || renderType === 'pie_chart')) { + var aggregateProps = LABKEY.Utils.isArray(properties) && properties.length === 1 + ? properties[0].aggregate : properties.aggregate; + + if (LABKEY.Utils.isDefined(aggregateProps)) { + var aggLabel = LABKEY.Utils.isObject(aggregateProps) + ? (aggregateProps.name ?? aggregateProps.label) + : LABKEY.Utils.capitalize(aggregateProps.toLowerCase()); + label = aggLabel + ' of ' + label; + } + else { + label = 'Sum of ' + label; + } + } + + return label; + }; + + /** + * Generate a plot title based on the selected measures array or object. + * @param renderType + * @param measures + * @returns {string} + */ + var getTitleFromMeasures = function(renderType, measures) + { + var queryLabels = []; + + if (LABKEY.Utils.isObject(measures)) + { + if (LABKEY.Utils.isArray(measures.y)) + { + $.each(measures.y, function(idx, m) + { + var measureQueryLabel = m.queryLabel || m.queryName; + if (queryLabels.indexOf(measureQueryLabel) === -1) + queryLabels.push(measureQueryLabel); + }); + } + else + { + var m = measures.x || measures.y; + queryLabels.push(m.queryLabel || m.queryName); + } + } + + return queryLabels.join(', '); + }; + + /** + * Get the sorted set of column metadata for the given schema/query/view. + * @param queryConfig + * @param successCallback + * @param callbackScope + */ + var getQueryColumns = function(queryConfig, successCallback, callbackScope) + { + LABKEY.Ajax.request({ + url: LABKEY.ActionURL.buildURL('visualization', 'getGenericReportColumns.api'), + method: 'GET', + params: { + schemaName: queryConfig.schemaName, + queryName: queryConfig.queryName, + viewName: queryConfig.viewName, + dataRegionName: queryConfig.dataRegionName, + includeCohort: true, + includeParticipantCategory : true + }, + success : function(response){ + var columnList = LABKEY.Utils.decode(response.responseText); + _queryColumnMetadata(queryConfig, columnList, successCallback, callbackScope) + }, + scope : this + }); + }; + + var _queryColumnMetadata = function(queryConfig, columnList, successCallback, callbackScope) + { + var columns = columnList.columns.all; + if (queryConfig.savedColumns) { + // make sure all savedColumns from the chart are included as options, they may not be in the view anymore + columns = columns.concat(queryConfig.savedColumns); + } + + LABKEY.Query.selectRows({ + maxRows: 0, // use maxRows 0 so that we just get the query metadata + schemaName: queryConfig.schemaName, + queryName: queryConfig.queryName, + viewName: queryConfig.viewName, + parameters: queryConfig.parameters, + requiredVersion: 9.1, + columns: columns, + method: 'POST', // Issue 31744: use POST as the columns list can be very long and cause a 400 error + success: function(response){ + var columnMetadata = _updateAndSortQueryFields(queryConfig, columnList, response.metaData.fields); + successCallback.call(callbackScope, columnMetadata); + }, + failure : function(response) { + // this likely means that the query no longer exists + successCallback.call(callbackScope, columnList, []); + }, + scope : this + }); + }; + + var _updateAndSortQueryFields = function(queryConfig, columnList, columnMetadata) + { + var queryFields = [], + queryFieldKeys = [], + columnTypes = LABKEY.Utils.isDefined(columnList.columns) ? columnList.columns : {}; + + $.each(columnMetadata, function(idx, column) + { + var f = $.extend(true, {}, column); + f.schemaName = queryConfig.schemaName; + f.queryName = queryConfig.queryName; + f.isCohortColumn = false; + f.isSubjectGroupColumn = false; + + // issue 23224: distinguish cohort and subject group fields in the list of query columns + if (columnTypes['cohort'] && columnTypes['cohort'].indexOf(f.fieldKey) > -1) + { + f.shortCaption = 'Study: ' + f.shortCaption; + f.isCohortColumn = true; + } + else if (columnTypes['subjectGroup'] && columnTypes['subjectGroup'].indexOf(f.fieldKey) > -1) + { + f.shortCaption = columnList.subject.nounSingular + ' Group: ' + f.shortCaption; + f.isSubjectGroupColumn = true; + } + + // Issue 31672: keep track of the distinct query field keys so we don't get duplicates + if (f.fieldKey.toLowerCase() != 'lsid' && queryFieldKeys.indexOf(f.fieldKey) == -1) { + queryFields.push(f); + queryFieldKeys.push(f.fieldKey); + } + }, this); + + // Sorts fields by their shortCaption, but put subject groups/categories/cohort at the end. + queryFields.sort(function(a, b) + { + if (a.isSubjectGroupColumn != b.isSubjectGroupColumn) + return a.isSubjectGroupColumn ? 1 : -1; + else if (a.isCohortColumn != b.isCohortColumn) + return a.isCohortColumn ? 1 : -1; + else if (a.shortCaption != b.shortCaption) + return a.shortCaption < b.shortCaption ? -1 : 1; + + return 0; + }); + + return queryFields; + }; + + /** + * Determine a reasonable width for the chart based on the chart type and selected measures / data. + * @param chartType + * @param measures + * @param measureStore + * @param defaultWidth + * @returns {int} + */ + var getChartTypeBasedWidth = function(chartType, measures, measureStore, defaultWidth) { + var width = 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; + } + } + 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; + }; + + /** + * Return the distinct set of y-axis sides for the given measures object. + * @param measures + */ + var getDistinctYAxisSides = function(measures) + { + var distinctSides = []; + $.each(ensureMeasuresAsArray(measures.y), function (idx, measure) { + if (LABKEY.Utils.isObject(measure)) { + var side = measure.yAxis || 'left'; + if (distinctSides.indexOf(side) === -1) { + distinctSides.push(side); + } + } + }, this); + return distinctSides; + }; + + /** + * Generate a default label for an array of measures by concatenating each meaures label together. + * @param measures + * @returns string concatenation of all measure labels + */ + var getDefaultMeasuresLabel = function(measures) + { + if (LABKEY.Utils.isDefined(measures)) { + if (!LABKEY.Utils.isArray(measures)) { + return measures.label || measures.queryName || ''; + } + + var label = '', sep = ''; + $.each(measures, function(idx, m) { + label += sep + (m.label || m.queryName); + sep = ', '; + }); + return label; + } + + return ''; + }; + + /** + * Given the saved labels object we convert it to include all label types (main, x, and y). Each label type defaults + * to empty string (''). + * @param {Object} labels The saved labels object. + * @returns {Object} + */ + var generateLabels = function(labels) { + return { + main: { value: labels.main || '' }, + subtitle: { value: labels.subtitle || '' }, + footer: { value: labels.footer || '' }, + x: { value: labels.x || '' }, + y: { value: labels.y || '' }, + yRight: { value: labels.yRight || '' } + }; + }; + + /** + * Generates an object containing {@link LABKEY.vis.Scale} objects used for the chart. + * @param {String} chartType The chartType from getChartType. + * @param {Object} measures The measures from generateMeasures. + * @param {Object} savedScales The scales object from the saved chart config. + * @param {Object} aes The aesthetic map object from genereateAes. + * @param {Object} measureStore The MeasureStore data using a selectRows API call. + * @param {Function} defaultFormatFn used to format values for tick marks. + * @returns {Object} + */ + var generateScales = function(chartType, measures, savedScales, aes, measureStore, defaultFormatFn) { + var scales = {}; + var data = LABKEY.Utils.isArray(measureStore.rows) ? measureStore.rows : measureStore.records(); + var fields = LABKEY.Utils.isObject(measureStore.metaData) ? measureStore.metaData.fields : measureStore.getResponseMetadata().fields; + var subjectColumn = getStudySubjectInfo().columnName; + var visitTableName = getStudySubjectInfo().tableName + 'Visit'; + var visitColName = visitTableName + '/Visit'; + var valExponentialDigits = 6; + + // Issue 38105: For plots with study visit labels on the x-axis, don't sort alphabetically + var sortFnX = measures.x && measures.x.fieldKey === visitColName ? undefined : LABKEY.vis.discreteSortFn; + + if (chartType === "box_plot") + { + scales.x = { + scaleType: 'discrete', // Force discrete x-axis scale for box plots. + sortFn: sortFnX, + tickLabelMax: DEFAULT_TICK_LABEL_MAX + }; + + var yMin = d3.min(data, aes.y); + var yMax = d3.max(data, aes.y); + var yPadding = ((yMax - yMin) * .1); + if (savedScales.y && savedScales.y.trans == "log") + { + // When subtracting padding we have to make sure we still produce valid values for a log scale. + // log([value less than 0]) = NaN. + // log(0) = -Infinity. + if (yMin - yPadding > 0) + { + yMin = yMin - yPadding; + } + } + else + { + yMin = yMin - yPadding; + } + + scales.y = { + min: yMin, + max: yMax + yPadding, + scaleType: 'continuous', + trans: savedScales.y ? savedScales.y.trans : 'linear' + }; + } + else + { + var xMeasureType = getMeasureType(measures.x); + + // Force discrete x-axis scale for bar plots. + var useContinuousScale = chartType != 'bar_chart' && isNumericType(xMeasureType); + + if (useContinuousScale) + { + scales.x = { + scaleType: 'continuous', + trans: savedScales.x ? savedScales.x.trans : 'linear' + }; + } + else + { + scales.x = { + scaleType: 'discrete', + sortFn: sortFnX, + tickLabelMax: DEFAULT_TICK_LABEL_MAX + }; + + //bar chart x-axis subcategories support + if (LABKEY.Utils.isDefined(measures.xSub)) { + scales.xSub = { + scaleType: 'discrete', + sortFn: LABKEY.vis.discreteSortFn, + tickLabelMax: DEFAULT_TICK_LABEL_MAX + }; + } + } + + // add both y (i.e. yLeft) and yRight, in case multiple y-axis measures are being plotted + scales.y = { + scaleType: 'continuous', + trans: savedScales.y ? savedScales.y.trans : 'linear' + }; + scales.yRight = { + scaleType: 'continuous', + trans: savedScales.yRight ? savedScales.yRight.trans : 'linear' + }; + } + + // if we have no data, show a default y-axis domain + if (scales.x && data.length == 0 && scales.x.scaleType == 'continuous') + scales.x.domain = [0,1]; + if (scales.y && data.length == 0) + scales.y.domain = [0,1]; + + // apply the field formatFn to the tick marks on the scales object + for (var i = 0; i < fields.length; i++) { + var type = fields[i].displayFieldJsonType ? fields[i].displayFieldJsonType : fields[i].type; + + var isMeasureXMatch = measures.x && _isFieldKeyMatch(measures.x, fields[i].fieldKey); + if (isMeasureXMatch && measures.x.name === subjectColumn && LABKEY.demoMode) { + scales.x.tickFormat = function(){return '******'}; + } + else if (isMeasureXMatch && isNumericType(type)) { + scales.x.tickFormat = _getNumberFormatFn(fields[i], defaultFormatFn); + } + + var yMeasures = ensureMeasuresAsArray(measures.y); + $.each(yMeasures, function(idx, yMeasure) { + var isMeasureYMatch = yMeasure && _isFieldKeyMatch(yMeasure, fields[i].fieldKey); + var isConvertedYMeasure = isMeasureYMatch && yMeasure.converted; + if (isMeasureYMatch && (isNumericType(type) || isConvertedYMeasure)) { + var tickFormatFn = _getNumberFormatFn(fields[i], defaultFormatFn); + + var ySide = yMeasure.yAxis === 'right' ? 'yRight' : 'y'; + scales[ySide].tickFormat = function(value) { + if (LABKEY.Utils.isNumber(value) && Math.abs(Math.round(value)).toString().length >= valExponentialDigits) { + return value.toExponential(); + } + else if (LABKEY.Utils.isFunction(tickFormatFn)) { + return tickFormatFn(value); + } + return value; + }; + } + }, this); + } + + _applySavedScaleDomain(scales, savedScales, 'x'); + if (LABKEY.Utils.isDefined(measures.xSub)) { + _applySavedScaleDomain(scales, savedScales, 'xSub'); + } + if (LABKEY.Utils.isDefined(measures.y)) { + _applySavedScaleDomain(scales, savedScales, 'y'); + _applySavedScaleDomain(scales, savedScales, 'yRight'); + } + + return scales; + }; + + // Issue 36227: if Ext4 is not available, try to generate our own number format function based on the "format" field metadata + var _getNumberFormatFn = function(field, defaultFormatFn) { + if (field.extFormatFn) { + if (window.Ext4) { + return eval(field.extFormatFn); + } + else if (field.format && LABKEY.Utils.isString(field.format) && field.format.indexOf('.') > -1) { + var precision = field.format.length - field.format.indexOf('.') - 1; + return function(v) { + return LABKEY.Utils.isNumber(v) ? v.toFixed(precision) : v; + } + } + } + + return defaultFormatFn; + }; + + var _isFieldKeyMatch = function(measure, fieldKey) { + if (LABKEY.Utils.isFunction(fieldKey.getName)) { + return fieldKey.getName() === measure.name || fieldKey.getName() === measure.fieldKey; + } else if (LABKEY.Utils.isArray(fieldKey)) { + fieldKey = fieldKey.join('/') + } + + return fieldKey === measure.name || fieldKey === measure.fieldKey; + }; + + var ensureMeasuresAsArray = function(measures) { + if (LABKEY.Utils.isDefined(measures)) { + return LABKEY.Utils.isArray(measures) ? $.extend(true, [], measures) : [$.extend(true, {}, measures)]; + } + return []; + }; + + var _applySavedScaleDomain = function(scales, savedScales, scaleName) { + if (savedScales[scaleName] && (savedScales[scaleName].min != null || savedScales[scaleName].max != null)) { + scales[scaleName].domain = [savedScales[scaleName].min, savedScales[scaleName].max]; + } + }; + + /** + * Generates the aesthetic map object needed by the visualization API to render the chart. See {@link LABKEY.vis.Plot} + * and {@link LABKEY.vis.Layer}. + * @param {String} chartType The chartType from getChartType. + * @param {Object} measures The measures from getMeasures. + * @param {String} schemaName The schemaName from the saved queryConfig. + * @param {String} queryName The queryName from the saved queryConfig. + * @returns {Object} + */ + 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); + + if (chartType === "box_plot") { + 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); + } + } else if (isNumericType(xMeasureType) || (chartType === 'scatter_plot' && measures.x.measure)) { + aes.x = generateContinuousAcc(xMeasureName); + } else { + aes.x = generateDiscreteAcc(xMeasureName, measures.x.label); + } + + // charts that have multiple y-measures selected will need to put the aes.y function on their specific layer + if (LABKEY.Utils.isDefined(measures.y) && !LABKEY.Utils.isArray(measures.y)) + { + var sideAesName = (measures.y.yAxis || 'left') === 'left' ? 'y' : 'yRight'; + var yMeasureName = measures.y.converted ? measures.y.convertedName : measures.y.name; + aes[sideAesName] = generateContinuousAcc(yMeasureName); + } + + if (chartType === "scatter_plot" || chartType === "line_plot") + { + aes.hoverText = generatePointHover(measures); + } + + if (chartType === "box_plot") + { + if (measures.color) { + aes.outlierColor = generateGroupingAcc(measures.color.name); + } + + if (measures.shape) { + aes.outlierShape = generateGroupingAcc(measures.shape.name); + } + + aes.hoverText = generateBoxplotHover(); + aes.outlierHoverText = generatePointHover(measures); + } + else if (chartType === 'bar_chart') + { + var xSubMeasureType = measures.xSub ? getMeasureType(measures.xSub) : null; + if (xSubMeasureType) + { + if (isNumericType(xSubMeasureType)) + aes.xSub = generateContinuousAcc(measures.xSub.name); + else + aes.xSub = generateDiscreteAcc(measures.xSub.name, measures.xSub.label); + } + } + + // color/shape aes are not dependent on chart type. If we have a box plot with all points enabled, then we + // create a second layer for points. So we'll need this no matter what. + if (measures.color) { + aes.color = generateGroupingAcc(measures.color.name); + } + + if (measures.shape) { + aes.shape = generateGroupingAcc(measures.shape.name); + } + + // also add the color and shape for the line plot series. + if (measures.series) { + aes.color = generateGroupingAcc(measures.series.name); + aes.shape = generateGroupingAcc(measures.series.name); + } + + if (measures.pointClickFn) { + aes.pointClickFn = generatePointClickFn( + measures, + schemaName, + queryName, + measures.pointClickFn + ); + } + + return aes; + }; + + var getYMeasureAes = function(measure) { + var yMeasureName = measure.converted ? measure.convertedName : measure.name; + return generateContinuousAcc(yMeasureName); + }; + + /** + * Generates a function that returns the text used for point hovers. + * @param {Object} measures The measures object from the saved chart config. + * @returns {Function} + */ + var generatePointHover = function(measures) + { + return function(row) { + var hover = '', sep = '', distinctNames = []; + + $.each(measures, function(key, measureObj) { + var measureArr = ensureMeasuresAsArray(measureObj); + $.each(measureArr, function(idx, measure) { + if (LABKEY.Utils.isObject(measure) && !LABKEY.Utils.isEmptyObj(measure) && distinctNames.indexOf(measure.name) == -1) { + 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); + }); + + return hover; + }; + }; + + /** + * Backwards compatibility for function that has been moved to LABKEY.vis.getAggregateData. + */ + var generateAggregateData = function(data, dimensionName, measureName, aggregate, nullDisplayValue) { + return LABKEY.vis.getAggregateData(data, dimensionName, null, measureName, aggregate, nullDisplayValue, false); + }; + + var _getRowValue = function(row, propName, valueName) + { + if (row.hasOwnProperty(propName)) { + // backwards compatibility for response row that is not a LABKEY.Query.Row + if (!(row instanceof LABKEY.Query.Row)) { + return row[propName].formattedValue || row[propName].displayValue || row[propName].value; + } + + var propValue = row.get(propName); + if (valueName != undefined && propValue.hasOwnProperty(valueName)) { + return propValue[valueName]; + } + else if (propValue.hasOwnProperty('formattedValue')) { + return propValue['formattedValue']; + } + else if (propValue.hasOwnProperty('displayValue')) { + return propValue['displayValue']; + } + return row.getValue(propName); + } + + return undefined; + }; + + /** + * Returns a function used to generate the hover text for box plots. + * @returns {Function} + */ + var generateBoxplotHover = function() { + return function(xValue, stats) { + return xValue + ':\nMin: ' + stats.min + '\nMax: ' + stats.max + '\nQ1: ' + stats.Q1 + '\nQ2: ' + stats.Q2 + + '\nQ3: ' + stats.Q3; + }; + }; + + /** + * Generates an accessor function that returns a discrete value from a row of data for a given measure and label. + * Used when an axis has a discrete measure (i.e. string). + * @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 + * @returns {Function} + */ + var generateDiscreteAcc = function(measureName, measureLabel, nullValueLabel) + { + return function(row) + { + var value = _getRowValue(row, measureName); + if (value === null) + value = nullValueLabel !== undefined ? nullValueLabel : "Not in " + measureLabel; + + return value; + }; + }; + + /** + * Generates an accessor function that returns a value from a row of data for a given measure. + * @param {String} measureName The name of the measure. + * @returns {Function} + */ + var generateContinuousAcc = function(measureName) + { + return function(row) + { + var value = _getRowValue(row, measureName, 'value'); + + if (value !== undefined) + { + if (Math.abs(value) === Infinity) + value = null; + + if (value === false || value === true) + value = value.toString(); + + return value; + } + + return undefined; + } + }; + + /** + * Generates an accesssor function for shape and color measures. + * @param {String} measureName The name of the measure. + * @returns {Function} + */ + var generateGroupingAcc = function(measureName) + { + return function(row) + { + var value = null; + if (LABKEY.Utils.isArray(row) && row.length > 0) { + value = _getRowValue(row[0], measureName); + } + else { + value = _getRowValue(row, measureName); + } + + if (value === null || value === undefined) + value = "n/a"; + + return value; + }; + }; + + /** + * Generates an accessor for boxplots that do not have an x-axis measure. Generally the measureName passed in is the + * queryName. + * @param {String} measureName The name of the measure. In this case it is generally the query name. + * @returns {Function} + */ + var generateMeasurelessAcc = function(measureName) { + // Used for box plots that do not have an x-axis measure. Instead we just return the queryName for every row. + return function(row) { + return measureName; + } + }; + + /** + * Generates the function to be executed when a user clicks a point. + * @param {Object} measures The measures from the saved chart config. + * @param {String} schemaName The schema name from the saved query config. + * @param {String} queryName The query name from the saved query config. + * @param {String} fnString The string value of the user-provided function to be executed when a point is clicked. + * @returns {Function} + */ + var generatePointClickFn = function(measures, schemaName, queryName, fnString){ + var measureInfo = { + schemaName: schemaName, + queryName: queryName + }; + + _addPointClickMeasureInfo(measureInfo, measures, 'x', 'xAxis'); + _addPointClickMeasureInfo(measureInfo, measures, 'y', 'yAxis'); + $.each(['color', 'shape', 'series'], function(idx, name) { + _addPointClickMeasureInfo(measureInfo, measures, name, name + 'Name'); + }, this); + + // using new Function is quicker than eval(), even in IE. + var pointClickFn = new Function('return ' + fnString)(); + return function(clickEvent, data){ + pointClickFn(data, measureInfo, clickEvent); + }; + }; + + var _addPointClickMeasureInfo = function(measureInfo, measures, name, key) { + if (LABKEY.Utils.isDefined(measures[name])) { + var measuresArr = ensureMeasuresAsArray(measures[name]); + $.each(measuresArr, function(idx, measure) { + if (!LABKEY.Utils.isDefined(measureInfo[key])) { + measureInfo[key] = measure.name; + } + else if (!LABKEY.Utils.isDefined(measureInfo[measure.name])) { + measureInfo[measure.name] = measure.name; + } + }, this); + } + }; + + /** + * Generates the Point Geom used for scatter plots and box plots with all points visible. + * @param {Object} chartOptions The saved chartOptions object from the chart config. + * @returns {LABKEY.vis.Geom.Point} + */ + var generatePointGeom = function(chartOptions){ + return new LABKEY.vis.Geom.Point({ + opacity: chartOptions.opacity, + size: chartOptions.pointSize, + color: '#' + chartOptions.pointFillColor, + position: chartOptions.position + }); + }; + + /** + * Generates the Boxplot Geom used for box plots. + * @param {Object} chartOptions The saved chartOptions object from the chart config. + * @returns {LABKEY.vis.Geom.Boxplot} + */ + var generateBoxplotGeom = function(chartOptions){ + return new LABKEY.vis.Geom.Boxplot({ + lineWidth: chartOptions.lineWidth, + outlierOpacity: chartOptions.opacity, + outlierFill: '#' + chartOptions.pointFillColor, + outlierSize: chartOptions.pointSize, + color: '#' + chartOptions.lineColor, + fill: chartOptions.boxFillColor == 'none' ? chartOptions.boxFillColor : '#' + chartOptions.boxFillColor, + position: chartOptions.position, + showOutliers: chartOptions.showOutliers + }); + }; + + /** + * Generates the Barplot Geom used for bar charts. + * @param {Object} chartOptions The saved chartOptions object from the chart config. + * @returns {LABKEY.vis.Geom.BarPlot} + */ + var generateBarGeom = function(chartOptions){ + return new LABKEY.vis.Geom.BarPlot({ + opacity: chartOptions.opacity, + color: '#' + chartOptions.lineColor, + fill: '#' + chartOptions.boxFillColor, + lineWidth: chartOptions.lineWidth + }); + }; + + /** + * Generates the Bin Geom used to bin a set of points. + * @param {Object} chartOptions The saved chartOptions object from the chart config. + * @returns {LABKEY.vis.Geom.Bin} + */ + var generateBinGeom = function(chartOptions) { + var colorRange = ["#e6e6e6", "#085D90"]; //light-gray and labkey blue is default + if (chartOptions.binColorGroup == 'SingleColor') { + var color = '#' + chartOptions.binSingleColor; + colorRange = ["#FFFFFF", color]; + } + else if (chartOptions.binColorGroup == 'Heat') { + colorRange = ["#fff6bc", "#e23202"]; + } + + return new LABKEY.vis.Geom.Bin({ + shape: chartOptions.binShape, + colorRange: colorRange, + size: chartOptions.binShape == 'square' ? 10 : 5 + }) + }; + + /** + * Generates a Geom based on the chartType. + * @param {String} chartType The chart type from getChartType. + * @param {Object} chartOptions The chartOptions object from the saved chart config. + * @returns {LABKEY.vis.Geom} + */ + var generateGeom = function(chartType, chartOptions) { + if (chartType == "box_plot") + return generateBoxplotGeom(chartOptions); + else if (chartType == "scatter_plot" || chartType == "line_plot") + return chartOptions.binned ? generateBinGeom(chartOptions) : generatePointGeom(chartOptions); + else if (chartType == "bar_chart") + return generateBarGeom(chartOptions); + }; + + /** + * Generate an array of plot configs for the given chart renderType and config options. + * @param renderTo + * @param chartConfig + * @param labels + * @param aes + * @param scales + * @param geom + * @param data + * @param trendlineData + * @returns {Array} array of plot config objects + */ + var generatePlotConfigs = function(renderTo, chartConfig, labels, aes, scales, geom, data, trendlineData) + { + var plotConfigArr = []; + + // if we have multiple y-measures and the request is to plot them separately, call the generatePlotConfig function + // for each y-measure separately with its own copy of the chartConfig object + if (chartConfig.geomOptions.chartLayout === 'per_measure' && LABKEY.Utils.isArray(chartConfig.measures.y)) { + + // if 'automatic across charts' scales are requested, need to manually calculate the min and max + if (chartConfig.scales.y && chartConfig.scales.y.type === 'automatic') { + scales.y = $.extend(scales.y, _getScaleDomainValuesForAllMeasures(data, chartConfig.measures.y, 'left')); + } + if (chartConfig.scales.yRight && chartConfig.scales.yRight.type === 'automatic') { + scales.yRight = $.extend(scales.yRight, _getScaleDomainValuesForAllMeasures(data, chartConfig.measures.y, 'right')); + } + + $.each(chartConfig.measures.y, function(idx, yMeasure) { + // copy the config and reset the measures.y array with the single measure + var newChartConfig = $.extend(true, {}, chartConfig); + newChartConfig.measures.y = $.extend(true, {}, yMeasure); + + // copy the labels object so that we can set the subtitle based on the y-measure + var newLabels = $.extend(true, {}, labels); + newLabels.subtitle = {value: yMeasure.label || yMeasure.name}; + + // only copy over the scales that are needed for this measures + var side = yMeasure.yAxis || 'left'; + var newScales = {x: $.extend(true, {}, scales.x)}; + if (side === 'left') { + newScales.y = $.extend(true, {}, scales.y); + } + else { + newScales.yRight = $.extend(true, {}, scales.yRight); + } + + plotConfigArr.push(generatePlotConfig(renderTo, newChartConfig, newLabels, aes, newScales, geom, data, trendlineData)); + }, this); + } + else { + plotConfigArr.push(generatePlotConfig(renderTo, chartConfig, labels, aes, scales, geom, data, trendlineData)); + } + + return plotConfigArr; + }; + + var _getScaleDomainValuesForAllMeasures = function(data, measures, side) { + var min = null, max = null; + + $.each(measures, function(idx, measure) { + var measureSide = measure.yAxis || 'left'; + if (side === measureSide) { + var accFn = LABKEY.vis.GenericChartHelper.getYMeasureAes(measure); + var tempMin = d3.min(data, accFn); + var tempMax = d3.max(data, accFn); + + if (min == null || tempMin < min) { + min = tempMin; + } + if (max == null || tempMax > max) { + max = tempMax; + } + } + }, this); + + return {domain: [min, max]}; + }; + + /** + * Generate the plot config for the given chart renderType and config options. + * @param renderTo + * @param chartConfig + * @param labels + * @param aes + * @param scales + * @param geom + * @param data + * @param trendlineData + * @returns {Object} + */ + var generatePlotConfig = function(renderTo, chartConfig, labels, aes, scales, geom, data, trendlineData) + { + var renderType = chartConfig.renderType, + layers = [], clipRect, + emptyTextFn = function(){return '';}, + plotConfig = { + renderTo: renderTo, + rendererType: 'd3', + width: chartConfig.width, + height: chartConfig.height, + gridLinesVisible: chartConfig.gridLinesVisible, + }; + + if (renderType === 'pie_chart') { + return _generatePieChartConfig(plotConfig, chartConfig, labels, data); + } + + clipRect = (scales.x && LABKEY.Utils.isArray(scales.x.domain)) || (scales.y && LABKEY.Utils.isArray(scales.y.domain)); + + // account for line chart hiding points + if (chartConfig.geomOptions.hideDataPoints) { + geom = null; + } + + // account for one or many y-measures by ensuring that we have an array of y-measures + var yMeasures = ensureMeasuresAsArray(chartConfig.measures.y); + + if (renderType === 'bar_chart') { + aes = { x: 'label', y: 'value' }; + + if (LABKEY.Utils.isDefined(chartConfig.measures.xSub)) + { + aes.xSub = 'subLabel'; + aes.color = 'label'; + } + + if (!scales.y) { + scales.y = {}; + } + + if (!scales.y.domain) { + var values = $.map(data, d => d.value + (d.error ?? 0)), + min = Math.min(0, Math.min.apply(Math, values)), + max = Math.max(0, Math.max.apply(Math, values)); + + scales.y.domain = [min, max]; + } else if (!scales.y.domain[0]) { + // if user has set a max but not a min, default to 0 for bar chart + scales.y.domain[0] = 0; + } + } + else if (renderType === 'box_plot' && chartConfig.pointType === 'all') + { + layers.push( + new LABKEY.vis.Layer({ + geom: LABKEY.vis.GenericChartHelper.generatePointGeom(chartConfig.geomOptions), + aes: {hoverText: LABKEY.vis.GenericChartHelper.generatePointHover(chartConfig.measures)} + }) + ); + } + else if (renderType === 'line_plot') { + var xName = chartConfig.measures.x.name, + isDate = isDateType(getMeasureType(chartConfig.measures.x)); + + $.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); + } + return aVal - bVal; + }, + hoverText: emptyTextFn(), + }; + + pathAes[yMeasure.yAxis === 'right' ? 'yRight' : 'yLeft'] = getYMeasureAes(yMeasure); + + // use the series measure's values for the distinct colors and grouping + const hasSeries = chartConfig.measures.series !== undefined; + if (hasSeries) { + pathAes.pathColor = generateGroupingAcc(chartConfig.measures.series.name); + pathAes.group = generateGroupingAcc(chartConfig.measures.series.name); + pathAes.hoverText = function (row) { return chartConfig.measures.series.label + ': ' + row.group }; + } + // if no series measures but we have multiple y-measures, force the color and grouping to be distinct for each measure + else if (yMeasures.length > 1) { + pathAes.pathColor = emptyTextFn; + pathAes.group = emptyTextFn; + } + + if (trendlineData) { + trendlineData.forEach(trendline => { + if (trendline.data) { + const layerAes = { x: 'x', y: 'y' }; + if (hasSeries) { + layerAes.pathColor = function () { return trendline.name }; + } + + layerAes.hoverText = generateTrendlinePathHover(trendline); + + layers.push( + new LABKEY.vis.Layer({ + geom: new LABKEY.vis.Geom.Path({ + color: '#' + chartConfig.geomOptions.pointFillColor, + size: chartConfig.geomOptions.lineWidth ? chartConfig.geomOptions.lineWidth : 3, + opacity:chartConfig.geomOptions.opacity, + }), + aes: layerAes, + data: trendline.data.generatedPoints, + }) + ); + } + }); + } else { + layers.push( + new LABKEY.vis.Layer({ + name: yMeasures.length > 1 ? yMeasure.label || yMeasure.name : undefined, + geom: new LABKEY.vis.Geom.Path({ + color: '#' + chartConfig.geomOptions.pointFillColor, + size: chartConfig.geomOptions.lineWidth?chartConfig.geomOptions.lineWidth:3, + opacity:chartConfig.geomOptions.opacity + }), + aes: pathAes + }) + ); + } + }, this); + } + + // Issue 34711: better guess at the max number of discrete x-axis tick mark labels to show based on the plot width + if (scales.x && scales.x.scaleType === 'discrete' && scales.x.tickLabelMax) { + // approx 30 px for a 45 degree rotated tick label + scales.x.tickLabelMax = Math.floor((plotConfig.width - 300) / 30); + } + + var margins = _getPlotMargins(renderType, scales, aes, data, plotConfig, chartConfig); + if (LABKEY.Utils.isObject(margins)) { + plotConfig.margins = margins; + } + + if (chartConfig.measures.color) + { + scales.color = { + colorType: chartConfig.geomOptions.colorPaletteScale, + scaleType: 'discrete' + } + } + + if ((renderType === 'line_plot' || renderType === 'scatter_plot') && yMeasures.length > 0) { + $.each(yMeasures, function (idx, yMeasure) { + var layerAes = {}; + layerAes[yMeasure.yAxis === 'right' ? 'yRight' : 'yLeft'] = getYMeasureAes(yMeasure); + + // if no series measures but we have multiple y-measures, force the color and shape to be distinct for each measure + if (!aes.color && yMeasures.length > 1) { + layerAes.color = emptyTextFn; + } + if (!aes.shape && yMeasures.length > 1) { + 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, + geom: geom, + aes: layerAes + }) + ); + }, this); + } + else { + layers.push( + new LABKEY.vis.Layer({ + data: data, + geom: geom + }) + ); + } + + plotConfig = $.extend(plotConfig, { + clipRect: clipRect, + data: data, + labels: labels, + aes: aes, + scales: scales, + layers: layers + }); + + return plotConfig; + }; + + const hasPremiumModule = function() { + return LABKEY.getModuleContext('api').moduleNames.indexOf('premium') > -1; + }; + + const TRENDLINE_OPTIONS = { + '': { label: 'Point-to-Point', value: '' }, + 'Linear': { label: 'Linear Regression', value: 'Linear', equation: 'y = x * slope + intercept' }, + 'Polynomial': { label: 'Polynomial', value: 'Polynomial', equation: 'y = a0 + a1 * x + a2 * x^2' }, + '3 Parameter': { label: 'Nonlinear 3PL', value: '3 Parameter', showMax: true, schemaPrefix: 'assay', equation: 'y = max * abs(x/inflection)^abs(slope) / [1 + abs(x/inflection)^abs(slope)]' }, + 'Three Parameter': { label: 'Nonlinear 3PL (Alternate)', value: 'Three Parameter', showMax: true, schemaPrefix: 'assay', equation: 'y = max / [1 + (inflection - x) * slope]' }, + '4 Parameter': { label: 'Nonlinear 4PL', value: '4 Parameter', schemaPrefix: 'assay', equation: 'y = max + (min - max) / [1 + (x/inflection)^slope]' }, + 'Four Parameter': { label: 'Nonlinear 4PL (Alternate)', value: 'Four Parameter', showMin: true, showMax: true, schemaPrefix: 'assay', equation: 'y = min + (max - min) / [1 + (inflection - x) * slope]' }, + 'Five Parameter': { label: 'Nonlinear 5PL', value: 'Five Parameter', showMin: true, showMax: true, schemaPrefix: 'assay', equation: 'y = min + (max - min) / [[1 + (inflection - x) * slope]^asymmetry]' }, + } + + const generateTrendlinePathHover = function(trendline) { + let hoverText = trendline.name + '\n'; + hoverText += '\n' + TRENDLINE_OPTIONS[trendline.data.curveFit.type].label + ':\n'; + Object.entries(trendline.data.curveFit).forEach(([key, value]) => { + if (key === 'coefficients') { + hoverText += key + ': '; + value.forEach((v, i) => { + hoverText += (i > 0 ? ', ' : '') + LABKEY.Utils.roundNumber(v, 4); + }); + hoverText += '\n'; + } + else if (key !== 'type') { + hoverText += key + ': ' + LABKEY.Utils.roundNumber(value, 4) + '\n'; + } + }); + hoverText += '\nStatistics:\n'; + Object.entries(trendline.data.stats).forEach(([key, value]) => { + const label = key === 'RSquared' ? 'R-Squared' : (key === 'adjustedRSquared' ? 'Adjusted R-Squared' : key); + hoverText += label + ': ' + LABKEY.Utils.roundNumber(value, 4) + '\n'; + }); + + return function () { return hoverText }; + }; + + // support for y-axis trendline data when a single y-axis measure is selected + const queryTrendlineData = async function(chartConfig, data) { + const chartType = getChartType(chartConfig); + const yMeasures = ensureMeasuresAsArray(chartConfig.measures.y); + if (chartType === 'line_plot' && chartConfig.geomOptions?.trendlineType && chartConfig.geomOptions.trendlineType !== '' && yMeasures.length === 1) { + const xName = chartConfig.measures.x.name; + const trendlineConfig = getTrendlineConfig(chartConfig, data); + try { + await _queryTrendlineData(trendlineConfig, xName, yMeasures[0].name); + return trendlineConfig.data; + } catch (reason) { + // skip this series and render without trendline + return trendlineConfig.data; + } + } + + return undefined; + }; + + const getTrendlineConfig = function(chartConfig, data) { + const config = { + type: chartConfig.geomOptions.trendlineType, + logXScale: chartConfig.scales.x && chartConfig.scales.x.trans === 'log', + asymptoteMin: chartConfig.geomOptions.trendlineAsymptoteMin, + asymptoteMax: chartConfig.geomOptions.trendlineAsymptoteMax, + data: chartConfig.measures.series + ? LABKEY.vis.groupCountData(data, generateGroupingAcc(chartConfig.measures.series.name)) + : [{name: 'All', rawData: data}], + }; + + // special case to only use logXScale for linear trendlines + if (config.type === 'Linear') { + config.logXScale = false; + } + + return config; + }; + + const _queryTrendlineData = async function(trendlineConfig, xName, yName) { + for (let series of trendlineConfig.data) { + try { + // we need at least 2 data points for curve fitting + if (series.rawData.length > 1) { + series.data = await _querySeriesTrendlineData(trendlineConfig, series, xName, yName); + } + } catch (e) { + console.error(e); + } + } + }; + + const _querySeriesTrendlineData = function(trendlineConfig, seriesData, xName, yName) { + return new Promise(function(resolve, reject) { + if (!hasPremiumModule()) { + reject('Premium module required for curve fitting.'); + return; + } + + const points = seriesData.rawData.map(function(row) { + return { + x: _getRowValue(row, xName, 'value'), + y: _getRowValue(row, yName, 'value'), + }; + }); + const xAcc = function(row) { return row.x }; + const xMin = d3.min(points, xAcc); + const xMax = d3.max(points, xAcc); + + LABKEY.Ajax.request({ + url: LABKEY.ActionURL.buildURL('premium', 'calculateCurveFit.api'), + method: 'POST', + jsonData: { + curveFitType: trendlineConfig.type, + points: points, + logXScale: trendlineConfig.logXScale, + asymptoteMin: trendlineConfig.asymptoteMin, + asymptoteMax: trendlineConfig.asymptoteMax, + xMin: xMin, + xMax: xMax, + numberOfPoints: 1000, + }, + success : LABKEY.Utils.getCallbackWrapper(function(response) { + resolve(response); + }), + failure : LABKEY.Utils.getCallbackWrapper(function(reason) { + reject(reason); + }, this, true), + }); + }); + }; + + 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; + // 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 1; + }; + + var _getPlotMargins = function(renderType, scales, aes, data, plotConfig, chartConfig) { + var margins = {}; + + // issue 29690: for bar and box plots, set default bottom margin based on the number of labels and the max label length + if (LABKEY.Utils.isArray(data)) { + var maxLen = 0; + $.each(data, function(idx, d) { + var val = LABKEY.Utils.isFunction(aes.x) ? aes.x(d) : d[aes.x]; + var subVal = LABKEY.Utils.isFunction(aes.xSub) ? aes.xSub(d) : d[aes.xSub]; + if (LABKEY.Utils.isString(subVal)) { + maxLen = Math.max(maxLen, subVal.length); + } else if (LABKEY.Utils.isString(val)) { + maxLen = Math.max(maxLen, val.length); + } + }); + + 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 + if (chartConfig && chartConfig.geomOptions) { + if (chartConfig.geomOptions.marginTop !== null) { + margins.top = chartConfig.geomOptions.marginTop; + } + if (chartConfig.geomOptions.marginRight !== null) { + margins.right = chartConfig.geomOptions.marginRight; + } + if (chartConfig.geomOptions.marginBottom !== null) { + margins.bottom = chartConfig.geomOptions.marginBottom; + } + if (chartConfig.geomOptions.marginLeft !== null) { + margins.left = chartConfig.geomOptions.marginLeft; + } + } + + return !LABKEY.Utils.isEmptyObj(margins) ? margins : null; + }; + + var _generatePieChartConfig = function(baseConfig, chartConfig, labels, data) + { + var hasData = data.length > 0; + + return $.extend(baseConfig, { + data: hasData ? data : [{label: '', value: 1}], + header: { + title: { text: labels.main.value }, + subtitle: { text: labels.subtitle.value }, + titleSubtitlePadding: 1 + }, + footer: { + text: hasData ? labels.footer.value : 'No data to display', + location: 'bottom-center' + }, + labels: { + mainLabel: { fontSize: 14 }, + percentage: { + fontSize: 14, + color: chartConfig.geomOptions.piePercentagesColor != null ? '#' + chartConfig.geomOptions.piePercentagesColor : undefined + }, + outer: { pieDistance: 20 }, + inner: { + format: hasData && chartConfig.geomOptions.showPiePercentages ? 'percentage' : 'none', + hideWhenLessThanPercentage: chartConfig.geomOptions.pieHideWhenLessThanPercentage + } + }, + size: { + pieInnerRadius: hasData ? chartConfig.geomOptions.pieInnerRadius + '%' : '100%', + pieOuterRadius: hasData ? chartConfig.geomOptions.pieOuterRadius + '%' : '90%' + }, + misc: { + gradient: { + enabled: chartConfig.geomOptions.gradientPercentage != 0, + percentage: chartConfig.geomOptions.gradientPercentage, + color: '#' + chartConfig.geomOptions.gradientColor + }, + colors: { + segments: hasData ? LABKEY.vis.Scale[chartConfig.geomOptions.colorPaletteScale]() : ['#333333'] + } + }, + effects: { highlightSegmentOnMouseover: false }, + tooltips: { enabled: true } + }); + }; + + /** + * Check if the MeasureStore selectRows API response has data. Return an error string if no data exists. + * @param measureStore + * @param includeFilterMsg true to include a message about removing filters + * @returns {String} + */ + var validateResponseHasData = function(measureStore, includeFilterMsg) + { + var dataArray = getMeasureStoreRecords(measureStore); + if (dataArray.length == 0) + { + return 'The response returned 0 rows of data. The query may be empty or the applied filters may be too strict.' + + (includeFilterMsg ? 'Try removing or adjusting any filters if possible.' : ''); + } + + return null; + }; + + var getMeasureStoreRecords = function(measureStore) { + return LABKEY.Utils.isDefined(measureStore) ? measureStore.rows || measureStore.records() : []; + } + + /** + * Verifies that the axis measure is actually present and has data. Also checks to make sure that data can be used in a log + * scale (if applicable). Returns an object with a success parameter (boolean) and a message parameter (string). If the + * success parameter is false there is a critical error and the chart cannot be rendered. If success is true the chart + * can be rendered. Message will contain an error or warning message if applicable. If message is not null and success + * is true, there is a warning. + * @param {String} chartType The chartType from getChartType. + * @param {Object} chartConfigOrMeasure The saved chartConfig object or a specific measure object. + * @param {String} measureName The name of the axis measure property. + * @param {Object} aes The aes object from generateAes. + * @param {Object} scales The scales object from generateScales. + * @param {Array} data The response data from selectRows. + * @param {Boolean} dataConversionHappened Whether we converted any values in the measure data + * @returns {Object} + */ + var validateAxisMeasure = function(chartType, chartConfigOrMeasure, measureName, aes, scales, data, dataConversionHappened) { + var measure = LABKEY.Utils.isObject(chartConfigOrMeasure) && chartConfigOrMeasure.measures ? chartConfigOrMeasure.measures[measureName] : chartConfigOrMeasure; + return _validateAxisMeasure(chartType, measure, measureName, aes, scales, data, dataConversionHappened); + }; + + var _validateAxisMeasure = function(chartType, measure, measureName, aes, scales, data, dataConversionHappened) { + var dataIsNull = true, measureUndefined = true, invalidLogValues = false, hasZeroes = false, message = null; + + // no need to check measures if we have no data + if (data.length === 0) { + return {success: true, message: message}; + } + + for (var i = 0; i < data.length; i ++) + { + var value = aes[measureName](data[i]); + + if (value !== undefined) + measureUndefined = false; + + if (value !== null) + dataIsNull = false; + + if (value && value < 0) + invalidLogValues = true; + + if (value === 0 ) + hasZeroes = true; + } + + if (measureUndefined) + { + message = 'The measure, ' + measure.name + ', was not found. It may have been renamed or removed.'; + return {success: false, message: message}; + } + + if ((chartType == 'scatter_plot' || chartType == 'line_plot' || measureName == 'y') && dataIsNull && !dataConversionHappened) + { + message = 'All data values for ' + measure.label + ' are null. Please choose a different measure or review/remove data filters.'; + return {success: true, message: message}; + } + + if (scales[measureName] && scales[measureName].trans == "log") + { + if (invalidLogValues) + { + message = "Unable to use a log scale on the " + measureName + "-axis. All " + measureName + + "-axis values must be >= 0. Reverting to linear scale on " + measureName + "-axis."; + scales[measureName].trans = 'linear'; + } + else if (hasZeroes) + { + message = "Some " + measureName + "-axis values are 0. Plotting all " + measureName + "-axis values as value+1."; + var accFn = aes[measureName]; + aes[measureName] = function(row){return accFn(row) + 1}; + } + } + + return {success: true, message: message}; + }; + + /** + * Deprecated - use validateAxisMeasure + */ + var validateXAxis = function(chartType, chartConfig, aes, scales, data){ + return this.validateAxisMeasure(chartType, chartConfig, 'x', aes, scales, data); + }; + /** + * Deprecated - use validateAxisMeasure + */ + var validateYAxis = function(chartType, chartConfig, aes, scales, data){ + return this.validateAxisMeasure(chartType, chartConfig, 'y', aes, scales, data); + }; + + var getMeasureType = function(measure) { + return LABKEY.Utils.isObject(measure) ? (measure.normalizedType || measure.type) : null; + }; + + var isNumericType = function(type) + { + var t = LABKEY.Utils.isString(type) ? type.toLowerCase() : null; + return t == 'int' || t == 'integer' || t == 'float' || t == 'double'; + }; + + var isDateType = function(type) + { + var t = LABKEY.Utils.isString(type) ? type.toLowerCase() : null; + return t == 'date'; + }; + + var getAllowableTypes = function(field) { + var numericTypes = ['int', 'float', 'double', 'INTEGER', 'DOUBLE'], + nonNumericTypes = ['string', 'date', 'boolean', 'STRING', 'TEXT', 'DATE', 'BOOLEAN'], + numericAndDateTypes = numericTypes.concat(['date','DATE']); + + if (field.altSelectionOnly) + return []; + else if (field.numericOnly) + return numericTypes; + else if (field.nonNumericOnly) + return nonNumericTypes; + else if (field.numericOrDateOnly) + return numericAndDateTypes; + else + return numericTypes.concat(nonNumericTypes); + } + + var isMeasureDimensionMatch = function(chartType, field, isMeasure, isDimension) { + if ((chartType === 'box_plot' || chartType === 'bar_chart')) { + //x-axis does not support 'measure' column types for these plot types + if (field.name === 'x' || field.name === 'xSub') + return isDimension; + else + return isMeasure; + } + + return (field.numericOnly && isMeasure) || (field.nonNumericOnly && isDimension); + } + + var getQueryConfigSortKey = function(measures) { + var sortKey = 'lsid'; // needed to keep expected ordering for legend data + + // Issue 38105: For plots with study visit labels on the x-axis, sort by visit display order and then sequenceNum + var visitTableName = LABKEY.vis.GenericChartHelper.getStudySubjectInfo().tableName + 'Visit'; + if (measures.x && measures.x.fieldKey === visitTableName + '/Visit') { + var displayOrderColName = visitTableName + '/Visit/DisplayOrder'; + var seqNumColName = visitTableName + '/SequenceNum'; + sortKey = displayOrderColName + ', ' + seqNumColName; + } + + return sortKey; + } + + var getStudySubjectInfo = function() + { + var studyCtx = LABKEY.getModuleContext("study") || {}; + return LABKEY.Utils.isObject(studyCtx.subject) ? studyCtx.subject : { + tableName: 'Participant', + columnName: 'ParticipantId', + nounPlural: 'Participants', + nounSingular: 'Participant' + }; + }; + + var _getStudyTimepointType = function() + { + var studyCtx = LABKEY.getModuleContext("study") || {}; + return LABKEY.Utils.isDefined(studyCtx.timepointType) ? studyCtx.timepointType : null; + }; + + var _getMeasureRestrictions = function (chartType, measure) + { + var measureRestrictions = {}; + $.each(getRenderTypes(), function (idx, renderType) + { + if (renderType.name === chartType) + { + $.each(renderType.fields, function (idx2, field) + { + if (field.name === measure) + { + measureRestrictions.numericOnly = field.numericOnly; + measureRestrictions.nonNumericOnly = field.nonNumericOnly; + return false; + } + }); + return false; + } + }); + + return measureRestrictions; + }; + + /** + * Converts data values passed in to the appropriate type based on measure/dimension information. + * @param chartConfig Chart configuration object + * @param aes Aesthetic mapping functions for each measure/axis + * @param renderType The type of plot or chart (e.g. scatter_plot, bar_chart) + * @param data The response data from SelectRows + * @returns {{processed: {}, warningMessage: *}} + */ + var doValueConversion = function(chartConfig, aes, renderType, data) + { + var measuresForProcessing = {}, measureRestrictions = {}, configMeasure; + for (var measureName in chartConfig.measures) { + if (chartConfig.measures.hasOwnProperty(measureName) && LABKEY.Utils.isObject(chartConfig.measures[measureName])) { + configMeasure = chartConfig.measures[measureName]; + $.extend(measureRestrictions, _getMeasureRestrictions(renderType, measureName)); + + var isGroupingMeasure = measureName === 'color' || measureName === 'shape' || measureName === 'series'; + var isXAxis = measureName === 'x' || measureName === 'xSub'; + var isScatterOrLine = renderType === 'scatter_plot' || renderType === 'line_plot'; + var isBarYCount = renderType === 'bar_chart' && configMeasure.aggregate && (configMeasure.aggregate === 'COUNT' || configMeasure.aggregate.value === 'COUNT'); + + if (configMeasure.measure && !isGroupingMeasure && !isBarYCount + && ((!isXAxis && measureRestrictions.numericOnly ) || isScatterOrLine) && !isNumericType(configMeasure.type)) { + measuresForProcessing[measureName] = {}; + measuresForProcessing[measureName].name = configMeasure.name; + measuresForProcessing[measureName].convertedName = configMeasure.name + "_converted"; + measuresForProcessing[measureName].label = configMeasure.label; + configMeasure.normalizedType = 'float'; + configMeasure.type = 'float'; + } + } + } + + var response = {processed: {}}; + if (!LABKEY.Utils.isEmptyObj(measuresForProcessing)) { + response = _processMeasureData(data, aes, measuresForProcessing); + } + + //generate error message for dropped values + var warningMessage = ''; + for (var measure in response.droppedValues) { + if (response.droppedValues.hasOwnProperty(measure) && response.droppedValues[measure].numDropped) { + warningMessage += " The " + + measure + "-axis measure '" + + response.droppedValues[measure].label + "' had " + + response.droppedValues[measure].numDropped + + " value(s) that could not be converted to a number and are not included in the plot."; + } + } + + return {processed: response.processed, warningMessage: warningMessage}; + }; + + /** + * Does the explicit type conversion for each measure deemed suitable to convert. Currently we only + * attempt to convert strings to numbers for measures. + * @param rows Data from SelectRows + * @param aes Aesthetic mapping function for the measure/dimensions + * @param measuresForProcessing The measures to be converted, if any + * @returns {{droppedValues: {}, processed: {}}} + */ + var _processMeasureData = function(rows, aes, measuresForProcessing) { + var droppedValues = {}, processedMeasures = {}, dataIsNull; + rows.forEach(function(row) { + //convert measures if applicable + if (!LABKEY.Utils.isEmptyObj(measuresForProcessing)) { + for (var measure in measuresForProcessing) { + if (measuresForProcessing.hasOwnProperty(measure)) { + dataIsNull = true; + if (!droppedValues[measure]) { + droppedValues[measure] = {}; + droppedValues[measure].label = measuresForProcessing[measure].label; + droppedValues[measure].numDropped = 0; + } + + if (aes.hasOwnProperty(measure)) { + var value = aes[measure](row); + if (value !== null) { + dataIsNull = false; + } + row[measuresForProcessing[measure].convertedName] = {value: null}; + if (typeof value !== 'number' && value !== null) { + + //only try to convert strings to numbers + if (typeof value === 'string') { + value = value.trim(); + } + else { + //dates, objects, booleans etc. to be assigned value: NULL + value = ''; + } + + var n = Number(value); + // empty strings convert to 0, which we must explicitly deny + if (value === '' || isNaN(n)) { + droppedValues[measure].numDropped++; + } + else { + row[measuresForProcessing[measure].convertedName].value = n; + } + } + } + + if (!processedMeasures[measure]) { + processedMeasures[measure] = { + converted: false, + convertedName: measuresForProcessing[measure].convertedName, + type: 'float', + normalizedType: 'float' + } + } + + processedMeasures[measure].converted = processedMeasures[measure].converted || !dataIsNull; + } + } + } + }); + + return {droppedValues: droppedValues, processed: processedMeasures}; + }; + + /** + * removes all traces of String -> Numeric Conversion from the given chart config + * @param chartConfig + * @returns {updated ChartConfig} + */ + var removeNumericConversionConfig = function(chartConfig) { + if (chartConfig && chartConfig.measures) { + for (var measureName in chartConfig.measures) { + if (chartConfig.measures.hasOwnProperty(measureName)) { + var measure = chartConfig.measures[measureName]; + if (measure && measure.converted && measure.convertedName) { + measure.converted = null; + measure.convertedName = null; + if (LABKEY.vis.GenericChartHelper.isNumericType(measure.type)) { + measure.type = 'string'; + measure.normalizedType = 'string'; + } + } + } + } + } + + return chartConfig; + }; + + var renderChartSVG = function(renderTo, queryConfig, chartConfig) { + queryChartData(renderTo, queryConfig, chartConfig, function(measureStore, trendlineData) { + generateChartSVG(renderTo, chartConfig, measureStore, trendlineData); + }); + }; + + var queryChartData = function(renderTo, queryConfig, chartConfig, callback) { + queryConfig.containerPath = LABKEY.container.path; + + if (queryConfig.filterArray && queryConfig.filterArray.length > 0) { + var filters = []; + + for (var i = 0; i < queryConfig.filterArray.length; i++) { + var f = queryConfig.filterArray[i]; + // Issue 37191: Check to see if 'f' is already a filter instance (either labkey-api-js/src/filter/Filter.ts or clientapi/core/Query.js) + if (f.hasOwnProperty('getValue') || f.getValue instanceof Function) { + filters.push(f); + } + else { + filters.push(LABKEY.Filter.create(f.name, f.value, LABKEY.Filter.getFilterTypeForURLSuffix(f.type))); + } + } + + queryConfig.filterArray = filters; + } + + queryConfig.success = async function(measureStore) { + const trendlineData = await queryTrendlineData(chartConfig, measureStore.records()); + callback.call(this, measureStore, trendlineData); + }; + + LABKEY.Query.MeasureStore.selectRows(queryConfig); + }; + + var generateDataForChartType = function(chartConfig, chartType, geom, data) { + let dimName = null; + 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; + } + 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, '[Blank]', false, aggErrorType, chartType === 'line_plot'); + if (aggErrorType) { + geom.errorAes = { getValue: d => d.error }; + } + } + + return data; + } + + var generateChartSVG = function(renderTo, chartConfig, measureStore, trendlineData) { + var responseMetaData = measureStore.getResponseMetadata(); + + // explicitly set the chart width/height if not set in the config + if (!chartConfig.hasOwnProperty('width') || chartConfig.width == null) chartConfig.width = 1000; + if (!chartConfig.hasOwnProperty('height') || chartConfig.height == null) chartConfig.height = 600; + + var chartType = getChartType(chartConfig); + var aes = generateAes(chartType, chartConfig.measures, responseMetaData.schemaName, responseMetaData.queryName); + var valueConversionResponse = doValueConversion(chartConfig, aes, chartType, measureStore.records()); + if (!LABKEY.Utils.isEmptyObj(valueConversionResponse.processed)) { + $.extend(true, chartConfig.measures, valueConversionResponse.processed); + aes = generateAes(chartType, chartConfig.measures, responseMetaData.schemaName, responseMetaData.queryName); + } + var data = measureStore.records(); + if (chartType === 'scatter_plot' && data.length > chartConfig.geomOptions.binThreshold) { + chartConfig.geomOptions.binned = true; + } + var scales = generateScales(chartType, chartConfig.measures, chartConfig.scales, aes, measureStore); + var geom = generateGeom(chartType, chartConfig.geomOptions); + var labels = generateLabels(chartConfig.labels); + + if (chartType === 'bar_chart' || chartType === 'pie_chart' || chartType === 'line_plot') { + data = generateDataForChartType(chartConfig, chartType, geom, data); + } + + var validation = _validateChartConfig(chartConfig, aes, scales, measureStore); + _renderMessages(renderTo, validation.messages); + if (!validation.success) + return; + + var plotConfigArr = generatePlotConfigs(renderTo, chartConfig, labels, aes, scales, geom, data, trendlineData); + $.each(plotConfigArr, function(idx, plotConfig) { + if (chartType === 'pie_chart') { + new LABKEY.vis.PieChart(plotConfig); + } + else { + new LABKEY.vis.Plot(plotConfig).render(); + } + }, this); + } + + var _renderMessages = function(divId, messages) { + if (messages && messages.length > 0) { + var errorDiv = document.createElement('div'); + errorDiv.innerHTML = '

Error rendering chart:

' + messages.join('
') + '
'; + document.getElementById(divId).appendChild(errorDiv); + } + }; + + var _validateChartConfig = function(chartConfig, aes, scales, measureStore) { + var hasNoDataMsg = validateResponseHasData(measureStore, false); + if (hasNoDataMsg != null) + return {success: false, messages: [hasNoDataMsg]}; + + var messages = [], firstRecord = measureStore.records()[0], measureNames = Object.keys(chartConfig.measures); + for (var i = 0; i < measureNames.length; i++) { + var measuresArr = ensureMeasuresAsArray(chartConfig.measures[measureNames[i]]); + for (var j = 0; j < measuresArr.length; j++) { + var measure = measuresArr[j]; + if (LABKEY.Utils.isObject(measure)) { + if (measure.name && !LABKEY.Utils.isDefined(firstRecord[measure.name])) { + return {success: false, messages: ['The measure, ' + measure.name + ', is not available. It may have been renamed or removed.']}; + } + + var validation; + if (measureNames[i] === 'y') { + var yAes = {y: getYMeasureAes(measure)}; + validation = validateAxisMeasure(chartConfig.renderType, measure, 'y', yAes, scales, measureStore.records()); + } + else if (measureNames[i] === 'x' || measureNames[i] === 'xSub') { + validation = validateAxisMeasure(chartConfig.renderType, measure, measureNames[i], aes, scales, measureStore.records()); + } + + if (LABKEY.Utils.isObject(validation)) { + if (validation.message != null) + messages.push(validation.message); + if (!validation.success) + return {success: false, messages: messages}; + } + } + } + } + + return {success: true, messages: messages}; + }; + + return { + // NOTE: the @function below is needed or JSDoc will not include the documentation for loadVisDependencies. Don't + // ask me why, I do not know. + /** + * @function + */ + getRenderTypes: getRenderTypes, + getChartType: getChartType, + getSelectedMeasureLabel: getSelectedMeasureLabel, + getTitleFromMeasures: getTitleFromMeasures, + getMeasureType: getMeasureType, + getAllowableTypes: getAllowableTypes, + getQueryColumns : getQueryColumns, + getChartTypeBasedWidth : getChartTypeBasedWidth, + getDistinctYAxisSides : getDistinctYAxisSides, + getYMeasureAes : getYMeasureAes, + getDefaultMeasuresLabel: getDefaultMeasuresLabel, + getStudySubjectInfo: getStudySubjectInfo, + getQueryConfigSortKey: getQueryConfigSortKey, + ensureMeasuresAsArray: ensureMeasuresAsArray, + isNumericType: isNumericType, + isMeasureDimensionMatch: isMeasureDimensionMatch, + generateLabels: generateLabels, + generateScales: generateScales, + generateAes: generateAes, + doValueConversion: doValueConversion, + removeNumericConversionConfig: removeNumericConversionConfig, + generateAggregateData: generateAggregateData, + generatePointHover: generatePointHover, + generateBoxplotHover: generateBoxplotHover, + generateDataForChartType: generateDataForChartType, + generateDiscreteAcc: generateDiscreteAcc, + generateContinuousAcc: generateContinuousAcc, + generateGroupingAcc: generateGroupingAcc, + generatePointClickFn: generatePointClickFn, + generateGeom: generateGeom, + generateBoxplotGeom: generateBoxplotGeom, + generatePointGeom: generatePointGeom, + generatePlotConfigs: generatePlotConfigs, + generatePlotConfig: generatePlotConfig, + validateResponseHasData: validateResponseHasData, + validateAxisMeasure: validateAxisMeasure, + validateXAxis: validateXAxis, + validateYAxis: validateYAxis, + renderChartSVG: renderChartSVG, + queryChartData: queryChartData, + generateChartSVG: generateChartSVG, + getMeasureStoreRecords: getMeasureStoreRecords, + queryTrendlineData: queryTrendlineData, + TRENDLINE_OPTIONS: TRENDLINE_OPTIONS, + /** + * Loads all of the required dependencies for a Generic Chart. + * @param {Function} callback The callback to be executed when all of the visualization dependencies have been loaded. + * @param {Object} scope The scope to be used when executing the callback. + */ + loadVisDependencies: LABKEY.requiresVisualization + }; }; \ No newline at end of file From 84cb3254ffa995c50d896caab3884a184e16376b Mon Sep 17 00:00:00 2001 From: cnathe Date: Fri, 17 Oct 2025 08:56:48 -0500 Subject: [PATCH 29/40] LKS chart wizard fix to not show aggregate/error bars options for multiple y-axis --- .../resources/web/vis/chartWizard/chartLayoutPanel.js | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/visualization/resources/web/vis/chartWizard/chartLayoutPanel.js b/visualization/resources/web/vis/chartWizard/chartLayoutPanel.js index 8749a0611d9..2f543859381 100644 --- a/visualization/resources/web/vis/chartWizard/chartLayoutPanel.js +++ b/visualization/resources/web/vis/chartWizard/chartLayoutPanel.js @@ -346,10 +346,11 @@ 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 sides in use - if (inputField.fieldLabel === 'Aggregate Method' || inputField.fieldLabel === 'Error Bars') { + // 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); - inputField.setVisible(sides.length < 2); + const yMeasureCount = LABKEY.Utils.isArray(measures.y) ? measures.y.length : (measures.y ? 1 : 0); + inputField.setVisible(yMeasureCount < 2 && sides.length < 2); } }, this); } From 2b09077270364e1c8b81e1c4dcf22f7e3a572341 Mon Sep 17 00:00:00 2001 From: cnathe Date: Fri, 17 Oct 2025 08:57:11 -0500 Subject: [PATCH 30/40] LKS chart wizard fix to not show aggregate/error bars options for multiple y-axis --- .../chartWizard/chartLayoutDialog/genericChartAxisPanel.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/visualization/resources/web/vis/chartWizard/chartLayoutDialog/genericChartAxisPanel.js b/visualization/resources/web/vis/chartWizard/chartLayoutDialog/genericChartAxisPanel.js index eaca3f483b1..7001d188ec7 100644 --- a/visualization/resources/web/vis/chartWizard/chartLayoutDialog/genericChartAxisPanel.js +++ b/visualization/resources/web/vis/chartWizard/chartLayoutDialog/genericChartAxisPanel.js @@ -402,10 +402,10 @@ Ext4.define('LABKEY.vis.GenericChartAxisPanel', { } // only show aggregate method for line chart and error bars option for both bar and line - if (this.axisName !== 'y' || !isLine) { + if (!(this.axisName === 'y' && isLine)) { this.setAggregateOptionVisible(false); } - if (this.axisName !== 'y' || !(isBar || isLine)) { + if (!(this.axisName === 'y' && (isBar || isLine))) { this.setErrorBarsOptionVisible(false); } }, From ee3f169819be3926ab3d7ace6229e658972240d2 Mon Sep 17 00:00:00 2001 From: cnathe Date: Fri, 17 Oct 2025 08:57:33 -0500 Subject: [PATCH 31/40] Bar chart default min fix for when max is set manually --- .../web/vis/genericChart/genericChartHelper.js | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/visualization/resources/web/vis/genericChart/genericChartHelper.js b/visualization/resources/web/vis/genericChart/genericChartHelper.js index d33386f9bc9..5d73162638a 100644 --- a/visualization/resources/web/vis/genericChart/genericChartHelper.js +++ b/visualization/resources/web/vis/genericChart/genericChartHelper.js @@ -1071,15 +1071,15 @@ LABKEY.vis.GenericChartHelper = new function(){ scales.y = {}; } - if (!scales.y.domain) { - var values = $.map(data, d => d.value + (d.error ?? 0)), - 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]) { + } 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] = 0; + scales.y.domain[0] = min; } } else if (renderType === 'box_plot' && chartConfig.pointType === 'all') From ec4a092454d4610e094b0f1f802cf7b9591f24c8 Mon Sep 17 00:00:00 2001 From: cnathe Date: Fri, 17 Oct 2025 09:37:32 -0500 Subject: [PATCH 32/40] misc cleanup --- core/webapp/vis/src/utils.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/webapp/vis/src/utils.js b/core/webapp/vis/src/utils.js index d8008835def..18de85bfad9 100644 --- a/core/webapp/vis/src/utils.js +++ b/core/webapp/vis/src/utils.js @@ -291,9 +291,9 @@ LABKEY.vis.getAggregateData = function(data, dimensionName, subDimensionName, me // 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 }; } From ad6ddc823e6ca0fdd8c2bb106315f4c7b406758a Mon Sep 17 00:00:00 2001 From: cnathe Date: Fri, 17 Oct 2025 10:31:59 -0500 Subject: [PATCH 33/40] wrapAxisTickLabel() revert for loop (we need to iterate through the words backwards) --- core/webapp/vis/src/internal/D3Renderer.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/core/webapp/vis/src/internal/D3Renderer.js b/core/webapp/vis/src/internal/D3Renderer.js index 7ca328a9545..b3df4053cd3 100644 --- a/core/webapp/vis/src/internal/D3Renderer.js +++ b/core/webapp/vis/src/internal/D3Renderer.js @@ -488,6 +488,7 @@ LABKEY.vis.internal.Axis = function() { 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) @@ -496,7 +497,7 @@ LABKEY.vis.internal.Axis = function() { .attr("y", y) .attr("dy", "0em"); - for (const word of words) { + while (word = words.pop()) { line.push(word); tspan.text(line.join(" ")); if (tspan.node().getComputedTextLength() > width) { From 8c24be03f73889a6eeb409eec738c1f593a855b3 Mon Sep 17 00:00:00 2001 From: cnathe Date: Fri, 17 Oct 2025 10:47:53 -0500 Subject: [PATCH 34/40] Issue 54125: Line chart x-axis to use row value instead of formatted value for sorting --- .../web/vis/genericChart/genericChartHelper.js | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/visualization/resources/web/vis/genericChart/genericChartHelper.js b/visualization/resources/web/vis/genericChart/genericChartHelper.js index 5d73162638a..0b5bc9bf031 100644 --- a/visualization/resources/web/vis/genericChart/genericChartHelper.js +++ b/visualization/resources/web/vis/genericChart/genericChartHelper.js @@ -1098,12 +1098,15 @@ 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){ + // 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) { + return 1; + } else if (bVal === null) { + return -1; + } else if (isDate){ return new Date(aVal) - new Date(bVal); } return aVal - bVal; From a92426c1af26c2b5f4417b4e9762f8e83dc2d28b Mon Sep 17 00:00:00 2001 From: cnathe Date: Mon, 20 Oct 2025 14:49:29 -0500 Subject: [PATCH 35/40] Issue 54125: use continuous domain and date format for x-axis date field in plots --- core/src/client/vis/utils.test.ts | 98 ++++++++++++++++++- core/webapp/vis/src/utils.js | 61 +++++++++++- .../vis/genericChart/genericChartHelper.js | 16 ++- 3 files changed, 171 insertions(+), 4 deletions(-) diff --git a/core/src/client/vis/utils.test.ts b/core/src/client/vis/utils.test.ts index bdce37c01d3..c7adb321b9b 100644 --- a/core/src/client/vis/utils.test.ts +++ b/core/src/client/vis/utils.test.ts @@ -163,4 +163,100 @@ describe('LABKEY.vis.getAggregateData', () => { 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 }]); }); -}); \ No newline at end of file +}); + +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]); + }); + }); + }); +}); diff --git a/core/webapp/vis/src/utils.js b/core/webapp/vis/src/utils.js index 18de85bfad9..6acab02ee28 100644 --- a/core/webapp/vis/src/utils.js +++ b/core/webapp/vis/src/utils.js @@ -225,7 +225,7 @@ LABKEY.vis.groupCountData = function(data, groupAccessor, subgroupAccessor, prop LABKEY.vis.getAggregateData = function(data, dimensionName, subDimensionName, measureName, aggregate, nullDisplayValue, includeTotal, errorBarType, keepNames = false) { 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], 'value');}, hasSubgroup = subDimensionName != undefined && subDimensionName != null, hasMeasure = measureName != undefined && measureName != null, measureAccessor = hasMeasure ? function(row){ return LABKEY.vis.getValue(row[measureName], 'value'); } : null; @@ -234,7 +234,7 @@ LABKEY.vis.getAggregateData = function(data, dimensionName, subDimensionName, me if (typeof subDimensionName === 'function') { subgroupAccessor = subDimensionName; } else { - subgroupAccessor = function (row) { return LABKEY.vis.getValue(row[subDimensionName]); } + subgroupAccessor = function (row) { return LABKEY.vis.getValue(row[subDimensionName], 'value'); } } } @@ -407,3 +407,60 @@ LABKEY.vis.getValue = function(obj, preferredProp) { return obj; }; + +LABKEY.vis.formatDate = function(date, format) { + const isValidDate = date instanceof Date && !isNaN(date); + if (!isValidDate) 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/visualization/resources/web/vis/genericChart/genericChartHelper.js b/visualization/resources/web/vis/genericChart/genericChartHelper.js index 0b5bc9bf031..6cb9d3ffdfa 100644 --- a/visualization/resources/web/vis/genericChart/genericChartHelper.js +++ b/visualization/resources/web/vis/genericChart/genericChartHelper.js @@ -488,6 +488,17 @@ 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); + const isValidDate = d instanceof Date && !isNaN(d); + return isValidDate ? LABKEY.vis.formatDate(new Date(v), dateFormat) : v; + }; + } + } var yMeasures = ensureMeasuresAsArray(measures.y); $.each(yMeasures, function(idx, yMeasure) { @@ -575,7 +586,10 @@ LABKEY.vis.GenericChartHelper = new function(){ var aes = {}, xMeasureType = getMeasureType(measures.x); var xMeasureName = !measures.x ? undefined : (measures.x.converted ? measures.x.convertedName : measures.x.name); - if (chartType === "box_plot") { + if (isDateType(xMeasureType)) { + // Issue 54125: use continuous instead of discrete accessor for date x-axis + aes.x = generateContinuousAcc(xMeasureName); + } else if (chartType === "box_plot") { if (!measures.x) { aes.x = generateMeasurelessAcc(queryName); } else { From 5373a1cec1f2ba3bf2581deb9f512fd6f37d8cb0 Mon Sep 17 00:00:00 2001 From: cnathe Date: Tue, 21 Oct 2025 14:15:34 -0500 Subject: [PATCH 36/40] revert line endings --- .../vis/genericChart/genericChartHelper.js | 4110 ++++++++--------- 1 file changed, 2055 insertions(+), 2055 deletions(-) diff --git a/visualization/resources/web/vis/genericChart/genericChartHelper.js b/visualization/resources/web/vis/genericChart/genericChartHelper.js index 6cb9d3ffdfa..48c86a53230 100644 --- a/visualization/resources/web/vis/genericChart/genericChartHelper.js +++ b/visualization/resources/web/vis/genericChart/genericChartHelper.js @@ -1,2056 +1,2056 @@ -/* - * Copyright (c) 2013-2019 LabKey Corporation - * - * Licensed under the Apache License, Version 2.0: http://www.apache.org/licenses/LICENSE-2.0 - */ -if(!LABKEY.vis) { - LABKEY.vis = {}; -} - -/** - * @namespace Namespace used to encapsulate functions related to creating Generic Charts (Box, Scatter, etc.). Used in the - * Generic Chart Wizard and when exporting Generic Charts as Scripts. - */ -LABKEY.vis.GenericChartHelper = new function(){ - - var DEFAULT_TICK_LABEL_MAX = 25; - var $ = jQuery; - - var getRenderTypes = function() { - return [ - { - name: 'bar_chart', - title: 'Bar', - imgUrl: LABKEY.contextPath + '/visualization/images/barchart.png', - fields: [ - {name: 'x', label: 'X Axis', required: true, nonNumericOnly: true}, - {name: 'xSub', label: 'Group By', required: false, nonNumericOnly: true}, - {name: 'y', label: 'Y Axis', numericOnly: true} - ], - layoutOptions: {line: true, opacity: true, axisBased: true} - }, - { - name: 'box_plot', - title: 'Box', - imgUrl: LABKEY.contextPath + '/visualization/images/boxplot.png', - fields: [ - {name: 'x', label: 'X Axis'}, - {name: 'y', label: 'Y Axis', required: true, numericOnly: true}, - {name: 'color', label: 'Color', nonNumericOnly: true}, - {name: 'shape', label: 'Shape', nonNumericOnly: true} - ], - layoutOptions: {point: true, box: true, line: true, opacity: true, axisBased: true} - }, - { - name: 'line_plot', - title: 'Line', - imgUrl: LABKEY.contextPath + '/visualization/images/timechart.png', - fields: [ - {name: 'x', label: 'X Axis', required: true, numericOrDateOnly: true}, - {name: 'y', label: 'Y Axis', required: true, numericOnly: true, allowMultiple: true}, - {name: 'series', label: 'Series', nonNumericOnly: true}, - {name: 'trendline', label: 'Trendline', required: false, altSelectionOnly: true, altFieldType: 'LABKEY.vis.TrendlineField'}, - ], - layoutOptions: {opacity: true, axisBased: true, series: true, chartLayout: true} - }, - { - name: 'pie_chart', - title: 'Pie', - imgUrl: LABKEY.contextPath + '/visualization/images/piechart.png', - fields: [ - {name: 'x', label: 'Categories', required: true, nonNumericOnly: true}, - // Issue #29046 'Remove "measure" option from pie chart' - // {name: 'y', label: 'Measure', numericOnly: true} - ], - layoutOptions: {pie: true} - }, - { - name: 'scatter_plot', - title: 'Scatter', - imgUrl: LABKEY.contextPath + '/visualization/images/scatterplot.png', - fields: [ - {name: 'x', label: 'X Axis', required: true}, - {name: 'y', label: 'Y Axis', required: true, numericOnly: true, allowMultiple: true}, - {name: 'color', label: 'Color', nonNumericOnly: true}, - {name: 'shape', label: 'Shape', nonNumericOnly: true} - ], - layoutOptions: {point: true, opacity: true, axisBased: true, binnable: true, chartLayout: true} - }, - { - name: 'time_chart', - title: 'Time', - hidden: _getStudyTimepointType() == null, - imgUrl: LABKEY.contextPath + '/visualization/images/timechart.png', - fields: [ - {name: 'x', label: 'X Axis', required: true, altSelectionOnly: true, altFieldType: 'LABKEY.vis.TimeChartXAxisField'}, - {name: 'y', label: 'Y Axis', required: true, numericOnly: true, allowMultiple: true} - ], - layoutOptions: {time: true, axisBased: true, chartLayout: true} - } - ]; - }; - - /** - * Gets the chart type (i.e. box or scatter) based on the chartConfig object. - */ - const getChartType = function(chartConfig) - { - const renderType = chartConfig.renderType - const xAxisType = chartConfig.measures.x ? (chartConfig.measures.x.normalizedType || chartConfig.measures.x.type) : null; - - if (renderType === 'time_chart' || renderType === "bar_chart" || renderType === "pie_chart" - || renderType === "box_plot" || renderType === "scatter_plot" || renderType === "line_plot") - { - return renderType; - } - - if (!xAxisType) - { - // On some charts (non-study box plots) we don't require an x-axis, instead we generate one box plot for - // all of the data of your y-axis. If there is no xAxisType, then we have a box plot. Scatter plots require - // an x-axis measure. - return 'box_plot'; - } - - return (xAxisType === 'string' || xAxisType === 'boolean') ? 'box_plot' : 'scatter_plot'; - }; - - /** - * Generate a default label for the selected measure for the given renderType. - * @param renderType - * @param measureName - the chart type's measure name - * @param properties - properties for the selected column, note that this can be an array of properties - */ - var getSelectedMeasureLabel = function(renderType, measureName, properties) - { - var label = getDefaultMeasuresLabel(properties); - - if (label !== '' && measureName === 'y' && (renderType === 'bar_chart' || renderType === 'pie_chart')) { - var aggregateProps = LABKEY.Utils.isArray(properties) && properties.length === 1 - ? properties[0].aggregate : properties.aggregate; - - if (LABKEY.Utils.isDefined(aggregateProps)) { - var aggLabel = LABKEY.Utils.isObject(aggregateProps) - ? (aggregateProps.name ?? aggregateProps.label) - : LABKEY.Utils.capitalize(aggregateProps.toLowerCase()); - label = aggLabel + ' of ' + label; - } - else { - label = 'Sum of ' + label; - } - } - - return label; - }; - - /** - * Generate a plot title based on the selected measures array or object. - * @param renderType - * @param measures - * @returns {string} - */ - var getTitleFromMeasures = function(renderType, measures) - { - var queryLabels = []; - - if (LABKEY.Utils.isObject(measures)) - { - if (LABKEY.Utils.isArray(measures.y)) - { - $.each(measures.y, function(idx, m) - { - var measureQueryLabel = m.queryLabel || m.queryName; - if (queryLabels.indexOf(measureQueryLabel) === -1) - queryLabels.push(measureQueryLabel); - }); - } - else - { - var m = measures.x || measures.y; - queryLabels.push(m.queryLabel || m.queryName); - } - } - - return queryLabels.join(', '); - }; - - /** - * Get the sorted set of column metadata for the given schema/query/view. - * @param queryConfig - * @param successCallback - * @param callbackScope - */ - var getQueryColumns = function(queryConfig, successCallback, callbackScope) - { - LABKEY.Ajax.request({ - url: LABKEY.ActionURL.buildURL('visualization', 'getGenericReportColumns.api'), - method: 'GET', - params: { - schemaName: queryConfig.schemaName, - queryName: queryConfig.queryName, - viewName: queryConfig.viewName, - dataRegionName: queryConfig.dataRegionName, - includeCohort: true, - includeParticipantCategory : true - }, - success : function(response){ - var columnList = LABKEY.Utils.decode(response.responseText); - _queryColumnMetadata(queryConfig, columnList, successCallback, callbackScope) - }, - scope : this - }); - }; - - var _queryColumnMetadata = function(queryConfig, columnList, successCallback, callbackScope) - { - var columns = columnList.columns.all; - if (queryConfig.savedColumns) { - // make sure all savedColumns from the chart are included as options, they may not be in the view anymore - columns = columns.concat(queryConfig.savedColumns); - } - - LABKEY.Query.selectRows({ - maxRows: 0, // use maxRows 0 so that we just get the query metadata - schemaName: queryConfig.schemaName, - queryName: queryConfig.queryName, - viewName: queryConfig.viewName, - parameters: queryConfig.parameters, - requiredVersion: 9.1, - columns: columns, - method: 'POST', // Issue 31744: use POST as the columns list can be very long and cause a 400 error - success: function(response){ - var columnMetadata = _updateAndSortQueryFields(queryConfig, columnList, response.metaData.fields); - successCallback.call(callbackScope, columnMetadata); - }, - failure : function(response) { - // this likely means that the query no longer exists - successCallback.call(callbackScope, columnList, []); - }, - scope : this - }); - }; - - var _updateAndSortQueryFields = function(queryConfig, columnList, columnMetadata) - { - var queryFields = [], - queryFieldKeys = [], - columnTypes = LABKEY.Utils.isDefined(columnList.columns) ? columnList.columns : {}; - - $.each(columnMetadata, function(idx, column) - { - var f = $.extend(true, {}, column); - f.schemaName = queryConfig.schemaName; - f.queryName = queryConfig.queryName; - f.isCohortColumn = false; - f.isSubjectGroupColumn = false; - - // issue 23224: distinguish cohort and subject group fields in the list of query columns - if (columnTypes['cohort'] && columnTypes['cohort'].indexOf(f.fieldKey) > -1) - { - f.shortCaption = 'Study: ' + f.shortCaption; - f.isCohortColumn = true; - } - else if (columnTypes['subjectGroup'] && columnTypes['subjectGroup'].indexOf(f.fieldKey) > -1) - { - f.shortCaption = columnList.subject.nounSingular + ' Group: ' + f.shortCaption; - f.isSubjectGroupColumn = true; - } - - // Issue 31672: keep track of the distinct query field keys so we don't get duplicates - if (f.fieldKey.toLowerCase() != 'lsid' && queryFieldKeys.indexOf(f.fieldKey) == -1) { - queryFields.push(f); - queryFieldKeys.push(f.fieldKey); - } - }, this); - - // Sorts fields by their shortCaption, but put subject groups/categories/cohort at the end. - queryFields.sort(function(a, b) - { - if (a.isSubjectGroupColumn != b.isSubjectGroupColumn) - return a.isSubjectGroupColumn ? 1 : -1; - else if (a.isCohortColumn != b.isCohortColumn) - return a.isCohortColumn ? 1 : -1; - else if (a.shortCaption != b.shortCaption) - return a.shortCaption < b.shortCaption ? -1 : 1; - - return 0; - }); - - return queryFields; - }; - - /** - * Determine a reasonable width for the chart based on the chart type and selected measures / data. - * @param chartType - * @param measures - * @param measureStore - * @param defaultWidth - * @returns {int} - */ - var getChartTypeBasedWidth = function(chartType, measures, measureStore, defaultWidth) { - var width = 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; - } - } - 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; - }; - - /** - * Return the distinct set of y-axis sides for the given measures object. - * @param measures - */ - var getDistinctYAxisSides = function(measures) - { - var distinctSides = []; - $.each(ensureMeasuresAsArray(measures.y), function (idx, measure) { - if (LABKEY.Utils.isObject(measure)) { - var side = measure.yAxis || 'left'; - if (distinctSides.indexOf(side) === -1) { - distinctSides.push(side); - } - } - }, this); - return distinctSides; - }; - - /** - * Generate a default label for an array of measures by concatenating each meaures label together. - * @param measures - * @returns string concatenation of all measure labels - */ - var getDefaultMeasuresLabel = function(measures) - { - if (LABKEY.Utils.isDefined(measures)) { - if (!LABKEY.Utils.isArray(measures)) { - return measures.label || measures.queryName || ''; - } - - var label = '', sep = ''; - $.each(measures, function(idx, m) { - label += sep + (m.label || m.queryName); - sep = ', '; - }); - return label; - } - - return ''; - }; - - /** - * Given the saved labels object we convert it to include all label types (main, x, and y). Each label type defaults - * to empty string (''). - * @param {Object} labels The saved labels object. - * @returns {Object} - */ - var generateLabels = function(labels) { - return { - main: { value: labels.main || '' }, - subtitle: { value: labels.subtitle || '' }, - footer: { value: labels.footer || '' }, - x: { value: labels.x || '' }, - y: { value: labels.y || '' }, - yRight: { value: labels.yRight || '' } - }; - }; - - /** - * Generates an object containing {@link LABKEY.vis.Scale} objects used for the chart. - * @param {String} chartType The chartType from getChartType. - * @param {Object} measures The measures from generateMeasures. - * @param {Object} savedScales The scales object from the saved chart config. - * @param {Object} aes The aesthetic map object from genereateAes. - * @param {Object} measureStore The MeasureStore data using a selectRows API call. - * @param {Function} defaultFormatFn used to format values for tick marks. - * @returns {Object} - */ - var generateScales = function(chartType, measures, savedScales, aes, measureStore, defaultFormatFn) { - var scales = {}; - var data = LABKEY.Utils.isArray(measureStore.rows) ? measureStore.rows : measureStore.records(); - var fields = LABKEY.Utils.isObject(measureStore.metaData) ? measureStore.metaData.fields : measureStore.getResponseMetadata().fields; - var subjectColumn = getStudySubjectInfo().columnName; - var visitTableName = getStudySubjectInfo().tableName + 'Visit'; - var visitColName = visitTableName + '/Visit'; - var valExponentialDigits = 6; - - // Issue 38105: For plots with study visit labels on the x-axis, don't sort alphabetically - var sortFnX = measures.x && measures.x.fieldKey === visitColName ? undefined : LABKEY.vis.discreteSortFn; - - if (chartType === "box_plot") - { - scales.x = { - scaleType: 'discrete', // Force discrete x-axis scale for box plots. - sortFn: sortFnX, - tickLabelMax: DEFAULT_TICK_LABEL_MAX - }; - - var yMin = d3.min(data, aes.y); - var yMax = d3.max(data, aes.y); - var yPadding = ((yMax - yMin) * .1); - if (savedScales.y && savedScales.y.trans == "log") - { - // When subtracting padding we have to make sure we still produce valid values for a log scale. - // log([value less than 0]) = NaN. - // log(0) = -Infinity. - if (yMin - yPadding > 0) - { - yMin = yMin - yPadding; - } - } - else - { - yMin = yMin - yPadding; - } - - scales.y = { - min: yMin, - max: yMax + yPadding, - scaleType: 'continuous', - trans: savedScales.y ? savedScales.y.trans : 'linear' - }; - } - else - { - var xMeasureType = getMeasureType(measures.x); - - // Force discrete x-axis scale for bar plots. - var useContinuousScale = chartType != 'bar_chart' && isNumericType(xMeasureType); - - if (useContinuousScale) - { - scales.x = { - scaleType: 'continuous', - trans: savedScales.x ? savedScales.x.trans : 'linear' - }; - } - else - { - scales.x = { - scaleType: 'discrete', - sortFn: sortFnX, - tickLabelMax: DEFAULT_TICK_LABEL_MAX - }; - - //bar chart x-axis subcategories support - if (LABKEY.Utils.isDefined(measures.xSub)) { - scales.xSub = { - scaleType: 'discrete', - sortFn: LABKEY.vis.discreteSortFn, - tickLabelMax: DEFAULT_TICK_LABEL_MAX - }; - } - } - - // add both y (i.e. yLeft) and yRight, in case multiple y-axis measures are being plotted - scales.y = { - scaleType: 'continuous', - trans: savedScales.y ? savedScales.y.trans : 'linear' - }; - scales.yRight = { - scaleType: 'continuous', - trans: savedScales.yRight ? savedScales.yRight.trans : 'linear' - }; - } - - // if we have no data, show a default y-axis domain - if (scales.x && data.length == 0 && scales.x.scaleType == 'continuous') - scales.x.domain = [0,1]; - if (scales.y && data.length == 0) - scales.y.domain = [0,1]; - - // apply the field formatFn to the tick marks on the scales object - for (var i = 0; i < fields.length; i++) { - var type = fields[i].displayFieldJsonType ? fields[i].displayFieldJsonType : fields[i].type; - - var isMeasureXMatch = measures.x && _isFieldKeyMatch(measures.x, fields[i].fieldKey); - if (isMeasureXMatch && measures.x.name === subjectColumn && LABKEY.demoMode) { - scales.x.tickFormat = function(){return '******'}; - } - 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); - const isValidDate = d instanceof Date && !isNaN(d); - return isValidDate ? LABKEY.vis.formatDate(new Date(v), dateFormat) : v; - }; - } - } - - var yMeasures = ensureMeasuresAsArray(measures.y); - $.each(yMeasures, function(idx, yMeasure) { - var isMeasureYMatch = yMeasure && _isFieldKeyMatch(yMeasure, fields[i].fieldKey); - var isConvertedYMeasure = isMeasureYMatch && yMeasure.converted; - if (isMeasureYMatch && (isNumericType(type) || isConvertedYMeasure)) { - var tickFormatFn = _getNumberFormatFn(fields[i], defaultFormatFn); - - var ySide = yMeasure.yAxis === 'right' ? 'yRight' : 'y'; - scales[ySide].tickFormat = function(value) { - if (LABKEY.Utils.isNumber(value) && Math.abs(Math.round(value)).toString().length >= valExponentialDigits) { - return value.toExponential(); - } - else if (LABKEY.Utils.isFunction(tickFormatFn)) { - return tickFormatFn(value); - } - return value; - }; - } - }, this); - } - - _applySavedScaleDomain(scales, savedScales, 'x'); - if (LABKEY.Utils.isDefined(measures.xSub)) { - _applySavedScaleDomain(scales, savedScales, 'xSub'); - } - if (LABKEY.Utils.isDefined(measures.y)) { - _applySavedScaleDomain(scales, savedScales, 'y'); - _applySavedScaleDomain(scales, savedScales, 'yRight'); - } - - return scales; - }; - - // Issue 36227: if Ext4 is not available, try to generate our own number format function based on the "format" field metadata - var _getNumberFormatFn = function(field, defaultFormatFn) { - if (field.extFormatFn) { - if (window.Ext4) { - return eval(field.extFormatFn); - } - else if (field.format && LABKEY.Utils.isString(field.format) && field.format.indexOf('.') > -1) { - var precision = field.format.length - field.format.indexOf('.') - 1; - return function(v) { - return LABKEY.Utils.isNumber(v) ? v.toFixed(precision) : v; - } - } - } - - return defaultFormatFn; - }; - - var _isFieldKeyMatch = function(measure, fieldKey) { - if (LABKEY.Utils.isFunction(fieldKey.getName)) { - return fieldKey.getName() === measure.name || fieldKey.getName() === measure.fieldKey; - } else if (LABKEY.Utils.isArray(fieldKey)) { - fieldKey = fieldKey.join('/') - } - - return fieldKey === measure.name || fieldKey === measure.fieldKey; - }; - - var ensureMeasuresAsArray = function(measures) { - if (LABKEY.Utils.isDefined(measures)) { - return LABKEY.Utils.isArray(measures) ? $.extend(true, [], measures) : [$.extend(true, {}, measures)]; - } - return []; - }; - - var _applySavedScaleDomain = function(scales, savedScales, scaleName) { - if (savedScales[scaleName] && (savedScales[scaleName].min != null || savedScales[scaleName].max != null)) { - scales[scaleName].domain = [savedScales[scaleName].min, savedScales[scaleName].max]; - } - }; - - /** - * Generates the aesthetic map object needed by the visualization API to render the chart. See {@link LABKEY.vis.Plot} - * and {@link LABKEY.vis.Layer}. - * @param {String} chartType The chartType from getChartType. - * @param {Object} measures The measures from getMeasures. - * @param {String} schemaName The schemaName from the saved queryConfig. - * @param {String} queryName The queryName from the saved queryConfig. - * @returns {Object} - */ - 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); - - if (isDateType(xMeasureType)) { - // Issue 54125: use continuous instead of discrete accessor for date x-axis - aes.x = generateContinuousAcc(xMeasureName); - } else if (chartType === "box_plot") { - 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); - } - } else if (isNumericType(xMeasureType) || (chartType === 'scatter_plot' && measures.x.measure)) { - aes.x = generateContinuousAcc(xMeasureName); - } else { - aes.x = generateDiscreteAcc(xMeasureName, measures.x.label); - } - - // charts that have multiple y-measures selected will need to put the aes.y function on their specific layer - if (LABKEY.Utils.isDefined(measures.y) && !LABKEY.Utils.isArray(measures.y)) - { - var sideAesName = (measures.y.yAxis || 'left') === 'left' ? 'y' : 'yRight'; - var yMeasureName = measures.y.converted ? measures.y.convertedName : measures.y.name; - aes[sideAesName] = generateContinuousAcc(yMeasureName); - } - - if (chartType === "scatter_plot" || chartType === "line_plot") - { - aes.hoverText = generatePointHover(measures); - } - - if (chartType === "box_plot") - { - if (measures.color) { - aes.outlierColor = generateGroupingAcc(measures.color.name); - } - - if (measures.shape) { - aes.outlierShape = generateGroupingAcc(measures.shape.name); - } - - aes.hoverText = generateBoxplotHover(); - aes.outlierHoverText = generatePointHover(measures); - } - else if (chartType === 'bar_chart') - { - var xSubMeasureType = measures.xSub ? getMeasureType(measures.xSub) : null; - if (xSubMeasureType) - { - if (isNumericType(xSubMeasureType)) - aes.xSub = generateContinuousAcc(measures.xSub.name); - else - aes.xSub = generateDiscreteAcc(measures.xSub.name, measures.xSub.label); - } - } - - // color/shape aes are not dependent on chart type. If we have a box plot with all points enabled, then we - // create a second layer for points. So we'll need this no matter what. - if (measures.color) { - aes.color = generateGroupingAcc(measures.color.name); - } - - if (measures.shape) { - aes.shape = generateGroupingAcc(measures.shape.name); - } - - // also add the color and shape for the line plot series. - if (measures.series) { - aes.color = generateGroupingAcc(measures.series.name); - aes.shape = generateGroupingAcc(measures.series.name); - } - - if (measures.pointClickFn) { - aes.pointClickFn = generatePointClickFn( - measures, - schemaName, - queryName, - measures.pointClickFn - ); - } - - return aes; - }; - - var getYMeasureAes = function(measure) { - var yMeasureName = measure.converted ? measure.convertedName : measure.name; - return generateContinuousAcc(yMeasureName); - }; - - /** - * Generates a function that returns the text used for point hovers. - * @param {Object} measures The measures object from the saved chart config. - * @returns {Function} - */ - var generatePointHover = function(measures) - { - return function(row) { - var hover = '', sep = '', distinctNames = []; - - $.each(measures, function(key, measureObj) { - var measureArr = ensureMeasuresAsArray(measureObj); - $.each(measureArr, function(idx, measure) { - if (LABKEY.Utils.isObject(measure) && !LABKEY.Utils.isEmptyObj(measure) && distinctNames.indexOf(measure.name) == -1) { - 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); - }); - - return hover; - }; - }; - - /** - * Backwards compatibility for function that has been moved to LABKEY.vis.getAggregateData. - */ - var generateAggregateData = function(data, dimensionName, measureName, aggregate, nullDisplayValue) { - return LABKEY.vis.getAggregateData(data, dimensionName, null, measureName, aggregate, nullDisplayValue, false); - }; - - var _getRowValue = function(row, propName, valueName) - { - if (row.hasOwnProperty(propName)) { - // backwards compatibility for response row that is not a LABKEY.Query.Row - if (!(row instanceof LABKEY.Query.Row)) { - return row[propName].formattedValue || row[propName].displayValue || row[propName].value; - } - - var propValue = row.get(propName); - if (valueName != undefined && propValue.hasOwnProperty(valueName)) { - return propValue[valueName]; - } - else if (propValue.hasOwnProperty('formattedValue')) { - return propValue['formattedValue']; - } - else if (propValue.hasOwnProperty('displayValue')) { - return propValue['displayValue']; - } - return row.getValue(propName); - } - - return undefined; - }; - - /** - * Returns a function used to generate the hover text for box plots. - * @returns {Function} - */ - var generateBoxplotHover = function() { - return function(xValue, stats) { - return xValue + ':\nMin: ' + stats.min + '\nMax: ' + stats.max + '\nQ1: ' + stats.Q1 + '\nQ2: ' + stats.Q2 + - '\nQ3: ' + stats.Q3; - }; - }; - - /** - * Generates an accessor function that returns a discrete value from a row of data for a given measure and label. - * Used when an axis has a discrete measure (i.e. string). - * @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 - * @returns {Function} - */ - var generateDiscreteAcc = function(measureName, measureLabel, nullValueLabel) - { - return function(row) - { - var value = _getRowValue(row, measureName); - if (value === null) - value = nullValueLabel !== undefined ? nullValueLabel : "Not in " + measureLabel; - - return value; - }; - }; - - /** - * Generates an accessor function that returns a value from a row of data for a given measure. - * @param {String} measureName The name of the measure. - * @returns {Function} - */ - var generateContinuousAcc = function(measureName) - { - return function(row) - { - var value = _getRowValue(row, measureName, 'value'); - - if (value !== undefined) - { - if (Math.abs(value) === Infinity) - value = null; - - if (value === false || value === true) - value = value.toString(); - - return value; - } - - return undefined; - } - }; - - /** - * Generates an accesssor function for shape and color measures. - * @param {String} measureName The name of the measure. - * @returns {Function} - */ - var generateGroupingAcc = function(measureName) - { - return function(row) - { - var value = null; - if (LABKEY.Utils.isArray(row) && row.length > 0) { - value = _getRowValue(row[0], measureName); - } - else { - value = _getRowValue(row, measureName); - } - - if (value === null || value === undefined) - value = "n/a"; - - return value; - }; - }; - - /** - * Generates an accessor for boxplots that do not have an x-axis measure. Generally the measureName passed in is the - * queryName. - * @param {String} measureName The name of the measure. In this case it is generally the query name. - * @returns {Function} - */ - var generateMeasurelessAcc = function(measureName) { - // Used for box plots that do not have an x-axis measure. Instead we just return the queryName for every row. - return function(row) { - return measureName; - } - }; - - /** - * Generates the function to be executed when a user clicks a point. - * @param {Object} measures The measures from the saved chart config. - * @param {String} schemaName The schema name from the saved query config. - * @param {String} queryName The query name from the saved query config. - * @param {String} fnString The string value of the user-provided function to be executed when a point is clicked. - * @returns {Function} - */ - var generatePointClickFn = function(measures, schemaName, queryName, fnString){ - var measureInfo = { - schemaName: schemaName, - queryName: queryName - }; - - _addPointClickMeasureInfo(measureInfo, measures, 'x', 'xAxis'); - _addPointClickMeasureInfo(measureInfo, measures, 'y', 'yAxis'); - $.each(['color', 'shape', 'series'], function(idx, name) { - _addPointClickMeasureInfo(measureInfo, measures, name, name + 'Name'); - }, this); - - // using new Function is quicker than eval(), even in IE. - var pointClickFn = new Function('return ' + fnString)(); - return function(clickEvent, data){ - pointClickFn(data, measureInfo, clickEvent); - }; - }; - - var _addPointClickMeasureInfo = function(measureInfo, measures, name, key) { - if (LABKEY.Utils.isDefined(measures[name])) { - var measuresArr = ensureMeasuresAsArray(measures[name]); - $.each(measuresArr, function(idx, measure) { - if (!LABKEY.Utils.isDefined(measureInfo[key])) { - measureInfo[key] = measure.name; - } - else if (!LABKEY.Utils.isDefined(measureInfo[measure.name])) { - measureInfo[measure.name] = measure.name; - } - }, this); - } - }; - - /** - * Generates the Point Geom used for scatter plots and box plots with all points visible. - * @param {Object} chartOptions The saved chartOptions object from the chart config. - * @returns {LABKEY.vis.Geom.Point} - */ - var generatePointGeom = function(chartOptions){ - return new LABKEY.vis.Geom.Point({ - opacity: chartOptions.opacity, - size: chartOptions.pointSize, - color: '#' + chartOptions.pointFillColor, - position: chartOptions.position - }); - }; - - /** - * Generates the Boxplot Geom used for box plots. - * @param {Object} chartOptions The saved chartOptions object from the chart config. - * @returns {LABKEY.vis.Geom.Boxplot} - */ - var generateBoxplotGeom = function(chartOptions){ - return new LABKEY.vis.Geom.Boxplot({ - lineWidth: chartOptions.lineWidth, - outlierOpacity: chartOptions.opacity, - outlierFill: '#' + chartOptions.pointFillColor, - outlierSize: chartOptions.pointSize, - color: '#' + chartOptions.lineColor, - fill: chartOptions.boxFillColor == 'none' ? chartOptions.boxFillColor : '#' + chartOptions.boxFillColor, - position: chartOptions.position, - showOutliers: chartOptions.showOutliers - }); - }; - - /** - * Generates the Barplot Geom used for bar charts. - * @param {Object} chartOptions The saved chartOptions object from the chart config. - * @returns {LABKEY.vis.Geom.BarPlot} - */ - var generateBarGeom = function(chartOptions){ - return new LABKEY.vis.Geom.BarPlot({ - opacity: chartOptions.opacity, - color: '#' + chartOptions.lineColor, - fill: '#' + chartOptions.boxFillColor, - lineWidth: chartOptions.lineWidth - }); - }; - - /** - * Generates the Bin Geom used to bin a set of points. - * @param {Object} chartOptions The saved chartOptions object from the chart config. - * @returns {LABKEY.vis.Geom.Bin} - */ - var generateBinGeom = function(chartOptions) { - var colorRange = ["#e6e6e6", "#085D90"]; //light-gray and labkey blue is default - if (chartOptions.binColorGroup == 'SingleColor') { - var color = '#' + chartOptions.binSingleColor; - colorRange = ["#FFFFFF", color]; - } - else if (chartOptions.binColorGroup == 'Heat') { - colorRange = ["#fff6bc", "#e23202"]; - } - - return new LABKEY.vis.Geom.Bin({ - shape: chartOptions.binShape, - colorRange: colorRange, - size: chartOptions.binShape == 'square' ? 10 : 5 - }) - }; - - /** - * Generates a Geom based on the chartType. - * @param {String} chartType The chart type from getChartType. - * @param {Object} chartOptions The chartOptions object from the saved chart config. - * @returns {LABKEY.vis.Geom} - */ - var generateGeom = function(chartType, chartOptions) { - if (chartType == "box_plot") - return generateBoxplotGeom(chartOptions); - else if (chartType == "scatter_plot" || chartType == "line_plot") - return chartOptions.binned ? generateBinGeom(chartOptions) : generatePointGeom(chartOptions); - else if (chartType == "bar_chart") - return generateBarGeom(chartOptions); - }; - - /** - * Generate an array of plot configs for the given chart renderType and config options. - * @param renderTo - * @param chartConfig - * @param labels - * @param aes - * @param scales - * @param geom - * @param data - * @param trendlineData - * @returns {Array} array of plot config objects - */ - var generatePlotConfigs = function(renderTo, chartConfig, labels, aes, scales, geom, data, trendlineData) - { - var plotConfigArr = []; - - // if we have multiple y-measures and the request is to plot them separately, call the generatePlotConfig function - // for each y-measure separately with its own copy of the chartConfig object - if (chartConfig.geomOptions.chartLayout === 'per_measure' && LABKEY.Utils.isArray(chartConfig.measures.y)) { - - // if 'automatic across charts' scales are requested, need to manually calculate the min and max - if (chartConfig.scales.y && chartConfig.scales.y.type === 'automatic') { - scales.y = $.extend(scales.y, _getScaleDomainValuesForAllMeasures(data, chartConfig.measures.y, 'left')); - } - if (chartConfig.scales.yRight && chartConfig.scales.yRight.type === 'automatic') { - scales.yRight = $.extend(scales.yRight, _getScaleDomainValuesForAllMeasures(data, chartConfig.measures.y, 'right')); - } - - $.each(chartConfig.measures.y, function(idx, yMeasure) { - // copy the config and reset the measures.y array with the single measure - var newChartConfig = $.extend(true, {}, chartConfig); - newChartConfig.measures.y = $.extend(true, {}, yMeasure); - - // copy the labels object so that we can set the subtitle based on the y-measure - var newLabels = $.extend(true, {}, labels); - newLabels.subtitle = {value: yMeasure.label || yMeasure.name}; - - // only copy over the scales that are needed for this measures - var side = yMeasure.yAxis || 'left'; - var newScales = {x: $.extend(true, {}, scales.x)}; - if (side === 'left') { - newScales.y = $.extend(true, {}, scales.y); - } - else { - newScales.yRight = $.extend(true, {}, scales.yRight); - } - - plotConfigArr.push(generatePlotConfig(renderTo, newChartConfig, newLabels, aes, newScales, geom, data, trendlineData)); - }, this); - } - else { - plotConfigArr.push(generatePlotConfig(renderTo, chartConfig, labels, aes, scales, geom, data, trendlineData)); - } - - return plotConfigArr; - }; - - var _getScaleDomainValuesForAllMeasures = function(data, measures, side) { - var min = null, max = null; - - $.each(measures, function(idx, measure) { - var measureSide = measure.yAxis || 'left'; - if (side === measureSide) { - var accFn = LABKEY.vis.GenericChartHelper.getYMeasureAes(measure); - var tempMin = d3.min(data, accFn); - var tempMax = d3.max(data, accFn); - - if (min == null || tempMin < min) { - min = tempMin; - } - if (max == null || tempMax > max) { - max = tempMax; - } - } - }, this); - - return {domain: [min, max]}; - }; - - /** - * Generate the plot config for the given chart renderType and config options. - * @param renderTo - * @param chartConfig - * @param labels - * @param aes - * @param scales - * @param geom - * @param data - * @param trendlineData - * @returns {Object} - */ - var generatePlotConfig = function(renderTo, chartConfig, labels, aes, scales, geom, data, trendlineData) - { - var renderType = chartConfig.renderType, - layers = [], clipRect, - emptyTextFn = function(){return '';}, - plotConfig = { - renderTo: renderTo, - rendererType: 'd3', - width: chartConfig.width, - height: chartConfig.height, - gridLinesVisible: chartConfig.gridLinesVisible, - }; - - if (renderType === 'pie_chart') { - return _generatePieChartConfig(plotConfig, chartConfig, labels, data); - } - - clipRect = (scales.x && LABKEY.Utils.isArray(scales.x.domain)) || (scales.y && LABKEY.Utils.isArray(scales.y.domain)); - - // account for line chart hiding points - if (chartConfig.geomOptions.hideDataPoints) { - geom = null; - } - - // account for one or many y-measures by ensuring that we have an array of y-measures - var yMeasures = ensureMeasuresAsArray(chartConfig.measures.y); - - if (renderType === 'bar_chart') { - aes = { x: 'label', y: 'value' }; - - if (LABKEY.Utils.isDefined(chartConfig.measures.xSub)) - { - aes.xSub = 'subLabel'; - aes.color = 'label'; - } - - if (!scales.y) { - scales.y = {}; - } - - 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') - { - layers.push( - new LABKEY.vis.Layer({ - geom: LABKEY.vis.GenericChartHelper.generatePointGeom(chartConfig.geomOptions), - aes: {hoverText: LABKEY.vis.GenericChartHelper.generatePointHover(chartConfig.measures)} - }) - ); - } - else if (renderType === 'line_plot') { - var xName = chartConfig.measures.x.name, - isDate = isDateType(getMeasureType(chartConfig.measures.x)); - - $.each(yMeasures, function(idx, yMeasure) { - var pathAes = { - sortFn: function(a, b) { - // 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) { - return 1; - } else if (bVal === null) { - return -1; - } else if (isDate){ - return new Date(aVal) - new Date(bVal); - } - return aVal - bVal; - }, - hoverText: emptyTextFn(), - }; - - pathAes[yMeasure.yAxis === 'right' ? 'yRight' : 'yLeft'] = getYMeasureAes(yMeasure); - - // use the series measure's values for the distinct colors and grouping - const hasSeries = chartConfig.measures.series !== undefined; - if (hasSeries) { - pathAes.pathColor = generateGroupingAcc(chartConfig.measures.series.name); - pathAes.group = generateGroupingAcc(chartConfig.measures.series.name); - pathAes.hoverText = function (row) { return chartConfig.measures.series.label + ': ' + row.group }; - } - // if no series measures but we have multiple y-measures, force the color and grouping to be distinct for each measure - else if (yMeasures.length > 1) { - pathAes.pathColor = emptyTextFn; - pathAes.group = emptyTextFn; - } - - if (trendlineData) { - trendlineData.forEach(trendline => { - if (trendline.data) { - const layerAes = { x: 'x', y: 'y' }; - if (hasSeries) { - layerAes.pathColor = function () { return trendline.name }; - } - - layerAes.hoverText = generateTrendlinePathHover(trendline); - - layers.push( - new LABKEY.vis.Layer({ - geom: new LABKEY.vis.Geom.Path({ - color: '#' + chartConfig.geomOptions.pointFillColor, - size: chartConfig.geomOptions.lineWidth ? chartConfig.geomOptions.lineWidth : 3, - opacity:chartConfig.geomOptions.opacity, - }), - aes: layerAes, - data: trendline.data.generatedPoints, - }) - ); - } - }); - } else { - layers.push( - new LABKEY.vis.Layer({ - name: yMeasures.length > 1 ? yMeasure.label || yMeasure.name : undefined, - geom: new LABKEY.vis.Geom.Path({ - color: '#' + chartConfig.geomOptions.pointFillColor, - size: chartConfig.geomOptions.lineWidth?chartConfig.geomOptions.lineWidth:3, - opacity:chartConfig.geomOptions.opacity - }), - aes: pathAes - }) - ); - } - }, this); - } - - // Issue 34711: better guess at the max number of discrete x-axis tick mark labels to show based on the plot width - if (scales.x && scales.x.scaleType === 'discrete' && scales.x.tickLabelMax) { - // approx 30 px for a 45 degree rotated tick label - scales.x.tickLabelMax = Math.floor((plotConfig.width - 300) / 30); - } - - var margins = _getPlotMargins(renderType, scales, aes, data, plotConfig, chartConfig); - if (LABKEY.Utils.isObject(margins)) { - plotConfig.margins = margins; - } - - if (chartConfig.measures.color) - { - scales.color = { - colorType: chartConfig.geomOptions.colorPaletteScale, - scaleType: 'discrete' - } - } - - if ((renderType === 'line_plot' || renderType === 'scatter_plot') && yMeasures.length > 0) { - $.each(yMeasures, function (idx, yMeasure) { - var layerAes = {}; - layerAes[yMeasure.yAxis === 'right' ? 'yRight' : 'yLeft'] = getYMeasureAes(yMeasure); - - // if no series measures but we have multiple y-measures, force the color and shape to be distinct for each measure - if (!aes.color && yMeasures.length > 1) { - layerAes.color = emptyTextFn; - } - if (!aes.shape && yMeasures.length > 1) { - 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, - geom: geom, - aes: layerAes - }) - ); - }, this); - } - else { - layers.push( - new LABKEY.vis.Layer({ - data: data, - geom: geom - }) - ); - } - - plotConfig = $.extend(plotConfig, { - clipRect: clipRect, - data: data, - labels: labels, - aes: aes, - scales: scales, - layers: layers - }); - - return plotConfig; - }; - - const hasPremiumModule = function() { - return LABKEY.getModuleContext('api').moduleNames.indexOf('premium') > -1; - }; - - const TRENDLINE_OPTIONS = { - '': { label: 'Point-to-Point', value: '' }, - 'Linear': { label: 'Linear Regression', value: 'Linear', equation: 'y = x * slope + intercept' }, - 'Polynomial': { label: 'Polynomial', value: 'Polynomial', equation: 'y = a0 + a1 * x + a2 * x^2' }, - '3 Parameter': { label: 'Nonlinear 3PL', value: '3 Parameter', showMax: true, schemaPrefix: 'assay', equation: 'y = max * abs(x/inflection)^abs(slope) / [1 + abs(x/inflection)^abs(slope)]' }, - 'Three Parameter': { label: 'Nonlinear 3PL (Alternate)', value: 'Three Parameter', showMax: true, schemaPrefix: 'assay', equation: 'y = max / [1 + (inflection - x) * slope]' }, - '4 Parameter': { label: 'Nonlinear 4PL', value: '4 Parameter', schemaPrefix: 'assay', equation: 'y = max + (min - max) / [1 + (x/inflection)^slope]' }, - 'Four Parameter': { label: 'Nonlinear 4PL (Alternate)', value: 'Four Parameter', showMin: true, showMax: true, schemaPrefix: 'assay', equation: 'y = min + (max - min) / [1 + (inflection - x) * slope]' }, - 'Five Parameter': { label: 'Nonlinear 5PL', value: 'Five Parameter', showMin: true, showMax: true, schemaPrefix: 'assay', equation: 'y = min + (max - min) / [[1 + (inflection - x) * slope]^asymmetry]' }, - } - - const generateTrendlinePathHover = function(trendline) { - let hoverText = trendline.name + '\n'; - hoverText += '\n' + TRENDLINE_OPTIONS[trendline.data.curveFit.type].label + ':\n'; - Object.entries(trendline.data.curveFit).forEach(([key, value]) => { - if (key === 'coefficients') { - hoverText += key + ': '; - value.forEach((v, i) => { - hoverText += (i > 0 ? ', ' : '') + LABKEY.Utils.roundNumber(v, 4); - }); - hoverText += '\n'; - } - else if (key !== 'type') { - hoverText += key + ': ' + LABKEY.Utils.roundNumber(value, 4) + '\n'; - } - }); - hoverText += '\nStatistics:\n'; - Object.entries(trendline.data.stats).forEach(([key, value]) => { - const label = key === 'RSquared' ? 'R-Squared' : (key === 'adjustedRSquared' ? 'Adjusted R-Squared' : key); - hoverText += label + ': ' + LABKEY.Utils.roundNumber(value, 4) + '\n'; - }); - - return function () { return hoverText }; - }; - - // support for y-axis trendline data when a single y-axis measure is selected - const queryTrendlineData = async function(chartConfig, data) { - const chartType = getChartType(chartConfig); - const yMeasures = ensureMeasuresAsArray(chartConfig.measures.y); - if (chartType === 'line_plot' && chartConfig.geomOptions?.trendlineType && chartConfig.geomOptions.trendlineType !== '' && yMeasures.length === 1) { - const xName = chartConfig.measures.x.name; - const trendlineConfig = getTrendlineConfig(chartConfig, data); - try { - await _queryTrendlineData(trendlineConfig, xName, yMeasures[0].name); - return trendlineConfig.data; - } catch (reason) { - // skip this series and render without trendline - return trendlineConfig.data; - } - } - - return undefined; - }; - - const getTrendlineConfig = function(chartConfig, data) { - const config = { - type: chartConfig.geomOptions.trendlineType, - logXScale: chartConfig.scales.x && chartConfig.scales.x.trans === 'log', - asymptoteMin: chartConfig.geomOptions.trendlineAsymptoteMin, - asymptoteMax: chartConfig.geomOptions.trendlineAsymptoteMax, - data: chartConfig.measures.series - ? LABKEY.vis.groupCountData(data, generateGroupingAcc(chartConfig.measures.series.name)) - : [{name: 'All', rawData: data}], - }; - - // special case to only use logXScale for linear trendlines - if (config.type === 'Linear') { - config.logXScale = false; - } - - return config; - }; - - const _queryTrendlineData = async function(trendlineConfig, xName, yName) { - for (let series of trendlineConfig.data) { - try { - // we need at least 2 data points for curve fitting - if (series.rawData.length > 1) { - series.data = await _querySeriesTrendlineData(trendlineConfig, series, xName, yName); - } - } catch (e) { - console.error(e); - } - } - }; - - const _querySeriesTrendlineData = function(trendlineConfig, seriesData, xName, yName) { - return new Promise(function(resolve, reject) { - if (!hasPremiumModule()) { - reject('Premium module required for curve fitting.'); - return; - } - - const points = seriesData.rawData.map(function(row) { - return { - x: _getRowValue(row, xName, 'value'), - y: _getRowValue(row, yName, 'value'), - }; - }); - const xAcc = function(row) { return row.x }; - const xMin = d3.min(points, xAcc); - const xMax = d3.max(points, xAcc); - - LABKEY.Ajax.request({ - url: LABKEY.ActionURL.buildURL('premium', 'calculateCurveFit.api'), - method: 'POST', - jsonData: { - curveFitType: trendlineConfig.type, - points: points, - logXScale: trendlineConfig.logXScale, - asymptoteMin: trendlineConfig.asymptoteMin, - asymptoteMax: trendlineConfig.asymptoteMax, - xMin: xMin, - xMax: xMax, - numberOfPoints: 1000, - }, - success : LABKEY.Utils.getCallbackWrapper(function(response) { - resolve(response); - }), - failure : LABKEY.Utils.getCallbackWrapper(function(reason) { - reject(reason); - }, this, true), - }); - }); - }; - - 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; - // 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 1; - }; - - var _getPlotMargins = function(renderType, scales, aes, data, plotConfig, chartConfig) { - var margins = {}; - - // issue 29690: for bar and box plots, set default bottom margin based on the number of labels and the max label length - if (LABKEY.Utils.isArray(data)) { - var maxLen = 0; - $.each(data, function(idx, d) { - var val = LABKEY.Utils.isFunction(aes.x) ? aes.x(d) : d[aes.x]; - var subVal = LABKEY.Utils.isFunction(aes.xSub) ? aes.xSub(d) : d[aes.xSub]; - if (LABKEY.Utils.isString(subVal)) { - maxLen = Math.max(maxLen, subVal.length); - } else if (LABKEY.Utils.isString(val)) { - maxLen = Math.max(maxLen, val.length); - } - }); - - 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 - if (chartConfig && chartConfig.geomOptions) { - if (chartConfig.geomOptions.marginTop !== null) { - margins.top = chartConfig.geomOptions.marginTop; - } - if (chartConfig.geomOptions.marginRight !== null) { - margins.right = chartConfig.geomOptions.marginRight; - } - if (chartConfig.geomOptions.marginBottom !== null) { - margins.bottom = chartConfig.geomOptions.marginBottom; - } - if (chartConfig.geomOptions.marginLeft !== null) { - margins.left = chartConfig.geomOptions.marginLeft; - } - } - - return !LABKEY.Utils.isEmptyObj(margins) ? margins : null; - }; - - var _generatePieChartConfig = function(baseConfig, chartConfig, labels, data) - { - var hasData = data.length > 0; - - return $.extend(baseConfig, { - data: hasData ? data : [{label: '', value: 1}], - header: { - title: { text: labels.main.value }, - subtitle: { text: labels.subtitle.value }, - titleSubtitlePadding: 1 - }, - footer: { - text: hasData ? labels.footer.value : 'No data to display', - location: 'bottom-center' - }, - labels: { - mainLabel: { fontSize: 14 }, - percentage: { - fontSize: 14, - color: chartConfig.geomOptions.piePercentagesColor != null ? '#' + chartConfig.geomOptions.piePercentagesColor : undefined - }, - outer: { pieDistance: 20 }, - inner: { - format: hasData && chartConfig.geomOptions.showPiePercentages ? 'percentage' : 'none', - hideWhenLessThanPercentage: chartConfig.geomOptions.pieHideWhenLessThanPercentage - } - }, - size: { - pieInnerRadius: hasData ? chartConfig.geomOptions.pieInnerRadius + '%' : '100%', - pieOuterRadius: hasData ? chartConfig.geomOptions.pieOuterRadius + '%' : '90%' - }, - misc: { - gradient: { - enabled: chartConfig.geomOptions.gradientPercentage != 0, - percentage: chartConfig.geomOptions.gradientPercentage, - color: '#' + chartConfig.geomOptions.gradientColor - }, - colors: { - segments: hasData ? LABKEY.vis.Scale[chartConfig.geomOptions.colorPaletteScale]() : ['#333333'] - } - }, - effects: { highlightSegmentOnMouseover: false }, - tooltips: { enabled: true } - }); - }; - - /** - * Check if the MeasureStore selectRows API response has data. Return an error string if no data exists. - * @param measureStore - * @param includeFilterMsg true to include a message about removing filters - * @returns {String} - */ - var validateResponseHasData = function(measureStore, includeFilterMsg) - { - var dataArray = getMeasureStoreRecords(measureStore); - if (dataArray.length == 0) - { - return 'The response returned 0 rows of data. The query may be empty or the applied filters may be too strict.' - + (includeFilterMsg ? 'Try removing or adjusting any filters if possible.' : ''); - } - - return null; - }; - - var getMeasureStoreRecords = function(measureStore) { - return LABKEY.Utils.isDefined(measureStore) ? measureStore.rows || measureStore.records() : []; - } - - /** - * Verifies that the axis measure is actually present and has data. Also checks to make sure that data can be used in a log - * scale (if applicable). Returns an object with a success parameter (boolean) and a message parameter (string). If the - * success parameter is false there is a critical error and the chart cannot be rendered. If success is true the chart - * can be rendered. Message will contain an error or warning message if applicable. If message is not null and success - * is true, there is a warning. - * @param {String} chartType The chartType from getChartType. - * @param {Object} chartConfigOrMeasure The saved chartConfig object or a specific measure object. - * @param {String} measureName The name of the axis measure property. - * @param {Object} aes The aes object from generateAes. - * @param {Object} scales The scales object from generateScales. - * @param {Array} data The response data from selectRows. - * @param {Boolean} dataConversionHappened Whether we converted any values in the measure data - * @returns {Object} - */ - var validateAxisMeasure = function(chartType, chartConfigOrMeasure, measureName, aes, scales, data, dataConversionHappened) { - var measure = LABKEY.Utils.isObject(chartConfigOrMeasure) && chartConfigOrMeasure.measures ? chartConfigOrMeasure.measures[measureName] : chartConfigOrMeasure; - return _validateAxisMeasure(chartType, measure, measureName, aes, scales, data, dataConversionHappened); - }; - - var _validateAxisMeasure = function(chartType, measure, measureName, aes, scales, data, dataConversionHappened) { - var dataIsNull = true, measureUndefined = true, invalidLogValues = false, hasZeroes = false, message = null; - - // no need to check measures if we have no data - if (data.length === 0) { - return {success: true, message: message}; - } - - for (var i = 0; i < data.length; i ++) - { - var value = aes[measureName](data[i]); - - if (value !== undefined) - measureUndefined = false; - - if (value !== null) - dataIsNull = false; - - if (value && value < 0) - invalidLogValues = true; - - if (value === 0 ) - hasZeroes = true; - } - - if (measureUndefined) - { - message = 'The measure, ' + measure.name + ', was not found. It may have been renamed or removed.'; - return {success: false, message: message}; - } - - if ((chartType == 'scatter_plot' || chartType == 'line_plot' || measureName == 'y') && dataIsNull && !dataConversionHappened) - { - message = 'All data values for ' + measure.label + ' are null. Please choose a different measure or review/remove data filters.'; - return {success: true, message: message}; - } - - if (scales[measureName] && scales[measureName].trans == "log") - { - if (invalidLogValues) - { - message = "Unable to use a log scale on the " + measureName + "-axis. All " + measureName - + "-axis values must be >= 0. Reverting to linear scale on " + measureName + "-axis."; - scales[measureName].trans = 'linear'; - } - else if (hasZeroes) - { - message = "Some " + measureName + "-axis values are 0. Plotting all " + measureName + "-axis values as value+1."; - var accFn = aes[measureName]; - aes[measureName] = function(row){return accFn(row) + 1}; - } - } - - return {success: true, message: message}; - }; - - /** - * Deprecated - use validateAxisMeasure - */ - var validateXAxis = function(chartType, chartConfig, aes, scales, data){ - return this.validateAxisMeasure(chartType, chartConfig, 'x', aes, scales, data); - }; - /** - * Deprecated - use validateAxisMeasure - */ - var validateYAxis = function(chartType, chartConfig, aes, scales, data){ - return this.validateAxisMeasure(chartType, chartConfig, 'y', aes, scales, data); - }; - - var getMeasureType = function(measure) { - return LABKEY.Utils.isObject(measure) ? (measure.normalizedType || measure.type) : null; - }; - - var isNumericType = function(type) - { - var t = LABKEY.Utils.isString(type) ? type.toLowerCase() : null; - return t == 'int' || t == 'integer' || t == 'float' || t == 'double'; - }; - - var isDateType = function(type) - { - var t = LABKEY.Utils.isString(type) ? type.toLowerCase() : null; - return t == 'date'; - }; - - var getAllowableTypes = function(field) { - var numericTypes = ['int', 'float', 'double', 'INTEGER', 'DOUBLE'], - nonNumericTypes = ['string', 'date', 'boolean', 'STRING', 'TEXT', 'DATE', 'BOOLEAN'], - numericAndDateTypes = numericTypes.concat(['date','DATE']); - - if (field.altSelectionOnly) - return []; - else if (field.numericOnly) - return numericTypes; - else if (field.nonNumericOnly) - return nonNumericTypes; - else if (field.numericOrDateOnly) - return numericAndDateTypes; - else - return numericTypes.concat(nonNumericTypes); - } - - var isMeasureDimensionMatch = function(chartType, field, isMeasure, isDimension) { - if ((chartType === 'box_plot' || chartType === 'bar_chart')) { - //x-axis does not support 'measure' column types for these plot types - if (field.name === 'x' || field.name === 'xSub') - return isDimension; - else - return isMeasure; - } - - return (field.numericOnly && isMeasure) || (field.nonNumericOnly && isDimension); - } - - var getQueryConfigSortKey = function(measures) { - var sortKey = 'lsid'; // needed to keep expected ordering for legend data - - // Issue 38105: For plots with study visit labels on the x-axis, sort by visit display order and then sequenceNum - var visitTableName = LABKEY.vis.GenericChartHelper.getStudySubjectInfo().tableName + 'Visit'; - if (measures.x && measures.x.fieldKey === visitTableName + '/Visit') { - var displayOrderColName = visitTableName + '/Visit/DisplayOrder'; - var seqNumColName = visitTableName + '/SequenceNum'; - sortKey = displayOrderColName + ', ' + seqNumColName; - } - - return sortKey; - } - - var getStudySubjectInfo = function() - { - var studyCtx = LABKEY.getModuleContext("study") || {}; - return LABKEY.Utils.isObject(studyCtx.subject) ? studyCtx.subject : { - tableName: 'Participant', - columnName: 'ParticipantId', - nounPlural: 'Participants', - nounSingular: 'Participant' - }; - }; - - var _getStudyTimepointType = function() - { - var studyCtx = LABKEY.getModuleContext("study") || {}; - return LABKEY.Utils.isDefined(studyCtx.timepointType) ? studyCtx.timepointType : null; - }; - - var _getMeasureRestrictions = function (chartType, measure) - { - var measureRestrictions = {}; - $.each(getRenderTypes(), function (idx, renderType) - { - if (renderType.name === chartType) - { - $.each(renderType.fields, function (idx2, field) - { - if (field.name === measure) - { - measureRestrictions.numericOnly = field.numericOnly; - measureRestrictions.nonNumericOnly = field.nonNumericOnly; - return false; - } - }); - return false; - } - }); - - return measureRestrictions; - }; - - /** - * Converts data values passed in to the appropriate type based on measure/dimension information. - * @param chartConfig Chart configuration object - * @param aes Aesthetic mapping functions for each measure/axis - * @param renderType The type of plot or chart (e.g. scatter_plot, bar_chart) - * @param data The response data from SelectRows - * @returns {{processed: {}, warningMessage: *}} - */ - var doValueConversion = function(chartConfig, aes, renderType, data) - { - var measuresForProcessing = {}, measureRestrictions = {}, configMeasure; - for (var measureName in chartConfig.measures) { - if (chartConfig.measures.hasOwnProperty(measureName) && LABKEY.Utils.isObject(chartConfig.measures[measureName])) { - configMeasure = chartConfig.measures[measureName]; - $.extend(measureRestrictions, _getMeasureRestrictions(renderType, measureName)); - - var isGroupingMeasure = measureName === 'color' || measureName === 'shape' || measureName === 'series'; - var isXAxis = measureName === 'x' || measureName === 'xSub'; - var isScatterOrLine = renderType === 'scatter_plot' || renderType === 'line_plot'; - var isBarYCount = renderType === 'bar_chart' && configMeasure.aggregate && (configMeasure.aggregate === 'COUNT' || configMeasure.aggregate.value === 'COUNT'); - - if (configMeasure.measure && !isGroupingMeasure && !isBarYCount - && ((!isXAxis && measureRestrictions.numericOnly ) || isScatterOrLine) && !isNumericType(configMeasure.type)) { - measuresForProcessing[measureName] = {}; - measuresForProcessing[measureName].name = configMeasure.name; - measuresForProcessing[measureName].convertedName = configMeasure.name + "_converted"; - measuresForProcessing[measureName].label = configMeasure.label; - configMeasure.normalizedType = 'float'; - configMeasure.type = 'float'; - } - } - } - - var response = {processed: {}}; - if (!LABKEY.Utils.isEmptyObj(measuresForProcessing)) { - response = _processMeasureData(data, aes, measuresForProcessing); - } - - //generate error message for dropped values - var warningMessage = ''; - for (var measure in response.droppedValues) { - if (response.droppedValues.hasOwnProperty(measure) && response.droppedValues[measure].numDropped) { - warningMessage += " The " - + measure + "-axis measure '" - + response.droppedValues[measure].label + "' had " - + response.droppedValues[measure].numDropped + - " value(s) that could not be converted to a number and are not included in the plot."; - } - } - - return {processed: response.processed, warningMessage: warningMessage}; - }; - - /** - * Does the explicit type conversion for each measure deemed suitable to convert. Currently we only - * attempt to convert strings to numbers for measures. - * @param rows Data from SelectRows - * @param aes Aesthetic mapping function for the measure/dimensions - * @param measuresForProcessing The measures to be converted, if any - * @returns {{droppedValues: {}, processed: {}}} - */ - var _processMeasureData = function(rows, aes, measuresForProcessing) { - var droppedValues = {}, processedMeasures = {}, dataIsNull; - rows.forEach(function(row) { - //convert measures if applicable - if (!LABKEY.Utils.isEmptyObj(measuresForProcessing)) { - for (var measure in measuresForProcessing) { - if (measuresForProcessing.hasOwnProperty(measure)) { - dataIsNull = true; - if (!droppedValues[measure]) { - droppedValues[measure] = {}; - droppedValues[measure].label = measuresForProcessing[measure].label; - droppedValues[measure].numDropped = 0; - } - - if (aes.hasOwnProperty(measure)) { - var value = aes[measure](row); - if (value !== null) { - dataIsNull = false; - } - row[measuresForProcessing[measure].convertedName] = {value: null}; - if (typeof value !== 'number' && value !== null) { - - //only try to convert strings to numbers - if (typeof value === 'string') { - value = value.trim(); - } - else { - //dates, objects, booleans etc. to be assigned value: NULL - value = ''; - } - - var n = Number(value); - // empty strings convert to 0, which we must explicitly deny - if (value === '' || isNaN(n)) { - droppedValues[measure].numDropped++; - } - else { - row[measuresForProcessing[measure].convertedName].value = n; - } - } - } - - if (!processedMeasures[measure]) { - processedMeasures[measure] = { - converted: false, - convertedName: measuresForProcessing[measure].convertedName, - type: 'float', - normalizedType: 'float' - } - } - - processedMeasures[measure].converted = processedMeasures[measure].converted || !dataIsNull; - } - } - } - }); - - return {droppedValues: droppedValues, processed: processedMeasures}; - }; - - /** - * removes all traces of String -> Numeric Conversion from the given chart config - * @param chartConfig - * @returns {updated ChartConfig} - */ - var removeNumericConversionConfig = function(chartConfig) { - if (chartConfig && chartConfig.measures) { - for (var measureName in chartConfig.measures) { - if (chartConfig.measures.hasOwnProperty(measureName)) { - var measure = chartConfig.measures[measureName]; - if (measure && measure.converted && measure.convertedName) { - measure.converted = null; - measure.convertedName = null; - if (LABKEY.vis.GenericChartHelper.isNumericType(measure.type)) { - measure.type = 'string'; - measure.normalizedType = 'string'; - } - } - } - } - } - - return chartConfig; - }; - - var renderChartSVG = function(renderTo, queryConfig, chartConfig) { - queryChartData(renderTo, queryConfig, chartConfig, function(measureStore, trendlineData) { - generateChartSVG(renderTo, chartConfig, measureStore, trendlineData); - }); - }; - - var queryChartData = function(renderTo, queryConfig, chartConfig, callback) { - queryConfig.containerPath = LABKEY.container.path; - - if (queryConfig.filterArray && queryConfig.filterArray.length > 0) { - var filters = []; - - for (var i = 0; i < queryConfig.filterArray.length; i++) { - var f = queryConfig.filterArray[i]; - // Issue 37191: Check to see if 'f' is already a filter instance (either labkey-api-js/src/filter/Filter.ts or clientapi/core/Query.js) - if (f.hasOwnProperty('getValue') || f.getValue instanceof Function) { - filters.push(f); - } - else { - filters.push(LABKEY.Filter.create(f.name, f.value, LABKEY.Filter.getFilterTypeForURLSuffix(f.type))); - } - } - - queryConfig.filterArray = filters; - } - - queryConfig.success = async function(measureStore) { - const trendlineData = await queryTrendlineData(chartConfig, measureStore.records()); - callback.call(this, measureStore, trendlineData); - }; - - LABKEY.Query.MeasureStore.selectRows(queryConfig); - }; - - var generateDataForChartType = function(chartConfig, chartType, geom, data) { - let dimName = null; - 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; - } - 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, '[Blank]', false, aggErrorType, chartType === 'line_plot'); - if (aggErrorType) { - geom.errorAes = { getValue: d => d.error }; - } - } - - return data; - } - - var generateChartSVG = function(renderTo, chartConfig, measureStore, trendlineData) { - var responseMetaData = measureStore.getResponseMetadata(); - - // explicitly set the chart width/height if not set in the config - if (!chartConfig.hasOwnProperty('width') || chartConfig.width == null) chartConfig.width = 1000; - if (!chartConfig.hasOwnProperty('height') || chartConfig.height == null) chartConfig.height = 600; - - var chartType = getChartType(chartConfig); - var aes = generateAes(chartType, chartConfig.measures, responseMetaData.schemaName, responseMetaData.queryName); - var valueConversionResponse = doValueConversion(chartConfig, aes, chartType, measureStore.records()); - if (!LABKEY.Utils.isEmptyObj(valueConversionResponse.processed)) { - $.extend(true, chartConfig.measures, valueConversionResponse.processed); - aes = generateAes(chartType, chartConfig.measures, responseMetaData.schemaName, responseMetaData.queryName); - } - var data = measureStore.records(); - if (chartType === 'scatter_plot' && data.length > chartConfig.geomOptions.binThreshold) { - chartConfig.geomOptions.binned = true; - } - var scales = generateScales(chartType, chartConfig.measures, chartConfig.scales, aes, measureStore); - var geom = generateGeom(chartType, chartConfig.geomOptions); - var labels = generateLabels(chartConfig.labels); - - if (chartType === 'bar_chart' || chartType === 'pie_chart' || chartType === 'line_plot') { - data = generateDataForChartType(chartConfig, chartType, geom, data); - } - - var validation = _validateChartConfig(chartConfig, aes, scales, measureStore); - _renderMessages(renderTo, validation.messages); - if (!validation.success) - return; - - var plotConfigArr = generatePlotConfigs(renderTo, chartConfig, labels, aes, scales, geom, data, trendlineData); - $.each(plotConfigArr, function(idx, plotConfig) { - if (chartType === 'pie_chart') { - new LABKEY.vis.PieChart(plotConfig); - } - else { - new LABKEY.vis.Plot(plotConfig).render(); - } - }, this); - } - - var _renderMessages = function(divId, messages) { - if (messages && messages.length > 0) { - var errorDiv = document.createElement('div'); - errorDiv.innerHTML = '

Error rendering chart:

' + messages.join('
') + '
'; - document.getElementById(divId).appendChild(errorDiv); - } - }; - - var _validateChartConfig = function(chartConfig, aes, scales, measureStore) { - var hasNoDataMsg = validateResponseHasData(measureStore, false); - if (hasNoDataMsg != null) - return {success: false, messages: [hasNoDataMsg]}; - - var messages = [], firstRecord = measureStore.records()[0], measureNames = Object.keys(chartConfig.measures); - for (var i = 0; i < measureNames.length; i++) { - var measuresArr = ensureMeasuresAsArray(chartConfig.measures[measureNames[i]]); - for (var j = 0; j < measuresArr.length; j++) { - var measure = measuresArr[j]; - if (LABKEY.Utils.isObject(measure)) { - if (measure.name && !LABKEY.Utils.isDefined(firstRecord[measure.name])) { - return {success: false, messages: ['The measure, ' + measure.name + ', is not available. It may have been renamed or removed.']}; - } - - var validation; - if (measureNames[i] === 'y') { - var yAes = {y: getYMeasureAes(measure)}; - validation = validateAxisMeasure(chartConfig.renderType, measure, 'y', yAes, scales, measureStore.records()); - } - else if (measureNames[i] === 'x' || measureNames[i] === 'xSub') { - validation = validateAxisMeasure(chartConfig.renderType, measure, measureNames[i], aes, scales, measureStore.records()); - } - - if (LABKEY.Utils.isObject(validation)) { - if (validation.message != null) - messages.push(validation.message); - if (!validation.success) - return {success: false, messages: messages}; - } - } - } - } - - return {success: true, messages: messages}; - }; - - return { - // NOTE: the @function below is needed or JSDoc will not include the documentation for loadVisDependencies. Don't - // ask me why, I do not know. - /** - * @function - */ - getRenderTypes: getRenderTypes, - getChartType: getChartType, - getSelectedMeasureLabel: getSelectedMeasureLabel, - getTitleFromMeasures: getTitleFromMeasures, - getMeasureType: getMeasureType, - getAllowableTypes: getAllowableTypes, - getQueryColumns : getQueryColumns, - getChartTypeBasedWidth : getChartTypeBasedWidth, - getDistinctYAxisSides : getDistinctYAxisSides, - getYMeasureAes : getYMeasureAes, - getDefaultMeasuresLabel: getDefaultMeasuresLabel, - getStudySubjectInfo: getStudySubjectInfo, - getQueryConfigSortKey: getQueryConfigSortKey, - ensureMeasuresAsArray: ensureMeasuresAsArray, - isNumericType: isNumericType, - isMeasureDimensionMatch: isMeasureDimensionMatch, - generateLabels: generateLabels, - generateScales: generateScales, - generateAes: generateAes, - doValueConversion: doValueConversion, - removeNumericConversionConfig: removeNumericConversionConfig, - generateAggregateData: generateAggregateData, - generatePointHover: generatePointHover, - generateBoxplotHover: generateBoxplotHover, - generateDataForChartType: generateDataForChartType, - generateDiscreteAcc: generateDiscreteAcc, - generateContinuousAcc: generateContinuousAcc, - generateGroupingAcc: generateGroupingAcc, - generatePointClickFn: generatePointClickFn, - generateGeom: generateGeom, - generateBoxplotGeom: generateBoxplotGeom, - generatePointGeom: generatePointGeom, - generatePlotConfigs: generatePlotConfigs, - generatePlotConfig: generatePlotConfig, - validateResponseHasData: validateResponseHasData, - validateAxisMeasure: validateAxisMeasure, - validateXAxis: validateXAxis, - validateYAxis: validateYAxis, - renderChartSVG: renderChartSVG, - queryChartData: queryChartData, - generateChartSVG: generateChartSVG, - getMeasureStoreRecords: getMeasureStoreRecords, - queryTrendlineData: queryTrendlineData, - TRENDLINE_OPTIONS: TRENDLINE_OPTIONS, - /** - * Loads all of the required dependencies for a Generic Chart. - * @param {Function} callback The callback to be executed when all of the visualization dependencies have been loaded. - * @param {Object} scope The scope to be used when executing the callback. - */ - loadVisDependencies: LABKEY.requiresVisualization - }; +/* + * Copyright (c) 2013-2019 LabKey Corporation + * + * Licensed under the Apache License, Version 2.0: http://www.apache.org/licenses/LICENSE-2.0 + */ +if(!LABKEY.vis) { + LABKEY.vis = {}; +} + +/** + * @namespace Namespace used to encapsulate functions related to creating Generic Charts (Box, Scatter, etc.). Used in the + * Generic Chart Wizard and when exporting Generic Charts as Scripts. + */ +LABKEY.vis.GenericChartHelper = new function(){ + + var DEFAULT_TICK_LABEL_MAX = 25; + var $ = jQuery; + + var getRenderTypes = function() { + return [ + { + name: 'bar_chart', + title: 'Bar', + imgUrl: LABKEY.contextPath + '/visualization/images/barchart.png', + fields: [ + {name: 'x', label: 'X Axis', required: true, nonNumericOnly: true}, + {name: 'xSub', label: 'Group By', required: false, nonNumericOnly: true}, + {name: 'y', label: 'Y Axis', numericOnly: true} + ], + layoutOptions: {line: true, opacity: true, axisBased: true} + }, + { + name: 'box_plot', + title: 'Box', + imgUrl: LABKEY.contextPath + '/visualization/images/boxplot.png', + fields: [ + {name: 'x', label: 'X Axis'}, + {name: 'y', label: 'Y Axis', required: true, numericOnly: true}, + {name: 'color', label: 'Color', nonNumericOnly: true}, + {name: 'shape', label: 'Shape', nonNumericOnly: true} + ], + layoutOptions: {point: true, box: true, line: true, opacity: true, axisBased: true} + }, + { + name: 'line_plot', + title: 'Line', + imgUrl: LABKEY.contextPath + '/visualization/images/timechart.png', + fields: [ + {name: 'x', label: 'X Axis', required: true, numericOrDateOnly: true}, + {name: 'y', label: 'Y Axis', required: true, numericOnly: true, allowMultiple: true}, + {name: 'series', label: 'Series', nonNumericOnly: true}, + {name: 'trendline', label: 'Trendline', required: false, altSelectionOnly: true, altFieldType: 'LABKEY.vis.TrendlineField'}, + ], + layoutOptions: {opacity: true, axisBased: true, series: true, chartLayout: true} + }, + { + name: 'pie_chart', + title: 'Pie', + imgUrl: LABKEY.contextPath + '/visualization/images/piechart.png', + fields: [ + {name: 'x', label: 'Categories', required: true, nonNumericOnly: true}, + // Issue #29046 'Remove "measure" option from pie chart' + // {name: 'y', label: 'Measure', numericOnly: true} + ], + layoutOptions: {pie: true} + }, + { + name: 'scatter_plot', + title: 'Scatter', + imgUrl: LABKEY.contextPath + '/visualization/images/scatterplot.png', + fields: [ + {name: 'x', label: 'X Axis', required: true}, + {name: 'y', label: 'Y Axis', required: true, numericOnly: true, allowMultiple: true}, + {name: 'color', label: 'Color', nonNumericOnly: true}, + {name: 'shape', label: 'Shape', nonNumericOnly: true} + ], + layoutOptions: {point: true, opacity: true, axisBased: true, binnable: true, chartLayout: true} + }, + { + name: 'time_chart', + title: 'Time', + hidden: _getStudyTimepointType() == null, + imgUrl: LABKEY.contextPath + '/visualization/images/timechart.png', + fields: [ + {name: 'x', label: 'X Axis', required: true, altSelectionOnly: true, altFieldType: 'LABKEY.vis.TimeChartXAxisField'}, + {name: 'y', label: 'Y Axis', required: true, numericOnly: true, allowMultiple: true} + ], + layoutOptions: {time: true, axisBased: true, chartLayout: true} + } + ]; + }; + + /** + * Gets the chart type (i.e. box or scatter) based on the chartConfig object. + */ + const getChartType = function(chartConfig) + { + const renderType = chartConfig.renderType + const xAxisType = chartConfig.measures.x ? (chartConfig.measures.x.normalizedType || chartConfig.measures.x.type) : null; + + if (renderType === 'time_chart' || renderType === "bar_chart" || renderType === "pie_chart" + || renderType === "box_plot" || renderType === "scatter_plot" || renderType === "line_plot") + { + return renderType; + } + + if (!xAxisType) + { + // On some charts (non-study box plots) we don't require an x-axis, instead we generate one box plot for + // all of the data of your y-axis. If there is no xAxisType, then we have a box plot. Scatter plots require + // an x-axis measure. + return 'box_plot'; + } + + return (xAxisType === 'string' || xAxisType === 'boolean') ? 'box_plot' : 'scatter_plot'; + }; + + /** + * Generate a default label for the selected measure for the given renderType. + * @param renderType + * @param measureName - the chart type's measure name + * @param properties - properties for the selected column, note that this can be an array of properties + */ + var getSelectedMeasureLabel = function(renderType, measureName, properties) + { + var label = getDefaultMeasuresLabel(properties); + + if (label !== '' && measureName === 'y' && (renderType === 'bar_chart' || renderType === 'pie_chart')) { + var aggregateProps = LABKEY.Utils.isArray(properties) && properties.length === 1 + ? properties[0].aggregate : properties.aggregate; + + if (LABKEY.Utils.isDefined(aggregateProps)) { + var aggLabel = LABKEY.Utils.isObject(aggregateProps) + ? (aggregateProps.name ?? aggregateProps.label) + : LABKEY.Utils.capitalize(aggregateProps.toLowerCase()); + label = aggLabel + ' of ' + label; + } + else { + label = 'Sum of ' + label; + } + } + + return label; + }; + + /** + * Generate a plot title based on the selected measures array or object. + * @param renderType + * @param measures + * @returns {string} + */ + var getTitleFromMeasures = function(renderType, measures) + { + var queryLabels = []; + + if (LABKEY.Utils.isObject(measures)) + { + if (LABKEY.Utils.isArray(measures.y)) + { + $.each(measures.y, function(idx, m) + { + var measureQueryLabel = m.queryLabel || m.queryName; + if (queryLabels.indexOf(measureQueryLabel) === -1) + queryLabels.push(measureQueryLabel); + }); + } + else + { + var m = measures.x || measures.y; + queryLabels.push(m.queryLabel || m.queryName); + } + } + + return queryLabels.join(', '); + }; + + /** + * Get the sorted set of column metadata for the given schema/query/view. + * @param queryConfig + * @param successCallback + * @param callbackScope + */ + var getQueryColumns = function(queryConfig, successCallback, callbackScope) + { + LABKEY.Ajax.request({ + url: LABKEY.ActionURL.buildURL('visualization', 'getGenericReportColumns.api'), + method: 'GET', + params: { + schemaName: queryConfig.schemaName, + queryName: queryConfig.queryName, + viewName: queryConfig.viewName, + dataRegionName: queryConfig.dataRegionName, + includeCohort: true, + includeParticipantCategory : true + }, + success : function(response){ + var columnList = LABKEY.Utils.decode(response.responseText); + _queryColumnMetadata(queryConfig, columnList, successCallback, callbackScope) + }, + scope : this + }); + }; + + var _queryColumnMetadata = function(queryConfig, columnList, successCallback, callbackScope) + { + var columns = columnList.columns.all; + if (queryConfig.savedColumns) { + // make sure all savedColumns from the chart are included as options, they may not be in the view anymore + columns = columns.concat(queryConfig.savedColumns); + } + + LABKEY.Query.selectRows({ + maxRows: 0, // use maxRows 0 so that we just get the query metadata + schemaName: queryConfig.schemaName, + queryName: queryConfig.queryName, + viewName: queryConfig.viewName, + parameters: queryConfig.parameters, + requiredVersion: 9.1, + columns: columns, + method: 'POST', // Issue 31744: use POST as the columns list can be very long and cause a 400 error + success: function(response){ + var columnMetadata = _updateAndSortQueryFields(queryConfig, columnList, response.metaData.fields); + successCallback.call(callbackScope, columnMetadata); + }, + failure : function(response) { + // this likely means that the query no longer exists + successCallback.call(callbackScope, columnList, []); + }, + scope : this + }); + }; + + var _updateAndSortQueryFields = function(queryConfig, columnList, columnMetadata) + { + var queryFields = [], + queryFieldKeys = [], + columnTypes = LABKEY.Utils.isDefined(columnList.columns) ? columnList.columns : {}; + + $.each(columnMetadata, function(idx, column) + { + var f = $.extend(true, {}, column); + f.schemaName = queryConfig.schemaName; + f.queryName = queryConfig.queryName; + f.isCohortColumn = false; + f.isSubjectGroupColumn = false; + + // issue 23224: distinguish cohort and subject group fields in the list of query columns + if (columnTypes['cohort'] && columnTypes['cohort'].indexOf(f.fieldKey) > -1) + { + f.shortCaption = 'Study: ' + f.shortCaption; + f.isCohortColumn = true; + } + else if (columnTypes['subjectGroup'] && columnTypes['subjectGroup'].indexOf(f.fieldKey) > -1) + { + f.shortCaption = columnList.subject.nounSingular + ' Group: ' + f.shortCaption; + f.isSubjectGroupColumn = true; + } + + // Issue 31672: keep track of the distinct query field keys so we don't get duplicates + if (f.fieldKey.toLowerCase() != 'lsid' && queryFieldKeys.indexOf(f.fieldKey) == -1) { + queryFields.push(f); + queryFieldKeys.push(f.fieldKey); + } + }, this); + + // Sorts fields by their shortCaption, but put subject groups/categories/cohort at the end. + queryFields.sort(function(a, b) + { + if (a.isSubjectGroupColumn != b.isSubjectGroupColumn) + return a.isSubjectGroupColumn ? 1 : -1; + else if (a.isCohortColumn != b.isCohortColumn) + return a.isCohortColumn ? 1 : -1; + else if (a.shortCaption != b.shortCaption) + return a.shortCaption < b.shortCaption ? -1 : 1; + + return 0; + }); + + return queryFields; + }; + + /** + * Determine a reasonable width for the chart based on the chart type and selected measures / data. + * @param chartType + * @param measures + * @param measureStore + * @param defaultWidth + * @returns {int} + */ + var getChartTypeBasedWidth = function(chartType, measures, measureStore, defaultWidth) { + var width = 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; + } + } + 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; + }; + + /** + * Return the distinct set of y-axis sides for the given measures object. + * @param measures + */ + var getDistinctYAxisSides = function(measures) + { + var distinctSides = []; + $.each(ensureMeasuresAsArray(measures.y), function (idx, measure) { + if (LABKEY.Utils.isObject(measure)) { + var side = measure.yAxis || 'left'; + if (distinctSides.indexOf(side) === -1) { + distinctSides.push(side); + } + } + }, this); + return distinctSides; + }; + + /** + * Generate a default label for an array of measures by concatenating each meaures label together. + * @param measures + * @returns string concatenation of all measure labels + */ + var getDefaultMeasuresLabel = function(measures) + { + if (LABKEY.Utils.isDefined(measures)) { + if (!LABKEY.Utils.isArray(measures)) { + return measures.label || measures.queryName || ''; + } + + var label = '', sep = ''; + $.each(measures, function(idx, m) { + label += sep + (m.label || m.queryName); + sep = ', '; + }); + return label; + } + + return ''; + }; + + /** + * Given the saved labels object we convert it to include all label types (main, x, and y). Each label type defaults + * to empty string (''). + * @param {Object} labels The saved labels object. + * @returns {Object} + */ + var generateLabels = function(labels) { + return { + main: { value: labels.main || '' }, + subtitle: { value: labels.subtitle || '' }, + footer: { value: labels.footer || '' }, + x: { value: labels.x || '' }, + y: { value: labels.y || '' }, + yRight: { value: labels.yRight || '' } + }; + }; + + /** + * Generates an object containing {@link LABKEY.vis.Scale} objects used for the chart. + * @param {String} chartType The chartType from getChartType. + * @param {Object} measures The measures from generateMeasures. + * @param {Object} savedScales The scales object from the saved chart config. + * @param {Object} aes The aesthetic map object from genereateAes. + * @param {Object} measureStore The MeasureStore data using a selectRows API call. + * @param {Function} defaultFormatFn used to format values for tick marks. + * @returns {Object} + */ + var generateScales = function(chartType, measures, savedScales, aes, measureStore, defaultFormatFn) { + var scales = {}; + var data = LABKEY.Utils.isArray(measureStore.rows) ? measureStore.rows : measureStore.records(); + var fields = LABKEY.Utils.isObject(measureStore.metaData) ? measureStore.metaData.fields : measureStore.getResponseMetadata().fields; + var subjectColumn = getStudySubjectInfo().columnName; + var visitTableName = getStudySubjectInfo().tableName + 'Visit'; + var visitColName = visitTableName + '/Visit'; + var valExponentialDigits = 6; + + // Issue 38105: For plots with study visit labels on the x-axis, don't sort alphabetically + var sortFnX = measures.x && measures.x.fieldKey === visitColName ? undefined : LABKEY.vis.discreteSortFn; + + if (chartType === "box_plot") + { + scales.x = { + scaleType: 'discrete', // Force discrete x-axis scale for box plots. + sortFn: sortFnX, + tickLabelMax: DEFAULT_TICK_LABEL_MAX + }; + + var yMin = d3.min(data, aes.y); + var yMax = d3.max(data, aes.y); + var yPadding = ((yMax - yMin) * .1); + if (savedScales.y && savedScales.y.trans == "log") + { + // When subtracting padding we have to make sure we still produce valid values for a log scale. + // log([value less than 0]) = NaN. + // log(0) = -Infinity. + if (yMin - yPadding > 0) + { + yMin = yMin - yPadding; + } + } + else + { + yMin = yMin - yPadding; + } + + scales.y = { + min: yMin, + max: yMax + yPadding, + scaleType: 'continuous', + trans: savedScales.y ? savedScales.y.trans : 'linear' + }; + } + else + { + var xMeasureType = getMeasureType(measures.x); + + // Force discrete x-axis scale for bar plots. + var useContinuousScale = chartType != 'bar_chart' && isNumericType(xMeasureType); + + if (useContinuousScale) + { + scales.x = { + scaleType: 'continuous', + trans: savedScales.x ? savedScales.x.trans : 'linear' + }; + } + else + { + scales.x = { + scaleType: 'discrete', + sortFn: sortFnX, + tickLabelMax: DEFAULT_TICK_LABEL_MAX + }; + + //bar chart x-axis subcategories support + if (LABKEY.Utils.isDefined(measures.xSub)) { + scales.xSub = { + scaleType: 'discrete', + sortFn: LABKEY.vis.discreteSortFn, + tickLabelMax: DEFAULT_TICK_LABEL_MAX + }; + } + } + + // add both y (i.e. yLeft) and yRight, in case multiple y-axis measures are being plotted + scales.y = { + scaleType: 'continuous', + trans: savedScales.y ? savedScales.y.trans : 'linear' + }; + scales.yRight = { + scaleType: 'continuous', + trans: savedScales.yRight ? savedScales.yRight.trans : 'linear' + }; + } + + // if we have no data, show a default y-axis domain + if (scales.x && data.length == 0 && scales.x.scaleType == 'continuous') + scales.x.domain = [0,1]; + if (scales.y && data.length == 0) + scales.y.domain = [0,1]; + + // apply the field formatFn to the tick marks on the scales object + for (var i = 0; i < fields.length; i++) { + var type = fields[i].displayFieldJsonType ? fields[i].displayFieldJsonType : fields[i].type; + + var isMeasureXMatch = measures.x && _isFieldKeyMatch(measures.x, fields[i].fieldKey); + if (isMeasureXMatch && measures.x.name === subjectColumn && LABKEY.demoMode) { + scales.x.tickFormat = function(){return '******'}; + } + 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); + const isValidDate = d instanceof Date && !isNaN(d); + return isValidDate ? LABKEY.vis.formatDate(new Date(v), dateFormat) : v; + }; + } + } + + var yMeasures = ensureMeasuresAsArray(measures.y); + $.each(yMeasures, function(idx, yMeasure) { + var isMeasureYMatch = yMeasure && _isFieldKeyMatch(yMeasure, fields[i].fieldKey); + var isConvertedYMeasure = isMeasureYMatch && yMeasure.converted; + if (isMeasureYMatch && (isNumericType(type) || isConvertedYMeasure)) { + var tickFormatFn = _getNumberFormatFn(fields[i], defaultFormatFn); + + var ySide = yMeasure.yAxis === 'right' ? 'yRight' : 'y'; + scales[ySide].tickFormat = function(value) { + if (LABKEY.Utils.isNumber(value) && Math.abs(Math.round(value)).toString().length >= valExponentialDigits) { + return value.toExponential(); + } + else if (LABKEY.Utils.isFunction(tickFormatFn)) { + return tickFormatFn(value); + } + return value; + }; + } + }, this); + } + + _applySavedScaleDomain(scales, savedScales, 'x'); + if (LABKEY.Utils.isDefined(measures.xSub)) { + _applySavedScaleDomain(scales, savedScales, 'xSub'); + } + if (LABKEY.Utils.isDefined(measures.y)) { + _applySavedScaleDomain(scales, savedScales, 'y'); + _applySavedScaleDomain(scales, savedScales, 'yRight'); + } + + return scales; + }; + + // Issue 36227: if Ext4 is not available, try to generate our own number format function based on the "format" field metadata + var _getNumberFormatFn = function(field, defaultFormatFn) { + if (field.extFormatFn) { + if (window.Ext4) { + return eval(field.extFormatFn); + } + else if (field.format && LABKEY.Utils.isString(field.format) && field.format.indexOf('.') > -1) { + var precision = field.format.length - field.format.indexOf('.') - 1; + return function(v) { + return LABKEY.Utils.isNumber(v) ? v.toFixed(precision) : v; + } + } + } + + return defaultFormatFn; + }; + + var _isFieldKeyMatch = function(measure, fieldKey) { + if (LABKEY.Utils.isFunction(fieldKey.getName)) { + return fieldKey.getName() === measure.name || fieldKey.getName() === measure.fieldKey; + } else if (LABKEY.Utils.isArray(fieldKey)) { + fieldKey = fieldKey.join('/') + } + + return fieldKey === measure.name || fieldKey === measure.fieldKey; + }; + + var ensureMeasuresAsArray = function(measures) { + if (LABKEY.Utils.isDefined(measures)) { + return LABKEY.Utils.isArray(measures) ? $.extend(true, [], measures) : [$.extend(true, {}, measures)]; + } + return []; + }; + + var _applySavedScaleDomain = function(scales, savedScales, scaleName) { + if (savedScales[scaleName] && (savedScales[scaleName].min != null || savedScales[scaleName].max != null)) { + scales[scaleName].domain = [savedScales[scaleName].min, savedScales[scaleName].max]; + } + }; + + /** + * Generates the aesthetic map object needed by the visualization API to render the chart. See {@link LABKEY.vis.Plot} + * and {@link LABKEY.vis.Layer}. + * @param {String} chartType The chartType from getChartType. + * @param {Object} measures The measures from getMeasures. + * @param {String} schemaName The schemaName from the saved queryConfig. + * @param {String} queryName The queryName from the saved queryConfig. + * @returns {Object} + */ + 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); + + if (isDateType(xMeasureType)) { + // Issue 54125: use continuous instead of discrete accessor for date x-axis + aes.x = generateContinuousAcc(xMeasureName); + } else if (chartType === "box_plot") { + 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); + } + } else if (isNumericType(xMeasureType) || (chartType === 'scatter_plot' && measures.x.measure)) { + aes.x = generateContinuousAcc(xMeasureName); + } else { + aes.x = generateDiscreteAcc(xMeasureName, measures.x.label); + } + + // charts that have multiple y-measures selected will need to put the aes.y function on their specific layer + if (LABKEY.Utils.isDefined(measures.y) && !LABKEY.Utils.isArray(measures.y)) + { + var sideAesName = (measures.y.yAxis || 'left') === 'left' ? 'y' : 'yRight'; + var yMeasureName = measures.y.converted ? measures.y.convertedName : measures.y.name; + aes[sideAesName] = generateContinuousAcc(yMeasureName); + } + + if (chartType === "scatter_plot" || chartType === "line_plot") + { + aes.hoverText = generatePointHover(measures); + } + + if (chartType === "box_plot") + { + if (measures.color) { + aes.outlierColor = generateGroupingAcc(measures.color.name); + } + + if (measures.shape) { + aes.outlierShape = generateGroupingAcc(measures.shape.name); + } + + aes.hoverText = generateBoxplotHover(); + aes.outlierHoverText = generatePointHover(measures); + } + else if (chartType === 'bar_chart') + { + var xSubMeasureType = measures.xSub ? getMeasureType(measures.xSub) : null; + if (xSubMeasureType) + { + if (isNumericType(xSubMeasureType)) + aes.xSub = generateContinuousAcc(measures.xSub.name); + else + aes.xSub = generateDiscreteAcc(measures.xSub.name, measures.xSub.label); + } + } + + // color/shape aes are not dependent on chart type. If we have a box plot with all points enabled, then we + // create a second layer for points. So we'll need this no matter what. + if (measures.color) { + aes.color = generateGroupingAcc(measures.color.name); + } + + if (measures.shape) { + aes.shape = generateGroupingAcc(measures.shape.name); + } + + // also add the color and shape for the line plot series. + if (measures.series) { + aes.color = generateGroupingAcc(measures.series.name); + aes.shape = generateGroupingAcc(measures.series.name); + } + + if (measures.pointClickFn) { + aes.pointClickFn = generatePointClickFn( + measures, + schemaName, + queryName, + measures.pointClickFn + ); + } + + return aes; + }; + + var getYMeasureAes = function(measure) { + var yMeasureName = measure.converted ? measure.convertedName : measure.name; + return generateContinuousAcc(yMeasureName); + }; + + /** + * Generates a function that returns the text used for point hovers. + * @param {Object} measures The measures object from the saved chart config. + * @returns {Function} + */ + var generatePointHover = function(measures) + { + return function(row) { + var hover = '', sep = '', distinctNames = []; + + $.each(measures, function(key, measureObj) { + var measureArr = ensureMeasuresAsArray(measureObj); + $.each(measureArr, function(idx, measure) { + if (LABKEY.Utils.isObject(measure) && !LABKEY.Utils.isEmptyObj(measure) && distinctNames.indexOf(measure.name) == -1) { + 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); + }); + + return hover; + }; + }; + + /** + * Backwards compatibility for function that has been moved to LABKEY.vis.getAggregateData. + */ + var generateAggregateData = function(data, dimensionName, measureName, aggregate, nullDisplayValue) { + return LABKEY.vis.getAggregateData(data, dimensionName, null, measureName, aggregate, nullDisplayValue, false); + }; + + var _getRowValue = function(row, propName, valueName) + { + if (row.hasOwnProperty(propName)) { + // backwards compatibility for response row that is not a LABKEY.Query.Row + if (!(row instanceof LABKEY.Query.Row)) { + return row[propName].formattedValue || row[propName].displayValue || row[propName].value; + } + + var propValue = row.get(propName); + if (valueName != undefined && propValue.hasOwnProperty(valueName)) { + return propValue[valueName]; + } + else if (propValue.hasOwnProperty('formattedValue')) { + return propValue['formattedValue']; + } + else if (propValue.hasOwnProperty('displayValue')) { + return propValue['displayValue']; + } + return row.getValue(propName); + } + + return undefined; + }; + + /** + * Returns a function used to generate the hover text for box plots. + * @returns {Function} + */ + var generateBoxplotHover = function() { + return function(xValue, stats) { + return xValue + ':\nMin: ' + stats.min + '\nMax: ' + stats.max + '\nQ1: ' + stats.Q1 + '\nQ2: ' + stats.Q2 + + '\nQ3: ' + stats.Q3; + }; + }; + + /** + * Generates an accessor function that returns a discrete value from a row of data for a given measure and label. + * Used when an axis has a discrete measure (i.e. string). + * @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 + * @returns {Function} + */ + var generateDiscreteAcc = function(measureName, measureLabel, nullValueLabel) + { + return function(row) + { + var value = _getRowValue(row, measureName); + if (value === null) + value = nullValueLabel !== undefined ? nullValueLabel : "Not in " + measureLabel; + + return value; + }; + }; + + /** + * Generates an accessor function that returns a value from a row of data for a given measure. + * @param {String} measureName The name of the measure. + * @returns {Function} + */ + var generateContinuousAcc = function(measureName) + { + return function(row) + { + var value = _getRowValue(row, measureName, 'value'); + + if (value !== undefined) + { + if (Math.abs(value) === Infinity) + value = null; + + if (value === false || value === true) + value = value.toString(); + + return value; + } + + return undefined; + } + }; + + /** + * Generates an accesssor function for shape and color measures. + * @param {String} measureName The name of the measure. + * @returns {Function} + */ + var generateGroupingAcc = function(measureName) + { + return function(row) + { + var value = null; + if (LABKEY.Utils.isArray(row) && row.length > 0) { + value = _getRowValue(row[0], measureName); + } + else { + value = _getRowValue(row, measureName); + } + + if (value === null || value === undefined) + value = "n/a"; + + return value; + }; + }; + + /** + * Generates an accessor for boxplots that do not have an x-axis measure. Generally the measureName passed in is the + * queryName. + * @param {String} measureName The name of the measure. In this case it is generally the query name. + * @returns {Function} + */ + var generateMeasurelessAcc = function(measureName) { + // Used for box plots that do not have an x-axis measure. Instead we just return the queryName for every row. + return function(row) { + return measureName; + } + }; + + /** + * Generates the function to be executed when a user clicks a point. + * @param {Object} measures The measures from the saved chart config. + * @param {String} schemaName The schema name from the saved query config. + * @param {String} queryName The query name from the saved query config. + * @param {String} fnString The string value of the user-provided function to be executed when a point is clicked. + * @returns {Function} + */ + var generatePointClickFn = function(measures, schemaName, queryName, fnString){ + var measureInfo = { + schemaName: schemaName, + queryName: queryName + }; + + _addPointClickMeasureInfo(measureInfo, measures, 'x', 'xAxis'); + _addPointClickMeasureInfo(measureInfo, measures, 'y', 'yAxis'); + $.each(['color', 'shape', 'series'], function(idx, name) { + _addPointClickMeasureInfo(measureInfo, measures, name, name + 'Name'); + }, this); + + // using new Function is quicker than eval(), even in IE. + var pointClickFn = new Function('return ' + fnString)(); + return function(clickEvent, data){ + pointClickFn(data, measureInfo, clickEvent); + }; + }; + + var _addPointClickMeasureInfo = function(measureInfo, measures, name, key) { + if (LABKEY.Utils.isDefined(measures[name])) { + var measuresArr = ensureMeasuresAsArray(measures[name]); + $.each(measuresArr, function(idx, measure) { + if (!LABKEY.Utils.isDefined(measureInfo[key])) { + measureInfo[key] = measure.name; + } + else if (!LABKEY.Utils.isDefined(measureInfo[measure.name])) { + measureInfo[measure.name] = measure.name; + } + }, this); + } + }; + + /** + * Generates the Point Geom used for scatter plots and box plots with all points visible. + * @param {Object} chartOptions The saved chartOptions object from the chart config. + * @returns {LABKEY.vis.Geom.Point} + */ + var generatePointGeom = function(chartOptions){ + return new LABKEY.vis.Geom.Point({ + opacity: chartOptions.opacity, + size: chartOptions.pointSize, + color: '#' + chartOptions.pointFillColor, + position: chartOptions.position + }); + }; + + /** + * Generates the Boxplot Geom used for box plots. + * @param {Object} chartOptions The saved chartOptions object from the chart config. + * @returns {LABKEY.vis.Geom.Boxplot} + */ + var generateBoxplotGeom = function(chartOptions){ + return new LABKEY.vis.Geom.Boxplot({ + lineWidth: chartOptions.lineWidth, + outlierOpacity: chartOptions.opacity, + outlierFill: '#' + chartOptions.pointFillColor, + outlierSize: chartOptions.pointSize, + color: '#' + chartOptions.lineColor, + fill: chartOptions.boxFillColor == 'none' ? chartOptions.boxFillColor : '#' + chartOptions.boxFillColor, + position: chartOptions.position, + showOutliers: chartOptions.showOutliers + }); + }; + + /** + * Generates the Barplot Geom used for bar charts. + * @param {Object} chartOptions The saved chartOptions object from the chart config. + * @returns {LABKEY.vis.Geom.BarPlot} + */ + var generateBarGeom = function(chartOptions){ + return new LABKEY.vis.Geom.BarPlot({ + opacity: chartOptions.opacity, + color: '#' + chartOptions.lineColor, + fill: '#' + chartOptions.boxFillColor, + lineWidth: chartOptions.lineWidth + }); + }; + + /** + * Generates the Bin Geom used to bin a set of points. + * @param {Object} chartOptions The saved chartOptions object from the chart config. + * @returns {LABKEY.vis.Geom.Bin} + */ + var generateBinGeom = function(chartOptions) { + var colorRange = ["#e6e6e6", "#085D90"]; //light-gray and labkey blue is default + if (chartOptions.binColorGroup == 'SingleColor') { + var color = '#' + chartOptions.binSingleColor; + colorRange = ["#FFFFFF", color]; + } + else if (chartOptions.binColorGroup == 'Heat') { + colorRange = ["#fff6bc", "#e23202"]; + } + + return new LABKEY.vis.Geom.Bin({ + shape: chartOptions.binShape, + colorRange: colorRange, + size: chartOptions.binShape == 'square' ? 10 : 5 + }) + }; + + /** + * Generates a Geom based on the chartType. + * @param {String} chartType The chart type from getChartType. + * @param {Object} chartOptions The chartOptions object from the saved chart config. + * @returns {LABKEY.vis.Geom} + */ + var generateGeom = function(chartType, chartOptions) { + if (chartType == "box_plot") + return generateBoxplotGeom(chartOptions); + else if (chartType == "scatter_plot" || chartType == "line_plot") + return chartOptions.binned ? generateBinGeom(chartOptions) : generatePointGeom(chartOptions); + else if (chartType == "bar_chart") + return generateBarGeom(chartOptions); + }; + + /** + * Generate an array of plot configs for the given chart renderType and config options. + * @param renderTo + * @param chartConfig + * @param labels + * @param aes + * @param scales + * @param geom + * @param data + * @param trendlineData + * @returns {Array} array of plot config objects + */ + var generatePlotConfigs = function(renderTo, chartConfig, labels, aes, scales, geom, data, trendlineData) + { + var plotConfigArr = []; + + // if we have multiple y-measures and the request is to plot them separately, call the generatePlotConfig function + // for each y-measure separately with its own copy of the chartConfig object + if (chartConfig.geomOptions.chartLayout === 'per_measure' && LABKEY.Utils.isArray(chartConfig.measures.y)) { + + // if 'automatic across charts' scales are requested, need to manually calculate the min and max + if (chartConfig.scales.y && chartConfig.scales.y.type === 'automatic') { + scales.y = $.extend(scales.y, _getScaleDomainValuesForAllMeasures(data, chartConfig.measures.y, 'left')); + } + if (chartConfig.scales.yRight && chartConfig.scales.yRight.type === 'automatic') { + scales.yRight = $.extend(scales.yRight, _getScaleDomainValuesForAllMeasures(data, chartConfig.measures.y, 'right')); + } + + $.each(chartConfig.measures.y, function(idx, yMeasure) { + // copy the config and reset the measures.y array with the single measure + var newChartConfig = $.extend(true, {}, chartConfig); + newChartConfig.measures.y = $.extend(true, {}, yMeasure); + + // copy the labels object so that we can set the subtitle based on the y-measure + var newLabels = $.extend(true, {}, labels); + newLabels.subtitle = {value: yMeasure.label || yMeasure.name}; + + // only copy over the scales that are needed for this measures + var side = yMeasure.yAxis || 'left'; + var newScales = {x: $.extend(true, {}, scales.x)}; + if (side === 'left') { + newScales.y = $.extend(true, {}, scales.y); + } + else { + newScales.yRight = $.extend(true, {}, scales.yRight); + } + + plotConfigArr.push(generatePlotConfig(renderTo, newChartConfig, newLabels, aes, newScales, geom, data, trendlineData)); + }, this); + } + else { + plotConfigArr.push(generatePlotConfig(renderTo, chartConfig, labels, aes, scales, geom, data, trendlineData)); + } + + return plotConfigArr; + }; + + var _getScaleDomainValuesForAllMeasures = function(data, measures, side) { + var min = null, max = null; + + $.each(measures, function(idx, measure) { + var measureSide = measure.yAxis || 'left'; + if (side === measureSide) { + var accFn = LABKEY.vis.GenericChartHelper.getYMeasureAes(measure); + var tempMin = d3.min(data, accFn); + var tempMax = d3.max(data, accFn); + + if (min == null || tempMin < min) { + min = tempMin; + } + if (max == null || tempMax > max) { + max = tempMax; + } + } + }, this); + + return {domain: [min, max]}; + }; + + /** + * Generate the plot config for the given chart renderType and config options. + * @param renderTo + * @param chartConfig + * @param labels + * @param aes + * @param scales + * @param geom + * @param data + * @param trendlineData + * @returns {Object} + */ + var generatePlotConfig = function(renderTo, chartConfig, labels, aes, scales, geom, data, trendlineData) + { + var renderType = chartConfig.renderType, + layers = [], clipRect, + emptyTextFn = function(){return '';}, + plotConfig = { + renderTo: renderTo, + rendererType: 'd3', + width: chartConfig.width, + height: chartConfig.height, + gridLinesVisible: chartConfig.gridLinesVisible, + }; + + if (renderType === 'pie_chart') { + return _generatePieChartConfig(plotConfig, chartConfig, labels, data); + } + + clipRect = (scales.x && LABKEY.Utils.isArray(scales.x.domain)) || (scales.y && LABKEY.Utils.isArray(scales.y.domain)); + + // account for line chart hiding points + if (chartConfig.geomOptions.hideDataPoints) { + geom = null; + } + + // account for one or many y-measures by ensuring that we have an array of y-measures + var yMeasures = ensureMeasuresAsArray(chartConfig.measures.y); + + if (renderType === 'bar_chart') { + aes = { x: 'label', y: 'value' }; + + if (LABKEY.Utils.isDefined(chartConfig.measures.xSub)) + { + aes.xSub = 'subLabel'; + aes.color = 'label'; + } + + if (!scales.y) { + scales.y = {}; + } + + 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') + { + layers.push( + new LABKEY.vis.Layer({ + geom: LABKEY.vis.GenericChartHelper.generatePointGeom(chartConfig.geomOptions), + aes: {hoverText: LABKEY.vis.GenericChartHelper.generatePointHover(chartConfig.measures)} + }) + ); + } + else if (renderType === 'line_plot') { + var xName = chartConfig.measures.x.name, + isDate = isDateType(getMeasureType(chartConfig.measures.x)); + + $.each(yMeasures, function(idx, yMeasure) { + var pathAes = { + sortFn: function(a, b) { + // 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) { + return 1; + } else if (bVal === null) { + return -1; + } else if (isDate){ + return new Date(aVal) - new Date(bVal); + } + return aVal - bVal; + }, + hoverText: emptyTextFn(), + }; + + pathAes[yMeasure.yAxis === 'right' ? 'yRight' : 'yLeft'] = getYMeasureAes(yMeasure); + + // use the series measure's values for the distinct colors and grouping + const hasSeries = chartConfig.measures.series !== undefined; + if (hasSeries) { + pathAes.pathColor = generateGroupingAcc(chartConfig.measures.series.name); + pathAes.group = generateGroupingAcc(chartConfig.measures.series.name); + pathAes.hoverText = function (row) { return chartConfig.measures.series.label + ': ' + row.group }; + } + // if no series measures but we have multiple y-measures, force the color and grouping to be distinct for each measure + else if (yMeasures.length > 1) { + pathAes.pathColor = emptyTextFn; + pathAes.group = emptyTextFn; + } + + if (trendlineData) { + trendlineData.forEach(trendline => { + if (trendline.data) { + const layerAes = { x: 'x', y: 'y' }; + if (hasSeries) { + layerAes.pathColor = function () { return trendline.name }; + } + + layerAes.hoverText = generateTrendlinePathHover(trendline); + + layers.push( + new LABKEY.vis.Layer({ + geom: new LABKEY.vis.Geom.Path({ + color: '#' + chartConfig.geomOptions.pointFillColor, + size: chartConfig.geomOptions.lineWidth ? chartConfig.geomOptions.lineWidth : 3, + opacity:chartConfig.geomOptions.opacity, + }), + aes: layerAes, + data: trendline.data.generatedPoints, + }) + ); + } + }); + } else { + layers.push( + new LABKEY.vis.Layer({ + name: yMeasures.length > 1 ? yMeasure.label || yMeasure.name : undefined, + geom: new LABKEY.vis.Geom.Path({ + color: '#' + chartConfig.geomOptions.pointFillColor, + size: chartConfig.geomOptions.lineWidth?chartConfig.geomOptions.lineWidth:3, + opacity:chartConfig.geomOptions.opacity + }), + aes: pathAes + }) + ); + } + }, this); + } + + // Issue 34711: better guess at the max number of discrete x-axis tick mark labels to show based on the plot width + if (scales.x && scales.x.scaleType === 'discrete' && scales.x.tickLabelMax) { + // approx 30 px for a 45 degree rotated tick label + scales.x.tickLabelMax = Math.floor((plotConfig.width - 300) / 30); + } + + var margins = _getPlotMargins(renderType, scales, aes, data, plotConfig, chartConfig); + if (LABKEY.Utils.isObject(margins)) { + plotConfig.margins = margins; + } + + if (chartConfig.measures.color) + { + scales.color = { + colorType: chartConfig.geomOptions.colorPaletteScale, + scaleType: 'discrete' + } + } + + if ((renderType === 'line_plot' || renderType === 'scatter_plot') && yMeasures.length > 0) { + $.each(yMeasures, function (idx, yMeasure) { + var layerAes = {}; + layerAes[yMeasure.yAxis === 'right' ? 'yRight' : 'yLeft'] = getYMeasureAes(yMeasure); + + // if no series measures but we have multiple y-measures, force the color and shape to be distinct for each measure + if (!aes.color && yMeasures.length > 1) { + layerAes.color = emptyTextFn; + } + if (!aes.shape && yMeasures.length > 1) { + 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, + geom: geom, + aes: layerAes + }) + ); + }, this); + } + else { + layers.push( + new LABKEY.vis.Layer({ + data: data, + geom: geom + }) + ); + } + + plotConfig = $.extend(plotConfig, { + clipRect: clipRect, + data: data, + labels: labels, + aes: aes, + scales: scales, + layers: layers + }); + + return plotConfig; + }; + + const hasPremiumModule = function() { + return LABKEY.getModuleContext('api').moduleNames.indexOf('premium') > -1; + }; + + const TRENDLINE_OPTIONS = { + '': { label: 'Point-to-Point', value: '' }, + 'Linear': { label: 'Linear Regression', value: 'Linear', equation: 'y = x * slope + intercept' }, + 'Polynomial': { label: 'Polynomial', value: 'Polynomial', equation: 'y = a0 + a1 * x + a2 * x^2' }, + '3 Parameter': { label: 'Nonlinear 3PL', value: '3 Parameter', showMax: true, schemaPrefix: 'assay', equation: 'y = max * abs(x/inflection)^abs(slope) / [1 + abs(x/inflection)^abs(slope)]' }, + 'Three Parameter': { label: 'Nonlinear 3PL (Alternate)', value: 'Three Parameter', showMax: true, schemaPrefix: 'assay', equation: 'y = max / [1 + (inflection - x) * slope]' }, + '4 Parameter': { label: 'Nonlinear 4PL', value: '4 Parameter', schemaPrefix: 'assay', equation: 'y = max + (min - max) / [1 + (x/inflection)^slope]' }, + 'Four Parameter': { label: 'Nonlinear 4PL (Alternate)', value: 'Four Parameter', showMin: true, showMax: true, schemaPrefix: 'assay', equation: 'y = min + (max - min) / [1 + (inflection - x) * slope]' }, + 'Five Parameter': { label: 'Nonlinear 5PL', value: 'Five Parameter', showMin: true, showMax: true, schemaPrefix: 'assay', equation: 'y = min + (max - min) / [[1 + (inflection - x) * slope]^asymmetry]' }, + } + + const generateTrendlinePathHover = function(trendline) { + let hoverText = trendline.name + '\n'; + hoverText += '\n' + TRENDLINE_OPTIONS[trendline.data.curveFit.type].label + ':\n'; + Object.entries(trendline.data.curveFit).forEach(([key, value]) => { + if (key === 'coefficients') { + hoverText += key + ': '; + value.forEach((v, i) => { + hoverText += (i > 0 ? ', ' : '') + LABKEY.Utils.roundNumber(v, 4); + }); + hoverText += '\n'; + } + else if (key !== 'type') { + hoverText += key + ': ' + LABKEY.Utils.roundNumber(value, 4) + '\n'; + } + }); + hoverText += '\nStatistics:\n'; + Object.entries(trendline.data.stats).forEach(([key, value]) => { + const label = key === 'RSquared' ? 'R-Squared' : (key === 'adjustedRSquared' ? 'Adjusted R-Squared' : key); + hoverText += label + ': ' + LABKEY.Utils.roundNumber(value, 4) + '\n'; + }); + + return function () { return hoverText }; + }; + + // support for y-axis trendline data when a single y-axis measure is selected + const queryTrendlineData = async function(chartConfig, data) { + const chartType = getChartType(chartConfig); + const yMeasures = ensureMeasuresAsArray(chartConfig.measures.y); + if (chartType === 'line_plot' && chartConfig.geomOptions?.trendlineType && chartConfig.geomOptions.trendlineType !== '' && yMeasures.length === 1) { + const xName = chartConfig.measures.x.name; + const trendlineConfig = getTrendlineConfig(chartConfig, data); + try { + await _queryTrendlineData(trendlineConfig, xName, yMeasures[0].name); + return trendlineConfig.data; + } catch (reason) { + // skip this series and render without trendline + return trendlineConfig.data; + } + } + + return undefined; + }; + + const getTrendlineConfig = function(chartConfig, data) { + const config = { + type: chartConfig.geomOptions.trendlineType, + logXScale: chartConfig.scales.x && chartConfig.scales.x.trans === 'log', + asymptoteMin: chartConfig.geomOptions.trendlineAsymptoteMin, + asymptoteMax: chartConfig.geomOptions.trendlineAsymptoteMax, + data: chartConfig.measures.series + ? LABKEY.vis.groupCountData(data, generateGroupingAcc(chartConfig.measures.series.name)) + : [{name: 'All', rawData: data}], + }; + + // special case to only use logXScale for linear trendlines + if (config.type === 'Linear') { + config.logXScale = false; + } + + return config; + }; + + const _queryTrendlineData = async function(trendlineConfig, xName, yName) { + for (let series of trendlineConfig.data) { + try { + // we need at least 2 data points for curve fitting + if (series.rawData.length > 1) { + series.data = await _querySeriesTrendlineData(trendlineConfig, series, xName, yName); + } + } catch (e) { + console.error(e); + } + } + }; + + const _querySeriesTrendlineData = function(trendlineConfig, seriesData, xName, yName) { + return new Promise(function(resolve, reject) { + if (!hasPremiumModule()) { + reject('Premium module required for curve fitting.'); + return; + } + + const points = seriesData.rawData.map(function(row) { + return { + x: _getRowValue(row, xName, 'value'), + y: _getRowValue(row, yName, 'value'), + }; + }); + const xAcc = function(row) { return row.x }; + const xMin = d3.min(points, xAcc); + const xMax = d3.max(points, xAcc); + + LABKEY.Ajax.request({ + url: LABKEY.ActionURL.buildURL('premium', 'calculateCurveFit.api'), + method: 'POST', + jsonData: { + curveFitType: trendlineConfig.type, + points: points, + logXScale: trendlineConfig.logXScale, + asymptoteMin: trendlineConfig.asymptoteMin, + asymptoteMax: trendlineConfig.asymptoteMax, + xMin: xMin, + xMax: xMax, + numberOfPoints: 1000, + }, + success : LABKEY.Utils.getCallbackWrapper(function(response) { + resolve(response); + }), + failure : LABKEY.Utils.getCallbackWrapper(function(reason) { + reject(reason); + }, this, true), + }); + }); + }; + + 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; + // 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 1; + }; + + var _getPlotMargins = function(renderType, scales, aes, data, plotConfig, chartConfig) { + var margins = {}; + + // issue 29690: for bar and box plots, set default bottom margin based on the number of labels and the max label length + if (LABKEY.Utils.isArray(data)) { + var maxLen = 0; + $.each(data, function(idx, d) { + var val = LABKEY.Utils.isFunction(aes.x) ? aes.x(d) : d[aes.x]; + var subVal = LABKEY.Utils.isFunction(aes.xSub) ? aes.xSub(d) : d[aes.xSub]; + if (LABKEY.Utils.isString(subVal)) { + maxLen = Math.max(maxLen, subVal.length); + } else if (LABKEY.Utils.isString(val)) { + maxLen = Math.max(maxLen, val.length); + } + }); + + 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 + if (chartConfig && chartConfig.geomOptions) { + if (chartConfig.geomOptions.marginTop !== null) { + margins.top = chartConfig.geomOptions.marginTop; + } + if (chartConfig.geomOptions.marginRight !== null) { + margins.right = chartConfig.geomOptions.marginRight; + } + if (chartConfig.geomOptions.marginBottom !== null) { + margins.bottom = chartConfig.geomOptions.marginBottom; + } + if (chartConfig.geomOptions.marginLeft !== null) { + margins.left = chartConfig.geomOptions.marginLeft; + } + } + + return !LABKEY.Utils.isEmptyObj(margins) ? margins : null; + }; + + var _generatePieChartConfig = function(baseConfig, chartConfig, labels, data) + { + var hasData = data.length > 0; + + return $.extend(baseConfig, { + data: hasData ? data : [{label: '', value: 1}], + header: { + title: { text: labels.main.value }, + subtitle: { text: labels.subtitle.value }, + titleSubtitlePadding: 1 + }, + footer: { + text: hasData ? labels.footer.value : 'No data to display', + location: 'bottom-center' + }, + labels: { + mainLabel: { fontSize: 14 }, + percentage: { + fontSize: 14, + color: chartConfig.geomOptions.piePercentagesColor != null ? '#' + chartConfig.geomOptions.piePercentagesColor : undefined + }, + outer: { pieDistance: 20 }, + inner: { + format: hasData && chartConfig.geomOptions.showPiePercentages ? 'percentage' : 'none', + hideWhenLessThanPercentage: chartConfig.geomOptions.pieHideWhenLessThanPercentage + } + }, + size: { + pieInnerRadius: hasData ? chartConfig.geomOptions.pieInnerRadius + '%' : '100%', + pieOuterRadius: hasData ? chartConfig.geomOptions.pieOuterRadius + '%' : '90%' + }, + misc: { + gradient: { + enabled: chartConfig.geomOptions.gradientPercentage != 0, + percentage: chartConfig.geomOptions.gradientPercentage, + color: '#' + chartConfig.geomOptions.gradientColor + }, + colors: { + segments: hasData ? LABKEY.vis.Scale[chartConfig.geomOptions.colorPaletteScale]() : ['#333333'] + } + }, + effects: { highlightSegmentOnMouseover: false }, + tooltips: { enabled: true } + }); + }; + + /** + * Check if the MeasureStore selectRows API response has data. Return an error string if no data exists. + * @param measureStore + * @param includeFilterMsg true to include a message about removing filters + * @returns {String} + */ + var validateResponseHasData = function(measureStore, includeFilterMsg) + { + var dataArray = getMeasureStoreRecords(measureStore); + if (dataArray.length == 0) + { + return 'The response returned 0 rows of data. The query may be empty or the applied filters may be too strict.' + + (includeFilterMsg ? 'Try removing or adjusting any filters if possible.' : ''); + } + + return null; + }; + + var getMeasureStoreRecords = function(measureStore) { + return LABKEY.Utils.isDefined(measureStore) ? measureStore.rows || measureStore.records() : []; + } + + /** + * Verifies that the axis measure is actually present and has data. Also checks to make sure that data can be used in a log + * scale (if applicable). Returns an object with a success parameter (boolean) and a message parameter (string). If the + * success parameter is false there is a critical error and the chart cannot be rendered. If success is true the chart + * can be rendered. Message will contain an error or warning message if applicable. If message is not null and success + * is true, there is a warning. + * @param {String} chartType The chartType from getChartType. + * @param {Object} chartConfigOrMeasure The saved chartConfig object or a specific measure object. + * @param {String} measureName The name of the axis measure property. + * @param {Object} aes The aes object from generateAes. + * @param {Object} scales The scales object from generateScales. + * @param {Array} data The response data from selectRows. + * @param {Boolean} dataConversionHappened Whether we converted any values in the measure data + * @returns {Object} + */ + var validateAxisMeasure = function(chartType, chartConfigOrMeasure, measureName, aes, scales, data, dataConversionHappened) { + var measure = LABKEY.Utils.isObject(chartConfigOrMeasure) && chartConfigOrMeasure.measures ? chartConfigOrMeasure.measures[measureName] : chartConfigOrMeasure; + return _validateAxisMeasure(chartType, measure, measureName, aes, scales, data, dataConversionHappened); + }; + + var _validateAxisMeasure = function(chartType, measure, measureName, aes, scales, data, dataConversionHappened) { + var dataIsNull = true, measureUndefined = true, invalidLogValues = false, hasZeroes = false, message = null; + + // no need to check measures if we have no data + if (data.length === 0) { + return {success: true, message: message}; + } + + for (var i = 0; i < data.length; i ++) + { + var value = aes[measureName](data[i]); + + if (value !== undefined) + measureUndefined = false; + + if (value !== null) + dataIsNull = false; + + if (value && value < 0) + invalidLogValues = true; + + if (value === 0 ) + hasZeroes = true; + } + + if (measureUndefined) + { + message = 'The measure, ' + measure.name + ', was not found. It may have been renamed or removed.'; + return {success: false, message: message}; + } + + if ((chartType == 'scatter_plot' || chartType == 'line_plot' || measureName == 'y') && dataIsNull && !dataConversionHappened) + { + message = 'All data values for ' + measure.label + ' are null. Please choose a different measure or review/remove data filters.'; + return {success: true, message: message}; + } + + if (scales[measureName] && scales[measureName].trans == "log") + { + if (invalidLogValues) + { + message = "Unable to use a log scale on the " + measureName + "-axis. All " + measureName + + "-axis values must be >= 0. Reverting to linear scale on " + measureName + "-axis."; + scales[measureName].trans = 'linear'; + } + else if (hasZeroes) + { + message = "Some " + measureName + "-axis values are 0. Plotting all " + measureName + "-axis values as value+1."; + var accFn = aes[measureName]; + aes[measureName] = function(row){return accFn(row) + 1}; + } + } + + return {success: true, message: message}; + }; + + /** + * Deprecated - use validateAxisMeasure + */ + var validateXAxis = function(chartType, chartConfig, aes, scales, data){ + return this.validateAxisMeasure(chartType, chartConfig, 'x', aes, scales, data); + }; + /** + * Deprecated - use validateAxisMeasure + */ + var validateYAxis = function(chartType, chartConfig, aes, scales, data){ + return this.validateAxisMeasure(chartType, chartConfig, 'y', aes, scales, data); + }; + + var getMeasureType = function(measure) { + return LABKEY.Utils.isObject(measure) ? (measure.normalizedType || measure.type) : null; + }; + + var isNumericType = function(type) + { + var t = LABKEY.Utils.isString(type) ? type.toLowerCase() : null; + return t == 'int' || t == 'integer' || t == 'float' || t == 'double'; + }; + + var isDateType = function(type) + { + var t = LABKEY.Utils.isString(type) ? type.toLowerCase() : null; + return t == 'date'; + }; + + var getAllowableTypes = function(field) { + var numericTypes = ['int', 'float', 'double', 'INTEGER', 'DOUBLE'], + nonNumericTypes = ['string', 'date', 'boolean', 'STRING', 'TEXT', 'DATE', 'BOOLEAN'], + numericAndDateTypes = numericTypes.concat(['date','DATE']); + + if (field.altSelectionOnly) + return []; + else if (field.numericOnly) + return numericTypes; + else if (field.nonNumericOnly) + return nonNumericTypes; + else if (field.numericOrDateOnly) + return numericAndDateTypes; + else + return numericTypes.concat(nonNumericTypes); + } + + var isMeasureDimensionMatch = function(chartType, field, isMeasure, isDimension) { + if ((chartType === 'box_plot' || chartType === 'bar_chart')) { + //x-axis does not support 'measure' column types for these plot types + if (field.name === 'x' || field.name === 'xSub') + return isDimension; + else + return isMeasure; + } + + return (field.numericOnly && isMeasure) || (field.nonNumericOnly && isDimension); + } + + var getQueryConfigSortKey = function(measures) { + var sortKey = 'lsid'; // needed to keep expected ordering for legend data + + // Issue 38105: For plots with study visit labels on the x-axis, sort by visit display order and then sequenceNum + var visitTableName = LABKEY.vis.GenericChartHelper.getStudySubjectInfo().tableName + 'Visit'; + if (measures.x && measures.x.fieldKey === visitTableName + '/Visit') { + var displayOrderColName = visitTableName + '/Visit/DisplayOrder'; + var seqNumColName = visitTableName + '/SequenceNum'; + sortKey = displayOrderColName + ', ' + seqNumColName; + } + + return sortKey; + } + + var getStudySubjectInfo = function() + { + var studyCtx = LABKEY.getModuleContext("study") || {}; + return LABKEY.Utils.isObject(studyCtx.subject) ? studyCtx.subject : { + tableName: 'Participant', + columnName: 'ParticipantId', + nounPlural: 'Participants', + nounSingular: 'Participant' + }; + }; + + var _getStudyTimepointType = function() + { + var studyCtx = LABKEY.getModuleContext("study") || {}; + return LABKEY.Utils.isDefined(studyCtx.timepointType) ? studyCtx.timepointType : null; + }; + + var _getMeasureRestrictions = function (chartType, measure) + { + var measureRestrictions = {}; + $.each(getRenderTypes(), function (idx, renderType) + { + if (renderType.name === chartType) + { + $.each(renderType.fields, function (idx2, field) + { + if (field.name === measure) + { + measureRestrictions.numericOnly = field.numericOnly; + measureRestrictions.nonNumericOnly = field.nonNumericOnly; + return false; + } + }); + return false; + } + }); + + return measureRestrictions; + }; + + /** + * Converts data values passed in to the appropriate type based on measure/dimension information. + * @param chartConfig Chart configuration object + * @param aes Aesthetic mapping functions for each measure/axis + * @param renderType The type of plot or chart (e.g. scatter_plot, bar_chart) + * @param data The response data from SelectRows + * @returns {{processed: {}, warningMessage: *}} + */ + var doValueConversion = function(chartConfig, aes, renderType, data) + { + var measuresForProcessing = {}, measureRestrictions = {}, configMeasure; + for (var measureName in chartConfig.measures) { + if (chartConfig.measures.hasOwnProperty(measureName) && LABKEY.Utils.isObject(chartConfig.measures[measureName])) { + configMeasure = chartConfig.measures[measureName]; + $.extend(measureRestrictions, _getMeasureRestrictions(renderType, measureName)); + + var isGroupingMeasure = measureName === 'color' || measureName === 'shape' || measureName === 'series'; + var isXAxis = measureName === 'x' || measureName === 'xSub'; + var isScatterOrLine = renderType === 'scatter_plot' || renderType === 'line_plot'; + var isBarYCount = renderType === 'bar_chart' && configMeasure.aggregate && (configMeasure.aggregate === 'COUNT' || configMeasure.aggregate.value === 'COUNT'); + + if (configMeasure.measure && !isGroupingMeasure && !isBarYCount + && ((!isXAxis && measureRestrictions.numericOnly ) || isScatterOrLine) && !isNumericType(configMeasure.type)) { + measuresForProcessing[measureName] = {}; + measuresForProcessing[measureName].name = configMeasure.name; + measuresForProcessing[measureName].convertedName = configMeasure.name + "_converted"; + measuresForProcessing[measureName].label = configMeasure.label; + configMeasure.normalizedType = 'float'; + configMeasure.type = 'float'; + } + } + } + + var response = {processed: {}}; + if (!LABKEY.Utils.isEmptyObj(measuresForProcessing)) { + response = _processMeasureData(data, aes, measuresForProcessing); + } + + //generate error message for dropped values + var warningMessage = ''; + for (var measure in response.droppedValues) { + if (response.droppedValues.hasOwnProperty(measure) && response.droppedValues[measure].numDropped) { + warningMessage += " The " + + measure + "-axis measure '" + + response.droppedValues[measure].label + "' had " + + response.droppedValues[measure].numDropped + + " value(s) that could not be converted to a number and are not included in the plot."; + } + } + + return {processed: response.processed, warningMessage: warningMessage}; + }; + + /** + * Does the explicit type conversion for each measure deemed suitable to convert. Currently we only + * attempt to convert strings to numbers for measures. + * @param rows Data from SelectRows + * @param aes Aesthetic mapping function for the measure/dimensions + * @param measuresForProcessing The measures to be converted, if any + * @returns {{droppedValues: {}, processed: {}}} + */ + var _processMeasureData = function(rows, aes, measuresForProcessing) { + var droppedValues = {}, processedMeasures = {}, dataIsNull; + rows.forEach(function(row) { + //convert measures if applicable + if (!LABKEY.Utils.isEmptyObj(measuresForProcessing)) { + for (var measure in measuresForProcessing) { + if (measuresForProcessing.hasOwnProperty(measure)) { + dataIsNull = true; + if (!droppedValues[measure]) { + droppedValues[measure] = {}; + droppedValues[measure].label = measuresForProcessing[measure].label; + droppedValues[measure].numDropped = 0; + } + + if (aes.hasOwnProperty(measure)) { + var value = aes[measure](row); + if (value !== null) { + dataIsNull = false; + } + row[measuresForProcessing[measure].convertedName] = {value: null}; + if (typeof value !== 'number' && value !== null) { + + //only try to convert strings to numbers + if (typeof value === 'string') { + value = value.trim(); + } + else { + //dates, objects, booleans etc. to be assigned value: NULL + value = ''; + } + + var n = Number(value); + // empty strings convert to 0, which we must explicitly deny + if (value === '' || isNaN(n)) { + droppedValues[measure].numDropped++; + } + else { + row[measuresForProcessing[measure].convertedName].value = n; + } + } + } + + if (!processedMeasures[measure]) { + processedMeasures[measure] = { + converted: false, + convertedName: measuresForProcessing[measure].convertedName, + type: 'float', + normalizedType: 'float' + } + } + + processedMeasures[measure].converted = processedMeasures[measure].converted || !dataIsNull; + } + } + } + }); + + return {droppedValues: droppedValues, processed: processedMeasures}; + }; + + /** + * removes all traces of String -> Numeric Conversion from the given chart config + * @param chartConfig + * @returns {updated ChartConfig} + */ + var removeNumericConversionConfig = function(chartConfig) { + if (chartConfig && chartConfig.measures) { + for (var measureName in chartConfig.measures) { + if (chartConfig.measures.hasOwnProperty(measureName)) { + var measure = chartConfig.measures[measureName]; + if (measure && measure.converted && measure.convertedName) { + measure.converted = null; + measure.convertedName = null; + if (LABKEY.vis.GenericChartHelper.isNumericType(measure.type)) { + measure.type = 'string'; + measure.normalizedType = 'string'; + } + } + } + } + } + + return chartConfig; + }; + + var renderChartSVG = function(renderTo, queryConfig, chartConfig) { + queryChartData(renderTo, queryConfig, chartConfig, function(measureStore, trendlineData) { + generateChartSVG(renderTo, chartConfig, measureStore, trendlineData); + }); + }; + + var queryChartData = function(renderTo, queryConfig, chartConfig, callback) { + queryConfig.containerPath = LABKEY.container.path; + + if (queryConfig.filterArray && queryConfig.filterArray.length > 0) { + var filters = []; + + for (var i = 0; i < queryConfig.filterArray.length; i++) { + var f = queryConfig.filterArray[i]; + // Issue 37191: Check to see if 'f' is already a filter instance (either labkey-api-js/src/filter/Filter.ts or clientapi/core/Query.js) + if (f.hasOwnProperty('getValue') || f.getValue instanceof Function) { + filters.push(f); + } + else { + filters.push(LABKEY.Filter.create(f.name, f.value, LABKEY.Filter.getFilterTypeForURLSuffix(f.type))); + } + } + + queryConfig.filterArray = filters; + } + + queryConfig.success = async function(measureStore) { + const trendlineData = await queryTrendlineData(chartConfig, measureStore.records()); + callback.call(this, measureStore, trendlineData); + }; + + LABKEY.Query.MeasureStore.selectRows(queryConfig); + }; + + var generateDataForChartType = function(chartConfig, chartType, geom, data) { + let dimName = null; + 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; + } + 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, '[Blank]', false, aggErrorType, chartType === 'line_plot'); + if (aggErrorType) { + geom.errorAes = { getValue: d => d.error }; + } + } + + return data; + } + + var generateChartSVG = function(renderTo, chartConfig, measureStore, trendlineData) { + var responseMetaData = measureStore.getResponseMetadata(); + + // explicitly set the chart width/height if not set in the config + if (!chartConfig.hasOwnProperty('width') || chartConfig.width == null) chartConfig.width = 1000; + if (!chartConfig.hasOwnProperty('height') || chartConfig.height == null) chartConfig.height = 600; + + var chartType = getChartType(chartConfig); + var aes = generateAes(chartType, chartConfig.measures, responseMetaData.schemaName, responseMetaData.queryName); + var valueConversionResponse = doValueConversion(chartConfig, aes, chartType, measureStore.records()); + if (!LABKEY.Utils.isEmptyObj(valueConversionResponse.processed)) { + $.extend(true, chartConfig.measures, valueConversionResponse.processed); + aes = generateAes(chartType, chartConfig.measures, responseMetaData.schemaName, responseMetaData.queryName); + } + var data = measureStore.records(); + if (chartType === 'scatter_plot' && data.length > chartConfig.geomOptions.binThreshold) { + chartConfig.geomOptions.binned = true; + } + var scales = generateScales(chartType, chartConfig.measures, chartConfig.scales, aes, measureStore); + var geom = generateGeom(chartType, chartConfig.geomOptions); + var labels = generateLabels(chartConfig.labels); + + if (chartType === 'bar_chart' || chartType === 'pie_chart' || chartType === 'line_plot') { + data = generateDataForChartType(chartConfig, chartType, geom, data); + } + + var validation = _validateChartConfig(chartConfig, aes, scales, measureStore); + _renderMessages(renderTo, validation.messages); + if (!validation.success) + return; + + var plotConfigArr = generatePlotConfigs(renderTo, chartConfig, labels, aes, scales, geom, data, trendlineData); + $.each(plotConfigArr, function(idx, plotConfig) { + if (chartType === 'pie_chart') { + new LABKEY.vis.PieChart(plotConfig); + } + else { + new LABKEY.vis.Plot(plotConfig).render(); + } + }, this); + } + + var _renderMessages = function(divId, messages) { + if (messages && messages.length > 0) { + var errorDiv = document.createElement('div'); + errorDiv.innerHTML = '

Error rendering chart:

' + messages.join('
') + '
'; + document.getElementById(divId).appendChild(errorDiv); + } + }; + + var _validateChartConfig = function(chartConfig, aes, scales, measureStore) { + var hasNoDataMsg = validateResponseHasData(measureStore, false); + if (hasNoDataMsg != null) + return {success: false, messages: [hasNoDataMsg]}; + + var messages = [], firstRecord = measureStore.records()[0], measureNames = Object.keys(chartConfig.measures); + for (var i = 0; i < measureNames.length; i++) { + var measuresArr = ensureMeasuresAsArray(chartConfig.measures[measureNames[i]]); + for (var j = 0; j < measuresArr.length; j++) { + var measure = measuresArr[j]; + if (LABKEY.Utils.isObject(measure)) { + if (measure.name && !LABKEY.Utils.isDefined(firstRecord[measure.name])) { + return {success: false, messages: ['The measure, ' + measure.name + ', is not available. It may have been renamed or removed.']}; + } + + var validation; + if (measureNames[i] === 'y') { + var yAes = {y: getYMeasureAes(measure)}; + validation = validateAxisMeasure(chartConfig.renderType, measure, 'y', yAes, scales, measureStore.records()); + } + else if (measureNames[i] === 'x' || measureNames[i] === 'xSub') { + validation = validateAxisMeasure(chartConfig.renderType, measure, measureNames[i], aes, scales, measureStore.records()); + } + + if (LABKEY.Utils.isObject(validation)) { + if (validation.message != null) + messages.push(validation.message); + if (!validation.success) + return {success: false, messages: messages}; + } + } + } + } + + return {success: true, messages: messages}; + }; + + return { + // NOTE: the @function below is needed or JSDoc will not include the documentation for loadVisDependencies. Don't + // ask me why, I do not know. + /** + * @function + */ + getRenderTypes: getRenderTypes, + getChartType: getChartType, + getSelectedMeasureLabel: getSelectedMeasureLabel, + getTitleFromMeasures: getTitleFromMeasures, + getMeasureType: getMeasureType, + getAllowableTypes: getAllowableTypes, + getQueryColumns : getQueryColumns, + getChartTypeBasedWidth : getChartTypeBasedWidth, + getDistinctYAxisSides : getDistinctYAxisSides, + getYMeasureAes : getYMeasureAes, + getDefaultMeasuresLabel: getDefaultMeasuresLabel, + getStudySubjectInfo: getStudySubjectInfo, + getQueryConfigSortKey: getQueryConfigSortKey, + ensureMeasuresAsArray: ensureMeasuresAsArray, + isNumericType: isNumericType, + isMeasureDimensionMatch: isMeasureDimensionMatch, + generateLabels: generateLabels, + generateScales: generateScales, + generateAes: generateAes, + doValueConversion: doValueConversion, + removeNumericConversionConfig: removeNumericConversionConfig, + generateAggregateData: generateAggregateData, + generatePointHover: generatePointHover, + generateBoxplotHover: generateBoxplotHover, + generateDataForChartType: generateDataForChartType, + generateDiscreteAcc: generateDiscreteAcc, + generateContinuousAcc: generateContinuousAcc, + generateGroupingAcc: generateGroupingAcc, + generatePointClickFn: generatePointClickFn, + generateGeom: generateGeom, + generateBoxplotGeom: generateBoxplotGeom, + generatePointGeom: generatePointGeom, + generatePlotConfigs: generatePlotConfigs, + generatePlotConfig: generatePlotConfig, + validateResponseHasData: validateResponseHasData, + validateAxisMeasure: validateAxisMeasure, + validateXAxis: validateXAxis, + validateYAxis: validateYAxis, + renderChartSVG: renderChartSVG, + queryChartData: queryChartData, + generateChartSVG: generateChartSVG, + getMeasureStoreRecords: getMeasureStoreRecords, + queryTrendlineData: queryTrendlineData, + TRENDLINE_OPTIONS: TRENDLINE_OPTIONS, + /** + * Loads all of the required dependencies for a Generic Chart. + * @param {Function} callback The callback to be executed when all of the visualization dependencies have been loaded. + * @param {Object} scope The scope to be used when executing the callback. + */ + loadVisDependencies: LABKEY.requiresVisualization + }; }; \ No newline at end of file From 1421ca0005212b0483343b21059313032ac6b0ba Mon Sep 17 00:00:00 2001 From: cnathe Date: Tue, 21 Oct 2025 14:52:02 -0500 Subject: [PATCH 37/40] fix for getAggregateData function to account for date vs non-date accessor --- core/webapp/vis/src/utils.js | 4 ++-- .../resources/web/vis/genericChart/genericChartHelper.js | 7 ++++++- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/core/webapp/vis/src/utils.js b/core/webapp/vis/src/utils.js index 6acab02ee28..59e28c87bb0 100644 --- a/core/webapp/vis/src/utils.js +++ b/core/webapp/vis/src/utils.js @@ -225,7 +225,7 @@ LABKEY.vis.groupCountData = function(data, groupAccessor, subgroupAccessor, prop LABKEY.vis.getAggregateData = function(data, dimensionName, subDimensionName, measureName, aggregate, nullDisplayValue, includeTotal, errorBarType, keepNames = false) { var results = [], subgroupAccessor, - groupAccessor = typeof dimensionName === 'function' ? dimensionName : function(row){ return LABKEY.vis.getValue(row[dimensionName], 'value');}, + groupAccessor = typeof dimensionName === 'function' ? dimensionName : function(row){ return LABKEY.vis.getValue(row[dimensionName]);}, hasSubgroup = subDimensionName != undefined && subDimensionName != null, hasMeasure = measureName != undefined && measureName != null, measureAccessor = hasMeasure ? function(row){ return LABKEY.vis.getValue(row[measureName], 'value'); } : null; @@ -234,7 +234,7 @@ LABKEY.vis.getAggregateData = function(data, dimensionName, subDimensionName, me if (typeof subDimensionName === 'function') { subgroupAccessor = subDimensionName; } else { - subgroupAccessor = function (row) { return LABKEY.vis.getValue(row[subDimensionName], 'value'); } + subgroupAccessor = function (row) { return LABKEY.vis.getValue(row[subDimensionName]); } } } diff --git a/visualization/resources/web/vis/genericChart/genericChartHelper.js b/visualization/resources/web/vis/genericChart/genericChartHelper.js index 48c86a53230..d0be4f77338 100644 --- a/visualization/resources/web/vis/genericChart/genericChartHelper.js +++ b/visualization/resources/web/vis/genericChart/genericChartHelper.js @@ -1869,6 +1869,7 @@ LABKEY.vis.GenericChartHelper = new function(){ 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; @@ -1876,6 +1877,7 @@ LABKEY.vis.GenericChartHelper = new function(){ 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; @@ -1900,7 +1902,10 @@ LABKEY.vis.GenericChartHelper = new function(){ } if (aggType) { - data = LABKEY.vis.getAggregateData(data, dimName, subDimName, measureName, aggType, '[Blank]', false, aggErrorType, chartType === 'line_plot'); + // for date measures, we need to use the 'value' of the row object for aggregation + const dimFn = function(row){ return LABKEY.vis.getValue(row[dimName], dimIsDate ? 'value' : undefined);} + + data = LABKEY.vis.getAggregateData(data, dimFn, subDimName, measureName, aggType, '[Blank]', false, aggErrorType, chartType === 'line_plot'); if (aggErrorType) { geom.errorAes = { getValue: d => d.error }; } From 0c966d13ef986aefbbf380c102cb666313cc7a7a Mon Sep 17 00:00:00 2001 From: cnathe Date: Tue, 21 Oct 2025 15:35:03 -0500 Subject: [PATCH 38/40] fix for getAggregateData function to account for date vs non-date accessor --- core/webapp/vis/src/utils.js | 5 +++-- .../resources/web/vis/genericChart/genericChartHelper.js | 5 +---- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/core/webapp/vis/src/utils.js b/core/webapp/vis/src/utils.js index 59e28c87bb0..0d6d0c79021 100644 --- a/core/webapp/vis/src/utils.js +++ b/core/webapp/vis/src/utils.js @@ -220,12 +220,13 @@ LABKEY.vis.groupCountData = function(data, groupAccessor, subgroupAccessor, prop * @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, errorBarType, keepNames = false) +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], 'value'); } : null; diff --git a/visualization/resources/web/vis/genericChart/genericChartHelper.js b/visualization/resources/web/vis/genericChart/genericChartHelper.js index d0be4f77338..c035a6c48d2 100644 --- a/visualization/resources/web/vis/genericChart/genericChartHelper.js +++ b/visualization/resources/web/vis/genericChart/genericChartHelper.js @@ -1902,10 +1902,7 @@ LABKEY.vis.GenericChartHelper = new function(){ } if (aggType) { - // for date measures, we need to use the 'value' of the row object for aggregation - const dimFn = function(row){ return LABKEY.vis.getValue(row[dimName], dimIsDate ? 'value' : undefined);} - - data = LABKEY.vis.getAggregateData(data, dimFn, subDimName, measureName, aggType, '[Blank]', false, aggErrorType, chartType === 'line_plot'); + data = LABKEY.vis.getAggregateData(data, dimName, subDimName, measureName, aggType, '[Blank]', false, aggErrorType, chartType === 'line_plot', dimIsDate ? 'value' : undefined); if (aggErrorType) { geom.errorAes = { getValue: d => d.error }; } From 92d98936590835b408906365b63e18f80e631779 Mon Sep 17 00:00:00 2001 From: cnathe Date: Wed, 22 Oct 2025 10:57:22 -0500 Subject: [PATCH 39/40] back to generateDiscreteAcc() for date fields instead of generateContinuousAcc, but account for value vs formatted value in acc --- core/src/client/vis/utils.test.ts | 17 ++++++++++ core/webapp/vis/src/utils.js | 7 ++-- .../vis/genericChart/genericChartHelper.js | 33 +++++++++++-------- 3 files changed, 41 insertions(+), 16 deletions(-) diff --git a/core/src/client/vis/utils.test.ts b/core/src/client/vis/utils.test.ts index c7adb321b9b..6047d0c645d 100644 --- a/core/src/client/vis/utils.test.ts +++ b/core/src/client/vis/utils.test.ts @@ -260,3 +260,20 @@ describe('LABKEY.vis.formatDate', () => { }); }); }); + +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/src/utils.js b/core/webapp/vis/src/utils.js index 0d6d0c79021..9dfbf0ab2c1 100644 --- a/core/webapp/vis/src/utils.js +++ b/core/webapp/vis/src/utils.js @@ -409,9 +409,12 @@ LABKEY.vis.getValue = function(obj, preferredProp) { return obj; }; +LABKEY.vis.isValidDate = function(date) { + return date instanceof Date && !isNaN(date); +} + LABKEY.vis.formatDate = function(date, format) { - const isValidDate = date instanceof Date && !isNaN(date); - if (!isValidDate) return date; + if (!LABKEY.vis.isValidDate(date)) return date; // Helper function to pad numbers with a leading zero const pad = (num) => num.toString().padStart(2, '0'); diff --git a/visualization/resources/web/vis/genericChart/genericChartHelper.js b/visualization/resources/web/vis/genericChart/genericChartHelper.js index c035a6c48d2..dabc1c03b5b 100644 --- a/visualization/resources/web/vis/genericChart/genericChartHelper.js +++ b/visualization/resources/web/vis/genericChart/genericChartHelper.js @@ -494,8 +494,7 @@ LABKEY.vis.GenericChartHelper = new function(){ const dateFormat = fields[i].format; scales.x.tickFormat = function(v){ const d = new Date(v); - const isValidDate = d instanceof Date && !isNaN(d); - return isValidDate ? LABKEY.vis.formatDate(new Date(v), dateFormat) : v; + return LABKEY.vis.isValidDate(d) ? LABKEY.vis.formatDate(d, dateFormat) : v; }; } } @@ -585,22 +584,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 - if (isDateType(xMeasureType)) { - // Issue 54125: use continuous instead of discrete accessor for date x-axis - aes.x = generateContinuousAcc(xMeasureName); - } else if (chartType === "box_plot") { + // Issue 50074: box plots with numeric x-axis to support null values + var nullValueLabel = isNumericType(xMeasureType) || isDateType(xMeasureType) ? "[Blank]" : undefined; + + 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 @@ -753,13 +753,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; @@ -1121,7 +1122,11 @@ LABKEY.vis.GenericChartHelper = new function(){ } else if (bVal === null) { return -1; } else if (isDate){ - return new Date(aVal) - new Date(bVal); + 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; }, From 679ef87783ecb3b32c227c8d8e8b17a130e364d7 Mon Sep 17 00:00:00 2001 From: cnathe Date: Wed, 22 Oct 2025 15:34:56 -0500 Subject: [PATCH 40/40] handle blank/null values in sorting aggregate data --- .../resources/web/vis/genericChart/genericChartHelper.js | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/visualization/resources/web/vis/genericChart/genericChartHelper.js b/visualization/resources/web/vis/genericChart/genericChartHelper.js index dabc1c03b5b..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() { @@ -589,7 +590,7 @@ LABKEY.vis.GenericChartHelper = new function(){ 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) ? "[Blank]" : undefined; + var nullValueLabel = isNumericType(xMeasureType) || isDateType(xMeasureType) ? NULL_VALUE_LABEL : undefined; if (isBox) { if (!measures.x) { @@ -1117,9 +1118,9 @@ LABKEY.vis.GenericChartHelper = new function(){ const aVal = _getRowValue(a, xName, 'value'); const bVal = _getRowValue(b, xName, 'value'); - if (aVal === null) { + if (aVal === null || aVal === NULL_VALUE_LABEL) { return 1; - } else if (bVal === null) { + } else if (bVal === null || bVal === NULL_VALUE_LABEL) { return -1; } else if (isDate){ const aDate = new Date(aVal); @@ -1907,7 +1908,7 @@ LABKEY.vis.GenericChartHelper = new function(){ } if (aggType) { - data = LABKEY.vis.getAggregateData(data, dimName, subDimName, measureName, aggType, '[Blank]', false, aggErrorType, chartType === 'line_plot', dimIsDate ? 'value' : undefined); + 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 }; }