1 /*
  2  * Copyright (c) 2013-2018 LabKey Corporation
  3  *
  4  * Licensed under the Apache License, Version 2.0: http://www.apache.org/licenses/LICENSE-2.0
  5  */
  6 if(!LABKEY.vis) {
  7     LABKEY.vis = {};
  8 }
  9 
 10 /**
 11  * @namespace Namespace used to encapsulate functions related to creating study Time Charts.
 12  * Used in the Chart Wizard and when exporting Time Charts as scripts.
 13  */
 14 LABKEY.vis.TimeChartHelper = new function() {
 15 
 16     var $ = jQuery;
 17 
 18     /**
 19      * Generate the main title and axis labels for the chart based on the specified x-axis and y-axis (left and right) labels.
 20      * @param {String} mainTitle The label to be used as the main chart title.
 21      * @param {String} subtitle The label to be used as the chart subtitle.
 22      * @param {Array} axisArr An array of axis information including the x-axis and y-axis (left and right) labels.
 23      * @returns {Object}
 24      */
 25     var generateLabels = function(mainTitle, axisArr, subtitle) {
 26         var xTitle = '', yLeftTitle = '', yRightTitle = '';
 27         for (var i = 0; i < axisArr.length; i++)
 28         {
 29             var axis = axisArr[i];
 30             if (axis.name == "y-axis")
 31             {
 32                 if (axis.side == "left")
 33                     yLeftTitle = axis.label;
 34                 else
 35                     yRightTitle = axis.label;
 36             }
 37             else
 38             {
 39                 xTitle = axis.label;
 40             }
 41         }
 42 
 43         return {
 44             main : { value : mainTitle },
 45             subtitle : { value : subtitle, color: '#404040' },
 46             x : { value : xTitle },
 47             yLeft : { value : yLeftTitle },
 48             yRight : { value : yRightTitle }
 49         };
 50     };
 51 
 52     /**
 53      * Generates an object containing {@link LABKEY.vis.Scale} objects used for the chart.
 54      * @param {Object} config The chart configuration object that defines the selected measures, axis info, subjects/groups, etc.
 55      * @param {Object} tickMap For visit based charts, the x-axis tick mark mapping, from generateTickMap.
 56      * @param {Object} numberFormats The number format functions to use for the x-axis and y-axis (left and right) tick marks.
 57      * @returns {Object}
 58      */
 59     var generateScales = function(config, tickMap, numberFormats) {
 60         if (config.measures.length == 0)
 61             throw "There must be at least one specified measure in the chartInfo config!";
 62 
 63         var xMin = null, xMax = null, xTrans = null, xTickFormat, xTickHoverText,
 64             yLeftMin = null, yLeftMax = null, yLeftTrans = null, yLeftTickFormat,
 65             yRightMin = null, yRightMax = null, yRightTrans = null, yRightTickFormat,
 66             valExponentialDigits = 6;
 67 
 68         for (var i = 0; i < config.axis.length; i++)
 69         {
 70             var axis = config.axis[i];
 71             if (axis.name == "y-axis")
 72             {
 73                 if (axis.side == "left")
 74                 {
 75                     yLeftMin = typeof axis.range.min == "number" ? axis.range.min : (config.hasNoData ? 0 : null);
 76                     yLeftMax = typeof axis.range.max == "number" ? axis.range.max : (config.hasNoData ? 10 : null);
 77                     yLeftTrans = axis.scale ? axis.scale : "linear";
 78                     yLeftTickFormat = function(value) {
 79                         if (LABKEY.Utils.isNumber(value) && Math.abs(Math.round(value)).toString().length >= valExponentialDigits) {
 80                             return value.toExponential();
 81                         }
 82                         else if (LABKEY.Utils.isFunction(numberFormats.left)) {
 83                             return numberFormats.left(value);
 84                         }
 85                         return value;
 86                     }
 87                 }
 88                 else
 89                 {
 90                     yRightMin = typeof axis.range.min == "number" ? axis.range.min : (config.hasNoData ? 0 : null);
 91                     yRightMax = typeof axis.range.max == "number" ? axis.range.max : (config.hasNoData ? 10 : null);
 92                     yRightTrans = axis.scale ? axis.scale : "linear";
 93                     yRightTickFormat = function(value) {
 94                         if (LABKEY.Utils.isNumber(value) && Math.abs(Math.round(value)).toString().length >= valExponentialDigits) {
 95                             return value.toExponential();
 96                         }
 97                         else if (LABKEY.Utils.isFunction(numberFormats.right)) {
 98                             return numberFormats.right(value);
 99                         }
100                         return value;
101                     }
102                 }
103             }
104             else
105             {
106                 xMin = typeof axis.range.min == "number" ? axis.range.min : null;
107                 xMax = typeof axis.range.max == "number" ? axis.range.max : null;
108                 xTrans = axis.scale ? axis.scale : "linear";
109             }
110         }
111 
112         if (config.measures[0].time != "date")
113         {
114             xTickFormat = function(value) {
115                 return tickMap[value] ? tickMap[value].label : "";
116             };
117 
118             xTickHoverText = function(value) {
119                 return tickMap[value] ? tickMap[value].description : "";
120             };
121         }
122         // Issue 27309: Don't show decimal values on x-axis for date-based time charts with interval = "Days"
123         else if (config.measures[0].time == 'date' && config.measures[0].dateOptions.interval == 'Days')
124         {
125             xTickFormat = function(value) {
126                 return LABKEY.Utils.isNumber(value) && value % 1 != 0 ? null : value;
127             };
128         }
129 
130         return {
131             x: {
132                 scaleType : 'continuous',
133                 trans : xTrans,
134                 domain : [xMin, xMax],
135                 tickFormat : xTickFormat ? xTickFormat : null,
136                 tickHoverText : xTickHoverText ? xTickHoverText : null
137             },
138             yLeft: {
139                 scaleType : 'continuous',
140                 trans : yLeftTrans,
141                 domain : [yLeftMin, yLeftMax],
142                 tickFormat : yLeftTickFormat ? yLeftTickFormat : null
143             },
144             yRight: {
145                 scaleType : 'continuous',
146                 trans : yRightTrans,
147                 domain : [yRightMin, yRightMax],
148                 tickFormat : yRightTickFormat ? yRightTickFormat : null
149             },
150             shape: {
151                 scaleType : 'discrete'
152             }
153         };
154     };
155 
156     /**
157      * Generate the x-axis interval column alias key. For date based charts, this will be a time interval (i.e. Days, Weeks, etc.)
158      * and for visit based charts, this will be the column alias for the visit field.
159      * @param {Object} config The chart configuration object that defines the selected measures, axis info, subjects/groups, etc.
160      * @param {Array} individualColumnAliases The array of column aliases for the individual subject data.
161      * @param {Array} aggregateColumnAliases The array of column aliases for the group/cohort aggregate data.
162      * @param {String} nounSingular The singular name of the study subject noun (i.e. Participant).
163      * @returns {String}
164      */
165     var generateIntervalKey = function(config, individualColumnAliases, aggregateColumnAliases, nounSingular)
166     {
167         nounSingular = nounSingular || getStudySubjectInfo().nounSingular;
168 
169         if (config.measures.length == 0)
170             throw "There must be at least one specified measure in the chartInfo config!";
171         if (!individualColumnAliases && !aggregateColumnAliases)
172             throw "We expect to either be displaying individual series lines or aggregate data!";
173 
174         if (config.measures[0].time == "date")
175         {
176             return config.measures[0].dateOptions.interval;
177         }
178         else
179         {
180             return individualColumnAliases ?
181                 LABKEY.vis.getColumnAlias(individualColumnAliases, nounSingular + "Visit/Visit") :
182                 LABKEY.vis.getColumnAlias(aggregateColumnAliases, nounSingular + "Visit/Visit");
183         }
184     };
185 
186     /**
187      * Generate that x-axis tick mark mapping for a visit based chart.
188      * @param {Object} visitMap For visit based charts, the study visit information map.
189      * @returns {Object}
190      */
191     var generateTickMap = function(visitMap) {
192         var tickMap = {};
193         for (var rowId in visitMap)
194         {
195             if (visitMap.hasOwnProperty(rowId))
196             {
197                 tickMap[visitMap[rowId].displayOrder] = {
198                     label: visitMap[rowId].displayName,
199                     description: visitMap[rowId].description || visitMap[rowId].displayName
200                 };
201             }
202         }
203 
204         return tickMap;
205     };
206 
207     /**
208      * Generates the aesthetic map object needed by the visualization API to render the chart. See {@link LABKEY.vis.Plot}
209      * and {@link LABKEY.vis.Layer}.
210      * @param {Object} config The chart configuration object that defines the selected measures, axis info, subjects/groups, etc.
211      * @param {Object} visitMap For visit based charts, the study visit information map.
212      * @param {Array} individualColumnAliases The array of column aliases for the individual subject data.
213      * @param {String} intervalKey The x-axis interval column alias key (i.e. Days, Weeks, etc.), from generateIntervalKey.
214      * @param {String} nounColumnName The name of the study subject noun column (i.e. ParticipantId).
215      * @returns {Object}
216      */
217     var generateAes = function(config, visitMap, individualColumnAliases, intervalKey, nounColumnName)
218     {
219         nounColumnName = nounColumnName || getStudySubjectInfo().columnName;
220 
221         if (config.measures.length == 0)
222             throw "There must be at least one specified measure in the chartInfo config!";
223 
224         var xAes;
225         if (config.measures[0].time == "date") {
226             xAes = function(row) {
227                 return _getRowValue(row, intervalKey);
228             };
229         }
230         else {
231             xAes = function(row) {
232                 return visitMap[_getRowValue(row, intervalKey, 'value')].displayOrder;
233             };
234         }
235 
236         var individualSubjectColumn = individualColumnAliases ? LABKEY.vis.getColumnAlias(individualColumnAliases, nounColumnName) : null;
237 
238         return {
239             x: xAes,
240             color: function(row) {
241                 return _getRowValue(row, individualSubjectColumn);
242             },
243             group: function(row) {
244                 return _getRowValue(row, individualSubjectColumn);
245             },
246             shape: function(row) {
247                 return _getRowValue(row, individualSubjectColumn);
248             },
249             pathColor: function(rows) {
250                 return LABKEY.Utils.isArray(rows) && rows.length > 0 ? _getRowValue(rows[0], individualSubjectColumn) : null;
251             }
252         };
253     };
254 
255     /**
256      * Generate an array of {@link LABKEY.vis.Layer} objects based on the selected chart series list.
257      * @param {Object} config The chart configuration object that defines the selected measures, axis info, subjects/groups, etc.
258      * @param {Object} visitMap For visit based charts, the study visit information map.
259      * @param {Array} individualColumnAliases The array of column aliases for the individual subject data.
260      * @param {Array} aggregateColumnAliases The array of column aliases for the group/cohort aggregate data.
261      * @param {Array} aggregateData The array of group/cohort aggregate data, from getChartData.
262      * @param {Array} seriesList The list of series that will be plotted for a given chart, from generateSeriesList.
263      * @param {String} intervalKey The x-axis interval column alias key (i.e. Days, Weeks, etc.), from generateIntervalKey.
264      * @param {String} nounColumnName The name of the study subject noun column (i.e. ParticipantId).
265      * @returns {Array}
266      */
267     var generateLayers = function(config, visitMap, individualColumnAliases, aggregateColumnAliases, aggregateData, seriesList, intervalKey, nounColumnName)
268     {
269         nounColumnName = nounColumnName || getStudySubjectInfo().columnName;
270 
271         if (config.measures.length == 0)
272             throw "There must be at least one specified measure in the chartInfo config!";
273         if (!individualColumnAliases && !aggregateColumnAliases)
274             throw "We expect to either be displaying individual series lines or aggregate data!";
275 
276         var layers = [];
277         var isDateBased = config.measures[0].time == "date";
278         var individualSubjectColumn = individualColumnAliases ? LABKEY.vis.getColumnAlias(individualColumnAliases, nounColumnName) : null;
279         var aggregateSubjectColumn = "UniqueId";
280 
281         var generateLayerAes = function(name, yAxisSide, columnName){
282             var yName = yAxisSide == "left" ? "yLeft" : "yRight";
283             var aes = {};
284             aes[yName] = function(row) {
285                 // Have to parseFloat because for some reason ObsCon from Luminex was returning strings not floats/ints.
286                 return row[columnName] ? parseFloat(_getRowValue(row, columnName)) : null;
287             };
288             return aes;
289         };
290 
291         var generateAggregateLayerAes = function(name, yAxisSide, columnName, intervalKey, subjectColumn, errorColumn){
292             var yName = yAxisSide == "left" ? "yLeft" : "yRight";
293             var aes = {};
294             aes[yName] = function(row) {
295                 // Have to parseFloat because for some reason ObsCon from Luminex was returning strings not floats/ints.
296                 return row[columnName] ? parseFloat(_getRowValue(row, columnName)) : null;
297             };
298             aes.group = aes.color = aes.shape = function(row) {
299                 return _getRowValue(row, subjectColumn);
300             };
301             aes.pathColor = function(rows) {
302                 return LABKEY.Utils.isArray(rows) && rows.length > 0 ? _getRowValue(rows[0], subjectColumn) : null;
303             };
304             aes.error = function(row) {
305                 return row[errorColumn] ? _getRowValue(row, errorColumn) : null;
306             };
307             return aes;
308         };
309 
310         var hoverTextFn = function(subjectColumn, intervalKey, name, columnName, visitMap, errorColumn, errorType){
311             if (visitMap)
312             {
313                 if (errorColumn)
314                 {
315                     return function(row){
316                         var subject = _getRowValue(row, subjectColumn);
317                         var errorVal = _getRowValue(row, errorColumn) || 'n/a';
318                         return ' ' + subject + ',\n '+ visitMap[_getRowValue(row, intervalKey, 'value')].displayName +
319                                 ',\n ' + name + ': ' + _getRowValue(row, columnName) +
320                                 ',\n ' + errorType + ': ' + errorVal;
321                     }
322                 }
323                 else
324                 {
325                     return function(row){
326                         var subject = _getRowValue(row, subjectColumn);
327                         return ' ' + subject + ',\n '+ visitMap[_getRowValue(row, intervalKey, 'value')].displayName +
328                                 ',\n ' + name + ': ' + _getRowValue(row, columnName);
329                     };
330                 }
331             }
332             else
333             {
334                 if (errorColumn)
335                 {
336                     return function(row){
337                         var subject = _getRowValue(row, subjectColumn);
338                         var errorVal = _getRowValue(row, errorColumn) || 'n/a';
339                         return ' ' + subject + ',\n ' + intervalKey + ': ' + _getRowValue(row, intervalKey) +
340                                 ',\n ' + name + ': ' + _getRowValue(row, columnName) +
341                                 ',\n ' + errorType + ': ' + errorVal;
342                     };
343                 }
344                 else
345                 {
346                     return function(row){
347                         var subject = _getRowValue(row, subjectColumn);
348                         return ' ' + subject + ',\n ' + intervalKey + ': ' + _getRowValue(row, intervalKey) +
349                                 ',\n ' + name + ': ' + _getRowValue(row, columnName);
350                     };
351                 }
352             }
353         };
354 
355         // Issue 15369: if two measures have the same name, use the alias for the subsequent series names (which will be unique)
356         // Issue 12369: if rendering two measures of the same pivoted value, use measure and pivot name for series names (which will be unique)
357         var useUniqueSeriesNames = false;
358         var uniqueChartSeriesNames = [];
359         for (var i = 0; i < seriesList.length; i++)
360         {
361             if (uniqueChartSeriesNames.indexOf(seriesList[i].name) > -1)
362             {
363                 useUniqueSeriesNames = true;
364                 break;
365             }
366             uniqueChartSeriesNames.push(seriesList[i].name);
367         }
368 
369         for (var i = seriesList.length -1; i >= 0; i--)
370         {
371             var chartSeries = seriesList[i];
372 
373             var chartSeriesName = chartSeries.label;
374             if (useUniqueSeriesNames)
375             {
376                 if (chartSeries.aliasLookupInfo.pivotValue)
377                     chartSeriesName = chartSeries.aliasLookupInfo.measureName + " " + chartSeries.aliasLookupInfo.pivotValue;
378                 else
379                     chartSeriesName = chartSeries.aliasLookupInfo.alias;
380             }
381 
382             var columnName = individualColumnAliases ? LABKEY.vis.getColumnAlias(individualColumnAliases, chartSeries.aliasLookupInfo) : LABKEY.vis.getColumnAlias(aggregateColumnAliases, chartSeries.aliasLookupInfo);
383             if (individualColumnAliases)
384             {
385                 if (!config.hideTrendLine) {
386                     var pathLayerConfig = {
387                         geom: new LABKEY.vis.Geom.Path({size: config.lineWidth}),
388                         aes: generateLayerAes(chartSeriesName, chartSeries.yAxisSide, columnName)
389                     };
390 
391                     if (seriesList.length > 1)
392                         pathLayerConfig.name = chartSeriesName;
393 
394                     layers.push(new LABKEY.vis.Layer(pathLayerConfig));
395                 }
396 
397                 if (!config.hideDataPoints)
398                 {
399                     var pointLayerConfig = {
400                         geom: new LABKEY.vis.Geom.Point(),
401                         aes: generateLayerAes(chartSeriesName, chartSeries.yAxisSide, columnName)
402                     };
403 
404                     if (seriesList.length > 1)
405                         pointLayerConfig.name = chartSeriesName;
406 
407                     if (isDateBased)
408                         pointLayerConfig.aes.hoverText = hoverTextFn(individualSubjectColumn, intervalKey, chartSeriesName, columnName, null, null, null);
409                     else
410                         pointLayerConfig.aes.hoverText = hoverTextFn(individualSubjectColumn, intervalKey, chartSeriesName, columnName, visitMap, null, null);
411 
412                     if (config.pointClickFn)
413                     {
414                         pointLayerConfig.aes.pointClickFn = generatePointClickFn(
415                                 config.pointClickFn,
416                                 {participant: individualSubjectColumn, interval: intervalKey, measure: columnName},
417                                 {schemaName: chartSeries.schemaName, queryName: chartSeries.queryName, name: chartSeriesName}
418                         );
419                     }
420 
421                     layers.push(new LABKEY.vis.Layer(pointLayerConfig));
422                 }
423             }
424 
425             if (aggregateData && aggregateColumnAliases)
426             {
427                 var errorBarType = null;
428                 if (config.errorBars == 'SD')
429                     errorBarType = '_STDDEV';
430                 else if (config.errorBars == 'SEM')
431                     errorBarType = '_STDERR';
432 
433                 var errorColumnName = errorBarType ? LABKEY.vis.getColumnAlias(aggregateColumnAliases, chartSeries.aliasLookupInfo) + errorBarType : null;
434 
435                 if (!config.hideTrendLine) {
436                     var aggregatePathLayerConfig = {
437                         data: aggregateData,
438                         geom: new LABKEY.vis.Geom.Path({size: config.lineWidth}),
439                         aes: generateAggregateLayerAes(chartSeriesName, chartSeries.yAxisSide, columnName, intervalKey, aggregateSubjectColumn, errorColumnName)
440                     };
441 
442                     if (seriesList.length > 1)
443                         aggregatePathLayerConfig.name = chartSeriesName;
444 
445                     layers.push(new LABKEY.vis.Layer(aggregatePathLayerConfig));
446                 }
447 
448                 if (errorColumnName)
449                 {
450                     var aggregateErrorLayerConfig = {
451                         data: aggregateData,
452                         geom: new LABKEY.vis.Geom.ErrorBar(),
453                         aes: generateAggregateLayerAes(chartSeriesName, chartSeries.yAxisSide, columnName, intervalKey, aggregateSubjectColumn, errorColumnName)
454                     };
455 
456                     if (seriesList.length > 1)
457                         aggregateErrorLayerConfig.name = chartSeriesName;
458 
459                     layers.push(new LABKEY.vis.Layer(aggregateErrorLayerConfig));
460                 }
461 
462                 if (!config.hideDataPoints)
463                 {
464                     var aggregatePointLayerConfig = {
465                         data: aggregateData,
466                         geom: new LABKEY.vis.Geom.Point(),
467                         aes: generateAggregateLayerAes(chartSeriesName, chartSeries.yAxisSide, columnName, intervalKey, aggregateSubjectColumn, errorColumnName)
468                     };
469 
470                     if (seriesList.length > 1)
471                         aggregatePointLayerConfig.name = chartSeriesName;
472 
473                     if (isDateBased)
474                         aggregatePointLayerConfig.aes.hoverText = hoverTextFn(aggregateSubjectColumn, intervalKey, chartSeriesName, columnName, null, errorColumnName, config.errorBars)
475                     else
476                         aggregatePointLayerConfig.aes.hoverText = hoverTextFn(aggregateSubjectColumn, intervalKey, chartSeriesName, columnName, visitMap, errorColumnName, config.errorBars);
477 
478                     if (config.pointClickFn)
479                     {
480                         aggregatePointLayerConfig.aes.pointClickFn = generatePointClickFn(
481                                 config.pointClickFn,
482                                 {group: aggregateSubjectColumn, interval: intervalKey, measure: columnName},
483                                 {schemaName: chartSeries.schemaName, queryName: chartSeries.queryName, name: chartSeriesName}
484                         );
485                     }
486 
487                     layers.push(new LABKEY.vis.Layer(aggregatePointLayerConfig));
488                 }
489             }
490         }
491 
492         return layers;
493     };
494 
495     // private function
496     var generatePointClickFn = function(fnString, columnMap, measureInfo){
497         // the developer is expected to return a function, so we encapalate it within the anonymous function
498         // (note: the function should have already be validated in a try/catch when applied via the developerOptionsPanel)
499 
500         // using new Function is quicker than eval(), even in IE.
501         var pointClickFn = new Function('return ' + fnString)();
502         return function(clickEvent, data) {
503             pointClickFn(data, columnMap, measureInfo, clickEvent);
504         };
505     };
506 
507     /**
508      * Generate the list of series to be plotted in a given Time Chart. A series will be created for each measure and
509      * dimension that is selected in the chart.
510      * @param {Array} measures The array of selected measures from the chart config.
511      * @returns {Array}
512      */
513     var generateSeriesList = function(measures) {
514         var arr = [];
515         for (var i = 0; i < measures.length; i++)
516         {
517             var md = measures[i];
518 
519             if (md.dimension && md.dimension.values)
520             {
521                 Ext4.each(md.dimension.values, function(val) {
522                     arr.push({
523                         schemaName: md.dimension.schemaName,
524                         queryName: md.dimension.queryName,
525                         name: val,
526                         label: val,
527                         measureIndex: i,
528                         yAxisSide: md.measure.yAxis,
529                         aliasLookupInfo: {measureName: md.measure.name, pivotValue: val}
530                     });
531                 });
532             }
533             else
534             {
535                 arr.push({
536                     schemaName: md.measure.schemaName,
537                     queryName: md.measure.queryName,
538                     name: md.measure.name,
539                     label: md.measure.label,
540                     measureIndex: i,
541                     yAxisSide: md.measure.yAxis,
542                     aliasLookupInfo: md.measure.alias ? {alias: md.measure.alias} : {measureName: md.measure.name}
543                 });
544             }
545         }
546         return arr;
547     };
548 
549     // private function
550     var generateDataSortArray = function(subject, firstMeasure, isDateBased, nounSingular)
551     {
552         nounSingular = nounSingular || getStudySubjectInfo().nounSingular;
553         var hasDateCol = firstMeasure.dateOptions && firstMeasure.dateOptions.dateCol;
554 
555         return [
556             subject,
557             {
558                 schemaName : hasDateCol ? firstMeasure.dateOptions.dateCol.schemaName : firstMeasure.measure.schemaName,
559                 queryName : hasDateCol ? firstMeasure.dateOptions.dateCol.queryName : firstMeasure.measure.queryName,
560                 name : isDateBased && hasDateCol ? firstMeasure.dateOptions.dateCol.name : getSubjectVisitColName(nounSingular, 'DisplayOrder')
561             },
562             {
563                 schemaName : hasDateCol ? firstMeasure.dateOptions.dateCol.schemaName : firstMeasure.measure.schemaName,
564                 queryName : hasDateCol ? firstMeasure.dateOptions.dateCol.queryName : firstMeasure.measure.queryName,
565                 name : (isDateBased ? nounSingular + "Visit/Visit" : getSubjectVisitColName(nounSingular, 'SequenceNumMin'))
566             }
567         ];
568     };
569 
570     var getSubjectVisitColName = function(nounSingular, suffix)
571     {
572         var nounSingular = nounSingular || getStudySubjectInfo().nounSingular;
573         return nounSingular + 'Visit/Visit/' + suffix;
574     };
575 
576     /**
577      * Determine whether or not the chart needs to clip the plotted lines and points based on manually set axis ranges.
578      * @param {Object} config The chart configuration object that defines the selected measures, axis info, subjects/groups, etc.
579      * @returns {boolean}
580      */
581     var generateApplyClipRect = function(config) {
582         var xAxisIndex = getAxisIndex(config.axis, "x-axis");
583         var leftAxisIndex = getAxisIndex(config.axis, "y-axis", "left");
584         var rightAxisIndex = getAxisIndex(config.axis, "y-axis", "right");
585 
586         return (
587             xAxisIndex > -1 && (config.axis[xAxisIndex].range.min != null || config.axis[xAxisIndex].range.max != null) ||
588             leftAxisIndex > -1 && (config.axis[leftAxisIndex].range.min != null || config.axis[leftAxisIndex].range.max != null) ||
589             rightAxisIndex > -1 && (config.axis[rightAxisIndex].range.min != null || config.axis[rightAxisIndex].range.max != null)
590         );
591     };
592 
593     /**
594      * Generates axis range min and max values based on the full Time Chart data. This will be used when plotting multiple
595      * charts that are set to use the same axis ranges across all charts in the report.
596      * @param {Object} config The chart configuration object that defines the selected measures, axis info, subjects/groups, etc.
597      * @param {Object} data The data object, from getChartData.
598      * @param {Array} seriesList The list of series that will be plotted for a given chart, from generateSeriesList.
599      * @param {String} nounSingular The singular name of the study subject noun (i.e. Participant).
600      */
601     var generateAcrossChartAxisRanges = function(config, data, seriesList, nounSingular)
602     {
603         nounSingular = nounSingular || getStudySubjectInfo().nounSingular;
604 
605         if (config.measures.length == 0)
606             throw "There must be at least one specified measure in the chartInfo config!";
607         if (!data.individual && !data.aggregate)
608             throw "We expect to either be displaying individual series lines or aggregate data!";
609 
610         var rows = [];
611         if (LABKEY.Utils.isDefined(data.individual)) {
612             rows = data.individual.measureStore.records();
613         }
614         else if (LABKEY.Utils.isDefined(data.aggregate)) {
615             rows = data.aggregate.measureStore.records();
616         }
617 
618         config.hasNoData = rows.length == 0;
619 
620         // In multi-chart case, we need to pre-compute the default axis ranges so that all charts share them
621         // (if 'automatic across charts' is selected for the given axis)
622         if (config.chartLayout != "single")
623         {
624             var leftMeasures = [],
625                 rightMeasures = [],
626                 xName, xFunc,
627                 min, max, tempMin, tempMax, errorBarType,
628                 leftAccessor, leftAccessorMax, leftAccessorMin, rightAccessorMax, rightAccessorMin, rightAccessor,
629                 columnAliases = data.individual ? data.individual.columnAliases : (data.aggregate ? data.aggregate.columnAliases : null),
630                 isDateBased = config.measures[0].time == "date",
631                 xAxisIndex = getAxisIndex(config.axis, "x-axis"),
632                 leftAxisIndex = getAxisIndex(config.axis, "y-axis", "left"),
633                 rightAxisIndex = getAxisIndex(config.axis, "y-axis", "right");
634 
635             for (var i = 0; i < seriesList.length; i++)
636             {
637                 var columnName = LABKEY.vis.getColumnAlias(columnAliases, seriesList[i].aliasLookupInfo);
638                 if (seriesList[i].yAxisSide == "left")
639                     leftMeasures.push(columnName);
640                 else if (seriesList[i].yAxisSide == "right")
641                     rightMeasures.push(columnName);
642             }
643 
644             if (isDateBased)
645             {
646                 xName = config.measures[0].dateOptions.interval;
647                 xFunc = function(row){
648                     return _getRowValue(row, xName);
649                 };
650             }
651             else
652             {
653                 var visitMap = data.individual ? data.individual.visitMap : data.aggregate.visitMap;
654                 xName = LABKEY.vis.getColumnAlias(columnAliases, nounSingular + "Visit/Visit");
655                 xFunc = function(row){
656                     return visitMap[_getRowValue(row, xName, 'value')].displayOrder;
657                 };
658             }
659 
660             if (config.axis[xAxisIndex].range.type != 'automatic_per_chart')
661             {
662                 if (config.axis[xAxisIndex].range.min == null)
663                     config.axis[xAxisIndex].range.min = d3.min(rows, xFunc);
664 
665                 if (config.axis[xAxisIndex].range.max == null)
666                     config.axis[xAxisIndex].range.max = d3.max(rows, xFunc);
667             }
668 
669             if (config.errorBars !== 'None')
670                 errorBarType = config.errorBars == 'SD' ? '_STDDEV' : '_STDERR';
671 
672             if (leftAxisIndex > -1)
673             {
674                 // If we have a left axis then we need to find the min/max
675                 min = null; max = null; tempMin = null; tempMax = null;
676                 leftAccessor = function(row) {
677                     return _getRowValue(row, leftMeasures[i]);
678                 };
679 
680                 if (errorBarType)
681                 {
682                     // If we have error bars we need to calculate min/max with the error values in mind.
683                     leftAccessorMin = function(row) {
684                         if (row.hasOwnProperty(leftMeasures[i] + errorBarType))
685                         {
686                             var error = _getRowValue(row, leftMeasures[i] + errorBarType);
687                             return _getRowValue(row, leftMeasures[i]) - error;
688                         }
689                         else
690                             return null;
691                     };
692 
693                     leftAccessorMax = function(row) {
694                         if (row.hasOwnProperty(leftMeasures[i] + errorBarType))
695                         {
696                             var error = _getRowValue(row, leftMeasures[i] + errorBarType);
697                             return _getRowValue(row, leftMeasures[i]) + error;
698                         }
699                         else
700                             return null;
701                     };
702                 }
703 
704                 if (config.axis[leftAxisIndex].range.type != 'automatic_per_chart')
705                 {
706                     if (config.axis[leftAxisIndex].range.min == null)
707                     {
708                         for (var i = 0; i < leftMeasures.length; i++)
709                         {
710                             tempMin = d3.min(rows, leftAccessorMin ? leftAccessorMin : leftAccessor);
711                             min = min == null ? tempMin : tempMin < min ? tempMin : min;
712                         }
713                         config.axis[leftAxisIndex].range.min = min;
714                     }
715 
716                     if (config.axis[leftAxisIndex].range.max == null)
717                     {
718                         for (var i = 0; i < leftMeasures.length; i++)
719                         {
720                             tempMax = d3.max(rows, leftAccessorMax ? leftAccessorMax : leftAccessor);
721                             max = max == null ? tempMax : tempMax > max ? tempMax : max;
722                         }
723                         config.axis[leftAxisIndex].range.max = max;
724                     }
725                 }
726             }
727 
728             if (rightAxisIndex > -1)
729             {
730                 // If we have a right axis then we need to find the min/max
731                 min = null; max = null; tempMin = null; tempMax = null;
732                 rightAccessor = function(row){
733                     return _getRowValue(row, rightMeasures[i]);
734                 };
735 
736                 if (errorBarType)
737                 {
738                     rightAccessorMin = function(row) {
739                         if (row.hasOwnProperty(rightMeasures[i] + errorBarType))
740                         {
741                             var error = _getRowValue(row, rightMeasures[i] + errorBarType);
742                             return _getRowValue(row, rightMeasures[i]) - error;
743                         }
744                         else
745                             return null;
746                     };
747 
748                     rightAccessorMax = function(row) {
749                         if (row.hasOwnProperty(rightMeasures[i] + errorBarType))
750                         {
751                             var error = _getRowValue(row, rightMeasures[i] + errorBarType);
752                             return _getRowValue(row, rightMeasures[i]) + error;
753                         }
754                         else
755                             return null;
756                     };
757                 }
758 
759                 if (config.axis[rightAxisIndex].range.type != 'automatic_per_chart')
760                 {
761                     if (config.axis[rightAxisIndex].range.min == null)
762                     {
763                         for (var i = 0; i < rightMeasures.length; i++)
764                         {
765                             tempMin = d3.min(rows, rightAccessorMin ? rightAccessorMin : rightAccessor);
766                             min = min == null ? tempMin : tempMin < min ? tempMin : min;
767                         }
768                         config.axis[rightAxisIndex].range.min = min;
769                     }
770 
771                     if (config.axis[rightAxisIndex].range.max == null)
772                     {
773                         for (var i = 0; i < rightMeasures.length; i++)
774                         {
775                             tempMax = d3.max(rows, rightAccessorMax ? rightAccessorMax : rightAccessor);
776                             max = max == null ? tempMax : tempMax > max ? tempMax : max;
777                         }
778                         config.axis[rightAxisIndex].range.max = max;
779                     }
780                 }
781             }
782         }
783     };
784 
785     /**
786      * Generates plot configs to be passed to the {@link LABKEY.vis.Plot} function for each chart in the report.
787      * @param {Object} config The chart configuration object that defines the selected measures, axis info, subjects/groups, etc.
788      * @param {Object} data The data object, from getChartData.
789      * @param {Array} seriesList The list of series that will be plotted for a given chart, from generateSeriesList.
790      * @param {boolean} applyClipRect A boolean indicating whether or not to clip the plotted data region, from generateApplyClipRect.
791      * @param {int} maxCharts The maximum number of charts to display in one report.
792      * @param {String} nounColumnName The name of the study subject noun column (i.e. ParticipantId).
793      * @returns {Array}
794      */
795     var generatePlotConfigs = function(config, data, seriesList, applyClipRect, maxCharts, nounColumnName) {
796         var plotConfigInfoArr = [],
797             subjectColumnName = null;
798 
799         nounColumnName = nounColumnName || getStudySubjectInfo().columnName;
800         if (data.individual)
801             subjectColumnName = LABKEY.vis.getColumnAlias(data.individual.columnAliases, nounColumnName);
802 
803         var generateGroupSeries = function(rows, groups, subjectColumn) {
804             // subjectColumn is the aliasColumnName looked up from the getData response columnAliases array
805             // groups is config.subject.groups
806             var dataByGroup = {};
807 
808             for (var i = 0; i < rows.length; i++)
809             {
810                 var rowSubject = _getRowValue(rows[i], subjectColumn);
811                 for (var j = 0; j < groups.length; j++)
812                 {
813                     if (groups[j].participantIds.indexOf(rowSubject) > -1)
814                     {
815                         if (!dataByGroup[groups[j].label])
816                             dataByGroup[groups[j].label] = [];
817 
818                         dataByGroup[groups[j].label].push(rows[i]);
819                     }
820                 }
821             }
822 
823             return dataByGroup;
824         };
825 
826         // four options: all series on one chart, one chart per subject, one chart per group, or one chart per measure/dimension
827         if (config.chartLayout == "per_subject")
828         {
829             var groupAccessor = function(row) {
830                 return _getRowValue(row, subjectColumnName);
831             };
832 
833             var dataPerParticipant = getDataWithSeriesCheck(data.individual.measureStore.records(), groupAccessor, seriesList, data.individual.columnAliases);
834             for (var participant in dataPerParticipant)
835             {
836                 if (dataPerParticipant.hasOwnProperty(participant))
837                 {
838                     // skip the group if there is no data for it
839                     if (!dataPerParticipant[participant].hasSeriesData)
840                         continue;
841 
842                     plotConfigInfoArr.push({
843                         title: config.title ? config.title : participant,
844                         subtitle: config.title ? participant : undefined,
845                         series: seriesList,
846                         individualData: dataPerParticipant[participant].data,
847                         applyClipRect: applyClipRect
848                     });
849 
850                     if (plotConfigInfoArr.length >= maxCharts)
851                         break;
852                 }
853             }
854         }
855         else if (config.chartLayout == "per_group")
856         {
857             var groupedIndividualData = null, groupedAggregateData = null;
858 
859             //Display individual lines
860             if (data.individual) {
861                 groupedIndividualData = generateGroupSeries(data.individual.measureStore.records(), config.subject.groups, subjectColumnName);
862             }
863 
864             // Display aggregate lines
865             if (data.aggregate) {
866                 var groupAccessor = function(row) {
867                     return _getRowValue(row, 'UniqueId');
868                 };
869 
870                 groupedAggregateData = getDataWithSeriesCheck(data.aggregate.measureStore.records(), groupAccessor, seriesList, data.aggregate.columnAliases);
871             }
872 
873             for (var i = 0; i < (config.subject.groups.length > maxCharts ? maxCharts : config.subject.groups.length); i++)
874             {
875                 var group = config.subject.groups[i];
876 
877                 // skip the group if there is no data for it
878                 if ((groupedIndividualData != null && !groupedIndividualData[group.label])
879                         || (groupedAggregateData != null && (!groupedAggregateData[group.label] || !groupedAggregateData[group.label].hasSeriesData)))
880                 {
881                     continue;
882                 }
883 
884                 plotConfigInfoArr.push({
885                     title: config.title ? config.title : group.label,
886                     subtitle: config.title ? group.label : undefined,
887                     series: seriesList,
888                     individualData: groupedIndividualData && groupedIndividualData[group.label] ? groupedIndividualData[group.label] : null,
889                     aggregateData: groupedAggregateData && groupedAggregateData[group.label] ? groupedAggregateData[group.label].data : null,
890                     applyClipRect: applyClipRect
891                 });
892 
893                 if (plotConfigInfoArr.length > maxCharts)
894                     break;
895             }
896         }
897         else if (config.chartLayout == "per_dimension")
898         {
899             for (var i = 0; i < (seriesList.length > maxCharts ? maxCharts : seriesList.length); i++)
900             {
901                 // skip the measure/dimension if there is no data for it
902                 if ((data.aggregate && !data.aggregate.hasData[seriesList[i].name])
903                         || (data.individual && !data.individual.hasData[seriesList[i].name]))
904                 {
905                     continue;
906                 }
907 
908                 plotConfigInfoArr.push({
909                     title: config.title ? config.title : seriesList[i].label,
910                     subtitle: config.title ? seriesList[i].label : undefined,
911                     series: [seriesList[i]],
912                     individualData: data.individual ? data.individual.measureStore.records() : null,
913                     aggregateData: data.aggregate ? data.aggregate.measureStore.records() : null,
914                     applyClipRect: applyClipRect
915                 });
916 
917                 if (plotConfigInfoArr.length > maxCharts)
918                     break;
919             }
920         }
921         else if (config.chartLayout == "single")
922         {
923             //Single Line Chart, with all participants or groups.
924             plotConfigInfoArr.push({
925                 title: config.title,
926                 series: seriesList,
927                 individualData: data.individual ? data.individual.measureStore.records() : null,
928                 aggregateData: data.aggregate ? data.aggregate.measureStore.records() : null,
929                 height: 610,
930                 style: null,
931                 applyClipRect: applyClipRect
932             });
933         }
934 
935         return plotConfigInfoArr;
936     };
937 
938     // private function
939     var getDataWithSeriesCheck = function(data, groupAccessor, seriesList, columnAliases) {
940         /*
941          Groups data by the groupAccessor passed in. Also, checks for the existance of any series data for that groupAccessor.
942          Returns an object where each attribute will be a groupAccessor with an array of data rows and a boolean for hasSeriesData
943          */
944         var groupedData = {};
945         for (var i = 0; i < data.length; i++)
946         {
947             var value = groupAccessor(data[i]);
948             if (!groupedData[value])
949             {
950                 groupedData[value] = {data: [], hasSeriesData: false};
951             }
952             groupedData[value].data.push(data[i]);
953 
954             for (var j = 0; j < seriesList.length; j++)
955             {
956                 var seriesAlias = LABKEY.vis.getColumnAlias(columnAliases, seriesList[j].aliasLookupInfo);
957                 if (seriesAlias && _getRowValue(data[i], seriesAlias) != null)
958                 {
959                     groupedData[value].hasSeriesData = true;
960                     break;
961                 }
962             }
963         }
964         return groupedData;
965     };
966 
967     /**
968      * Get the index in the axes array for a given axis (ie left y-axis).
969      * @param {Array} axes The array of specified axis information for this chart.
970      * @param {String} axisName The chart axis (i.e. x-axis or y-axis).
971      * @param {String} [side] The y-axis side (i.e. left or right).
972      * @returns {number}
973      */
974     var getAxisIndex = function(axes, axisName, side) {
975         var index = -1;
976         for (var i = 0; i < axes.length; i++)
977         {
978             if (!side && axes[i].name == axisName)
979             {
980                 index = i;
981                 break;
982             }
983             else if (axes[i].name == axisName && axes[i].side == side)
984             {
985                 index = i;
986                 break;
987             }
988         }
989         return index;
990     };
991 
992     /**
993      * Get the data needed for the specified Time Chart based on the chart config. Makes calls to the
994      * {@link LABKEY.Query.Visualization.getData} to get the individual subject data and grouped aggregate data.
995      * Calls the success callback function in the config when it has received all of the requested data.
996      * @param {Object} config The chart configuration object that defines the selected measures, axis info, subjects/groups, etc.
997      */
998     var getChartData = function(config) {
999         if (!config.success)
1000             throw "You must specify a success callback function!";
1001         if (!config.failure)
1002             throw "You must specify a failure callback function!";
1003         if (!config.chartInfo)
1004             throw "You must specify a chartInfo config!";
1005         if (config.chartInfo.measures.length == 0)
1006             throw "There must be at least one specified measure in the chartInfo config!";
1007         if (!config.chartInfo.displayIndividual && !config.chartInfo.displayAggregate)
1008             throw "We expect to either be displaying individual series lines or aggregate data!";
1009 
1010         // issue 22254: perf issues if we try to show individual lines for a group with a large number of subjects
1011         var subjectLength = config.chartInfo.subject.values ? config.chartInfo.subject.values.length : 0;
1012         if (config.chartInfo.displayIndividual && subjectLength > 10000)
1013         {
1014             config.chartInfo.displayIndividual = false;
1015             config.chartInfo.subject.values = undefined;
1016         }
1017 
1018         var chartData = {numberFormats: {}};
1019         var counter = config.chartInfo.displayIndividual && config.chartInfo.displayAggregate ? 2 : 1;
1020         var isDateBased = config.chartInfo.measures[0].time == "date";
1021         var seriesList = generateSeriesList(config.chartInfo.measures);
1022 
1023         // get the visit map info for those visits in the response data
1024         var trimVisitMapDomain = function(origVisitMap, visitsInDataArr) {
1025             var trimmedVisits = [];
1026             for (var v in origVisitMap) {
1027                 if (origVisitMap.hasOwnProperty(v)) {
1028                     if (visitsInDataArr.indexOf(parseInt(v)) != -1) {
1029                         trimmedVisits.push(Ext4.apply({id: v}, origVisitMap[v]));
1030                     }
1031                 }
1032             }
1033             // sort the trimmed visit list by displayOrder and then reset displayOrder starting at 1
1034             trimmedVisits.sort(function(a,b){return a.displayOrder - b.displayOrder});
1035             var newVisitMap = {};
1036             for (var i = 0; i < trimmedVisits.length; i++)
1037             {
1038                 trimmedVisits[i].displayOrder = i + 1;
1039                 newVisitMap[trimmedVisits[i].id] = trimmedVisits[i];
1040             }
1041 
1042             return newVisitMap;
1043         };
1044 
1045         var successCallback = function(response, dataType) {
1046             // check for success=false
1047             if (LABKEY.Utils.isDefined(response.success) && LABKEY.Utils.isBoolean(response.success) && !response.success)
1048             {
1049                 config.failure.call(config.scope, response);
1050                 return;
1051             }
1052 
1053             // Issue 16156: for date based charts, give error message if there are no calculated interval values
1054             if (isDateBased) {
1055                 var intervalAlias = config.chartInfo.measures[0].dateOptions.interval;
1056                 var uniqueNonNullValues = Ext4.Array.clean(response.measureStore.members(intervalAlias));
1057                 chartData.hasIntervalData = uniqueNonNullValues.length > 0;
1058             }
1059             else {
1060                 chartData.hasIntervalData = true;
1061             }
1062 
1063             // make sure each measure/dimension has at least some data, and get a list of which visits are in the data response
1064             // also keep track of which measure/dimensions have negative values (for log scale)
1065             response.hasData = {};
1066             response.hasNegativeValues = {};
1067             Ext4.each(seriesList, function(s) {
1068                 var alias = LABKEY.vis.getColumnAlias(response.columnAliases, s.aliasLookupInfo);
1069                 var uniqueNonNullValues = Ext4.Array.clean(response.measureStore.members(alias));
1070 
1071                 response.hasData[s.name] = uniqueNonNullValues.length > 0;
1072                 response.hasNegativeValues[s.name] = Ext4.Array.min(uniqueNonNullValues) < 0;
1073             });
1074 
1075             // trim the visit map domain to just those visits in the response data
1076             if (!isDateBased) {
1077                 var nounSingular = config.nounSingular || getStudySubjectInfo().nounSingular;
1078                 var visitMappedName = LABKEY.vis.getColumnAlias(response.columnAliases, nounSingular + "Visit/Visit");
1079                 var visitsInData = response.measureStore.members(visitMappedName);
1080                 response.visitMap = trimVisitMapDomain(response.visitMap, visitsInData);
1081             }
1082             else {
1083                 response.visitMap = {};
1084             }
1085 
1086             chartData[dataType] = response;
1087 
1088             generateNumberFormats(config.chartInfo, chartData, config.defaultNumberFormat);
1089 
1090             // if we have all request data back, return the result
1091             counter--;
1092             if (counter == 0)
1093                 config.success.call(config.scope, chartData);
1094         };
1095 
1096         var getSelectRowsSort = function(response, dataType)
1097         {
1098             var nounSingular = config.nounSingular || getStudySubjectInfo().nounSingular,
1099                 sort = dataType == 'aggregate' ? 'GroupingOrder,UniqueId' : response.measureToColumn[config.chartInfo.subject.name];
1100 
1101             if (isDateBased)
1102             {
1103                 sort += ',' + config.chartInfo.measures[0].dateOptions.interval;
1104             }
1105             else
1106             {
1107                 // Issue 28529: if we have a SubjectVisit/sequencenum column, use that instead of SubjectVisit/Visit/SequenceNumMin
1108                 var sequenceNumCol = response.measureToColumn[nounSingular + 'Visit/sequencenum'];
1109                 if (!LABKEY.Utils.isDefined(sequenceNumCol))
1110                     sequenceNumCol = response.measureToColumn[getSubjectVisitColName(nounSingular, 'SequenceNumMin')];
1111 
1112                 sort += ',' + response.measureToColumn[getSubjectVisitColName(nounSingular, 'DisplayOrder')] + ',' + sequenceNumCol;
1113             }
1114 
1115             return sort;
1116         };
1117 
1118         var queryTempResultsForRows = function(response, dataType)
1119         {
1120             // Issue 28529: re-query for the actual data off of the temp query results
1121             LABKEY.Query.MeasureStore.selectRows({
1122                 containerPath: config.containerPath,
1123                 schemaName: response.schemaName,
1124                 queryName: response.queryName,
1125                 requiredVersion : 13.2,
1126                 maxRows: -1,
1127                 sort: getSelectRowsSort(response, dataType),
1128                 success: function(measureStore) {
1129                     response.measureStore = measureStore;
1130                     successCallback(response, dataType);
1131                 }
1132             });
1133         };
1134 
1135         if (config.chartInfo.displayIndividual)
1136         {
1137             //Get data for individual lines.
1138             LABKEY.Query.Visualization.getData({
1139                 metaDataOnly: true,
1140                 containerPath: config.containerPath,
1141                 success: function(response) {
1142                     queryTempResultsForRows(response, "individual");
1143                 },
1144                 failure : function(info, response, options) {
1145                     config.failure.call(config.scope, info, Ext4.JSON.decode(response.responseText));
1146                 },
1147                 measures: config.chartInfo.measures,
1148                 sorts: generateDataSortArray(config.chartInfo.subject, config.chartInfo.measures[0], isDateBased, config.nounSingular),
1149                 limit : config.dataLimit || 10000,
1150                 parameters : config.chartInfo.parameters,
1151                 filterUrl: config.chartInfo.filterUrl,
1152                 filterQuery: config.chartInfo.filterQuery
1153             });
1154         }
1155 
1156         if (config.chartInfo.displayAggregate)
1157         {
1158             //Get data for Aggregates lines.
1159             var groups = [];
1160             for (var i = 0; i < config.chartInfo.subject.groups.length; i++)
1161             {
1162                 var group = config.chartInfo.subject.groups[i];
1163                 // encode the group id & type, so we can distinguish between cohort and participant group in the union table
1164                 groups.push(group.id + '-' + group.type);
1165             }
1166 
1167             LABKEY.Query.Visualization.getData({
1168                 metaDataOnly: true,
1169                 containerPath: config.containerPath,
1170                 success: function(response) {
1171                     queryTempResultsForRows(response, "aggregate");
1172                 },
1173                 failure : function(info) {
1174                     config.failure.call(config.scope, info);
1175                 },
1176                 measures: config.chartInfo.measures,
1177                 groupBys: [
1178                     // Issue 18747: if grouping by cohorts and ptid groups, order it so the cohorts are first
1179                     {schemaName: 'study', queryName: 'ParticipantGroupCohortUnion', name: 'GroupingOrder', values: [0,1]},
1180                     {schemaName: 'study', queryName: 'ParticipantGroupCohortUnion', name: 'UniqueId', values: groups}
1181                 ],
1182                 sorts: generateDataSortArray(config.chartInfo.subject, config.chartInfo.measures[0], isDateBased, config.nounSingular),
1183                 limit : config.dataLimit || 10000,
1184                 parameters : config.chartInfo.parameters,
1185                 filterUrl: config.chartInfo.filterUrl,
1186                 filterQuery: config.chartInfo.filterQuery
1187             });
1188         }
1189     };
1190 
1191     /**
1192      * Get the set of measures from the tables/queries in the study schema.
1193      * @param successCallback
1194      * @param callbackScope
1195      */
1196     var getStudyMeasures = function(successCallback, callbackScope)
1197     {
1198         if (getStudyTimepointType() != null)
1199         {
1200             LABKEY.Query.Visualization.getMeasures({
1201                 filters: ['study|~'],
1202                 dateMeasures: false,
1203                 success: function (measures, response)
1204                 {
1205                     var o = Ext4.JSON.decode(response.responseText);
1206                     successCallback.call(callbackScope, o.measures);
1207                 },
1208                 failure: this.onFailure,
1209                 scope: this
1210             });
1211         }
1212         else
1213         {
1214             successCallback.call(callbackScope, []);
1215         }
1216     };
1217 
1218     /**
1219      * If this is a container with a configured study, get the timepoint type from the study module context.
1220      * @returns {String|null}
1221      */
1222     var getStudyTimepointType = function()
1223     {
1224         var studyCtx = LABKEY.getModuleContext("study") || {};
1225         return LABKEY.Utils.isDefined(studyCtx.timepointType) ? studyCtx.timepointType : null;
1226     };
1227 
1228     /**
1229      * Generate the number format functions for the left and right y-axis and attach them to the chart data object
1230      * @param {Object} config The chart configuration object that defines the selected measures, axis info, subjects/groups, etc.
1231      * @param {Object} data The data object, from getChartData.
1232      * @param {Object} defaultNumberFormat
1233      */
1234     var generateNumberFormats = function(config, data, defaultNumberFormat) {
1235         var fields = data.individual ? data.individual.metaData.fields : data.aggregate.metaData.fields;
1236 
1237         for (var i = 0; i < config.axis.length; i++)
1238         {
1239             var axis = config.axis[i];
1240             if (axis.side)
1241             {
1242                 // Find the first measure with the matching side that has a numberFormat.
1243                 for (var j = 0; j < config.measures.length; j++)
1244                 {
1245                     var measure = config.measures[j].measure;
1246 
1247                     if (data.numberFormats[axis.side])
1248                         break;
1249 
1250                     if (measure.yAxis == axis.side)
1251                     {
1252                         var metaDataName = measure.alias;
1253                         for (var k = 0; k < fields.length; k++)
1254                         {
1255                             var field = fields[k];
1256                             if (field.name == metaDataName)
1257                             {
1258                                 if (field.extFormatFn)
1259                                 {
1260                                     data.numberFormats[axis.side] = eval(field.extFormatFn);
1261                                     break;
1262                                 }
1263                             }
1264                         }
1265                     }
1266                 }
1267 
1268                 if (!data.numberFormats[axis.side])
1269                 {
1270                     // If after all the searching we still don't have a numberformat use the default number format.
1271                     data.numberFormats[axis.side] = defaultNumberFormat;
1272                 }
1273             }
1274         }
1275     };
1276 
1277     /**
1278      * Verifies the information in the chart config to make sure it has proper measures, axis info, subjects/groups, etc.
1279      * Returns an object with a success parameter (boolean) and a message parameter (string). If the success pararameter
1280      * is false there is a critical error and the chart cannot be rendered. If success is true the chart can be rendered.
1281      * Message will contain an error or warning message if applicable. If message is not null and success is true, there is a warning.
1282      * @param {Object} config The chart configuration object that defines the selected measures, axis info, subjects/groups, etc.
1283      * @returns {Object}
1284      */
1285     var validateChartConfig = function(config) {
1286         var message = "";
1287 
1288         if (!config.measures || config.measures.length == 0)
1289         {
1290             message = "No measure selected. Please select at lease one measure.";
1291             return {success: false, message: message};
1292         }
1293 
1294         if (!config.axis || getAxisIndex(config.axis, "x-axis") == -1)
1295         {
1296             message = "Could not find x-axis in chart measure information.";
1297             return {success: false, message: message};
1298         }
1299 
1300         if (config.chartSubjectSelection == "subjects" && config.subject.values.length == 0)
1301         {
1302             var nounSingular = getStudySubjectInfo().nounSingular;
1303             message = "No " + nounSingular.toLowerCase() + " selected. " +
1304                     "Please select at least one " + nounSingular.toLowerCase() + ".";
1305             return {success: false, message: message};
1306         }
1307 
1308         if (config.chartSubjectSelection == "groups" && config.subject.groups.length < 1)
1309         {
1310             message = "No group selected. Please select at least one group.";
1311             return {success: false, message: message};
1312         }
1313 
1314         if (generateSeriesList(config.measures).length == 0)
1315         {
1316             message = "No series or dimension selected. Please select at least one series/dimension value.";
1317             return {success: false, message: message};
1318         }
1319 
1320         if (!(config.displayIndividual || config.displayAggregate))
1321         {
1322             message = "Please select either \"Show Individual Lines\" or \"Show Mean\".";
1323             return {success: false, message: message};
1324         }
1325 
1326         // issue 22254: perf issues if we try to show individual lines for a group with a large number of subjects
1327         var subjectLength = config.subject.values ? config.subject.values.length : 0;
1328         if (config.displayIndividual && subjectLength > 10000)
1329         {
1330             var nounPlural = getStudySubjectInfo().nounPlural;
1331             message = "Unable to display individual series lines for greater than 10,000 total " + nounPlural.toLowerCase() + ".";
1332             return {success: false, message: message};
1333         }
1334 
1335         return {success: true, message: message};
1336     };
1337 
1338     /**
1339      * Verifies that the chart data contains the expected interval values and measure/dimension data. Also checks to make
1340      * sure that data can be used in a log scale (if applicable). Returns an object with a success parameter (boolean)
1341      * and a message parameter (string). If the success pararameter is false there is a critical error and the chart
1342      * cannot be rendered. If success is true the chart can be rendered. Message will contain an error or warning
1343      * message if applicable. If message is not null and success is true, there is a warning.
1344      * @param {Object} data The data object, from getChartData.
1345      * @param {Object} config The chart configuration object that defines the selected measures, axis info, subjects/groups, etc.
1346      * @param {Array} seriesList The list of series that will be plotted for a given chart, from generateSeriesList.
1347      * @param {int} limit The data limit for a single report.
1348      * @returns {Object}
1349      */
1350     var validateChartData = function(data, config, seriesList, limit) {
1351         var message = "",
1352             sep = "",
1353             msg = "",
1354             commaSep = "",
1355             noDataCounter = 0;
1356 
1357         // warn the user if the data limit has been reached
1358         var individualDataCount = LABKEY.Utils.isDefined(data.individual) ? data.individual.measureStore.records().length : null;
1359         var aggregateDataCount = LABKEY.Utils.isDefined(data.aggregate) ? data.aggregate.measureStore.records().length : null;
1360         if (individualDataCount >= limit || aggregateDataCount >= limit) {
1361             message += sep + "The data limit for plotting has been reached. Consider filtering your data.";
1362             sep = "<br/>";
1363         }
1364 
1365         // for date based charts, give error message if there are no calculated interval values
1366         if (!data.hasIntervalData)
1367         {
1368             message += sep + "No calculated interval values (i.e. Days, Months, etc.) for the selected 'Measure Date' and 'Interval Start Date'.";
1369             sep = "<br/>";
1370         }
1371 
1372         // check to see if any of the measures don't have data
1373         Ext4.iterate(data.aggregate ? data.aggregate.hasData : data.individual.hasData, function(key, value) {
1374             if (!value)
1375             {
1376                 noDataCounter++;
1377                 msg += commaSep + key;
1378                 commaSep = ", ";
1379             }
1380         }, this);
1381         if (msg.length > 0)
1382         {
1383             msg = "No data found for the following measures/dimensions: " + msg;
1384 
1385             // if there is no data for any series, add to explanation
1386             if (noDataCounter == seriesList.length)
1387             {
1388                 var isDateBased = config && config.measures[0].time == "date";
1389                 if (isDateBased)
1390                     msg += ". This may be the result of a missing start date value for the selected subject(s).";
1391             }
1392 
1393             message += sep + msg;
1394             sep = "<br/>";
1395         }
1396 
1397         // check to make sure that data can be used in a log scale (if applicable)
1398         if (config)
1399         {
1400             var leftAxisIndex = getAxisIndex(config.axis, "y-axis", "left");
1401             var rightAxisIndex = getAxisIndex(config.axis, "y-axis", "right");
1402 
1403             Ext4.each(config.measures, function(md){
1404                 var m = md.measure;
1405 
1406                 // check the left y-axis
1407                 if (m.yAxis == "left" && leftAxisIndex > -1 && config.axis[leftAxisIndex].scale == "log"
1408                         && ((data.individual && data.individual.hasNegativeValues && data.individual.hasNegativeValues[m.name])
1409                         || (data.aggregate && data.aggregate.hasNegativeValues && data.aggregate.hasNegativeValues[m.name])))
1410                 {
1411                     config.axis[leftAxisIndex].scale = "linear";
1412                     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.";
1413                     sep = "<br/>";
1414                 }
1415 
1416                 // check the right y-axis
1417                 if (m.yAxis == "right" && rightAxisIndex > -1 && config.axis[rightAxisIndex].scale == "log"
1418                         && ((data.individual && data.individual.hasNegativeValues[m.name])
1419                         || (data.aggregate && data.aggregate.hasNegativeValues[m.name])))
1420                 {
1421                     config.axis[rightAxisIndex].scale = "linear";
1422                     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.";
1423                     sep = "<br/>";
1424                 }
1425 
1426             });
1427         }
1428 
1429         return {success: true, message: message};
1430     };
1431 
1432     /**
1433      * Support backwards compatibility for charts saved prior to chartInfo reconfiguration (2011-08-31).
1434      * Support backwards compatibility for save thumbnail options (2012-06-19).
1435      * @param chartInfo
1436      * @param savedReportInfo
1437      */
1438     var convertSavedReportConfig = function(chartInfo, savedReportInfo)
1439     {
1440         if (LABKEY.Utils.isDefined(chartInfo))
1441         {
1442             Ext4.applyIf(chartInfo, {
1443                 axis: [],
1444                 //This is for charts saved prior to 2011-10-07
1445                 chartSubjectSelection: chartInfo.chartLayout == 'per_group' ? 'groups' : 'subjects',
1446                 displayIndividual: true,
1447                 displayAggregate: false
1448             });
1449             for (var i = 0; i < chartInfo.measures.length; i++)
1450             {
1451                 var md = chartInfo.measures[i];
1452 
1453                 Ext4.applyIf(md.measure, {yAxis: "left"});
1454 
1455                 // if the axis info is in md, move it to the axis array
1456                 if (md.axis)
1457                 {
1458                     // default the y-axis to the left side if not specified
1459                     if (md.axis.name == "y-axis")
1460                         Ext4.applyIf(md.axis, {side: "left"});
1461 
1462                     // move the axis info to the axis array
1463                     if (getAxisIndex(chartInfo.axis, md.axis.name, md.axis.side) == -1)
1464                         chartInfo.axis.push(Ext4.apply({}, md.axis));
1465 
1466                     // if the chartInfo has an x-axis measure, move the date info it to the related y-axis measures
1467                     if (md.axis.name == "x-axis")
1468                     {
1469                         for (var j = 0; j < chartInfo.measures.length; j++)
1470                         {
1471                             var schema = md.measure.schemaName;
1472                             var query = md.measure.queryName;
1473                             if (chartInfo.measures[j].axis && chartInfo.measures[j].axis.name == "y-axis"
1474                                     && chartInfo.measures[j].measure.schemaName == schema
1475                                     && chartInfo.measures[j].measure.queryName == query)
1476                             {
1477                                 chartInfo.measures[j].dateOptions = {
1478                                     dateCol: Ext4.apply({}, md.measure),
1479                                     zeroDateCol: Ext4.apply({}, md.dateOptions.zeroDateCol),
1480                                     interval: md.dateOptions.interval
1481                                 };
1482                             }
1483                         }
1484 
1485                         // remove the x-axis date measure from the measures array
1486                         chartInfo.measures.splice(i, 1);
1487                         i--;
1488                     }
1489                     else
1490                     {
1491                         // remove the axis property from the measure
1492                         delete md.axis;
1493                     }
1494                 }
1495             }
1496         }
1497 
1498         if (LABKEY.Utils.isObject(chartInfo) && LABKEY.Utils.isObject(savedReportInfo))
1499         {
1500             if (chartInfo.saveThumbnail != undefined)
1501             {
1502                 if (savedReportInfo.reportProps == null)
1503                     savedReportInfo.reportProps = {};
1504 
1505                 Ext4.applyIf(savedReportInfo.reportProps, {
1506                     thumbnailType: !chartInfo.saveThumbnail ? 'NONE' : 'AUTO'
1507                 });
1508             }
1509         }
1510     };
1511 
1512     var getStudySubjectInfo = function()
1513     {
1514         var studyCtx = LABKEY.getModuleContext("study") || {};
1515         return LABKEY.Utils.isObject(studyCtx.subject) ? studyCtx.subject : {
1516             tableName: 'Participant',
1517             columnName: 'ParticipantId',
1518             nounPlural: 'Participants',
1519             nounSingular: 'Participant'
1520         };
1521     };
1522 
1523     var getMeasureAlias = function(measure)
1524     {
1525         if (LABKEY.Utils.isString(measure.alias))
1526             return measure.alias;
1527         else
1528             return measure.schemaName + '_' + measure.queryName + '_' + measure.name;
1529     };
1530 
1531     var getMeasuresLabelBySide = function(measures, side)
1532     {
1533         var labels = [];
1534         Ext4.each(measures, function(measure)
1535         {
1536             if (measure.yAxis == side && labels.indexOf(measure.label) == -1)
1537                 labels.push(measure.label);
1538         });
1539 
1540         return labels.join(', ');
1541     };
1542 
1543     var getDistinctYAxisSides = function(measures)
1544     {
1545         return Ext4.Array.unique(Ext4.Array.pluck(measures, 'yAxis'));
1546     };
1547 
1548     var _getRowValue = function(row, propName, valueName)
1549     {
1550         if (row.hasOwnProperty(propName)) {
1551             // backwards compatibility for response row that is not a LABKEY.Query.Row
1552             if (!(row instanceof LABKEY.Query.Row)) {
1553                 return row[propName].displayValue || row[propName].value;
1554             }
1555 
1556             var propValue = row.get(propName);
1557             if (valueName != undefined && propValue.hasOwnProperty(valueName)) {
1558                 return propValue[valueName];
1559             }
1560             else if (propValue.hasOwnProperty('displayValue')) {
1561                 return propValue['displayValue'];
1562             }
1563             return row.getValue(propName);
1564         }
1565 
1566         return undefined;
1567     };
1568 
1569     var renderChartSVG = function(renderTo, queryConfig, chartConfig) {
1570         // Before we load the data, validate some information about the chart config
1571         var messages = [];
1572         var validation = validateChartConfig(chartConfig);
1573         if (validation.message != null)
1574         {
1575             messages.push(validation.message);
1576         }
1577         if (!validation.success)
1578         {
1579             _renderMessages(renderTo, messages);
1580             return;
1581         }
1582 
1583         var nounSingular = 'Participant';
1584         var subjectColumnName = 'ParticipantId';
1585         if (LABKEY.moduleContext.study && LABKEY.moduleContext.study.subject)
1586         {
1587             nounSingular = LABKEY.moduleContext.study.subject.nounSingular;
1588             subjectColumnName = LABKEY.moduleContext.study.subject.columnName;
1589         }
1590 
1591         // When all the dependencies are loaded, we load the data using time chart helper getChartData
1592         Ext4.applyIf(queryConfig, {
1593             chartInfo: chartConfig,
1594             containerPath: LABKEY.container.path,
1595             nounSingular: nounSingular,
1596             subjectColumnName: subjectColumnName,
1597             dataLimit: 10000,
1598             maxCharts: 20,
1599             defaultMultiChartHeight: 380,
1600             defaultSingleChartHeight: 600,
1601             defaultWidth: 1075,
1602             defaultNumberFormat: function(v) { return v.toFixed(1); }
1603         });
1604 
1605         queryConfig.success = function(response) {
1606             _getChartDataCallback(renderTo, queryConfig, chartConfig, response);
1607         };
1608         queryConfig.failure = function(info) {
1609             _renderMessages(renderTo, ['Error: ' + info.exception]);
1610         };
1611 
1612         LABKEY.vis.TimeChartHelper.getChartData(queryConfig);
1613     };
1614 
1615     var _getChartDataCallback = function(renderTo, queryConfig, chartConfig, responseData) {
1616         var individualColumnAliases = responseData.individual ? responseData.individual.columnAliases : null;
1617         var aggregateColumnAliases = responseData.aggregate ? responseData.aggregate.columnAliases : null;
1618         var visitMap = responseData.individual ? responseData.individual.visitMap : responseData.aggregate.visitMap;
1619         var intervalKey = generateIntervalKey(chartConfig, individualColumnAliases, aggregateColumnAliases, queryConfig.nounSingular);
1620         var aes = generateAes(chartConfig, visitMap, individualColumnAliases, intervalKey, queryConfig.subjectColumnName);
1621         var tickMap = generateTickMap(visitMap);
1622         var seriesList = generateSeriesList(chartConfig.measures);
1623         var applyClipRect = generateApplyClipRect(chartConfig);
1624 
1625         // Once we have the data, we can set all of the axis min/max range values
1626         generateAcrossChartAxisRanges(chartConfig, responseData, seriesList, queryConfig.nounSingular);
1627         var scales = generateScales(chartConfig, tickMap, responseData.numberFormats);
1628 
1629         // Validate that the chart data has expected values and give warnings if certain elements are not present
1630         var messages = [];
1631         var validation = validateChartData(responseData, chartConfig, seriesList, queryConfig.dataLimit, false);
1632         if (validation.message != null)
1633         {
1634             messages.push(validation.message);
1635         }
1636         if (!validation.success)
1637         {
1638             _renderMessages(renderTo, messages);
1639             return;
1640         }
1641 
1642         // For time charts, we allow multiple plots to be displayed by participant, group, or measure/dimension
1643         var plotConfigsArr = generatePlotConfigs(chartConfig, responseData, seriesList, applyClipRect, queryConfig.maxCharts, queryConfig.subjectColumnName);
1644         for (var configIndex = 0; configIndex < plotConfigsArr.length; configIndex++)
1645         {
1646             var clipRect = plotConfigsArr[configIndex].applyClipRect;
1647             var series = plotConfigsArr[configIndex].series;
1648             var height = chartConfig.height || (plotConfigsArr.length > 1 ? queryConfig.defaultMultiChartHeight : queryConfig.defaultSingleChartHeight);
1649             var width = chartConfig.width || queryConfig.defaultWidth;
1650             var labels = generateLabels(plotConfigsArr[configIndex].title, chartConfig.axis, plotConfigsArr[configIndex].subtitle);
1651             var layers = generateLayers(chartConfig, visitMap, individualColumnAliases, aggregateColumnAliases, plotConfigsArr[configIndex].aggregateData, series, intervalKey, queryConfig.subjectColumnName);
1652             var data = plotConfigsArr[configIndex].individualData ? plotConfigsArr[configIndex].individualData : plotConfigsArr[configIndex].aggregateData;
1653 
1654             var plotConfig = {
1655                 renderTo: renderTo,
1656                 rendererType: 'd3',
1657                 clipRect: clipRect,
1658                 width: width,
1659                 height: height,
1660                 labels: labels,
1661                 aes: aes,
1662                 scales: scales,
1663                 layers: layers,
1664                 data: data
1665             };
1666 
1667             var plot = new LABKEY.vis.Plot(plotConfig);
1668             plot.render();
1669         }
1670 
1671         // Give a warning if the max number of charts has been exceeded
1672         if (plotConfigsArr.length >= queryConfig.maxCharts)
1673             messages.push('Only showing the first ' + queryConfig.maxCharts + ' charts.');
1674 
1675         _renderMessages(renderTo, messages);
1676     };
1677 
1678     var _renderMessages = function(id, messages) {
1679         var messageDiv;
1680         var el = document.getElementById(id);
1681         var child;
1682         if (el && el.children.length > 0)
1683             child = el.children[0];
1684 
1685         for (var i = 0; i < messages.length; i++)
1686         {
1687             messageDiv = document.createElement('div');
1688             messageDiv.setAttribute('style', 'font-style:italic');
1689             messageDiv.innerHTML = messages[i];
1690             if (child)
1691                 el.insertBefore(messageDiv, child);
1692             else
1693                 el.appendChild(messageDiv);
1694         }
1695     };
1696 
1697     return {
1698         /**
1699          * Loads all of the required dependencies for a Time Chart.
1700          * @param {Function} callback The callback to be executed when all of the visualization dependencies have been loaded.
1701          * @param {Object} scope The scope to be used when executing the callback.
1702          */
1703         loadVisDependencies: LABKEY.requiresVisualization,
1704         generateAcrossChartAxisRanges : generateAcrossChartAxisRanges,
1705         generateAes : generateAes,
1706         generateApplyClipRect : generateApplyClipRect,
1707         generateIntervalKey : generateIntervalKey,
1708         generateLabels : generateLabels,
1709         generateLayers : generateLayers,
1710         generatePlotConfigs : generatePlotConfigs,
1711         generateScales : generateScales,
1712         generateSeriesList : generateSeriesList,
1713         generateTickMap : generateTickMap,
1714         generateNumberFormats : generateNumberFormats,
1715         getAxisIndex : getAxisIndex,
1716         getMeasureAlias : getMeasureAlias,
1717         getMeasuresLabelBySide : getMeasuresLabelBySide,
1718         getDistinctYAxisSides : getDistinctYAxisSides,
1719         getStudyTimepointType : getStudyTimepointType,
1720         getStudySubjectInfo : getStudySubjectInfo,
1721         getStudyMeasures : getStudyMeasures,
1722         getChartData : getChartData,
1723         validateChartConfig : validateChartConfig,
1724         validateChartData : validateChartData,
1725         convertSavedReportConfig : convertSavedReportConfig,
1726         renderChartSVG: renderChartSVG
1727     };
1728 };
1729