1 /*
  2  * Copyright (c) 2013-2016 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. Used in the
 12  * Time Chart Wizard and when exporting Time Charts as scripts.
 13  */
 14 LABKEY.vis.TimeChartHelper = new function() {
 15 
 16     var studyNounSingular = 'Participant',
 17         studyNounPlural = 'Participants',
 18         studyNounColumnName = 'ParticipantId';
 19 
 20     if (LABKEY.moduleContext.study && LABKEY.moduleContext.study.subject)
 21     {
 22         studyNounSingular = LABKEY.moduleContext.study.subject.nounSingular;
 23         studyNounPlural = LABKEY.moduleContext.study.subject.nounPlural;
 24         studyNounColumnName = LABKEY.moduleContext.study.subject.columnName;
 25     }
 26 
 27     /**
 28      * Generate the main title and axis labels for the chart based on the specified x-axis and y-axis (left and right) labels.
 29      * @param {String} mainTitle The label to be used as the main chart title.
 30      * @param {Array} axisArr An array of axis information including the x-axis and y-axis (left and right) labels.
 31      * @returns {Object}
 32      */
 33     var generateLabels = function(mainTitle, axisArr) {
 34         var xTitle = '', yLeftTitle = '', yRightTitle = '';
 35         for (var i = 0; i < axisArr.length; i++)
 36         {
 37             var axis = axisArr[i];
 38             if (axis.name == "y-axis")
 39             {
 40                 if (axis.side == "left")
 41                     yLeftTitle = axis.label;
 42                 else
 43                     yRightTitle = axis.label;
 44             }
 45             else
 46             {
 47                 xTitle = axis.label;
 48             }
 49         }
 50 
 51         return {
 52             main : {
 53                 value : mainTitle
 54             },
 55             x : {
 56                 value : xTitle
 57             },
 58             yLeft : {
 59                 value : yLeftTitle
 60             },
 61             yRight : {
 62                 value : yRightTitle
 63             }
 64         };
 65     };
 66 
 67     /**
 68      * Generates an object containing {@link LABKEY.vis.Scale} objects used for the chart.
 69      * @param {Object} config The chart configuration object that defines the selected measures, axis info, subjects/groups, etc.
 70      * @param {Object} tickMap For visit based charts, the x-axis tick mark mapping, from generateTickMap.
 71      * @param {Object} numberFormats The number format functions to use for the x-axis and y-axis (left and right) tick marks.
 72      * @returns {Object}
 73      */
 74     var generateScales = function(config, tickMap, numberFormats) {
 75         if (config.measures.length == 0)
 76             throw "There must be at least one specified measure in the chartInfo config!";
 77 
 78         var xMin = null, xMax = null, xTrans = null, xTickFormat, xTickHoverText,
 79             yLeftMin = null, yLeftMax = null, yLeftTrans = null, yLeftTickFormat,
 80             yRightMin = null, yRightMax = null, yRightTrans = null, yRightTickFormat;
 81 
 82         for (var i = 0; i < config.axis.length; i++)
 83         {
 84             var axis = config.axis[i];
 85             if (axis.name == "y-axis")
 86             {
 87                 if (axis.side == "left")
 88                 {
 89                     yLeftMin = typeof axis.range.min == "number" ? axis.range.min : (config.hasNoData ? 0 : null);
 90                     yLeftMax = typeof axis.range.max == "number" ? axis.range.max : (config.hasNoData ? 10 : null);
 91                     yLeftTrans = axis.scale ? axis.scale : "linear";
 92                     yLeftTickFormat = numberFormats.left ? numberFormats.left : null;
 93                 }
 94                 else
 95                 {
 96                     yRightMin = typeof axis.range.min == "number" ? axis.range.min : (config.hasNoData ? 0 : null);
 97                     yRightMax = typeof axis.range.max == "number" ? axis.range.max : (config.hasNoData ? 10 : null);
 98                     yRightTrans = axis.scale ? axis.scale : "linear";
 99                     yRightTickFormat = numberFormats.right ? numberFormats.right : null;
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         if (config.measures.length == 0)
165             throw "There must be at least one specified measure in the chartInfo config!";
166         if (!individualColumnAliases && !aggregateColumnAliases)
167             throw "We expect to either be displaying individual series lines or aggregate data!";
168 
169         if (config.measures[0].time == "date")
170         {
171             return config.measures[0].dateOptions.interval;
172         }
173         else
174         {
175             return individualColumnAliases ?
176                 LABKEY.vis.getColumnAlias(individualColumnAliases, (nounSingular || studyNounSingular) + "Visit/Visit") :
177                 LABKEY.vis.getColumnAlias(aggregateColumnAliases, (nounSingular || studyNounSingular) + "Visit/Visit");
178         }
179     };
180 
181     /**
182      * Generate that x-axis tick mark mapping for a visit based chart.
183      * @param {Object} visitMap For visit based charts, the study visit information map.
184      * @returns {Object}
185      */
186     var generateTickMap = function(visitMap) {
187         var tickMap = {};
188         for (var rowId in visitMap)
189         {
190             if (visitMap.hasOwnProperty(rowId))
191             {
192                 tickMap[visitMap[rowId].displayOrder] = {
193                     label: visitMap[rowId].displayName,
194                     description: visitMap[rowId].description || visitMap[rowId].displayName
195                 };
196             }
197         }
198 
199         return tickMap;
200     };
201 
202     /**
203      * Generates the aesthetic map object needed by the visualization API to render the chart. See {@link LABKEY.vis.Plot}
204      * and {@link LABKEY.vis.Layer}.
205      * @param {Object} config The chart configuration object that defines the selected measures, axis info, subjects/groups, etc.
206      * @param {Object} visitMap For visit based charts, the study visit information map.
207      * @param {Array} individualColumnAliases The array of column aliases for the individual subject data.
208      * @param {String} intervalKey The x-axis interval column alias key (i.e. Days, Weeks, etc.), from generateIntervalKey.
209      * @param {String} nounColumnName The name of the study subject noun column (i.e. ParticipantId).
210      * @returns {Object}
211      */
212     var generateAes = function(config, visitMap, individualColumnAliases, intervalKey, nounColumnName) {
213         if (config.measures.length == 0)
214             throw "There must be at least one specified measure in the chartInfo config!";
215 
216         var xAes;
217         if (config.measures[0].time == "date")
218             xAes = function(row) { return (row[intervalKey] ? row[intervalKey].value : null); };
219         else
220             xAes = function(row) { return visitMap[row[intervalKey].value].displayOrder; };
221 
222         var individualSubjectColumn = individualColumnAliases ? LABKEY.vis.getColumnAlias(individualColumnAliases, nounColumnName || studyNounColumnName) : null;
223 
224         return {
225             x: xAes,
226             color: function(row) { return (row[individualSubjectColumn] ? row[individualSubjectColumn].value : null); },
227             group: function(row) { return (row[individualSubjectColumn] ? row[individualSubjectColumn].value : null); },
228             shape: function(row) { return (row[individualSubjectColumn] ? row[individualSubjectColumn].value : null); },
229             pathColor: function(rows) { return (rows[0][individualSubjectColumn] ? rows[0][individualSubjectColumn].value : null); }
230         };
231     };
232 
233     /**
234      * Generate an array of {@link LABKEY.vis.Layer} objects based on the selected chart series list.
235      * @param {Object} config The chart configuration object that defines the selected measures, axis info, subjects/groups, etc.
236      * @param {Object} visitMap For visit based charts, the study visit information map.
237      * @param {Array} individualColumnAliases The array of column aliases for the individual subject data.
238      * @param {Array} aggregateColumnAliases The array of column aliases for the group/cohort aggregate data.
239      * @param {Array} aggregateData The array of group/cohort aggregate data, from getChartData.
240      * @param {Array} seriesList The list of series that will be plotted for a given chart, from generateSeriesList.
241      * @param {String} intervalKey The x-axis interval column alias key (i.e. Days, Weeks, etc.), from generateIntervalKey.
242      * @param {String} nounColumnName The name of the study subject noun column (i.e. ParticipantId).
243      * @returns {Array}
244      */
245     var generateLayers = function(config, visitMap, individualColumnAliases, aggregateColumnAliases, aggregateData, seriesList, intervalKey, nounColumnName) {
246         if (config.measures.length == 0)
247             throw "There must be at least one specified measure in the chartInfo config!";
248         if (!individualColumnAliases && !aggregateColumnAliases)
249             throw "We expect to either be displaying individual series lines or aggregate data!";
250 
251         var layers = [];
252         var isDateBased = config.measures[0].time == "date";
253         var individualSubjectColumn = individualColumnAliases ? LABKEY.vis.getColumnAlias(individualColumnAliases, nounColumnName || studyNounColumnName) : null;
254         var aggregateSubjectColumn = "UniqueId";
255 
256         var generateLayerAes = function(name, yAxisSide, columnName){
257             var yName = yAxisSide == "left" ? "yLeft" : "yRight";
258             var aes = {};
259             aes[yName] = function(row){return (row[columnName] ? parseFloat(row[columnName].value) : null)}; // Have to parseFloat because for some reason ObsCon from Luminex was returning strings not floats/ints.
260             return aes;
261         };
262 
263         var generateAggregateLayerAes = function(name, yAxisSide, columnName, intervalKey, subjectColumn, errorColumn){
264             var yName = yAxisSide == "left" ? "yLeft" : "yRight";
265             var aes = {};
266             aes[yName] = function(row){return (row[columnName] ? parseFloat(row[columnName].value) : null)}; // Have to parseFloat because for some reason ObsCon from Luminex was returning strings not floats/ints.
267             aes.group = aes.color = aes.shape = function(row){return row[subjectColumn].displayValue};
268             aes.pathColor = function(rows){return rows[0][subjectColumn].displayValue};
269             aes.error = function(row){return (row[errorColumn] ? row[errorColumn].value : null)};
270             return aes;
271         };
272 
273         var hoverTextFn = function(subjectColumn, intervalKey, name, columnName, visitMap, errorColumn, errorType){
274             if (visitMap)
275             {
276                 if (errorColumn)
277                 {
278                     return function(row){
279                         var subject = row[subjectColumn].displayValue ? row[subjectColumn].displayValue : row[subjectColumn].value;
280                         var errorVal = row[errorColumn].value ? row[errorColumn].value : 'n/a';
281                         return ' ' + subject + ',\n '+ visitMap[row[intervalKey].value].displayName + ',\n ' + name + ': ' + row[columnName].value +
282                                 ',\n ' + errorType + ': ' + errorVal;
283                     }
284                 }
285                 else
286                 {
287                     return function(row){
288                         var subject = row[subjectColumn].displayValue ? row[subjectColumn].displayValue : row[subjectColumn].value;
289                         return ' ' + subject + ',\n '+ visitMap[row[intervalKey].value].displayName + ',\n ' + name + ': ' + row[columnName].value;
290                     };
291                 }
292             }
293             else
294             {
295                 if (errorColumn)
296                 {
297                     return function(row){
298                         var subject = row[subjectColumn].displayValue ? row[subjectColumn].displayValue : row[subjectColumn].value;
299                         var errorVal = row[errorColumn].value ? row[errorColumn].value : 'n/a';
300                         return ' ' + subject + ',\n ' + intervalKey + ': ' + row[intervalKey].value + ',\n ' + name + ': ' + row[columnName].value +
301                                 ',\n ' + errorType + ': ' + errorVal;
302                     };
303                 }
304                 else
305                 {
306                     return function(row){
307                         var subject = row[subjectColumn].displayValue ? row[subjectColumn].displayValue : row[subjectColumn].value;
308                         return ' ' + subject + ',\n ' + intervalKey + ': ' + row[intervalKey].value + ',\n ' + name + ': ' + row[columnName].value;
309                     };
310                 }
311             }
312         };
313 
314         // Issue 15369: if two measures have the same name, use the alias for the subsequent series names (which will be unique)
315         // Issue 12369: if rendering two measures of the same pivoted value, use measure and pivot name for series names (which will be unique)
316         var useUniqueSeriesNames = false;
317         var uniqueChartSeriesNames = [];
318         for (var i = 0; i < seriesList.length; i++)
319         {
320             if (uniqueChartSeriesNames.indexOf(seriesList[i].name) > -1)
321             {
322                 useUniqueSeriesNames = true;
323                 break;
324             }
325             uniqueChartSeriesNames.push(seriesList[i].name);
326         }
327 
328         for (var i = seriesList.length -1; i >= 0; i--)
329         {
330             var chartSeries = seriesList[i];
331 
332             var chartSeriesName = chartSeries.label;
333             if (useUniqueSeriesNames)
334             {
335                 if (chartSeries.aliasLookupInfo.pivotValue)
336                     chartSeriesName = chartSeries.aliasLookupInfo.measureName + " " + chartSeries.aliasLookupInfo.pivotValue;
337                 else
338                     chartSeriesName = chartSeries.aliasLookupInfo.alias;
339             }
340 
341             var columnName = individualColumnAliases ? LABKEY.vis.getColumnAlias(individualColumnAliases, chartSeries.aliasLookupInfo) : LABKEY.vis.getColumnAlias(aggregateColumnAliases, chartSeries.aliasLookupInfo);
342             if (individualColumnAliases)
343             {
344                 var pathLayerConfig = {
345                     geom: new LABKEY.vis.Geom.Path({size: config.lineWidth}),
346                     aes: generateLayerAes(chartSeriesName, chartSeries.yAxisSide, columnName)
347                 };
348 
349                 if (seriesList.length > 1)
350                     pathLayerConfig.name = chartSeriesName;
351 
352                 layers.push(new LABKEY.vis.Layer(pathLayerConfig));
353 
354                 if (!config.hideDataPoints)
355                 {
356                     var pointLayerConfig = {
357                         geom: new LABKEY.vis.Geom.Point(),
358                         aes: generateLayerAes(chartSeriesName, chartSeries.yAxisSide, columnName)
359                     };
360 
361                     if (seriesList.length > 1)
362                         pointLayerConfig.name = chartSeriesName;
363 
364                     if (isDateBased)
365                         pointLayerConfig.aes.hoverText = hoverTextFn(individualSubjectColumn, intervalKey, chartSeriesName, columnName, null, null, null);
366                     else
367                         pointLayerConfig.aes.hoverText = hoverTextFn(individualSubjectColumn, intervalKey, chartSeriesName, columnName, visitMap, null, null);
368 
369                     if (config.pointClickFn)
370                     {
371                         pointLayerConfig.aes.pointClickFn = generatePointClickFn(
372                                 config.pointClickFn,
373                                 {participant: individualSubjectColumn, interval: intervalKey, measure: columnName},
374                                 {schemaName: chartSeries.schemaName, queryName: chartSeries.queryName, name: chartSeriesName}
375                         );
376                     }
377 
378                     layers.push(new LABKEY.vis.Layer(pointLayerConfig));
379                 }
380             }
381 
382             if (aggregateData && aggregateColumnAliases)
383             {
384                 var errorBarType = null;
385                 if (config.errorBars == 'SD')
386                     errorBarType = '_STDDEV';
387                 else if (config.errorBars == 'SEM')
388                     errorBarType = '_STDERR';
389 
390                 var errorColumnName = errorBarType ? LABKEY.vis.getColumnAlias(aggregateColumnAliases, chartSeries.aliasLookupInfo) + errorBarType : null;
391 
392                 var aggregatePathLayerConfig = {
393                     data: aggregateData,
394                     geom: new LABKEY.vis.Geom.Path({size: config.lineWidth}),
395                     aes: generateAggregateLayerAes(chartSeriesName, chartSeries.yAxisSide, columnName, intervalKey, aggregateSubjectColumn, errorColumnName)
396                 };
397 
398                 if (seriesList.length > 1)
399                     aggregatePathLayerConfig.name = chartSeriesName;
400 
401                 layers.push(new LABKEY.vis.Layer(aggregatePathLayerConfig));
402 
403                 if (errorColumnName)
404                 {
405                     var aggregateErrorLayerConfig = {
406                         data: aggregateData,
407                         geom: new LABKEY.vis.Geom.ErrorBar(),
408                         aes: generateAggregateLayerAes(chartSeriesName, chartSeries.yAxisSide, columnName, intervalKey, aggregateSubjectColumn, errorColumnName)
409                     };
410 
411                     if (seriesList.length > 1)
412                         aggregateErrorLayerConfig.name = chartSeriesName;
413 
414                     layers.push(new LABKEY.vis.Layer(aggregateErrorLayerConfig));
415                 }
416 
417                 if (!config.hideDataPoints)
418                 {
419                     var aggregatePointLayerConfig = {
420                         data: aggregateData,
421                         geom: new LABKEY.vis.Geom.Point(),
422                         aes: generateAggregateLayerAes(chartSeriesName, chartSeries.yAxisSide, columnName, intervalKey, aggregateSubjectColumn, errorColumnName)
423                     };
424 
425                     if (seriesList.length > 1)
426                         aggregatePointLayerConfig.name = chartSeriesName;
427 
428                     if (isDateBased)
429                         aggregatePointLayerConfig.aes.hoverText = hoverTextFn(aggregateSubjectColumn, intervalKey, chartSeriesName, columnName, null, errorColumnName, config.errorBars)
430                     else
431                         aggregatePointLayerConfig.aes.hoverText = hoverTextFn(aggregateSubjectColumn, intervalKey, chartSeriesName, columnName, visitMap, errorColumnName, config.errorBars);
432 
433                     if (config.pointClickFn)
434                     {
435                         aggregatePointLayerConfig.aes.pointClickFn = generatePointClickFn(
436                                 config.pointClickFn,
437                                 {group: aggregateSubjectColumn, interval: intervalKey, measure: columnName},
438                                 {schemaName: chartSeries.schemaName, queryName: chartSeries.queryName, name: chartSeriesName}
439                         );
440                     }
441 
442                     layers.push(new LABKEY.vis.Layer(aggregatePointLayerConfig));
443                 }
444             }
445         }
446 
447         return layers;
448     };
449 
450     // private function
451     var generatePointClickFn = function(fnString, columnMap, measureInfo){
452         // the developer is expected to return a function, so we encapalate it within the anonymous function
453         // (note: the function should have already be validated in a try/catch when applied via the developerOptionsPanel)
454 
455         // using new Function is quicker than eval(), even in IE.
456         var pointClickFn = new Function('return ' + fnString)();
457         return function(clickEvent, data) {
458             pointClickFn(data, columnMap, measureInfo, clickEvent);
459         };
460     };
461 
462     /**
463      * Generate the list of series to be plotted in a given Time Chart. A series will be created for each measure and
464      * dimension that is selected in the chart.
465      * @param {Array} measures The array of selected measures from the chart config.
466      * @returns {Array}
467      */
468     var generateSeriesList = function(measures) {
469         var arr = [];
470         for (var i = 0; i < measures.length; i++)
471         {
472             var md = measures[i];
473 
474             if (md.dimension && md.dimension.values)
475             {
476                 Ext4.each(md.dimension.values, function(val) {
477                     arr.push({
478                         schemaName: md.dimension.schemaName,
479                         queryName: md.dimension.queryName,
480                         name: val,
481                         label: val,
482                         measureIndex: i,
483                         yAxisSide: md.measure.yAxis,
484                         aliasLookupInfo: {measureName: md.measure.name, pivotValue: val}
485                     });
486                 });
487             }
488             else
489             {
490                 arr.push({
491                     schemaName: md.measure.schemaName,
492                     queryName: md.measure.queryName,
493                     name: md.measure.name,
494                     label: md.measure.label,
495                     measureIndex: i,
496                     yAxisSide: md.measure.yAxis,
497                     aliasLookupInfo: md.measure.alias ? {alias: md.measure.alias} : {measureName: md.measure.name}
498                 });
499             }
500         }
501         return arr;
502     };
503 
504     // private function
505     var generateDataSortArray = function(subject, firstMeasure, isDateBased, nounSingular) {
506         return [
507             subject,
508             {
509                 schemaName : firstMeasure.dateOptions ? firstMeasure.dateOptions.dateCol.schemaName : firstMeasure.measure.schemaName,
510                 queryName : firstMeasure.dateOptions ? firstMeasure.dateOptions.dateCol.queryName : firstMeasure.measure.queryName,
511                 name : isDateBased ? firstMeasure.dateOptions.dateCol.name : (nounSingular || studyNounSingular) + "Visit/Visit/DisplayOrder"
512             },
513             {
514                 schemaName : firstMeasure.dateOptions ? firstMeasure.dateOptions.dateCol.schemaName : firstMeasure.measure.schemaName,
515                 queryName : firstMeasure.dateOptions ? firstMeasure.dateOptions.dateCol.queryName : firstMeasure.measure.queryName,
516                 name : (nounSingular || studyNounSingular) + (isDateBased ? "Visit/Visit" : "Visit/Visit/SequenceNumMin")
517             }
518         ];
519     };
520 
521     /**
522      * Determine whether or not the chart needs to clip the plotted lines and points based on manually set axis ranges.
523      * @param {Object} config The chart configuration object that defines the selected measures, axis info, subjects/groups, etc.
524      * @returns {boolean}
525      */
526     var generateApplyClipRect = function(config) {
527         var xAxisIndex = getAxisIndex(config.axis, "x-axis");
528         var leftAxisIndex = getAxisIndex(config.axis, "y-axis", "left");
529         var rightAxisIndex = getAxisIndex(config.axis, "y-axis", "right");
530 
531         return (
532             xAxisIndex > -1 && (config.axis[xAxisIndex].range.min != null || config.axis[xAxisIndex].range.max != null) ||
533             leftAxisIndex > -1 && (config.axis[leftAxisIndex].range.min != null || config.axis[leftAxisIndex].range.max != null) ||
534             rightAxisIndex > -1 && (config.axis[rightAxisIndex].range.min != null || config.axis[rightAxisIndex].range.max != null)
535         );
536     };
537 
538     /**
539      * Generates axis range min and max values based on the full Time Chart data. This will be used when plotting multiple
540      * charts that are set to use the same axis ranges across all charts in the report.
541      * @param {Object} config The chart configuration object that defines the selected measures, axis info, subjects/groups, etc.
542      * @param {Object} data The data object, from getChartData.
543      * @param {Array} seriesList The list of series that will be plotted for a given chart, from generateSeriesList.
544      * @param {String} nounSingular The singular name of the study subject noun (i.e. Participant).
545      */
546     var generateAcrossChartAxisRanges = function(config, data, seriesList, nounSingular) {
547         if (config.measures.length == 0)
548             throw "There must be at least one specified measure in the chartInfo config!";
549         if (!data.individual && !data.aggregate)
550             throw "We expect to either be displaying individual series lines or aggregate data!";
551 
552         var rows = data.individual ? data.individual.rows : (data.aggregate ? data.aggregate.rows : []);
553         config.hasNoData = rows.length == 0;
554 
555         // In multi-chart case, we need to precompute the default axis ranges so that all charts share them
556         // (if 'automatic across charts' is selected for the given axis)
557         if (config.chartLayout != "single")
558         {
559             var leftMeasures = [],
560                 rightMeasures = [],
561                 xName, xFunc,
562                 min, max, tempMin, tempMax, errorBarType,
563                 leftAccessor, leftAccessorMax, leftAccessorMin, rightAccessorMax, rightAccessorMin, rightAccessor,
564                 columnAliases = data.individual ? data.individual.columnAliases : (data.aggregate ? data.aggregate.columnAliases : null),
565                 isDateBased = config.measures[0].time == "date",
566                 xAxisIndex = getAxisIndex(config.axis, "x-axis"),
567                 leftAxisIndex = getAxisIndex(config.axis, "y-axis", "left"),
568                 rightAxisIndex = getAxisIndex(config.axis, "y-axis", "right");
569 
570             for (var i = 0; i < seriesList.length; i++)
571             {
572                 var columnName = LABKEY.vis.getColumnAlias(columnAliases, seriesList[i].aliasLookupInfo);
573                 if (seriesList[i].yAxisSide == "left")
574                     leftMeasures.push(columnName);
575                 else if (seriesList[i].yAxisSide == "right")
576                     rightMeasures.push(columnName);
577             }
578 
579             if (isDateBased)
580             {
581                 xName = config.measures[0].dateOptions.interval;
582                 xFunc = function(row){
583                     return row[xName].value;
584                 };
585             }
586             else
587             {
588                 var visitMap = data.individual ? data.individual.visitMap : data.aggregate.visitMap;
589                 xName = LABKEY.vis.getColumnAlias(columnAliases, (nounSingular || studyNounSingular) + "Visit/Visit");
590                 xFunc = function(row){
591                     return visitMap[row[xName].value].displayOrder;
592                 };
593             }
594 
595             if (config.axis[xAxisIndex].range.type != 'automatic_per_chart')
596             {
597                 if (!config.axis[xAxisIndex].range.min)
598                     config.axis[xAxisIndex].range.min = d3.min(rows, xFunc);
599 
600                 if (!config.axis[xAxisIndex].range.max)
601                     config.axis[xAxisIndex].range.max = d3.max(rows, xFunc);
602             }
603 
604             if (config.errorBars !== 'None')
605                 errorBarType = config.errorBars == 'SD' ? '_STDDEV' : '_STDERR';
606 
607             if (leftAxisIndex > -1)
608             {
609                 // If we have a left axis then we need to find the min/max
610                 min = null; max = null; tempMin = null; tempMax = null;
611                 leftAccessor = function(row) {
612                     return (row[leftMeasures[i]] ? row[leftMeasures[i]].value : null);
613                 };
614 
615                 if (errorBarType)
616                 {
617                     // If we have error bars we need to calculate min/max with the error values in mind.
618                     leftAccessorMin = function(row){
619                         if (row[leftMeasures[i] + errorBarType])
620                         {
621                             var error = row[leftMeasures[i] + errorBarType].value;
622                             return row[leftMeasures[i]].value - error;
623                         }
624                         else
625                             return null;
626                     };
627 
628                     leftAccessorMax = function(row){
629                         if (row[leftMeasures[i] + errorBarType])
630                         {
631                             var error = row[leftMeasures[i] + errorBarType].value;
632                             return row[leftMeasures[i]].value + error;
633                         }
634                         else
635                             return null;
636                     };
637                 }
638 
639                 if (config.axis[leftAxisIndex].range.type != 'automatic_per_chart')
640                 {
641                     if (!config.axis[leftAxisIndex].range.min)
642                     {
643                         for (var i = 0; i < leftMeasures.length; i++)
644                         {
645                             tempMin = d3.min(rows, leftAccessorMin ? leftAccessorMin : leftAccessor);
646                             min = min == null ? tempMin : tempMin < min ? tempMin : min;
647                         }
648                         config.axis[leftAxisIndex].range.min = min;
649                     }
650 
651                     if (!config.axis[leftAxisIndex].range.max)
652                     {
653                         for (var i = 0; i < leftMeasures.length; i++)
654                         {
655                             tempMax = d3.max(rows, leftAccessorMax ? leftAccessorMax : leftAccessor);
656                             max = max == null ? tempMax : tempMax > max ? tempMax : max;
657                         }
658                         config.axis[leftAxisIndex].range.max = max;
659                     }
660                 }
661             }
662 
663             if (rightAxisIndex > -1)
664             {
665                 // If we have a right axis then we need to find the min/max
666                 min = null; max = null; tempMin = null; tempMax = null;
667                 rightAccessor = function(row){
668                     return row[rightMeasures[i]].value
669                 };
670 
671                 if (errorBarType)
672                 {
673                     rightAccessorMin = function(row){
674                         var error = row[rightMeasures[i] + errorBarType].value;
675                         return row[rightMeasures[i]].value - error;
676                     };
677 
678                     rightAccessorMax = function(row){
679                         var error = row[rightMeasures[i] + errorBarType].value;
680                         return row[rightMeasures[i]].value + error;
681                     };
682                 }
683 
684                 if (config.axis[rightAxisIndex].range.type != 'automatic_per_chart')
685                 {
686                     if (!config.axis[rightAxisIndex].range.min)
687                     {
688                         for (var i = 0; i < rightMeasures.length; i++)
689                         {
690                             tempMin = d3.min(rows, rightAccessorMin ? rightAccessorMin : rightAccessor);
691                             min = min == null ? tempMin : tempMin < min ? tempMin : min;
692                         }
693                         config.axis[rightAxisIndex].range.min = min;
694                     }
695 
696                     if (!config.axis[rightAxisIndex].range.max)
697                     {
698                         for (var i = 0; i < rightMeasures.length; i++)
699                         {
700                             tempMax = d3.max(rows, rightAccessorMax ? rightAccessorMax : rightAccessor);
701                             max = max == null ? tempMax : tempMax > max ? tempMax : max;
702                         }
703                         config.axis[rightAxisIndex].range.max = max;
704                     }
705                 }
706             }
707         }
708     };
709 
710     /**
711      * Generates plot configs to be passed to the {@link LABKEY.vis.Plot} function for each chart in the report.
712      * @param {Object} config The chart configuration object that defines the selected measures, axis info, subjects/groups, etc.
713      * @param {Object} data The data object, from getChartData.
714      * @param {Array} seriesList The list of series that will be plotted for a given chart, from generateSeriesList.
715      * @param {boolean} applyClipRect A boolean indicating whether or not to clip the plotted data region, from generateApplyClipRect.
716      * @param {int} maxCharts The maximum number of charts to display in one report.
717      * @param {String} nounColumnName The name of the study subject noun column (i.e. ParticipantId).
718      * @returns {Array}
719      */
720     var generatePlotConfigs = function(config, data, seriesList, applyClipRect, maxCharts, nounColumnName) {
721         var plotConfigInfoArr = [],
722             subjectColumnName = null;
723 
724         if (data.individual)
725             subjectColumnName = LABKEY.vis.getColumnAlias(data.individual.columnAliases, nounColumnName || studyNounColumnName);
726 
727         var generateGroupSeries = function(rows, groups, subjectColumn) {
728             // subjectColumn is the aliasColumnName looked up from the getData response columnAliases array
729             // groups is config.subject.groups
730             var dataByGroup = {};
731 
732             for (var i = 0; i < rows.length; i++)
733             {
734                 var rowSubject = rows[i][subjectColumn].value;
735                 for (var j = 0; j < groups.length; j++)
736                 {
737                     if (groups[j].participantIds.indexOf(rowSubject) > -1)
738                     {
739                         if (!dataByGroup[groups[j].label])
740                             dataByGroup[groups[j].label] = [];
741 
742                         dataByGroup[groups[j].label].push(rows[i]);
743                     }
744                 }
745             }
746 
747             return dataByGroup;
748         };
749 
750         var concatChartTitle = function(mainTitle, subTitle) {
751             return mainTitle + (mainTitle ? ': ' : '') + subTitle;
752         };
753 
754         // four options: all series on one chart, one chart per subject, one chart per group, or one chart per measure/dimension
755         if (config.chartLayout == "per_subject")
756         {
757             var dataPerParticipant = getDataWithSeriesCheck(data.individual.rows, function(row){return row[subjectColumnName].value}, seriesList, data.individual.columnAliases);
758             for (var participant in dataPerParticipant)
759             {
760                 if (dataPerParticipant.hasOwnProperty(participant))
761                 {
762                     // skip the group if there is no data for it
763                     if (!dataPerParticipant[participant].hasSeriesData)
764                         continue;
765 
766                     plotConfigInfoArr.push({
767                         title: concatChartTitle(config.title, participant),
768                         series: seriesList,
769                         individualData: dataPerParticipant[participant].data,
770                         style: config.subject.values.length > 1 ? 'border-bottom: solid black 1px;' : null,
771                         applyClipRect: applyClipRect
772                     });
773 
774                     if (plotConfigInfoArr.length > maxCharts)
775                         break;
776                 }
777             }
778         }
779         else if (config.chartLayout == "per_group")
780         {
781             var groupedIndividualData = null, groupedAggregateData = null;
782 
783             //Display individual lines
784             if (data.individual)
785                 groupedIndividualData = generateGroupSeries(data.individual.rows, config.subject.groups, subjectColumnName);
786 
787             // Display aggregate lines
788             if (data.aggregate)
789                 groupedAggregateData = getDataWithSeriesCheck(data.aggregate.rows, function(row){return row.UniqueId.displayValue}, seriesList, data.aggregate.columnAliases);
790 
791             for (var i = 0; i < (config.subject.groups.length > maxCharts ? maxCharts : config.subject.groups.length); i++)
792             {
793                 var group = config.subject.groups[i];
794 
795                 // skip the group if there is no data for it
796                 if ((groupedIndividualData != null && !groupedIndividualData[group.label])
797                         || (groupedAggregateData != null && (!groupedAggregateData[group.label] || !groupedAggregateData[group.label].hasSeriesData)))
798                 {
799                     continue;
800                 }
801 
802                 plotConfigInfoArr.push({
803                     title: concatChartTitle(config.title, group.label),
804                     series: seriesList,
805                     individualData: groupedIndividualData && groupedIndividualData[group.label] ? groupedIndividualData[group.label] : null,
806                     aggregateData: groupedAggregateData && groupedAggregateData[group.label] ? groupedAggregateData[group.label].data : null,
807                     style: config.subject.groups.length > 1 ? 'border-bottom: solid black 1px;' : null,
808                     applyClipRect: applyClipRect
809                 });
810 
811                 if (plotConfigInfoArr.length > maxCharts)
812                     break;
813             }
814         }
815         else if (config.chartLayout == "per_dimension")
816         {
817             for (var i = 0; i < (seriesList.length > maxCharts ? maxCharts : seriesList.length); i++)
818             {
819                 // skip the measure/dimension if there is no data for it
820                 if ((data.aggregate && !data.aggregate.hasData[seriesList[i].name])
821                         || (data.individual && !data.individual.hasData[seriesList[i].name]))
822                 {
823                     continue;
824                 }
825 
826                 plotConfigInfoArr.push({
827                     title: concatChartTitle(config.title, seriesList[i].label),
828                     series: [seriesList[i]],
829                     individualData: data.individual ? data.individual.rows : null,
830                     aggregateData: data.aggregate ? data.aggregate.rows : null,
831                     style: seriesList.length > 1 ? 'border-bottom: solid black 1px;' : null,
832                     applyClipRect: applyClipRect
833                 });
834 
835                 if (plotConfigInfoArr.length > maxCharts)
836                     break;
837             }
838         }
839         else if (config.chartLayout == "single")
840         {
841             //Single Line Chart, with all participants or groups.
842             plotConfigInfoArr.push({
843                 title: config.title,
844                 series: seriesList,
845                 individualData: data.individual ? data.individual.rows : null,
846                 aggregateData: data.aggregate ? data.aggregate.rows : null,
847                 height: 610,
848                 style: null,
849                 applyClipRect: applyClipRect
850             });
851         }
852 
853         return plotConfigInfoArr;
854     };
855 
856     // private function
857     var getDataWithSeriesCheck = function(data, groupAccessor, seriesList, columnAliases) {
858         /*
859          Groups data by the groupAccessor passed in. Also, checks for the existance of any series data for that groupAccessor.
860          Returns an object where each attribute will be a groupAccessor with an array of data rows and a boolean for hasSeriesData
861          */
862         var groupedData = {};
863         for (var i = 0; i < data.length; i++)
864         {
865             var value = groupAccessor(data[i]);
866             if (!groupedData[value])
867             {
868                 groupedData[value] = {data: [], hasSeriesData: false};
869             }
870             groupedData[value].data.push(data[i]);
871 
872             for (var j = 0; j < seriesList.length; j++)
873             {
874                 var seriesAlias = LABKEY.vis.getColumnAlias(columnAliases, seriesList[j].aliasLookupInfo);
875                 if (seriesAlias && data[i][seriesAlias] && data[i][seriesAlias].value)
876                 {
877                     groupedData[value].hasSeriesData = true;
878                     break;
879                 }
880             }
881         }
882         return groupedData;
883     };
884 
885     /**
886      * Get the index in the axes array for a given axis (ie left y-axis).
887      * @param {Array} axes The array of specified axis information for this chart.
888      * @param {String} axisName The chart axis (i.e. x-axis or y-axis).
889      * @param {String} [side] The y-axis side (i.e. left or right).
890      * @returns {number}
891      */
892     var getAxisIndex = function(axes, axisName, side) {
893         var index = -1;
894         for (var i = 0; i < axes.length; i++)
895         {
896             if (!side && axes[i].name == axisName)
897             {
898                 index = i;
899                 break;
900             }
901             else if (axes[i].name == axisName && axes[i].side == side)
902             {
903                 index = i;
904                 break;
905             }
906         }
907         return index;
908     };
909 
910     /**
911      * Get the data needed for the specified Time Chart based on the chart config. Makes calls to the
912      * {@link LABKEY.Query.Visualization.getData} to get the individual subject data and grouped aggregate data.
913      * Calls the success callback function in the config when it has received all of the requested data.
914      * @param {Object} config The chart configuration object that defines the selected measures, axis info, subjects/groups, etc.
915      */
916     var getChartData = function(config) {
917         if (!config.success)
918             throw "You must specify a success callback function!";
919         if (!config.failure)
920             throw "You must specify a failure callback function!";
921         if (!config.chartInfo)
922             throw "You must specify a chartInfo config!";
923         if (config.chartInfo.measures.length == 0)
924             throw "There must be at least one specified measure in the chartInfo config!";
925         if (!config.chartInfo.displayIndividual && !config.chartInfo.displayAggregate)
926             throw "We expect to either be displaying individual series lines or aggregate data!";
927 
928         // issue 22254: perf issues if we try to show individual lines for a group with a large number of subjects
929         var subjectLength = config.chartInfo.subject.values ? config.chartInfo.subject.values.length : 0;
930         if (config.chartInfo.displayIndividual && subjectLength > 10000)
931         {
932             config.chartInfo.displayIndividual = false;
933             config.chartInfo.subject.values = undefined;
934         }
935 
936         var chartData = {numberFormats: {}};
937         var counter = config.chartInfo.displayIndividual && config.chartInfo.displayAggregate ? 2 : 1;
938         var isDateBased = config.chartInfo.measures[0].time == "date";
939         var seriesList = generateSeriesList(config.chartInfo.measures);
940 
941         // Issue 16156: for date based charts, give error message if there are no calculated interval values
942         chartData.hasIntervalData = !isDateBased;
943         var checkForIntervalValues = function(row) {
944             if (isDateBased)
945             {
946                 var intervalAlias = config.chartInfo.measures[0].dateOptions.interval;
947                 if (row[intervalAlias] && row[intervalAlias].value != null)
948                     chartData.hasIntervalData = true;
949             }
950         };
951 
952         var trimVisitMapDomain = function(origVisitMap, visitsInDataArr) {
953             // get the visit map info for those visits in the response data
954             var trimmedVisits = [];
955             for (var v in origVisitMap)
956             {
957                 if (origVisitMap.hasOwnProperty(v))
958                 {
959                     if (visitsInDataArr.indexOf(v) != -1)
960                     {
961                         trimmedVisits.push(Ext4.apply({id: v}, origVisitMap[v]));
962                     }
963                 }
964             }
965             // sort the trimmed visit list by displayOrder and then reset displayOrder starting at 1
966             trimmedVisits.sort(function(a,b){return a.displayOrder - b.displayOrder});
967             var newVisitMap = {};
968             for (var i = 0; i < trimmedVisits.length; i++)
969             {
970                 trimmedVisits[i].displayOrder = i + 1;
971                 newVisitMap[trimmedVisits[i].id] = trimmedVisits[i];
972             }
973 
974             return newVisitMap;
975         };
976 
977         var successCallback = function(response, dataType) {
978 
979             // make sure each measure/dimension has at least some data, and get a list of which visits are in the data response
980             // also keep track of which measure/dimensions have negative values (for log scale)
981             var visitsInData = [];
982             response.hasData = {};
983             response.hasNegativeValues = {};
984             Ext4.each(seriesList, function(s) {
985                 response.hasData[s.name] = false;
986                 response.hasNegativeValues[s.name] = false;
987                 for (var i = 0; i < response.rows.length; i++)
988                 {
989                     var row = response.rows[i];
990                     var alias = LABKEY.vis.getColumnAlias(response.columnAliases, s.aliasLookupInfo);
991                     if (row[alias] && row[alias].value != null)
992                     {
993                         response.hasData[s.name] = true;
994 
995                         if (row[alias].value < 0)
996                             response.hasNegativeValues[s.name] = true;
997                     }
998 
999                     var visitMappedName = LABKEY.vis.getColumnAlias(response.columnAliases, (config.nounSingular || studyNounSingular) + "Visit/Visit");
1000                     if (!isDateBased && row[visitMappedName])
1001                     {
1002                         var visitVal = row[visitMappedName].value;
1003                         if (visitsInData.indexOf(visitVal) == -1)
1004                             visitsInData.push(visitVal.toString());
1005                     }
1006 
1007                     checkForIntervalValues(row);
1008                 }
1009             });
1010 
1011             // trim the visit map domain to just those visits in the response data
1012             response.visitMap = trimVisitMapDomain(response.visitMap, visitsInData);
1013 
1014             chartData[dataType] = response;
1015 
1016             generateNumberFormats(config.chartInfo, chartData, config.defaultNumberFormat);
1017 
1018             // if we have all request data back, return the result
1019             counter--;
1020             if (counter == 0)
1021                 config.success.call(config.scope, chartData);
1022         };
1023 
1024         if (config.chartInfo.displayIndividual)
1025         {
1026             //Get data for individual lines.
1027             LABKEY.Query.Visualization.getData({
1028                 containerPath: config.containerPath,
1029                 success: function(response) {
1030                     successCallback(response, "individual");
1031                 },
1032                 failure : function(info, response, options) {
1033                     config.failure.call(config.scope, info);
1034                 },
1035                 measures: config.chartInfo.measures,
1036                 sorts: generateDataSortArray(config.chartInfo.subject, config.chartInfo.measures[0], isDateBased, config.nounSingular),
1037                 limit : config.dataLimit || 10000,
1038                 parameters : config.chartInfo.parameters,
1039                 filterUrl: config.chartInfo.filterUrl,
1040                 filterQuery: config.chartInfo.filterQuery
1041             });
1042         }
1043 
1044         if (config.chartInfo.displayAggregate)
1045         {
1046             //Get data for Aggregates lines.
1047             var groups = [];
1048             for (var i = 0; i < config.chartInfo.subject.groups.length; i++)
1049             {
1050                 var group = config.chartInfo.subject.groups[i];
1051                 // encode the group id & type, so we can distinguish between cohort and participant group in the union table
1052                 groups.push(group.id + '-' + group.type);
1053             }
1054 
1055             LABKEY.Query.Visualization.getData({
1056                 containerPath: config.containerPath,
1057                 success: function(response) {
1058                     successCallback(response, "aggregate");
1059                 },
1060                 failure : function(info) {
1061                     config.failure.call(config.scope, info);
1062                 },
1063                 measures: config.chartInfo.measures,
1064                 groupBys: [
1065                     // Issue 18747: if grouping by cohorts and ptid groups, order it so the cohorts are first
1066                     {schemaName: 'study', queryName: 'ParticipantGroupCohortUnion', name: 'GroupingOrder', values: [0,1]},
1067                     {schemaName: 'study', queryName: 'ParticipantGroupCohortUnion', name: 'UniqueId', values: groups}
1068                 ],
1069                 sorts: generateDataSortArray(config.chartInfo.subject, config.chartInfo.measures[0], isDateBased, config.nounSingular),
1070                 limit : config.dataLimit || 10000,
1071                 parameters : config.chartInfo.parameters,
1072                 filterUrl: config.chartInfo.filterUrl,
1073                 filterQuery: config.chartInfo.filterQuery
1074             });
1075         }
1076     };
1077 
1078     /**
1079      * Generate the number format functions for the left and right y-axis and attach them to the chart data object
1080      * @param {Object} config The chart configuration object that defines the selected measures, axis info, subjects/groups, etc.
1081      * @param {Object} data The data object, from getChartData.
1082      * @param {Object} defaultNumberFormat
1083      */
1084     var generateNumberFormats = function(config, data, defaultNumberFormat) {
1085         var fields = data.individual ? data.individual.metaData.fields : data.aggregate.metaData.fields;
1086 
1087         for (var i = 0; i < config.axis.length; i++)
1088         {
1089             var axis = config.axis[i];
1090             if (axis.side)
1091             {
1092                 // Find the first measure with the matching side that has a numberFormat.
1093                 for (var j = 0; j < config.measures.length; j++)
1094                 {
1095                     var measure = config.measures[j].measure;
1096 
1097                     if (data.numberFormats[axis.side])
1098                         break;
1099 
1100                     if (measure.yAxis == axis.side)
1101                     {
1102                         var metaDataName = measure.alias;
1103                         for (var k = 0; k < fields.length; k++)
1104                         {
1105                             var field = fields[k];
1106                             if (field.name == metaDataName)
1107                             {
1108                                 if (field.extFormatFn)
1109                                 {
1110                                     data.numberFormats[axis.side] = eval(field.extFormatFn);
1111                                     break;
1112                                 }
1113                             }
1114                         }
1115                     }
1116                 }
1117 
1118                 if (!data.numberFormats[axis.side])
1119                 {
1120                     // If after all the searching we still don't have a numberformat use the default number format.
1121                     data.numberFormats[axis.side] = defaultNumberFormat;
1122                 }
1123             }
1124         }
1125     };
1126 
1127     /**
1128      * Verifies the information in the chart config to make sure it has proper measures, axis info, subjects/groups, etc.
1129      * Returns an object with a success parameter (boolean) and a message parameter (string). If the success pararameter
1130      * is false there is a critical error and the chart cannot be rendered. If success is true the chart can be rendered.
1131      * Message will contain an error or warning message if applicable. If message is not null and success is true, there is a warning.
1132      * @param {Object} config The chart configuration object that defines the selected measures, axis info, subjects/groups, etc.
1133      * @returns {Object}
1134      */
1135     var validateChartConfig = function(config) {
1136         var message = "";
1137 
1138         if (!config.measures || config.measures.length == 0)
1139         {
1140             message = "No measure selected. Please select at lease one measure.";
1141             return {success: false, message: message};
1142         }
1143 
1144         if (!config.axis || getAxisIndex(config.axis, "x-axis") == -1)
1145         {
1146             message = "Could not find x-axis in chart measure information.";
1147             return {success: false, message: message};
1148         }
1149 
1150         if (config.chartSubjectSelection == "subjects" && config.subject.values.length == 0)
1151         {
1152             message = "No " + studyNounSingular.toLowerCase() + " selected. " +
1153                     "Please select at least one " + studyNounSingular.toLowerCase() + ".";
1154             return {success: false, message: message};
1155         }
1156 
1157         if (config.chartSubjectSelection == "groups" && config.subject.groups.length < 1)
1158         {
1159             message = "No group selected. Please select at least one group.";
1160             return {success: false, message: message};
1161         }
1162 
1163         if (generateSeriesList(config.measures).length == 0)
1164         {
1165             message = "No series or dimension selected. Please select at least one series/dimension value.";
1166             return {success: false, message: message};
1167         }
1168 
1169         if (!(config.displayIndividual || config.displayAggregate))
1170         {
1171             message = "Please select either \"Show Individual Lines\" or \"Show Mean\".";
1172             return {success: false, message: message};
1173         }
1174 
1175         // issue 22254: perf issues if we try to show individual lines for a group with a large number of subjects
1176         var subjectLength = config.subject.values ? config.subject.values.length : 0;
1177         if (config.displayIndividual && subjectLength > 10000)
1178         {
1179             message = "Unable to display individual series lines for greater than 10,000 total " + studyNounPlural.toLowerCase() + ".";
1180             return {success: false, message: message};
1181         }
1182 
1183         return {success: true, message: message};
1184     };
1185 
1186     /**
1187      * Verifies that the chart data contains the expected interval values and measure/dimension data. Also checks to make
1188      * sure that data can be used in a log scale (if applicable). Returns an object with a success parameter (boolean)
1189      * and a message parameter (string). If the success pararameter is false there is a critical error and the chart
1190      * cannot be rendered. If success is true the chart can be rendered. Message will contain an error or warning
1191      * message if applicable. If message is not null and success is true, there is a warning.
1192      * @param {Object} data The data object, from getChartData.
1193      * @param {Object} config The chart configuration object that defines the selected measures, axis info, subjects/groups, etc.
1194      * @param {Array} seriesList The list of series that will be plotted for a given chart, from generateSeriesList.
1195      * @param {int} limit The data limit for a single report.
1196      * @returns {Object}
1197      */
1198     var validateChartData = function(data, config, seriesList, limit) {
1199         var message = "",
1200             sep = "",
1201             msg = "",
1202             commaSep = "",
1203             noDataCounter = 0;
1204 
1205         // warn the user if the data limit has been reached
1206         if ((data.individual && data.individual.rows.length == limit) || (data.aggregate && data.aggregate.rows.length == limit))
1207         {
1208             message += sep + "The data limit for plotting has been reached. Consider filtering your data.";
1209             sep = "<br/>";
1210         }
1211 
1212         // for date based charts, give error message if there are no calculated interval values
1213         if (!data.hasIntervalData)
1214         {
1215             message += sep + "No calculated interval values (i.e. Days, Months, etc.) for the selected 'Measure Date' and 'Interval Start Date'.";
1216             sep = "<br/>";
1217         }
1218 
1219         // check to see if any of the measures don't have data
1220         Ext4.iterate(data.aggregate ? data.aggregate.hasData : data.individual.hasData, function(key, value) {
1221             if (!value)
1222             {
1223                 noDataCounter++;
1224                 msg += commaSep + key;
1225                 commaSep = ", ";
1226             }
1227         }, this);
1228         if (msg.length > 0)
1229         {
1230             msg = "No data found for the following measures/dimensions: " + msg;
1231 
1232             // if there is no data for any series, add to explanation
1233             if (noDataCounter == seriesList.length)
1234             {
1235                 var isDateBased = config && config.measures[0].time == "date";
1236                 if (isDateBased)
1237                     msg += ". This may be the result of a missing start date value for the selected subject(s).";
1238             }
1239 
1240             message += sep + msg;
1241             sep = "<br/>";
1242         }
1243 
1244         // check to make sure that data can be used in a log scale (if applicable)
1245         if (config)
1246         {
1247             var leftAxisIndex = getAxisIndex(config.axis, "y-axis", "left");
1248             var rightAxisIndex = getAxisIndex(config.axis, "y-axis", "right");
1249 
1250             Ext4.each(config.measures, function(md){
1251                 var m = md.measure;
1252 
1253                 // check the left y-axis
1254                 if (m.yAxis == "left" && leftAxisIndex > -1 && config.axis[leftAxisIndex].scale == "log"
1255                         && ((data.individual && data.individual.hasNegativeValues && data.individual.hasNegativeValues[m.name])
1256                         || (data.aggregate && data.aggregate.hasNegativeValues && data.aggregate.hasNegativeValues[m.name])))
1257                 {
1258                     config.axis[leftAxisIndex].scale = "linear";
1259                     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.";
1260                     sep = "<br/>";
1261                 }
1262 
1263                 // check the right y-axis
1264                 if (m.yAxis == "right" && rightAxisIndex > -1 && config.axis[rightAxisIndex].scale == "log"
1265                         && ((data.individual && data.individual.hasNegativeValues[m.name])
1266                         || (data.aggregate && data.aggregate.hasNegativeValues[m.name])))
1267                 {
1268                     config.axis[rightAxisIndex].scale = "linear";
1269                     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.";
1270                     sep = "<br/>";
1271                 }
1272 
1273             });
1274         }
1275 
1276         return {success: true, message: message};
1277     };
1278 
1279     return {
1280         /**
1281          * Loads all of the required dependencies for a Time Chart.
1282          * @param {Function} callback The callback to be executed when all of the visualization dependencies have been loaded.
1283          * @param {Object} scope The scope to be used when executing the callback.
1284          */
1285         loadVisDependencies: LABKEY.requiresVisualization,
1286         generateAcrossChartAxisRanges : generateAcrossChartAxisRanges,
1287         generateAes : generateAes,
1288         generateApplyClipRect : generateApplyClipRect,
1289         generateIntervalKey : generateIntervalKey,
1290         generateLabels : generateLabels,
1291         generateLayers : generateLayers,
1292         generatePlotConfigs : generatePlotConfigs,
1293         generateScales : generateScales,
1294         generateSeriesList : generateSeriesList,
1295         generateTickMap : generateTickMap,
1296         generateNumberFormats : generateNumberFormats,
1297         getAxisIndex : getAxisIndex,
1298         getChartData : getChartData,
1299         validateChartConfig : validateChartConfig,
1300         validateChartData : validateChartData
1301     };
1302 };
1303