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