1 /*
  2  * Copyright (c) 2013-2019 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 Generic Charts (Box, Scatter, etc.). Used in the
 12  * Generic Chart Wizard and when exporting Generic Charts as Scripts.
 13  */
 14 LABKEY.vis.GenericChartHelper = new function(){
 15 
 16     var DEFAULT_TICK_LABEL_MAX = 25;
 17     var $ = jQuery;
 18 
 19     var getRenderTypes = function() {
 20         return [
 21             {
 22                 name: 'bar_chart',
 23                 title: 'Bar',
 24                 imgUrl: LABKEY.contextPath + '/visualization/images/barchart.png',
 25                 fields: [
 26                     {name: 'x', label: 'X Axis Categories', required: true, nonNumericOnly: true},
 27                     {name: 'xSub', label: 'Split Categories By', required: false, nonNumericOnly: true},
 28                     {name: 'y', label: 'Y Axis', numericOnly: true}
 29                 ],
 30                 layoutOptions: {line: true, opacity: true, axisBased: true}
 31             },
 32             {
 33                 name: 'box_plot',
 34                 title: 'Box',
 35                 imgUrl: LABKEY.contextPath + '/visualization/images/boxplot.png',
 36                 fields: [
 37                     {name: 'x', label: 'X Axis Categories'},
 38                     {name: 'y', label: 'Y Axis', required: true, numericOnly: true},
 39                     {name: 'color', label: 'Color', nonNumericOnly: true},
 40                     {name: 'shape', label: 'Shape', nonNumericOnly: true}
 41                 ],
 42                 layoutOptions: {point: true, box: true, line: true, opacity: true, axisBased: true}
 43             },
 44             {
 45                 name: 'line_plot',
 46                 title: 'Line',
 47                 imgUrl: LABKEY.contextPath + '/visualization/images/timechart.png',
 48                 fields: [
 49                     {name: 'x', label: 'X Axis', required: true, numericOrDateOnly: true},
 50                     {name: 'y', label: 'Y Axis', required: true, numericOnly: true, allowMultiple: true},
 51                     {name: 'series', label: 'Series', nonNumericOnly: true}
 52                 ],
 53                 layoutOptions: {opacity: true, axisBased: true, series: true, chartLayout: true}
 54             },
 55             {
 56                 name: 'pie_chart',
 57                 title: 'Pie',
 58                 imgUrl: LABKEY.contextPath + '/visualization/images/piechart.png',
 59                 fields: [
 60                     {name: 'x', label: 'Categories', required: true, nonNumericOnly: true},
 61                     // Issue #29046  'Remove "measure" option from pie chart'
 62                     // {name: 'y', label: 'Measure', numericOnly: true}
 63                 ],
 64                 layoutOptions: {pie: true}
 65             },
 66             {
 67                 name: 'scatter_plot',
 68                 title: 'Scatter',
 69                 imgUrl: LABKEY.contextPath + '/visualization/images/scatterplot.png',
 70                 fields: [
 71                     {name: 'x', label: 'X Axis', required: true},
 72                     {name: 'y', label: 'Y Axis', required: true, numericOnly: true, allowMultiple: true},
 73                     {name: 'color', label: 'Color', nonNumericOnly: true},
 74                     {name: 'shape', label: 'Shape', nonNumericOnly: true}
 75                 ],
 76                 layoutOptions: {point: true, opacity: true, axisBased: true, binnable: true, chartLayout: true}
 77             },
 78             {
 79                 name: 'time_chart',
 80                 title: 'Time',
 81                 hidden: _getStudyTimepointType() == null,
 82                 imgUrl: LABKEY.contextPath + '/visualization/images/timechart.png',
 83                 fields: [
 84                     {name: 'x', label: 'X Axis', required: true, altSelectionOnly: true, altFieldType: 'LABKEY.vis.TimeChartXAxisField'},
 85                     {name: 'y', label: 'Y Axis', required: true, numericOnly: true, allowMultiple: true}
 86                 ],
 87                 layoutOptions: {time: true, axisBased: true, chartLayout: true}
 88             }
 89         ];
 90     };
 91 
 92     /**
 93      * Gets the chart type (i.e. box or scatter).
 94      * @param {String} renderType The selected renderType, this can be SCATTER_PLOT, BOX_PLOT, or BAR_CHART. Determined
 95      * at chart creation time in the Generic Chart Wizard.
 96      * @param {String} xAxisType The datatype of the x-axis, i.e. String, Boolean, Number.
 97      * @returns {String}
 98      */
 99     var getChartType = function(renderType, xAxisType)
100     {
101         if (renderType === 'time_chart' || renderType === "bar_chart" || renderType === "pie_chart"
102             || renderType === "box_plot" || renderType === "scatter_plot" || renderType === "line_plot")
103         {
104             return renderType;
105         }
106 
107         if (!xAxisType)
108         {
109             // On some charts (non-study box plots) we don't require an x-axis, instead we generate one box plot for
110             // all of the data of your y-axis. If there is no xAxisType, then we have a box plot. Scatter plots require
111             // an x-axis measure.
112             return 'box_plot';
113         }
114 
115         return (xAxisType === 'string' || xAxisType === 'boolean') ? 'box_plot' : 'scatter_plot';
116     };
117 
118     /**
119      * Generate a default label for the selected measure for the given renderType.
120      * @param renderType
121      * @param measureName - the chart type's measure name
122      * @param properties - properties for the selected column, note that this can be an array of properties
123      */
124     var getSelectedMeasureLabel = function(renderType, measureName, properties)
125     {
126         var label = getDefaultMeasuresLabel(properties);
127 
128         if (label !== '' && measureName === 'y' && (renderType === 'bar_chart' || renderType === 'pie_chart')) {
129             var aggregateProps = LABKEY.Utils.isArray(properties) && properties.length === 1
130                     ? properties[0].aggregate : properties.aggregate;
131 
132             if (LABKEY.Utils.isDefined(aggregateProps)) {
133                 var aggLabel = LABKEY.Utils.isObject(aggregateProps) ? aggregateProps.name : LABKEY.Utils.capitalize(aggregateProps.toLowerCase());
134                 label = aggLabel + ' of ' + label;
135             }
136             else {
137                 label = 'Sum of ' + label;
138             }
139         }
140 
141         return label;
142     };
143 
144     /**
145      * Generate a plot title based on the selected measures array or object.
146      * @param renderType
147      * @param measures
148      * @returns {string}
149      */
150     var getTitleFromMeasures = function(renderType, measures)
151     {
152         var queryLabels = [];
153 
154         if (LABKEY.Utils.isObject(measures))
155         {
156             if (LABKEY.Utils.isArray(measures.y))
157             {
158                 $.each(measures.y, function(idx, m)
159                 {
160                     var measureQueryLabel = m.queryLabel || m.queryName;
161                     if (queryLabels.indexOf(measureQueryLabel) === -1)
162                         queryLabels.push(measureQueryLabel);
163                 });
164             }
165             else
166             {
167                 var m = measures.x || measures.y;
168                 queryLabels.push(m.queryLabel || m.queryName);
169             }
170         }
171 
172         return queryLabels.join(', ');
173     };
174 
175     /**
176      * Get the sorted set of column metadata for the given schema/query/view.
177      * @param queryConfig
178      * @param successCallback
179      * @param callbackScope
180      */
181     var getQueryColumns = function(queryConfig, successCallback, callbackScope)
182     {
183         LABKEY.Ajax.request({
184             url: LABKEY.ActionURL.buildURL('visualization', 'getGenericReportColumns.api'),
185             method: 'GET',
186             params: {
187                 schemaName: queryConfig.schemaName,
188                 queryName: queryConfig.queryName,
189                 viewName: queryConfig.viewName,
190                 dataRegionName: queryConfig.dataRegionName,
191                 includeCohort: true,
192                 includeParticipantCategory : true
193             },
194             success : function(response){
195                 var columnList = LABKEY.Utils.decode(response.responseText);
196                 _queryColumnMetadata(queryConfig, columnList, successCallback, callbackScope)
197             },
198             scope   : this
199         });
200     };
201 
202     var _queryColumnMetadata = function(queryConfig, columnList, successCallback, callbackScope)
203     {
204         LABKEY.Query.selectRows({
205             maxRows: 0, // use maxRows 0 so that we just get the query metadata
206             schemaName: queryConfig.schemaName,
207             queryName: queryConfig.queryName,
208             viewName: queryConfig.viewName,
209             parameters: queryConfig.parameters,
210             requiredVersion: 9.1,
211             columns: columnList.columns.all,
212             method: 'POST', // Issue 31744: use POST as the columns list can be very long and cause a 400 error
213             success: function(response){
214                 var columnMetadata = _updateAndSortQueryFields(queryConfig, columnList, response.metaData.fields);
215                 successCallback.call(callbackScope, columnMetadata);
216             },
217             failure : function(response) {
218                 // this likely means that the query no longer exists
219                 successCallback.call(callbackScope, columnList, []);
220             },
221             scope   : this
222         });
223     };
224 
225     var _updateAndSortQueryFields = function(queryConfig, columnList, columnMetadata)
226     {
227         var queryFields = [],
228             queryFieldKeys = [],
229             columnTypes = LABKEY.Utils.isDefined(columnList.columns) ? columnList.columns : {};
230 
231         $.each(columnMetadata, function(idx, column)
232         {
233             var f = $.extend(true, {}, column);
234             f.schemaName = queryConfig.schemaName;
235             f.queryName = queryConfig.queryName;
236             f.isCohortColumn = false;
237             f.isSubjectGroupColumn = false;
238 
239             // issue 23224: distinguish cohort and subject group fields in the list of query columns
240             if (columnTypes['cohort'] && columnTypes['cohort'].indexOf(f.fieldKey) > -1)
241             {
242                 f.shortCaption = 'Study: ' + f.shortCaption;
243                 f.isCohortColumn = true;
244             }
245             else if (columnTypes['subjectGroup'] && columnTypes['subjectGroup'].indexOf(f.fieldKey) > -1)
246             {
247                 f.shortCaption = columnList.subject.nounSingular + ' Group: ' + f.shortCaption;
248                 f.isSubjectGroupColumn = true;
249             }
250 
251             // Issue 31672: keep track of the distinct query field keys so we don't get duplicates
252             if (f.fieldKey.toLowerCase() != 'lsid' && queryFieldKeys.indexOf(f.fieldKey) == -1) {
253                 queryFields.push(f);
254                 queryFieldKeys.push(f.fieldKey);
255             }
256         }, this);
257 
258         // Sorts fields by their shortCaption, but put subject groups/categories/cohort at the end.
259         queryFields.sort(function(a, b)
260         {
261             if (a.isSubjectGroupColumn != b.isSubjectGroupColumn)
262                 return a.isSubjectGroupColumn ? 1 : -1;
263             else if (a.isCohortColumn != b.isCohortColumn)
264                 return a.isCohortColumn ? 1 : -1;
265             else if (a.shortCaption != b.shortCaption)
266                 return a.shortCaption < b.shortCaption ? -1 : 1;
267 
268             return 0;
269         });
270 
271         return queryFields;
272     };
273 
274     /**
275      * Determine a reasonable width for the chart based on the chart type and selected measures / data.
276      * @param chartType
277      * @param measures
278      * @param measureStore
279      * @param defaultWidth
280      * @returns {int}
281      */
282     var getChartTypeBasedWidth = function(chartType, measures, measureStore, defaultWidth) {
283         var width = defaultWidth;
284 
285         if (chartType == 'bar_chart' && LABKEY.Utils.isObject(measures.x)) {
286             // 15px per bar + 15px between bars + 300 for default margins
287             var xBarCount = measureStore.members(measures.x.name).length;
288             width = Math.max((xBarCount * 15 * 2) + 300, defaultWidth);
289 
290             if (LABKEY.Utils.isObject(measures.xSub)) {
291                 // 15px per bar per group + 200px between groups + 300 for default margins
292                 var xSubCount = measureStore.members(measures.xSub.name).length;
293                 width = (xBarCount * xSubCount * 15) + (xSubCount * 200) + 300;
294             }
295         }
296         else if (chartType == 'box_plot' && LABKEY.Utils.isObject(measures.x)) {
297             // 20px per box + 20px between boxes + 300 for default margins
298             var xBoxCount = measureStore.members(measures.x.name).length;
299             width = Math.max((xBoxCount * 20 * 2) + 300, defaultWidth);
300         }
301 
302         return width;
303     };
304 
305     /**
306      * Return the distinct set of y-axis sides for the given measures object.
307      * @param measures
308      */
309     var getDistinctYAxisSides = function(measures)
310     {
311         var distinctSides = [];
312         $.each(ensureMeasuresAsArray(measures.y), function (idx, measure) {
313             if (LABKEY.Utils.isObject(measure)) {
314                 var side = measure.yAxis || 'left';
315                 if (distinctSides.indexOf(side) === -1) {
316                     distinctSides.push(side);
317                 }
318             }
319         }, this);
320         return distinctSides;
321     };
322 
323     /**
324      * Generate a default label for an array of measures by concatenating each meaures label together.
325      * @param measures
326      * @returns string concatenation of all measure labels
327      */
328     var getDefaultMeasuresLabel = function(measures)
329     {
330         if (LABKEY.Utils.isDefined(measures)) {
331             if (!LABKEY.Utils.isArray(measures)) {
332                 return measures.label || measures.queryName || '';
333             }
334 
335             var label = '', sep = '';
336             $.each(measures, function(idx, m) {
337                 label += sep + (m.label || m.queryName);
338                 sep = ', ';
339             });
340             return label;
341         }
342 
343         return '';
344     };
345 
346     /**
347      * Given the saved labels object we convert it to include all label types (main, x, and y). Each label type defaults
348      * to empty string ('').
349      * @param {Object} labels The saved labels object.
350      * @returns {Object}
351      */
352     var generateLabels = function(labels) {
353         return {
354             main: { value: labels.main || '' },
355             subtitle: { value: labels.subtitle || '' },
356             footer: { value: labels.footer || '' },
357             x: { value: labels.x || '' },
358             y: { value: labels.y || '' },
359             yRight: { value: labels.yRight || '' }
360         };
361     };
362 
363     /**
364      * Generates an object containing {@link LABKEY.vis.Scale} objects used for the chart.
365      * @param {String} chartType The chartType from getChartType.
366      * @param {Object} measures The measures from generateMeasures.
367      * @param {Object} savedScales The scales object from the saved chart config.
368      * @param {Object} aes The aesthetic map object from genereateAes.
369      * @param {Object} measureStore The MeasureStore data using a selectRows API call.
370      * @param {Function} defaultFormatFn used to format values for tick marks.
371      * @returns {Object}
372      */
373     var generateScales = function(chartType, measures, savedScales, aes, measureStore, defaultFormatFn) {
374         var scales = {};
375         var data = LABKEY.Utils.isArray(measureStore.rows) ? measureStore.rows : measureStore.records();
376         var fields = LABKEY.Utils.isObject(measureStore.metaData) ? measureStore.metaData.fields : measureStore.getResponseMetadata().fields;
377         var subjectColumn = _getStudySubjectInfo().columnName;
378         var valExponentialDigits = 6;
379 
380         if (chartType === "box_plot")
381         {
382             scales.x = {
383                 scaleType: 'discrete', // Force discrete x-axis scale for box plots.
384                 sortFn: LABKEY.vis.discreteSortFn,
385                 tickLabelMax: DEFAULT_TICK_LABEL_MAX
386             };
387 
388             var yMin = d3.min(data, aes.y);
389             var yMax = d3.max(data, aes.y);
390             var yPadding = ((yMax - yMin) * .1);
391             if (savedScales.y && savedScales.y.trans == "log")
392             {
393                 // When subtracting padding we have to make sure we still produce valid values for a log scale.
394                 // log([value less than 0]) = NaN.
395                 // log(0) = -Infinity.
396                 if (yMin - yPadding > 0)
397                 {
398                     yMin = yMin - yPadding;
399                 }
400             }
401             else
402             {
403                 yMin = yMin - yPadding;
404             }
405 
406             scales.y = {
407                 min: yMin,
408                 max: yMax + yPadding,
409                 scaleType: 'continuous',
410                 trans: savedScales.y ? savedScales.y.trans : 'linear'
411             };
412         }
413         else
414         {
415             var xMeasureType = getMeasureType(measures.x);
416 
417             // Force discrete x-axis scale for bar plots.
418             var useContinuousScale = chartType != 'bar_chart' && isNumericType(xMeasureType);
419 
420             if (useContinuousScale)
421             {
422                 scales.x = {
423                     scaleType: 'continuous',
424                     trans: savedScales.x ? savedScales.x.trans : 'linear'
425                 };
426             }
427             else
428             {
429                 scales.x = {
430                     scaleType: 'discrete',
431                     sortFn: LABKEY.vis.discreteSortFn,
432                     tickLabelMax: DEFAULT_TICK_LABEL_MAX
433                 };
434 
435                 //bar chart x-axis subcategories support
436                 if (LABKEY.Utils.isDefined(measures.xSub)) {
437                     scales.xSub = {
438                         scaleType: 'discrete',
439                         sortFn: LABKEY.vis.discreteSortFn,
440                         tickLabelMax: DEFAULT_TICK_LABEL_MAX
441                     };
442                 }
443             }
444 
445             // add both y (i.e. yLeft) and yRight, in case multiple y-axis measures are being plotted
446             scales.y = {
447                 scaleType: 'continuous',
448                 trans: savedScales.y ? savedScales.y.trans : 'linear'
449             };
450             scales.yRight = {
451                 scaleType: 'continuous',
452                 trans: savedScales.yRight ? savedScales.yRight.trans : 'linear'
453             };
454         }
455 
456         // if we have no data, show a default y-axis domain
457         if (scales.x && data.length == 0 && scales.x.scaleType == 'continuous')
458             scales.x.domain = [0,1];
459         if (scales.y && data.length == 0)
460             scales.y.domain = [0,1];
461 
462         // apply the field formatFn to the tick marks on the scales object
463         for (var i = 0; i < fields.length; i++) {
464             var type = fields[i].displayFieldJsonType ? fields[i].displayFieldJsonType : fields[i].type;
465 
466             var isMeasureXMatch = measures.x && _isFieldKeyMatch(measures.x, fields[i].fieldKey);
467             if (isMeasureXMatch && measures.x.name === subjectColumn && LABKEY.demoMode) {
468                 scales.x.tickFormat = function(){return '******'};
469             }
470             else if (isMeasureXMatch && isNumericType(type)) {
471                 scales.x.tickFormat = _getNumberFormatFn(fields[i], defaultFormatFn);
472             }
473 
474             var yMeasures = ensureMeasuresAsArray(measures.y);
475             $.each(yMeasures, function(idx, yMeasure) {
476                 var isMeasureYMatch = yMeasure && _isFieldKeyMatch(yMeasure, fields[i].fieldKey);
477                 var isConvertedYMeasure = isMeasureYMatch && yMeasure.converted;
478                 if (isMeasureYMatch && (isNumericType(type) || isConvertedYMeasure)) {
479                     var tickFormatFn = _getNumberFormatFn(fields[i], defaultFormatFn);
480 
481                     var ySide = yMeasure.yAxis === 'right' ? 'yRight' : 'y';
482                     scales[ySide].tickFormat = function(value) {
483                         if (LABKEY.Utils.isNumber(value) && Math.abs(Math.round(value)).toString().length >= valExponentialDigits) {
484                             return value.toExponential();
485                         }
486                         else if (LABKEY.Utils.isFunction(tickFormatFn)) {
487                             return tickFormatFn(value);
488                         }
489                         return value;
490                     };
491                 }
492             }, this);
493         }
494 
495         _applySavedScaleDomain(scales, savedScales, 'x');
496         if (LABKEY.Utils.isDefined(measures.xSub)) {
497             _applySavedScaleDomain(scales, savedScales, 'xSub');
498         }
499         if (LABKEY.Utils.isDefined(measures.y)) {
500             _applySavedScaleDomain(scales, savedScales, 'y');
501             _applySavedScaleDomain(scales, savedScales, 'yRight');
502         }
503 
504         return scales;
505     };
506 
507     // Issue 36227: if Ext4 is not available, try to generate our own number format function based on the "format" field metadata
508     var _getNumberFormatFn = function(field, defaultFormatFn) {
509         if (field.extFormatFn) {
510             if (window.Ext4) {
511                 return eval(field.extFormatFn);
512             }
513             else if (field.format && LABKEY.Utils.isString(field.format) && field.format.indexOf('.') > -1) {
514                 var precision = field.format.length - field.format.indexOf('.') - 1;
515                 return function(v) {
516                     return LABKEY.Utils.isNumber(v) ? v.toFixed(precision) : v;
517                 }
518             }
519         }
520 
521         return defaultFormatFn;
522     };
523 
524     var _isFieldKeyMatch = function(measure, fieldKey) {
525         if (LABKEY.Utils.isFunction(fieldKey.getName)) {
526             return fieldKey.getName() === measure.name || fieldKey.getName() === measure.fieldKey;
527         }
528 
529         return fieldKey === measure.name || fieldKey === measure.fieldKey;
530     };
531 
532     var ensureMeasuresAsArray = function(measures) {
533         if (LABKEY.Utils.isDefined(measures)) {
534             return LABKEY.Utils.isArray(measures) ? $.extend(true, [], measures) : [$.extend(true, {}, measures)];
535         }
536         return [];
537     };
538 
539     var _applySavedScaleDomain = function(scales, savedScales, scaleName) {
540         if (savedScales[scaleName] && (savedScales[scaleName].min != null || savedScales[scaleName].max != null)) {
541             scales[scaleName].domain = [savedScales[scaleName].min, savedScales[scaleName].max];
542         }
543     };
544 
545     /**
546      * Generates the aesthetic map object needed by the visualization API to render the chart. See {@link LABKEY.vis.Plot}
547      * and {@link LABKEY.vis.Layer}.
548      * @param {String} chartType The chartType from getChartType.
549      * @param {Object} measures The measures from getMeasures.
550      * @param {String} schemaName The schemaName from the saved queryConfig.
551      * @param {String} queryName The queryName from the saved queryConfig.
552      * @returns {Object}
553      */
554     var generateAes = function(chartType, measures, schemaName, queryName) {
555         var aes = {}, xMeasureType = getMeasureType(measures.x);
556 
557         if (chartType == "box_plot" && !measures.x)
558         {
559             aes.x = generateMeasurelessAcc(queryName);
560         }
561         else if (isNumericType(xMeasureType) || (chartType == 'scatter_plot' && measures.x.measure))
562         {
563             var xMeasureName = measures.x.converted ? measures.x.convertedName : measures.x.name;
564             aes.x = generateContinuousAcc(xMeasureName);
565         }
566         else
567         {
568             var xMeasureName = measures.x.converted ? measures.x.convertedName : measures.x.name;
569             aes.x = generateDiscreteAcc(xMeasureName, measures.x.label);
570         }
571 
572         // charts that have multiple y-measures selected will need to put the aes.y function on their specific layer
573         if (LABKEY.Utils.isDefined(measures.y) && !LABKEY.Utils.isArray(measures.y))
574         {
575             var sideAesName = (measures.y.yAxis || 'left') === 'left' ? 'y' : 'yRight';
576             var yMeasureName = measures.y.converted ? measures.y.convertedName : measures.y.name;
577             aes[sideAesName] = generateContinuousAcc(yMeasureName);
578         }
579 
580         if (chartType === "scatter_plot" || chartType === "line_plot")
581         {
582             aes.hoverText = generatePointHover(measures);
583         }
584 
585         if (chartType === "box_plot")
586         {
587             if (measures.color) {
588                 aes.outlierColor = generateGroupingAcc(measures.color.name);
589             }
590 
591             if (measures.shape) {
592                 aes.outlierShape = generateGroupingAcc(measures.shape.name);
593             }
594 
595             aes.hoverText = generateBoxplotHover();
596             aes.outlierHoverText = generatePointHover(measures);
597         }
598         else if (chartType === 'bar_chart')
599         {
600             var xSubMeasureType = measures.xSub ? getMeasureType(measures.xSub) : null;
601             if (xSubMeasureType)
602             {
603                 if (isNumericType(xSubMeasureType))
604                     aes.xSub = generateContinuousAcc(measures.xSub.name);
605                 else
606                     aes.xSub = generateDiscreteAcc(measures.xSub.name, measures.xSub.label);
607             }
608         }
609 
610         // color/shape aes are not dependent on chart type. If we have a box plot with all points enabled, then we
611         // create a second layer for points. So we'll need this no matter what.
612         if (measures.color) {
613             aes.color = generateGroupingAcc(measures.color.name);
614         }
615 
616         if (measures.shape) {
617             aes.shape = generateGroupingAcc(measures.shape.name);
618         }
619 
620         // also add the color and shape for the line plot series.
621         if (measures.series) {
622             aes.color = generateGroupingAcc(measures.series.name);
623             aes.shape = generateGroupingAcc(measures.series.name);
624         }
625 
626         if (measures.pointClickFn) {
627             aes.pointClickFn = generatePointClickFn(
628                     measures,
629                     schemaName,
630                     queryName,
631                     measures.pointClickFn
632             );
633         }
634 
635         return aes;
636     };
637 
638     var getYMeasureAes = function(measure) {
639         var yMeasureName = measure.converted ? measure.convertedName : measure.name;
640         return generateContinuousAcc(yMeasureName);
641     };
642 
643     /**
644      * Generates a function that returns the text used for point hovers.
645      * @param {Object} measures The measures object from the saved chart config.
646      * @returns {Function}
647      */
648     var generatePointHover = function(measures)
649     {
650         return function(row) {
651             var hover = '', sep = '', distinctNames = [];
652 
653             $.each(measures, function(key, measureObj) {
654                 var measureArr = ensureMeasuresAsArray(measureObj);
655                 $.each(measureArr, function(idx, measure) {
656                     if (LABKEY.Utils.isObject(measure) && distinctNames.indexOf(measure.name) == -1) {
657                         hover += sep + measure.label + ': ' + _getRowValue(row, measure.name);
658                         sep = ', \n';
659 
660                         distinctNames.push(measure.name);
661                     }
662                 }, this);
663             });
664 
665             return hover;
666         };
667     };
668 
669     /**
670      * Backwards compatibility for function that has been moved to LABKEY.vis.getAggregateData.
671      */
672     var generateAggregateData = function(data, dimensionName, measureName, aggregate, nullDisplayValue) {
673         return LABKEY.vis.getAggregateData(data, dimensionName, null, measureName, aggregate, nullDisplayValue, false);
674     };
675 
676     var _getRowValue = function(row, propName, valueName)
677     {
678         if (row.hasOwnProperty(propName)) {
679             // backwards compatibility for response row that is not a LABKEY.Query.Row
680             if (!(row instanceof LABKEY.Query.Row)) {
681                 return row[propName].displayValue || row[propName].value;
682             }
683 
684             var propValue = row.get(propName);
685             if (valueName != undefined && propValue.hasOwnProperty(valueName)) {
686                 return propValue[valueName];
687             }
688             else if (propValue.hasOwnProperty('displayValue')) {
689                 return propValue['displayValue'];
690             }
691             return row.getValue(propName);
692         }
693 
694         return undefined;
695     };
696 
697     /**
698      * Returns a function used to generate the hover text for box plots.
699      * @returns {Function}
700      */
701     var generateBoxplotHover = function() {
702         return function(xValue, stats) {
703             return xValue + ':\nMin: ' + stats.min + '\nMax: ' + stats.max + '\nQ1: ' + stats.Q1 + '\nQ2: ' + stats.Q2 +
704                     '\nQ3: ' + stats.Q3;
705         };
706     };
707 
708     /**
709      * Generates an accessor function that returns a discrete value from a row of data for a given measure and label.
710      * Used when an axis has a discrete measure (i.e. string).
711      * @param {String} measureName The name of the measure.
712      * @param {String} measureLabel The label of the measure.
713      * @returns {Function}
714      */
715     var generateDiscreteAcc = function(measureName, measureLabel)
716     {
717         return function(row)
718         {
719             var value = _getRowValue(row, measureName);
720             if (value === null)
721                 value = "Not in " + measureLabel;
722 
723             return value;
724         };
725     };
726 
727     /**
728      * Generates an accessor function that returns a value from a row of data for a given measure.
729      * @param {String} measureName The name of the measure.
730      * @returns {Function}
731      */
732     var generateContinuousAcc = function(measureName)
733     {
734         return function(row)
735         {
736             var value = _getRowValue(row, measureName, 'value');
737 
738             if (value !== undefined)
739             {
740                 if (Math.abs(value) === Infinity)
741                     value = null;
742 
743                 if (value === false || value === true)
744                     value = value.toString();
745 
746                 return value;
747             }
748 
749             return undefined;
750         }
751     };
752 
753     /**
754      * Generates an accesssor function for shape and color measures.
755      * @param {String} measureName The name of the measure.
756      * @returns {Function}
757      */
758     var generateGroupingAcc = function(measureName)
759     {
760         return function(row)
761         {
762             var value = null;
763             if (LABKEY.Utils.isArray(row) && row.length > 0) {
764                 value = _getRowValue(row[0], measureName);
765             }
766             else {
767                 value = _getRowValue(row, measureName);
768             }
769 
770             if (value === null || value === undefined)
771                 value = "n/a";
772 
773             return value;
774         };
775     };
776 
777     /**
778      * Generates an accessor for boxplots that do not have an x-axis measure. Generally the measureName passed in is the
779      * queryName.
780      * @param {String} measureName The name of the measure. In this case it is generally the query name.
781      * @returns {Function}
782      */
783     var generateMeasurelessAcc = function(measureName) {
784         // Used for box plots that do not have an x-axis measure. Instead we just return the queryName for every row.
785         return function(row) {
786             return measureName;
787         }
788     };
789 
790     /**
791      * Generates the function to be executed when a user clicks a point.
792      * @param {Object} measures The measures from the saved chart config.
793      * @param {String} schemaName The schema name from the saved query config.
794      * @param {String} queryName The query name from the saved query config.
795      * @param {String} fnString The string value of the user-provided function to be executed when a point is clicked.
796      * @returns {Function}
797      */
798     var generatePointClickFn = function(measures, schemaName, queryName, fnString){
799         var measureInfo = {
800             schemaName: schemaName,
801             queryName: queryName
802         };
803 
804         _addPointClickMeasureInfo(measureInfo, measures, 'x', 'xAxis');
805         _addPointClickMeasureInfo(measureInfo, measures, 'y', 'yAxis');
806         $.each(['color', 'shape', 'series'], function(idx, name) {
807             _addPointClickMeasureInfo(measureInfo, measures, name, name + 'Name');
808         }, this);
809 
810         // using new Function is quicker than eval(), even in IE.
811         var pointClickFn = new Function('return ' + fnString)();
812         return function(clickEvent, data){
813             pointClickFn(data, measureInfo, clickEvent);
814         };
815     };
816 
817     var _addPointClickMeasureInfo = function(measureInfo, measures, name, key) {
818         if (LABKEY.Utils.isDefined(measures[name])) {
819             var measuresArr = ensureMeasuresAsArray(measures[name]);
820             $.each(measuresArr, function(idx, measure) {
821                 if (!LABKEY.Utils.isDefined(measureInfo[key])) {
822                     measureInfo[key] = measure.name;
823                 }
824                 else if (!LABKEY.Utils.isDefined(measureInfo[measure.name])) {
825                     measureInfo[measure.name] = measure.name;
826                 }
827             }, this);
828         }
829     };
830 
831     /**
832      * Generates the Point Geom used for scatter plots and box plots with all points visible.
833      * @param {Object} chartOptions The saved chartOptions object from the chart config.
834      * @returns {LABKEY.vis.Geom.Point}
835      */
836     var generatePointGeom = function(chartOptions){
837         return new LABKEY.vis.Geom.Point({
838             opacity: chartOptions.opacity,
839             size: chartOptions.pointSize,
840             color: '#' + chartOptions.pointFillColor,
841             position: chartOptions.position
842         });
843     };
844 
845     /**
846      * Generates the Boxplot Geom used for box plots.
847      * @param {Object} chartOptions The saved chartOptions object from the chart config.
848      * @returns {LABKEY.vis.Geom.Boxplot}
849      */
850     var generateBoxplotGeom = function(chartOptions){
851         return new LABKEY.vis.Geom.Boxplot({
852             lineWidth: chartOptions.lineWidth,
853             outlierOpacity: chartOptions.opacity,
854             outlierFill: '#' + chartOptions.pointFillColor,
855             outlierSize: chartOptions.pointSize,
856             color: '#' + chartOptions.lineColor,
857             fill: chartOptions.boxFillColor == 'none' ? chartOptions.boxFillColor : '#' + chartOptions.boxFillColor,
858             position: chartOptions.position,
859             showOutliers: chartOptions.showOutliers
860         });
861     };
862 
863     /**
864      * Generates the Barplot Geom used for bar charts.
865      * @param {Object} chartOptions The saved chartOptions object from the chart config.
866      * @returns {LABKEY.vis.Geom.BarPlot}
867      */
868     var generateBarGeom = function(chartOptions){
869         return new LABKEY.vis.Geom.BarPlot({
870             opacity: chartOptions.opacity,
871             color: '#' + chartOptions.lineColor,
872             fill: '#' + chartOptions.boxFillColor,
873             lineWidth: chartOptions.lineWidth
874         });
875     };
876 
877     /**
878      * Generates the Bin Geom used to bin a set of points.
879      * @param {Object} chartOptions The saved chartOptions object from the chart config.
880      * @returns {LABKEY.vis.Geom.Bin}
881      */
882     var generateBinGeom = function(chartOptions) {
883         var colorRange = ["#e6e6e6", "#085D90"]; //light-gray and labkey blue is default
884         if (chartOptions.binColorGroup == 'SingleColor') {
885             var color = '#' + chartOptions.binSingleColor;
886             colorRange = ["#FFFFFF", color];
887         }
888         else if (chartOptions.binColorGroup == 'Heat') {
889             colorRange = ["#fff6bc", "#e23202"];
890         }
891 
892         return new LABKEY.vis.Geom.Bin({
893             shape: chartOptions.binShape,
894             colorRange: colorRange,
895             size: chartOptions.binShape == 'square' ? 10 : 5
896         })
897     };
898 
899     /**
900      * Generates a Geom based on the chartType.
901      * @param {String} chartType The chart type from getChartType.
902      * @param {Object} chartOptions The chartOptions object from the saved chart config.
903      * @returns {LABKEY.vis.Geom}
904      */
905     var generateGeom = function(chartType, chartOptions) {
906         if (chartType == "box_plot")
907             return generateBoxplotGeom(chartOptions);
908         else if (chartType == "scatter_plot" || chartType == "line_plot")
909             return chartOptions.binned ? generateBinGeom(chartOptions) : generatePointGeom(chartOptions);
910         else if (chartType == "bar_chart")
911             return generateBarGeom(chartOptions);
912     };
913 
914     /**
915      * Generate an array of plot configs for the given chart renderType and config options.
916      * @param renderTo
917      * @param chartConfig
918      * @param labels
919      * @param aes
920      * @param scales
921      * @param geom
922      * @param data
923      * @returns {Array} array of plot config objects
924      */
925     var generatePlotConfigs = function(renderTo, chartConfig, labels, aes, scales, geom, data)
926     {
927         var plotConfigArr = [];
928 
929         // if we have multiple y-measures and the request is to plot them separately, call the generatePlotConfig function
930         // for each y-measure separately with its own copy of the chartConfig object
931         if (chartConfig.geomOptions.chartLayout === 'per_measure' && LABKEY.Utils.isArray(chartConfig.measures.y)) {
932 
933             // if 'automatic across charts' scales are requested, need to manually calculate the min and max
934             if (chartConfig.scales.y && chartConfig.scales.y.type === 'automatic') {
935                 scales.y = $.extend(scales.y, _getScaleDomainValuesForAllMeasures(data, chartConfig.measures.y, 'left'));
936             }
937             if (chartConfig.scales.yRight && chartConfig.scales.yRight.type === 'automatic') {
938                 scales.yRight = $.extend(scales.yRight, _getScaleDomainValuesForAllMeasures(data, chartConfig.measures.y, 'right'));
939             }
940 
941             $.each(chartConfig.measures.y, function(idx, yMeasure) {
942                 // copy the config and reset the measures.y array with the single measure
943                 var newChartConfig = $.extend(true, {}, chartConfig);
944                 newChartConfig.measures.y = $.extend(true, {}, yMeasure);
945 
946                 // copy the labels object so that we can set the subtitle based on the y-measure
947                 var newLabels = $.extend(true, {}, labels);
948                 newLabels.subtitle = {value: yMeasure.label || yMeasure.name};
949 
950                 // only copy over the scales that are needed for this measures
951                 var side = yMeasure.yAxis || 'left';
952                 var newScales = {x: $.extend(true, {}, scales.x)};
953                 if (side === 'left') {
954                     newScales.y = $.extend(true, {}, scales.y);
955                 }
956                 else {
957                     newScales.yRight = $.extend(true, {}, scales.yRight);
958                 }
959 
960                 plotConfigArr.push(generatePlotConfig(renderTo, newChartConfig, newLabels, aes, newScales, geom, data));
961             }, this);
962         }
963         else {
964             plotConfigArr.push(generatePlotConfig(renderTo, chartConfig, labels, aes, scales, geom, data));
965         }
966 
967         return plotConfigArr;
968     };
969 
970     var _getScaleDomainValuesForAllMeasures = function(data, measures, side) {
971         var min = null, max = null;
972 
973         $.each(measures, function(idx, measure) {
974             var measureSide = measure.yAxis || 'left';
975             if (side === measureSide) {
976                 var accFn = LABKEY.vis.GenericChartHelper.getYMeasureAes(measure);
977                 var tempMin = d3.min(data, accFn);
978                 var tempMax = d3.max(data, accFn);
979 
980                 if (min == null || tempMin < min) {
981                     min = tempMin;
982                 }
983                 if (max == null || tempMax > max) {
984                     max = tempMax;
985                 }
986             }
987         }, this);
988 
989         return {domain: [min, max]};
990     };
991 
992     /**
993      * Generate the plot config for the given chart renderType and config options.
994      * @param renderTo
995      * @param chartConfig
996      * @param labels
997      * @param aes
998      * @param scales
999      * @param geom
1000      * @param data
1001      * @returns {Object}
1002      */
1003     var generatePlotConfig = function(renderTo, chartConfig, labels, aes, scales, geom, data)
1004     {
1005         var renderType = chartConfig.renderType,
1006             layers = [], clipRect,
1007             emptyTextFn = function(){return '';},
1008             plotConfig = {
1009                 renderTo: renderTo,
1010                 rendererType: 'd3',
1011                 width: chartConfig.width,
1012                 height: chartConfig.height
1013             };
1014 
1015         if (renderType === 'pie_chart') {
1016             return _generatePieChartConfig(plotConfig, chartConfig, labels, data);
1017         }
1018 
1019         clipRect = (scales.x && LABKEY.Utils.isArray(scales.x.domain)) || (scales.y && LABKEY.Utils.isArray(scales.y.domain));
1020 
1021         // account for one or many y-measures by ensuring that we have an array of y-measures
1022         var yMeasures = ensureMeasuresAsArray(chartConfig.measures.y);
1023 
1024         if (renderType === 'bar_chart') {
1025             aes = { x: 'label', y: 'value' };
1026 
1027             if (LABKEY.Utils.isDefined(chartConfig.measures.xSub))
1028             {
1029                 aes.xSub = 'subLabel';
1030                 aes.color = 'label';
1031             }
1032 
1033             if (!scales.y) {
1034                 scales.y = {};
1035             }
1036 
1037             if (!scales.y.domain) {
1038                 var values = $.map(data, function(d) {return d.value;}),
1039                     min = Math.min(0, Math.min.apply(Math, values)),
1040                     max = Math.max(0, Math.max.apply(Math, values));
1041 
1042                 scales.y.domain = [min, max];
1043             }
1044         }
1045         else if (renderType === 'box_plot' && chartConfig.pointType === 'all')
1046         {
1047             layers.push(
1048                 new LABKEY.vis.Layer({
1049                     geom: LABKEY.vis.GenericChartHelper.generatePointGeom(chartConfig.geomOptions),
1050                     aes: {hoverText: LABKEY.vis.GenericChartHelper.generatePointHover(chartConfig.measures)}
1051                 })
1052             );
1053         }
1054         else if (renderType === 'line_plot') {
1055             var xName = chartConfig.measures.x.name,
1056                 isDate = isDateType(getMeasureType(chartConfig.measures.x));
1057 
1058             $.each(yMeasures, function(idx, yMeasure) {
1059                 var pathAes = {
1060                     sortFn: function(a, b) {
1061                         // No need to handle the case for a or b or a.getValue() or b.getValue() null as they are
1062                         // not currently included in this plot.
1063                         if (isDate){
1064                             return new Date(a.getValue(xName)) - new Date(b.getValue(xName));
1065                         }
1066                         return a.getValue(xName) - b.getValue(xName);
1067                     }
1068                 };
1069 
1070                 pathAes[yMeasure.yAxis === 'right' ? 'yRight' : 'yLeft'] = getYMeasureAes(yMeasure);
1071 
1072                 // use the series measure's values for the distinct colors and grouping
1073                 if (chartConfig.measures.series) {
1074                     pathAes.pathColor = generateGroupingAcc(chartConfig.measures.series.name);
1075                     pathAes.group = generateGroupingAcc(chartConfig.measures.series.name);
1076                 }
1077                 // if no series measures but we have multiple y-measures, force the color and grouping to be distinct for each measure
1078                 else if (yMeasures.length > 1) {
1079                     pathAes.pathColor = emptyTextFn;
1080                     pathAes.group = emptyTextFn;
1081                 }
1082 
1083                 layers.push(
1084                     new LABKEY.vis.Layer({
1085                         name: yMeasures.length > 1 ? yMeasure.label || yMeasure.name : undefined,
1086                         geom: new LABKEY.vis.Geom.Path({
1087                             color: '#' + chartConfig.geomOptions.pointFillColor,
1088                             size: chartConfig.geomOptions.lineWidth?chartConfig.geomOptions.lineWidth:3,
1089                             opacity:chartConfig.geomOptions.opacity
1090                         }),
1091                         aes: pathAes
1092                     })
1093                 );
1094             }, this);
1095         }
1096 
1097         // Issue 34711: better guess at the max number of discrete x-axis tick mark labels to show based on the plot width
1098         if (scales.x && scales.x.scaleType === 'discrete' && scales.x.tickLabelMax) {
1099             // approx 30 px for a 45 degree rotated tick label
1100             scales.x.tickLabelMax = Math.floor((plotConfig.width - 300) / 30);
1101         }
1102 
1103         var margins = _getPlotMargins(renderType, scales, aes, data, plotConfig, chartConfig);
1104         if (LABKEY.Utils.isObject(margins)) {
1105             plotConfig.margins = margins;
1106         }
1107 
1108         if (chartConfig.measures.color)
1109         {
1110             scales.color = {
1111                 colorType: chartConfig.geomOptions.colorPaletteScale,
1112                 scaleType: 'discrete'
1113             }
1114         }
1115 
1116         if ((renderType === 'line_plot' || renderType === 'scatter_plot') && yMeasures.length > 0) {
1117             $.each(yMeasures, function (idx, yMeasure) {
1118                 var layerAes = {};
1119                 layerAes[yMeasure.yAxis === 'right' ? 'yRight' : 'yLeft'] = getYMeasureAes(yMeasure);
1120 
1121                 // if no series measures but we have multiple y-measures, force the color and shape to be distinct for each measure
1122                 if (!aes.color && yMeasures.length > 1) {
1123                     layerAes.color = emptyTextFn;
1124                 }
1125                 if (!aes.shape && yMeasures.length > 1) {
1126                     layerAes.shape = emptyTextFn;
1127                 }
1128 
1129                 layers.push(
1130                     new LABKEY.vis.Layer({
1131                         name: yMeasures.length > 1 ? yMeasure.label || yMeasure.name : undefined,
1132                         geom: geom,
1133                         aes: layerAes
1134                     })
1135                 );
1136             }, this);
1137         }
1138         else {
1139             layers.push(
1140                 new LABKEY.vis.Layer({
1141                     data: data,
1142                     geom: geom
1143                 })
1144             );
1145         }
1146 
1147         plotConfig = $.extend(plotConfig, {
1148             clipRect: clipRect,
1149             data: data,
1150             labels: labels,
1151             aes: aes,
1152             scales: scales,
1153             layers: layers
1154         });
1155 
1156         return plotConfig;
1157     };
1158 
1159     var _willRotateXAxisTickText = function(scales, plotConfig, maxTickLength, data) {
1160         if (scales.x && scales.x.scaleType === 'discrete') {
1161             var tickCount = scales.x && scales.x.tickLabelMax ? Math.min(scales.x.tickLabelMax, data.length) : data.length;
1162             return (tickCount * maxTickLength * 5) > (plotConfig.width - 150);
1163         }
1164 
1165         return false;
1166     };
1167 
1168     var _getPlotMargins = function(renderType, scales, aes, data, plotConfig, chartConfig) {
1169         var margins = {};
1170 
1171         // issue 29690: for bar and box plots, set default bottom margin based on the number of labels and the max label length
1172         if (LABKEY.Utils.isArray(data)) {
1173             var maxLen = 0;
1174             $.each(data, function(idx, d) {
1175                 var val = LABKEY.Utils.isFunction(aes.x) ? aes.x(d) : d[aes.x];
1176                 if (LABKEY.Utils.isString(val)) {
1177                     maxLen = Math.max(maxLen, val.length);
1178                 }
1179             });
1180 
1181             if (_willRotateXAxisTickText(scales, plotConfig, maxLen, data)) {
1182                 // min bottom margin: 50, max bottom margin: 275
1183                 var bottomMargin = Math.min(Math.max(50, maxLen*5), 275);
1184                 margins.bottom = bottomMargin;
1185             }
1186         }
1187 
1188         // issue 31857: allow custom margins to be set in Chart Layout dialog
1189         if (chartConfig && chartConfig.geomOptions) {
1190             if (chartConfig.geomOptions.marginTop !== null) {
1191                 margins.top = chartConfig.geomOptions.marginTop;
1192             }
1193             if (chartConfig.geomOptions.marginRight !== null) {
1194                 margins.right = chartConfig.geomOptions.marginRight;
1195             }
1196             if (chartConfig.geomOptions.marginBottom !== null) {
1197                 margins.bottom = chartConfig.geomOptions.marginBottom;
1198             }
1199             if (chartConfig.geomOptions.marginLeft !== null) {
1200                 margins.left = chartConfig.geomOptions.marginLeft;
1201             }
1202         }
1203 
1204         return !LABKEY.Utils.isEmptyObj(margins) ? margins : null;
1205     };
1206 
1207     var _generatePieChartConfig = function(baseConfig, chartConfig, labels, data)
1208     {
1209         var hasData = data.length > 0;
1210 
1211         return $.extend(baseConfig, {
1212             data: hasData ? data : [{label: '', value: 1}],
1213             header: {
1214                 title: { text: labels.main.value },
1215                 subtitle: { text: labels.subtitle.value },
1216                 titleSubtitlePadding: 1
1217             },
1218             footer: {
1219                 text: hasData ? labels.footer.value : 'No data to display',
1220                 location: 'bottom-center'
1221             },
1222             labels: {
1223                 mainLabel: { fontSize: 14 },
1224                 percentage: {
1225                     fontSize: 14,
1226                     color: chartConfig.geomOptions.piePercentagesColor != null ? '#' + chartConfig.geomOptions.piePercentagesColor : undefined
1227                 },
1228                 outer: { pieDistance: 20 },
1229                 inner: {
1230                     format: hasData && chartConfig.geomOptions.showPiePercentages ? 'percentage' : 'none',
1231                     hideWhenLessThanPercentage: chartConfig.geomOptions.pieHideWhenLessThanPercentage
1232                 }
1233             },
1234             size: {
1235                 pieInnerRadius: hasData ? chartConfig.geomOptions.pieInnerRadius + '%' : '100%',
1236                 pieOuterRadius: hasData ? chartConfig.geomOptions.pieOuterRadius + '%' : '90%'
1237             },
1238             misc: {
1239                 gradient: {
1240                     enabled: chartConfig.geomOptions.gradientPercentage != 0,
1241                     percentage: chartConfig.geomOptions.gradientPercentage,
1242                     color: '#' + chartConfig.geomOptions.gradientColor
1243                 },
1244                 colors: {
1245                     segments: hasData ? LABKEY.vis.Scale[chartConfig.geomOptions.colorPaletteScale]() : ['#333333']
1246                 }
1247             },
1248             effects: { highlightSegmentOnMouseover: false },
1249             tooltips: { enabled: true }
1250         });
1251     };
1252 
1253     /**
1254      * Check if the MeasureStore selectRows API response has data. Return an error string if no data exists.
1255      * @param measureStore
1256      * @param includeFilterMsg true to include a message about removing filters
1257      * @returns {String}
1258      */
1259     var validateResponseHasData = function(measureStore, includeFilterMsg)
1260     {
1261         var dataArray = LABKEY.Utils.isDefined(measureStore) ? measureStore.rows || measureStore.records() : [];
1262         if (dataArray.length == 0)
1263         {
1264             return 'The response returned 0 rows of data. The query may be empty or the applied filters may be too strict.'
1265                 + (includeFilterMsg ? 'Try removing or adjusting any filters if possible.' : '');
1266         }
1267 
1268         return null;
1269     };
1270 
1271     /**
1272      * Verifies that the axis measure is actually present and has data. Also checks to make sure that data can be used in a log
1273      * scale (if applicable). Returns an object with a success parameter (boolean) and a message parameter (string). If the
1274      * success parameter is false there is a critical error and the chart cannot be rendered. If success is true the chart
1275      * can be rendered. Message will contain an error or warning message if applicable. If message is not null and success
1276      * is true, there is a warning.
1277      * @param {String} chartType The chartType from getChartType.
1278      * @param {Object} chartConfigOrMeasure The saved chartConfig object or a specific measure object.
1279      * @param {String} measureName The name of the axis measure property.
1280      * @param {Object} aes The aes object from generateAes.
1281      * @param {Object} scales The scales object from generateScales.
1282      * @param {Array} data The response data from selectRows.
1283      * @param {Boolean} dataConversionHappened Whether we converted any values in the measure data
1284      * @returns {Object}
1285      */
1286     var validateAxisMeasure = function(chartType, chartConfigOrMeasure, measureName, aes, scales, data, dataConversionHappened) {
1287         var measure = LABKEY.Utils.isObject(chartConfigOrMeasure) && chartConfigOrMeasure.measures ? chartConfigOrMeasure.measures[measureName] : chartConfigOrMeasure;
1288         return _validateAxisMeasure(chartType, measure, measureName, aes, scales, data, dataConversionHappened);
1289     };
1290 
1291     var _validateAxisMeasure = function(chartType, measure, measureName, aes, scales, data, dataConversionHappened) {
1292         var dataIsNull = true, measureUndefined = true, invalidLogValues = false, hasZeroes = false, message = null;
1293 
1294         // no need to check measures if we have no data
1295         if (data.length === 0) {
1296             return {success: true, message: message};
1297         }
1298 
1299         for (var i = 0; i < data.length; i ++)
1300         {
1301             var value = aes[measureName](data[i]);
1302 
1303             if (value !== undefined)
1304                 measureUndefined = false;
1305 
1306             if (value !== null)
1307                 dataIsNull = false;
1308 
1309             if (value && value < 0)
1310                 invalidLogValues = true;
1311 
1312             if (value === 0 )
1313                 hasZeroes = true;
1314         }
1315 
1316         if (measureUndefined)
1317         {
1318             message = 'The measure, ' + measure.name + ', was not found. It may have been renamed or removed.';
1319             return {success: false, message: message};
1320         }
1321 
1322         if ((chartType == 'scatter_plot' || chartType == 'line_plot' || measureName == 'y') && dataIsNull && !dataConversionHappened)
1323         {
1324             message = 'All data values for ' + measure.label + ' are null. Please choose a different measure.';
1325             return {success: false, message: message};
1326         }
1327 
1328         if (scales[measureName] && scales[measureName].trans == "log")
1329         {
1330             if (invalidLogValues)
1331             {
1332                 message = "Unable to use a log scale on the " + measureName + "-axis. All " + measureName
1333                         + "-axis values must be >= 0. Reverting to linear scale on " + measureName + "-axis.";
1334                 scales[measureName].trans = 'linear';
1335             }
1336             else if (hasZeroes)
1337             {
1338                 message = "Some " + measureName + "-axis values are 0. Plotting all " + measureName + "-axis values as value+1.";
1339                 var accFn = aes[measureName];
1340                 aes[measureName] = function(row){return accFn(row) + 1};
1341             }
1342         }
1343 
1344         return {success: true, message: message};
1345     };
1346 
1347     /**
1348      * Deprecated - use validateAxisMeasure
1349      */
1350     var validateXAxis = function(chartType, chartConfig, aes, scales, data){
1351         return this.validateAxisMeasure(chartType, chartConfig, 'x', aes, scales, data);
1352     };
1353     /**
1354      * Deprecated - use validateAxisMeasure
1355      */
1356     var validateYAxis = function(chartType, chartConfig, aes, scales, data){
1357         return this.validateAxisMeasure(chartType, chartConfig, 'y', aes, scales, data);
1358     };
1359 
1360     var getMeasureType = function(measure) {
1361         return LABKEY.Utils.isObject(measure) ? (measure.normalizedType || measure.type) : null;
1362     };
1363 
1364     var isNumericType = function(type)
1365     {
1366         var t = LABKEY.Utils.isString(type) ? type.toLowerCase() : null;
1367         return t == 'int' || t == 'integer' || t == 'float' || t == 'double';
1368     };
1369 
1370     var isDateType = function(type)
1371     {
1372         var t = LABKEY.Utils.isString(type) ? type.toLowerCase() : null;
1373         return t == 'date';
1374     };
1375 
1376     var _getStudySubjectInfo = function()
1377     {
1378         var studyCtx = LABKEY.getModuleContext("study") || {};
1379         return LABKEY.Utils.isObject(studyCtx.subject) ? studyCtx.subject : {
1380             tableName: 'Participant',
1381             columnName: 'ParticipantId',
1382             nounPlural: 'Participants',
1383             nounSingular: 'Participant'
1384         };
1385     };
1386 
1387     var _getStudyTimepointType = function()
1388     {
1389         var studyCtx = LABKEY.getModuleContext("study") || {};
1390         return LABKEY.Utils.isDefined(studyCtx.timepointType) ? studyCtx.timepointType : null;
1391     };
1392 
1393     var _getMeasureRestrictions = function (chartType, measure)
1394     {
1395         var measureRestrictions = {};
1396         $.each(getRenderTypes(), function (idx, renderType)
1397         {
1398             if (renderType.name === chartType)
1399             {
1400                 $.each(renderType.fields, function (idx2, field)
1401                 {
1402                     if (field.name === measure)
1403                     {
1404                         measureRestrictions.numericOnly = field.numericOnly;
1405                         measureRestrictions.nonNumericOnly = field.nonNumericOnly;
1406                         return false;
1407                     }
1408                 });
1409                 return false;
1410             }
1411         });
1412 
1413         return measureRestrictions;
1414     };
1415 
1416     /**
1417      * Converts data values passed in to the appropriate type based on measure/dimension information.
1418      * @param chartConfig Chart configuration object
1419      * @param aes Aesthetic mapping functions for each measure/axis
1420      * @param renderType The type of plot or chart (e.g. scatter_plot, bar_chart)
1421      * @param data The response data from SelectRows
1422      * @returns {{processed: {}, warningMessage: *}}
1423      */
1424     var doValueConversion = function(chartConfig, aes, renderType, data)
1425     {
1426         var measuresForProcessing = {}, measureRestrictions = {}, configMeasure;
1427         for (var measureName in chartConfig.measures) {
1428             if (chartConfig.measures.hasOwnProperty(measureName) && LABKEY.Utils.isObject(chartConfig.measures[measureName])) {
1429                 configMeasure = chartConfig.measures[measureName];
1430                 $.extend(measureRestrictions, _getMeasureRestrictions(renderType, measureName));
1431 
1432                 var isGroupingMeasure = measureName === 'color' || measureName === 'shape' || measureName === 'series';
1433                 var isXAxis = measureName === 'x' || measureName === 'xSub';
1434                 var isScatterOrLine = renderType === 'scatter_plot' || renderType === 'line_plot';
1435                 var isBarYCount = renderType === 'bar_chart' && configMeasure.aggregate && (configMeasure.aggregate === 'COUNT' || configMeasure.aggregate.value === 'COUNT');
1436 
1437                 if (configMeasure.measure && !isGroupingMeasure && !isBarYCount
1438                         && ((!isXAxis && measureRestrictions.numericOnly ) || isScatterOrLine) && !isNumericType(configMeasure.type)) {
1439                     measuresForProcessing[measureName] = {};
1440                     measuresForProcessing[measureName].name = configMeasure.name;
1441                     measuresForProcessing[measureName].convertedName = configMeasure.name + "_converted";
1442                     measuresForProcessing[measureName].label = configMeasure.label;
1443                     configMeasure.normalizedType = 'float';
1444                     configMeasure.type = 'float';
1445                 }
1446             }
1447         }
1448 
1449         var response = {processed: {}};
1450         if (!LABKEY.Utils.isEmptyObj(measuresForProcessing)) {
1451             response = _processMeasureData(data, aes, measuresForProcessing);
1452         }
1453 
1454         //generate error message for dropped values
1455         var warningMessage = '';
1456         for (var measure in response.droppedValues) {
1457             if (response.droppedValues.hasOwnProperty(measure) && response.droppedValues[measure].numDropped) {
1458                 warningMessage += " The "
1459                         + measure + "-axis measure '"
1460                         + response.droppedValues[measure].label + "' had "
1461                         + response.droppedValues[measure].numDropped +
1462                         " value(s) that could not be converted to a number and are not included in the plot.";
1463             }
1464         }
1465 
1466         return {processed: response.processed, warningMessage: warningMessage};
1467     };
1468 
1469     /**
1470      * Does the explicit type conversion for each measure deemed suitable to convert. Currently we only
1471      * attempt to convert strings to numbers for measures.
1472      * @param rows Data from SelectRows
1473      * @param aes Aesthetic mapping function for the measure/dimensions
1474      * @param measuresForProcessing The measures to be converted, if any
1475      * @returns {{droppedValues: {}, processed: {}}}
1476      */
1477     var _processMeasureData = function(rows, aes, measuresForProcessing) {
1478         var droppedValues = {}, processedMeasures = {}, dataIsNull;
1479         rows.forEach(function(row) {
1480             //convert measures if applicable
1481             if (!LABKEY.Utils.isEmptyObj(measuresForProcessing)) {
1482                 for (var measure in measuresForProcessing) {
1483                     if (measuresForProcessing.hasOwnProperty(measure)) {
1484                         dataIsNull = true;
1485                         if (!droppedValues[measure]) {
1486                             droppedValues[measure] = {};
1487                             droppedValues[measure].label = measuresForProcessing[measure].label;
1488                             droppedValues[measure].numDropped = 0;
1489                         }
1490 
1491                         if (aes.hasOwnProperty(measure)) {
1492                             var value = aes[measure](row);
1493                             if (value !== null) {
1494                                 dataIsNull = false;
1495                             }
1496                             row[measuresForProcessing[measure].convertedName] = {value: null};
1497                             if (typeof value !== 'number' && value !== null) {
1498 
1499                                 //only try to convert strings to numbers
1500                                 if (typeof value === 'string') {
1501                                     value = value.trim();
1502                                 }
1503                                 else {
1504                                     //dates, objects, booleans etc. to be assigned value: NULL
1505                                     value = '';
1506                                 }
1507 
1508                                 var n = Number(value);
1509                                 // empty strings convert to 0, which we must explicitly deny
1510                                 if (value === '' || isNaN(n)) {
1511                                     droppedValues[measure].numDropped++;
1512                                 }
1513                                 else {
1514                                     row[measuresForProcessing[measure].convertedName].value = n;
1515                                 }
1516                             }
1517                         }
1518 
1519                         if (!processedMeasures[measure]) {
1520                             processedMeasures[measure] = {
1521                                 converted: false,
1522                                 convertedName: measuresForProcessing[measure].convertedName,
1523                                 type: 'float',
1524                                 normalizedType: 'float'
1525                             }
1526                         }
1527 
1528                         processedMeasures[measure].converted = processedMeasures[measure].converted || !dataIsNull;
1529                     }
1530                 }
1531             }
1532         });
1533 
1534         return {droppedValues: droppedValues, processed: processedMeasures};
1535     };
1536 
1537     /**
1538      * removes all traces of String -> Numeric Conversion from the given chart config
1539      * @param chartConfig
1540      * @returns {updated ChartConfig}
1541      */
1542     var removeNumericConversionConfig = function(chartConfig) {
1543         if (chartConfig && chartConfig.measures) {
1544             for (var measureName in chartConfig.measures) {
1545                 if (chartConfig.measures.hasOwnProperty(measureName)) {
1546                     var measure = chartConfig.measures[measureName];
1547                     if (measure && measure.converted && measure.convertedName) {
1548                         measure.converted = null;
1549                         measure.convertedName = null;
1550                         if (LABKEY.vis.GenericChartHelper.isNumericType(measure.type)) {
1551                             measure.type = 'string';
1552                             measure.normalizedType = 'string';
1553                         }
1554                     }
1555                 }
1556             }
1557         }
1558 
1559         return chartConfig;
1560     };
1561 
1562     var renderChartSVG = function(renderTo, queryConfig, chartConfig) {
1563         queryConfig.containerPath = LABKEY.container.path;
1564 
1565         if (queryConfig.filterArray && queryConfig.filterArray.length > 0) {
1566             var filters = [];
1567 
1568             for (var i = 0; i < queryConfig.filterArray.length; i++) {
1569                 var f = queryConfig.filterArray[i];
1570                 // Issue 37191: Check to see if 'f' is already a filter instance (either labkey-api-js/src/filter/Filter.ts or clientapi/core/Query.js)
1571                 if (f.hasOwnProperty('getValue') || f.getValue instanceof Function) {
1572                     filters.push(f);
1573                 }
1574                 else {
1575                     filters.push(LABKEY.Filter.create(f.name,  f.value, LABKEY.Filter.getFilterTypeForURLSuffix(f.type)));
1576                 }
1577             }
1578 
1579             queryConfig.filterArray = filters;
1580         }
1581 
1582         queryConfig.success = function(measureStore) {
1583             _renderChartSVG(renderTo, chartConfig, measureStore);
1584         };
1585 
1586         LABKEY.Query.MeasureStore.selectRows(queryConfig);
1587     };
1588 
1589     var _renderChartSVG = function(renderTo, chartConfig, measureStore) {
1590         var responseMetaData = measureStore.getResponseMetadata();
1591 
1592         // explicitly set the chart width/height if not set in the config
1593         if (!chartConfig.hasOwnProperty('width') || chartConfig.width == null) chartConfig.width = 1000;
1594         if (!chartConfig.hasOwnProperty('height') || chartConfig.height == null) chartConfig.height = 600;
1595 
1596         var xAxisType = chartConfig.measures.x ? (chartConfig.measures.x.normalizedType || chartConfig.measures.x.type) : null;
1597         var chartType = getChartType(chartConfig.renderType, xAxisType);
1598         var aes = generateAes(chartType, chartConfig.measures, responseMetaData.schemaName, responseMetaData.queryName);
1599         var valueConversionResponse = doValueConversion(chartConfig, aes, chartType, measureStore.records());
1600         if (!LABKEY.Utils.isEmptyObj(valueConversionResponse.processed)) {
1601             $.extend(true, chartConfig.measures, valueConversionResponse.processed);
1602             aes = generateAes(chartType, chartConfig.measures, responseMetaData.schemaName, responseMetaData.queryName);
1603         }
1604         var data = measureStore.records();
1605         if (chartType === 'scatter_plot' && data.length > chartConfig.geomOptions.binThreshold) {
1606             chartConfig.geomOptions.binned = true;
1607         }
1608         var scales = generateScales(chartType, chartConfig.measures, chartConfig.scales, aes, measureStore);
1609         var geom = generateGeom(chartType, chartConfig.geomOptions);
1610         var labels = generateLabels(chartConfig.labels);
1611 
1612         if (chartType === 'bar_chart' || chartType === 'pie_chart') {
1613             var dimName = null, subDimName = null; measureName = null, aggType = 'COUNT';
1614 
1615             if (chartConfig.measures.x) {
1616                 dimName = chartConfig.measures.x.converted ? chartConfig.measures.x.convertedName : chartConfig.measures.x.name;
1617             }
1618             if (chartConfig.measures.xSub) {
1619                 subDimName = chartConfig.measures.xSub.converted ? chartConfig.measures.xSub.convertedName : chartConfig.measures.xSub.name;
1620             }
1621             if (chartConfig.measures.y) {
1622                 measureName = chartConfig.measures.y.converted ? chartConfig.measures.y.convertedName : chartConfig.measures.y.name;
1623 
1624                 if (LABKEY.Utils.isDefined(chartConfig.measures.y.aggregate)) {
1625                     aggType = chartConfig.measures.y.aggregate;
1626                     aggType = LABKEY.Utils.isObject(aggType) ? aggType.value : aggType;
1627                 }
1628                 else if (measureName != null) {
1629                     aggType = 'SUM';
1630                 }
1631             }
1632 
1633             data = LABKEY.vis.getAggregateData(data, dimName, subDimName, measureName, aggType, '[Blank]', false);
1634         }
1635 
1636         var validation = _validateChartConfig(chartConfig, aes, scales, measureStore);
1637         _renderMessages(renderTo, validation.messages);
1638         if (!validation.success)
1639             return;
1640 
1641         var plotConfigArr = generatePlotConfigs(renderTo, chartConfig, labels, aes, scales, geom, data);
1642         $.each(plotConfigArr, function(idx, plotConfig) {
1643             if (chartType === 'pie_chart') {
1644                 new LABKEY.vis.PieChart(plotConfig);
1645             }
1646             else {
1647                 new LABKEY.vis.Plot(plotConfig).render();
1648             }
1649         }, this);
1650     };
1651 
1652     var _renderMessages = function(divId, messages) {
1653         if (messages && messages.length > 0) {
1654             var errorDiv = document.createElement('div');
1655             errorDiv.setAttribute('style', 'padding: 10px; background-color: #ffe5e5; color: #d83f48; font-weight: bold;');
1656             errorDiv.innerHTML = messages.join('<br/>');
1657             document.getElementById(divId).appendChild(errorDiv);
1658         }
1659     };
1660 
1661     var _validateChartConfig = function(chartConfig, aes, scales, measureStore) {
1662         var hasNoDataMsg = validateResponseHasData(measureStore, false);
1663         if (hasNoDataMsg != null)
1664             return {success: false, messages: [hasNoDataMsg]};
1665 
1666         var messages = [], firstRecord = measureStore.records()[0], measureNames = Object.keys(chartConfig.measures);
1667         for (var i = 0; i < measureNames.length; i++) {
1668             var measuresArr = ensureMeasuresAsArray(chartConfig.measures[measureNames[i]]);
1669             for (var j = 0; j < measuresArr.length; j++) {
1670                 var measure = measuresArr[j];
1671                 if (LABKEY.Utils.isObject(measure)) {
1672                     if (measure.name && !LABKEY.Utils.isDefined(firstRecord[measure.name])) {
1673                         return {success: false, messages: ['The measure, ' + measure.name + ', is not available. It may have been renamed or removed.']};
1674                     }
1675 
1676                     var validation;
1677                     if (measureNames[i] === 'y') {
1678                         var yAes = {y: getYMeasureAes(measure)};
1679                         validation = validateAxisMeasure(chartConfig.renderType, measure, 'y', yAes, scales, measureStore.records());
1680                     }
1681                     else if (measureNames[i] === 'x' || measureNames[i] === 'xSub') {
1682                         validation = validateAxisMeasure(chartConfig.renderType, measure, measureNames[i], aes, scales, measureStore.records());
1683                     }
1684 
1685                     if (LABKEY.Utils.isObject(validation)) {
1686                         if (validation.message != null)
1687                             messages.push(validation.message);
1688                         if (!validation.success)
1689                             return {success: false, messages: messages};
1690                     }
1691                 }
1692             }
1693         }
1694 
1695         return {success: true, messages: messages};
1696     };
1697 
1698     return {
1699         // NOTE: the @function below is needed or JSDoc will not include the documentation for loadVisDependencies. Don't
1700         // ask me why, I do not know.
1701         /**
1702          * @function
1703          */
1704         getRenderTypes: getRenderTypes,
1705         getChartType: getChartType,
1706         getSelectedMeasureLabel: getSelectedMeasureLabel,
1707         getTitleFromMeasures: getTitleFromMeasures,
1708         getMeasureType: getMeasureType,
1709         getQueryColumns : getQueryColumns,
1710         getChartTypeBasedWidth : getChartTypeBasedWidth,
1711         getDistinctYAxisSides : getDistinctYAxisSides,
1712         getYMeasureAes : getYMeasureAes,
1713         getDefaultMeasuresLabel: getDefaultMeasuresLabel,
1714         ensureMeasuresAsArray: ensureMeasuresAsArray,
1715         isNumericType: isNumericType,
1716         generateLabels: generateLabels,
1717         generateScales: generateScales,
1718         generateAes: generateAes,
1719         doValueConversion: doValueConversion,
1720         removeNumericConversionConfig: removeNumericConversionConfig,
1721         generateAggregateData: generateAggregateData,
1722         generatePointHover: generatePointHover,
1723         generateBoxplotHover: generateBoxplotHover,
1724         generateDiscreteAcc: generateDiscreteAcc,
1725         generateContinuousAcc: generateContinuousAcc,
1726         generateGroupingAcc: generateGroupingAcc,
1727         generatePointClickFn: generatePointClickFn,
1728         generateGeom: generateGeom,
1729         generateBoxplotGeom: generateBoxplotGeom,
1730         generatePointGeom: generatePointGeom,
1731         generatePlotConfigs: generatePlotConfigs,
1732         generatePlotConfig: generatePlotConfig,
1733         validateResponseHasData: validateResponseHasData,
1734         validateAxisMeasure: validateAxisMeasure,
1735         validateXAxis: validateXAxis,
1736         validateYAxis: validateYAxis,
1737         renderChartSVG: renderChartSVG,
1738         /**
1739          * Loads all of the required dependencies for a Generic Chart.
1740          * @param {Function} callback The callback to be executed when all of the visualization dependencies have been loaded.
1741          * @param {Object} scope The scope to be used when executing the callback.
1742          */
1743         loadVisDependencies: LABKEY.requiresVisualization
1744     };
1745 };